extends Node ## ## MemoryManager - central budget authority for low-RAM (4GB) Android devices. ## ## Responsibilities: ## - Track a hard RAM budget for loadable assets and evict (LRU) when exceeded. ## - Provide lazy `acquire(id)` / `release(id)` reference counting so a heavy ## feature (hair sim, cloth, sculpt grid) only lives in RAM while in use. ## - Never hold a strong reference to an evictable asset itself; it holds the ## *path* and hands out instances, so eviction actually frees memory. ## ## Design notes: ## - Godot is ref-counted; an asset is truly freed only when every strong ref ## is dropped. Callers MUST call release() so we can drop the cache ref. ## - We poll OS memory pressure and force-evict before the OOM killer arrives. ## signal budget_exceeded(evicted_count: int) signal memory_warning(used_mb: float, budget_mb: float) signal asset_loaded(id: String, cost_mb: float) signal asset_evicted(id: String, freed_mb: float) ## Total RAM this process is allowed to use for evictable assets. @export var asset_budget_mb: float = 512.0 ## Soft threshold (fraction of budget) that triggers a warning. @export var warn_ratio: float = 0.8 ## How often (seconds) to poll the OS memory monitor. @export var poll_interval: float = 1.0 ## id -> { path, cost_mb, refs, last_used, resource } var _entries: Dictionary = {} ## Monotonic counter used for LRU ordering. var _clock: int = 0 var _poll_accum: float = 0.0 ## When true, release() cannot drop below zero refs (double-free guard). var _initialized := false func _ready() -> void: set_process(true) _apply_platform_budget() _initialized = true print("[MemoryManager] budget=%.0f MB" % asset_budget_mb) func _apply_platform_budget() -> void: # A 64-bit OS reports total RAM in bytes; keep a conservative slice for us. var total_mb := float(OS.get_memory_info().get("physical", 0)) / 1048576.0 if total_mb > 0.0: # Never claim more than 1/8 of physical RAM for evictable assets. asset_budget_mb = min(asset_budget_mb, total_mb * 0.125) # Hard floor so tiny test environments still work. asset_budget_mb = max(asset_budget_mb, 64.0) ## Register an asset path as loadable. Does NOT load it (that is the point: lazy). ## `cost_mb` is a caller estimate; use VertexBufferManager.estimate_cost for meshes. func register(id: String, path: String, cost_mb: float) -> void: assert(not _entries.has(id), "MemoryManager: duplicate id '%s'" % id) _entries[id] = { "path": path, "cost_mb": cost_mb, "refs": 0, "last_used": 0, "resource": null, } ## Acquire an asset, loading it on first use. Every acquire must be matched ## with exactly one release(). func acquire(id: String) -> Resource: if not _entries.has(id): push_error("MemoryManager.acquire: unknown id '%s'" % id) return null var e: Dictionary = _entries[id] if e["resource"] == null: _ensure_room(e["cost_mb"]) var res: Resource = load(e["path"]) if res == null: push_error("MemoryManager: failed to load '%s'" % e["path"]) return null e["resource"] = res asset_loaded.emit(id, e["cost_mb"]) e["refs"] = int(e["refs"]) + 1 e["last_used"] = _next_clock() return e["resource"] ## Release one reference. When refs hit zero the asset becomes evictable. func release(id: String) -> void: if not _entries.has(id): return var e: Dictionary = _entries[id] e["refs"] = maxi(0, int(e["refs"]) - 1) e["last_used"] = _next_clock() ## Force-unload an asset regardless of refs. Only use when you know all live ## references are gone (e.g. a panel was destroyed). func purge(id: String) -> void: if not _entries.has(id): return var e: Dictionary = _entries[id] if e["resource"] != null: var freed := float(e["cost_mb"]) if e["refs"] == 0: e["resource"] = null asset_evicted.emit(id, freed) e["refs"] = 0 func get_loaded_mb() -> float: var total := 0.0 for id in _entries: if _entries[id]["resource"] != null: total += float(_entries[id]["cost_mb"]) return total func _next_clock() -> int: _clock += 1 return _clock func _process(delta: float) -> void: _poll_accum += delta if _poll_accum < poll_interval: return _poll_accum = 0.0 var used := get_loaded_mb() if used >= asset_budget_mb * warn_ratio: memory_warning.emit(used, asset_budget_mb) # Also react to real OS pressure independent of our accounting. if OS.get_memory_info().get("physical", 0) > 0: var avail_mb := float(OS.get_memory_info()["available"]) / 1048576.0 if OS.get_memory_info().has("available") else INF if avail_mb < 128.0: _ensure_room(asset_budget_mb * 0.25, true) ## Evict unreferenced (LRU) assets until `need_mb` of room exists. func _ensure_room(need_mb: float, force: bool = false) -> void: while get_loaded_mb() + need_mb > asset_budget_mb: var victim := _pick_lru_victim() if victim == "": break var e: Dictionary = _entries[victim] var freed := float(e["cost_mb"]) e["resource"] = null e["refs"] = 0 asset_evicted.emit(victim, freed) if force and not _has_more_victims(): break budget_exceeded.emit(0) func _pick_lru_victim() -> String: var best := "" var best_time := 0x7FFFFFFF for id in _entries: var e: Dictionary = _entries[id] if e["resource"] == null: continue if int(e["refs"]) > 0: continue if int(e["last_used"]) < best_time: best_time = int(e["last_used"]) best = id return best func _has_more_victims() -> bool: return _pick_lru_victim() != "" func debug_snapshot() -> Dictionary: return { "budget_mb": asset_budget_mb, "loaded_mb": get_loaded_mb(), "entries": _entries.size(), }