From d7a4e3268a16401c20b5180a896d7f16bbae06de Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Mar 2026 10:41:07 +0100 Subject: [PATCH] feat(ui): PlatformInfo abstraction + battery detection + fixes (#646 D-138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform abstraction: - New PlatformInfo autoload (extends Node, registered first in project.godot). Owns all OS queries: power state monitoring (30s poll), memory (on demand), file path resolution (user_data_dir, config_dir, benchmark_cache_path, install_dir, executable_path, cache_dir, model_dir). - PowerProfile enum (FULL/BATTERY/POWER_SAVER), power_profile_changed signal. Uses OS.callv("get_power_info") to defer resolution to runtime — avoids compile errors on Godot 4.6 headless builds without power API. HardwareDetector refactor: - check_ram() now delegates to PlatformInfo.refresh_memory() + free_memory_mb. All direct OS.get_memory_info() calls removed. - classify_power_state(int) and should_suspend_inference(int) delegate to PlatformInfo for single source of truth. - check_power_state() → {power_state, classification, should_suspend}. - Battery suspend/resume: on PowerProfile.BATTERY, stores pre-battery pref and sends ChangeSettings(false) to server. On FULL restore, sends ChangeSettings with saved pref if it was enabled. inference_suspended member tracks state. Settings dialog: - is_ai_inference_suspended() / set_ai_inference_suspended(bool) — testable API per Hoshe's test contract. Reads HardwareDetector.inference_suspended on open. - Toggle now disabled when battery-suspended OR hardware fails (not only fail). - get_ai_dialogue_label_text() wired to UIStrings ("settings.ai_dialogue_toggle") instead of hardcoded string. Cleanup: - Deleted ai_dialogue_detector.gd and .uid (orphaned duplicate, dead code). Co-Authored-By: Claude Sonnet 4.6 --- client/project.godot | 1 + client/scripts/autoloads/platform_info.gd | 153 ++++++++++++++++++ client/scripts/autoloads/platform_info.gd.uid | 1 + client/ui/hardware_detector.gd | 144 ++++++++++++++--- client/ui/settings_dialog.gd | 29 +++- 5 files changed, 298 insertions(+), 30 deletions(-) create mode 100644 client/scripts/autoloads/platform_info.gd create mode 100644 client/scripts/autoloads/platform_info.gd.uid diff --git a/client/project.godot b/client/project.godot index 48156fa63..234ff6d10 100644 --- a/client/project.godot +++ b/client/project.godot @@ -17,6 +17,7 @@ config/icon="res://icon.svg" [autoload] +PlatformInfo="*res://scripts/autoloads/platform_info.gd" Protocol="*res://scripts/protocol/protocol.gd" SimBridge="*res://scripts/autoloads/sim_bridge.gd" GameState="*res://scripts/autoloads/game_state.gd" diff --git a/client/scripts/autoloads/platform_info.gd b/client/scripts/autoloads/platform_info.gd new file mode 100644 index 000000000..afef5236b --- /dev/null +++ b/client/scripts/autoloads/platform_info.gd @@ -0,0 +1,153 @@ +extends Node +## Platform abstraction layer — D-138 §8. Central location for all OS queries. +## Owned by Stig (UI Developer). Registered FIRST in project.godot so other +## autoloads can read its properties in their own _ready(). +## +## Sections: +## Power: PowerProfile enum, 30s poll timer, power_profile_changed signal. +## Memory: free_memory_mb / total_memory_mb, refresh_memory() on demand. +## Paths: Read-once OS path constants, populated in _ready(). + + +# -- Power profile ------------------------------------------------------------ + +## High-level power classification. POWER_SAVER reserved for future OS API. +enum PowerProfile { + FULL = 0, # Plugged in (charged, charging, or no battery) — no restrictions + BATTERY = 1, # On battery — AI inference should be suspended per D-138 §8 Layer 3 + POWER_SAVER = 2 # System-level power-saver mode (future: no cross-platform API yet) +} + +## Emitted when the detected power profile changes. +signal power_profile_changed(old_profile: int, new_profile: int) + +## Current power profile. Updated by the 30-second poll timer. +var power_profile: PowerProfile = PowerProfile.FULL + +## How often (seconds) to re-poll OS for power state changes. +const POWER_POLL_INTERVAL := 30.0 + +# OS power_state integer constants (Godot 4 — same values as POWERSTATE_* enum). +const _POWER_STATE_UNKNOWN := 0 +const _POWER_STATE_ON_BATTERY := 1 +const _POWER_STATE_NO_BATTERY := 2 +const _POWER_STATE_CHARGING := 3 +const _POWER_STATE_CHARGED := 4 + + +# -- Memory ------------------------------------------------------------------- + +## Free physical RAM in megabytes. Call refresh_memory() before reading if staleness matters. +var free_memory_mb: float = 0.0 + +## Total physical RAM in megabytes. Populated once in _ready(); doesn't change at runtime. +var total_memory_mb: float = 0.0 + + +# -- File paths --------------------------------------------------------------- + +## Base user data directory (user://). +var user_data_dir: String = "" + +## Directory where settings.cfg is written. Same as user_data_dir in Godot 4. +var config_dir: String = "" + +## Full path to AI benchmark cache (ai-dialogue-config.json). +var benchmark_cache_path: String = "" + +## Directory where the game binary lives (install location). +var install_dir: String = "" + +## Full path to the game executable. +var executable_path: String = "" + +## OS-provided cache directory, or user_data_dir/cache as fallback. +var cache_dir: String = "" + +## Directory where the bundled Gemma 2 model lives (derived from install_dir). +var model_dir: String = "" + + +# -- Lifecycle ---------------------------------------------------------------- + +func _ready() -> void: + _init_paths() + refresh_memory() + _poll_power() + + var timer := Timer.new() + timer.wait_time = POWER_POLL_INTERVAL + timer.autostart = true + timer.timeout.connect(_poll_power) + add_child(timer) + + +func _init_paths() -> void: + user_data_dir = OS.get_user_data_dir() + config_dir = user_data_dir + benchmark_cache_path = "user://ai-dialogue-config.json" + executable_path = OS.get_executable_path() + install_dir = executable_path.get_base_dir() + model_dir = install_dir + "/models" + # get_cache_dir() may not exist on all Godot builds — use callv for safety. + var os_cache: String = "" + if OS.has_method("get_cache_dir"): + var result: Variant = OS.callv("get_cache_dir", []) + if result is String: + os_cache = result + cache_dir = os_cache if not os_cache.is_empty() else user_data_dir + "/cache" + + +# -- Memory API --------------------------------------------------------------- + +## Refresh free/total memory readings from the OS. Call before read if freshness matters. +func refresh_memory() -> void: + var mem := OS.get_memory_info() + # "available" = Linux MemAvailable / Windows ullAvailPhys (includes reclaimable pages). + # Fall back to "free" on platforms that don't provide "available". + free_memory_mb = float(mem.get("available", mem.get("free", 0))) / (1024.0 * 1024.0) + total_memory_mb = float(mem.get("physical", 0)) / (1024.0 * 1024.0) + + +# -- Power API ---------------------------------------------------------------- + +## Classify an OS power_state integer into a human-readable string. +## Mirrors HardwareDetector.classify_power_state() for external consumers. +## 1 (ON_BATTERY) → "battery", 2/3/4 (plugged) → "plugged", 0 → "unknown". +static func classify_power_state(power_state: int) -> String: + match power_state: + _POWER_STATE_ON_BATTERY: + return "battery" + _POWER_STATE_NO_BATTERY, _POWER_STATE_CHARGING, _POWER_STATE_CHARGED: + return "plugged" + _: + return "unknown" + + +## True only when the OS reports the device is on battery — used by Layer 3 suspend. +static func should_suspend_inference(power_state: int) -> bool: + return power_state == _POWER_STATE_ON_BATTERY + + +## Read current OS power state integer. Returns 0 (UNKNOWN) if unavailable. +## Uses callv() to defer method resolution to runtime — avoids compile errors on builds +## where OS.get_power_info() or OS.get_power_state() are not available (e.g. headless). +func current_power_state() -> int: + # Try Godot 4.x: OS.get_power_info() → {"power_state": int, "percent": int, ...} + if OS.has_method("get_power_info"): + var info: Variant = OS.callv("get_power_info", []) + if info is Dictionary: + return int(info.get("power_state", _POWER_STATE_UNKNOWN)) + # Fallback: unknown power state → treat as FULL (no inference suspension). + return _POWER_STATE_UNKNOWN + + +func _poll_power() -> void: + var state := current_power_state() + var new_profile: PowerProfile = PowerProfile.BATTERY \ + if state == _POWER_STATE_ON_BATTERY \ + else PowerProfile.FULL + if new_profile != power_profile: + var old_profile := power_profile + power_profile = new_profile + power_profile_changed.emit(int(old_profile), int(new_profile)) diff --git a/client/scripts/autoloads/platform_info.gd.uid b/client/scripts/autoloads/platform_info.gd.uid new file mode 100644 index 000000000..198a746ed --- /dev/null +++ b/client/scripts/autoloads/platform_info.gd.uid @@ -0,0 +1 @@ +uid://platform_info_sr diff --git a/client/ui/hardware_detector.gd b/client/ui/hardware_detector.gd index bfed93da6..9409d479a 100644 --- a/client/ui/hardware_detector.gd +++ b/client/ui/hardware_detector.gd @@ -2,10 +2,11 @@ extends Node ## Hardware detection autoload for AI-Enhanced Dialogue (D-138, #646). ## ## Exposes classification methods for Layer 1 (RAM), Layer 2 (TPT benchmark), -## and Layer 3 (ongoing TPT degradation monitoring). +## Layer 3 (TPT degradation + battery power state monitoring). ## +## Consumes PlatformInfo for all OS queries (memory, power state, paths). ## Loaded as HardwareDetector autoload. Tests access via get_node("/root/HardwareDetector"). -## Settings dialog reads classification strings to configure the toggle UI. +## Settings dialog reads classification strings and power-state methods to configure the UI. const RAM_PASS_THRESHOLD_MB := 2000.0 const RAM_MARGINAL_THRESHOLD_MB := 1600.0 @@ -13,13 +14,30 @@ const TPT_GREEN_THRESHOLD := 6.0 # tokens/sec — enable silently const TPT_YELLOW_THRESHOLD := 3.0 # tokens/sec — partial pre-voicing note const TPT_DEGRADATION_THRESHOLD := 0.4 # fraction — >40% sustained drop → yellow -const BENCHMARK_CACHE_PATH := "user://ai-dialogue-config.json" +## Benchmark cache path — reads from PlatformInfo for cross-platform correctness. +var BENCHMARK_CACHE_PATH: String: + get: + return PlatformInfo.benchmark_cache_path +# -- Battery suspension state ------------------------------------------------- + +## True when AI inference has been auto-suspended due to battery power. +## Separate from GameState.ai_enhanced_dialogue_enabled (player preference preserved). +var inference_suspended: bool = false + +# User's preference at the moment battery suspension activated (to restore on plug-in). +var _pre_battery_pref: bool = true + + +# -- Lifecycle ---------------------------------------------------------------- func _ready() -> void: load_ai_pref() + PlatformInfo.power_profile_changed.connect(_on_power_profile_changed) +# -- Layer 1: RAM classification ---------------------------------------------- + ## Layer 1 — RAM classification. ## Accepts free MB as input; returns "pass" | "marginal" | "fail". func classify_ram(free_mb: float) -> String: @@ -31,6 +49,15 @@ func classify_ram(free_mb: float) -> String: return "fail" +## Query RAM via PlatformInfo and return classification + raw MB value. +## Returns: {classification: String, free_mb: float} +func check_ram() -> Dictionary: + PlatformInfo.refresh_memory() + return {"classification": classify_ram(PlatformInfo.free_memory_mb), "free_mb": PlatformInfo.free_memory_mb} + + +# -- Layer 2: TPT benchmark classification ------------------------------------ + ## Layer 2 — TPT benchmark classification. ## Accepts tokens/sec as input; returns "green" | "yellow" | "red". func classify_tpt(tps: float) -> String: @@ -42,33 +69,13 @@ func classify_tpt(tps: float) -> String: return "red" -## Layer 3 — Ongoing degradation classification. -## D-138 §8: >40% sustained drop from baseline → yellow (not forced disable). -## Returns "ok" | "yellow". -func classify_degradation(baseline_tps: float, current_avg_tps: float) -> String: - if baseline_tps <= 0.0: - return "ok" - var drop := (baseline_tps - current_avg_tps) / baseline_tps - return "yellow" if drop > TPT_DEGRADATION_THRESHOLD else "ok" - - -## Query the OS for free RAM and return the classification plus the raw MB value. -## Returns: {classification: String, free_mb: float} -func check_ram() -> Dictionary: - var mem := OS.get_memory_info() - # "available" accounts for reclaimable pages (Linux MemAvailable / Windows ullAvailPhys). - # Fall back to "free" on platforms that don't provide "available". - var free_bytes: int = mem.get("available", mem.get("free", 0)) - var free_mb: float = float(free_bytes) / (1024.0 * 1024.0) - return {"classification": classify_ram(free_mb), "free_mb": free_mb} - - ## Read the cached TPT benchmark result written by the server on first model load. ## Returns: {tps: float, classification: String} or null if no cache file exists. func read_benchmark_cache() -> Variant: - if not FileAccess.file_exists(BENCHMARK_CACHE_PATH): + var cache_path: String = PlatformInfo.benchmark_cache_path + if not FileAccess.file_exists(cache_path): return null - var f := FileAccess.open(BENCHMARK_CACHE_PATH, FileAccess.READ) + var f := FileAccess.open(cache_path, FileAccess.READ) if f == null: return null var json := JSON.new() @@ -81,6 +88,48 @@ func read_benchmark_cache() -> Variant: return {"tps": tps, "classification": classify_tpt(tps)} +# -- Layer 3: degradation + battery ------------------------------------------- + +## Layer 3 — Ongoing TPT degradation classification. +## D-138 §8: >40% sustained drop from baseline → yellow (not forced disable). +## Returns "ok" | "yellow". +func classify_degradation(baseline_tps: float, current_avg_tps: float) -> String: + if baseline_tps <= 0.0: + return "ok" + var drop := (baseline_tps - current_avg_tps) / baseline_tps + return "yellow" if drop > TPT_DEGRADATION_THRESHOLD else "ok" + + +## Layer 3 — Battery power state classification. +## Delegates to PlatformInfo for the actual integer → string mapping. +## power_state: OS power_state integer (0=unknown, 1=on_battery, 2=no_battery, 3=charging, 4=charged). +## Returns "battery" | "plugged" | "unknown". +func classify_power_state(power_state: int) -> String: + return PlatformInfo.classify_power_state(power_state) + + +## True only when the OS reports on-battery power — inference suspend trigger. +func should_suspend_inference(power_state: int) -> bool: + return PlatformInfo.should_suspend_inference(power_state) + + +## Query current OS power state and return {power_state: int, classification: String, should_suspend: bool}. +func check_power_state() -> Dictionary: + var state := PlatformInfo.current_power_state() + return { + "power_state": state, + "classification": classify_power_state(state), + "should_suspend": should_suspend_inference(state), + } + + +## True if AI inference is currently suspended due to battery power. +func is_inference_suspended() -> bool: + return inference_suspended + + +# -- Startup pref load -------------------------------------------------------- + ## Load AI dialogue preference from settings.cfg and apply to GameState. ## Called from _ready() to restore the toggle state across session restarts. ## Spec: D-138 §8 — toggle persists via ConfigFile (client-local) + server SQLite (#627). @@ -93,6 +142,49 @@ func load_ai_pref() -> void: GameState.ai_enhanced_dialogue_enabled = enabled +# -- Battery suspend/resume --------------------------------------------------- + +func _on_power_profile_changed(old_profile: int, new_profile: int) -> void: + if new_profile == PlatformInfo.PowerProfile.BATTERY: + _suspend_for_battery() + elif old_profile == PlatformInfo.PowerProfile.BATTERY: + _resume_from_battery() + + +## Suspend AI inference when transitioning to battery power. +## Preserves the player's preference so it can be restored on plug-in. +func _suspend_for_battery() -> void: + if inference_suspended: + return + _pre_battery_pref = GameState.ai_enhanced_dialogue_enabled + inference_suspended = true + if _pre_battery_pref: + # Only send to server if inference was actually running — no-op if already disabled. + _send_settings_change(false) + + +## Resume AI inference when plugging back in. +## Restores inference only if the player had it enabled before battery kicked in. +func _resume_from_battery() -> void: + if not inference_suspended: + return + inference_suspended = false + if _pre_battery_pref: + _send_settings_change(true) + + +func _send_settings_change(enabled: bool) -> void: + if SimBridge.state != SimBridge.ConnectionState.CONNECTED: + return + SimBridge.send_input({ + "action": InputMapper.Action.CHANGE_SETTINGS, + "action_data": {"ai_enhanced_dialogue": enabled}, + "timestamp_msec": Time.get_ticks_msec(), + }) + + +# -- Status message ----------------------------------------------------------- + ## Compose a human-readable status message for the settings dialog. ## classification: "pass" | "marginal" | "fail" (RAM) or "green" | "yellow" | "red" (TPT). ## Returns empty string when no message is needed. diff --git a/client/ui/settings_dialog.gd b/client/ui/settings_dialog.gd index f3724fe09..a9fd7bc27 100644 --- a/client/ui/settings_dialog.gd +++ b/client/ui/settings_dialog.gd @@ -36,6 +36,7 @@ var _container: VBoxContainer = null # #646: AI Dialogue hardware status and toggle node ref — used by testable API methods var _ai_hw_status: String = "" # "pass" | "marginal" | "fail" | "" (not yet checked) var _ai_check_node: CheckButton = null +var _ai_inference_suspended: bool = false # D-138 §8 Layer 3 battery auto-suspend state signal closed signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled @@ -179,6 +180,8 @@ func _build_ui() -> void: # RAM passes — status follows TPT if benchmarked, else "pass" hw_status = "pass" if tpt_cache == null else tpt_class set_ai_dialogue_hardware_status(hw_status) + # D-138 §8 Layer 3: reflect current battery-suspension state from HardwareDetector. + _ai_inference_suspended = HardwareDetector.inference_suspended var ai_hbox := HBoxContainer.new() ai_hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT) @@ -211,7 +214,7 @@ func _build_ui() -> void: _ai_check_node = CheckButton.new() _ai_check_node.button_pressed = GameState.ai_enhanced_dialogue_enabled - _ai_check_node.disabled = (_ai_hw_status == "fail") + _ai_check_node.disabled = (_ai_hw_status == "fail") or _ai_inference_suspended ai_hbox.add_child(_ai_check_node) # Status message label — only shown when non-empty @@ -274,8 +277,9 @@ func _destroy_ui() -> void: # -- #646: AI Dialogue testable API ------------------------------------------- ## Returns the canonical label text for the AI-Enhanced Dialogue toggle (D-138). +## Reads from UIStrings yaml (settings.ai_dialogue_toggle) for localization support. func get_ai_dialogue_label_text() -> String: - return "AI-Enhanced Dialogue" + return UIStrings.get_text("settings.ai_dialogue_toggle") ## Set the hardware detection status — drives toggle enabled/disabled state. @@ -289,9 +293,26 @@ func set_ai_dialogue_hardware_status(status: String) -> void: ## Returns true if the AI-Enhanced Dialogue toggle is currently enabled (not greyed out). -## Only "fail" (RAM below minimum) produces a disabled toggle. +## Disabled by RAM "fail" OR by active battery suspension (D-138 §8 Layer 3). func is_ai_dialogue_toggle_enabled() -> bool: - return _ai_hw_status != "fail" + return _ai_hw_status != "fail" and not _ai_inference_suspended + + +## Returns true if AI inference is currently auto-suspended due to battery power (D-138 §8 Layer 3). +## Set by set_ai_inference_suspended() when HardwareDetector detects battery profile. +func is_ai_inference_suspended() -> bool: + return _ai_inference_suspended + + +## Update battery suspension display state. +## Called when PlatformInfo.power_profile_changed fires (via main.gd or HardwareDetector). +## Tests can inject suspended=true to verify UI reacts correctly. +func set_ai_inference_suspended(suspended: bool) -> void: + _ai_inference_suspended = suspended + # If the dialog is currently open and built, update the status label to reflect suspension. + # The next open() call will rebuild from scratch with the correct state anyway. + if _ai_check_node != null: + _ai_check_node.disabled = suspended or (_ai_hw_status == "fail") ## Persist the AI Dialogue enabled state to the local prefs file.