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>
154 lines
5.7 KiB
GDScript
154 lines
5.7 KiB
GDScript
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))
|