extends Node ## Hardware detection autoload for AI-Enhanced Dialogue (D-138, #646). ## ## Exposes classification methods for Layer 1 (RAM), Layer 2 (TPT benchmark), ## 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 and power-state methods to configure the UI. const RAM_PASS_THRESHOLD_MB := 2000.0 const RAM_MARGINAL_THRESHOLD_MB := 1600.0 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 ## 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: assert(PlatformInfo != null, "PlatformInfo must load before HardwareDetector") load_ai_pref() _pre_battery_pref = GameState.ai_enhanced_dialogue_enabled 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: if free_mb >= RAM_PASS_THRESHOLD_MB: return "pass" if free_mb >= RAM_MARGINAL_THRESHOLD_MB: return "marginal" 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: if tps >= TPT_GREEN_THRESHOLD: return "green" if tps >= TPT_YELLOW_THRESHOLD: return "yellow" return "red" ## 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: var cache_path: String = PlatformInfo.benchmark_cache_path if not FileAccess.file_exists(cache_path): return null var text: String = FileAccess.get_file_as_string(cache_path) if text.is_empty(): return null var json := JSON.new() if json.parse(text) != OK: return null var data: Variant = json.get_data() if not data is Dictionary or not data.has("tps"): return null var tps: float = float(data["tps"]) 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). func load_ai_pref() -> void: var cfg := ConfigFile.new() if cfg.load("user://settings.cfg") != OK: return var enabled: Variant = cfg.get_value("ai_dialogue", "enabled", null) if enabled is bool: 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. func status_message( ram_classification: String, tpt_classification: String, free_mb: float, tps: float ) -> String: if ram_classification == "fail": return ( ( "AI-Enhanced Dialogue requires 2 GB of free memory. Your system currently has %.0f MB available." + " Close other applications and try again, or leave the setting off — the game is complete either way." ) % free_mb ) if ram_classification == "marginal": return "Only %.0f MB free — performance may vary. You can still enable it." % free_mb match tpt_classification: "yellow": return ( ( "Running at %.0f t/s — pre-voicing will work for main characters and key scenes." + " Background NPCs may show base text until the queue catches up." ) % tps ) "red": return ( ("Running very slowly at %.0f t/s — we recommend leaving this off, but the choice is yours.") % tps ) return ""