feat(ui): PlatformInfo abstraction + battery detection + fixes (#646 D-138)
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 <noreply@anthropic.com>
This commit is contained in:
+118
-26
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user