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))