## Metro Mod Loader -- community mod loader for Road to Vostok (Godot 4.6+).
## Loads .vmz/.pck archives from <game>/mods/ via a pre-game config window.
## Unpacked folder mods are also recognized when Developer Mode is enabled
## (toggle in the launcher's Mods tab).
## Two-pass architecture: mounts archives at file-scope, optionally restarts to
## prepend mod autoloads before the game's own autoloads via [autoload_prepend].
##
## This file is BUILT from src/*.gd via build.sh -- do not edit modloader.gd
## directly; edit the source fragments and rebuild.
extends Node

## ----- constants.gd -----
## All top-level const declarations and module-scope state (vars, signals).
## These must appear in the compiled modloader.gd before any function body
## that references them.

# release-please bumps MODLOADER_VERSION automatically via Conventional Commits:
#   feat: ... -> minor bump
#   fix: ...  -> patch bump
#   feat!: or BREAKING CHANGE: -> major bump
# The major/minor/patch accessors parse this single source of truth so mods can
# compare against it without hand-maintaining a second set of constants.
# x-release-please-start-version
const MODLOADER_VERSION := "3.1.1"
# x-release-please-end

const MODLOADER_RES_PATH := "res://modloader.gd"
const MOD_DIR := "mods"
const TMP_DIR := "user://vmz_mount_cache"
const UI_CONFIG_PATH := "user://mod_config.cfg"
# Sentinel value for `[settings] active_profile` written by Reset to Vanilla.
# Has no stored sections -- `_apply_profile_to_entries` treats it as "all off".
const VANILLA_PROFILE := "__vanilla__"
const CONFLICT_REPORT_PATH := "user://modloader_conflicts.txt"
const PASS_STATE_PATH := "user://mod_pass_state.cfg"
const HEARTBEAT_PATH := "user://modloader_heartbeat.txt"
const PASS2_DIRTY_PATH := "user://modloader_pass2_dirty"
const SAFE_MODE_FILE := "modloader_safe_mode"
const DISABLED_FILE := "modloader_disabled"
const MAX_RESTART_COUNT := 2

const HOOK_PACK_DIR := "user://modloader_hooks"
# Hook pack filename: "<prefix>_<timestamp_ms>.zip". A fresh filename per
# _generate_hook_pack call sidesteps ProjectSettings.load_resource_pack's
# path-dedup (a same-path re-mount is a no-op and the VFS keeps stale file
# offsets from the original mount -- FileAccess reads return prior-session
# bytes even though ZIPPacker rewrote the file on disk). Different filename
# = new mount = fresh offsets. Orphan files from prior sessions are cleaned
# up at static-init in _mount_previous_session before any mount happens.
const HOOK_PACK_PREFIX := "framework_pack"
const HOOK_PACK_MOUNT_BASE := "res://modloader_hooks"
const VANILLA_CACHE_DIR := "user://modloader_hooks/vanilla"
const MODWORKSHOP_VERSIONS_URL := "https://api.modworkshop.net/mods/versions"
const MODWORKSHOP_DOWNLOAD_URL_TEMPLATE := "https://api.modworkshop.net/mods/%s/download"
const MODWORKSHOP_BATCH_SIZE := 100
const API_CHECK_TIMEOUT := 15.0
const API_DOWNLOAD_TIMEOUT := 30.0

const PRIORITY_MIN := -999
const PRIORITY_MAX := 999
const TRACKED_EXTENSIONS: Array[String] = ["gd", "tscn", "tres", "gdns", "gdnlib", "scn"]

# Scripts skipped from rewrite. Dispatch-wrapper overhead and set_script
# semantics break these specific use patterns. Inherited from tetra's original
# RTVLib skip_list and still applicable to the source-rewrite system:
# coroutines, short-lived effect instances, and @tool scripts all need to
# stay untouched to preserve game behavior.
const RTV_SKIP_LIST: Array[String] = [
	"TreeRenderer.gd",     # @tool script -- editor-only, no runtime hooks needed
	"MuzzleFlash.gd",      # 50ms flash effect -- dispatch overhead breaks timing
	"Hit.gd",              # per-shot instantiated -- overhead compounds under fire
	"ParticleInstance.gd", # GPUParticles3D -- set_script corrupts draw_passes array
	"Message.gd",          # await-based _ready -- dispatch wrapper doesn't await super, kills coroutine
	"Mine.gd",             # queue_free after detonation -- wrapper lifecycle breaks timing
	"Explosion.gd",        # await + @onready -- coroutine dies, particles don't emit
]

# Resource scripts serialized to user:// -- wrapping breaks save files.
# ResourceSaver embeds the script path; saves would become mod-dependent.
const RTV_RESOURCE_SERIALIZED_SKIP: Array[String] = [
	"CharacterSave.gd", "ContainerSave.gd", "FurnitureSave.gd",
	"ItemSave.gd", "Preferences.gd", "ShelterSave.gd",
	"SlotData.gd", "SwitchSave.gd", "TraderSave.gd",
	"Validator.gd", "WorldSave.gd",
]

# Resource scripts loaded from res:// only -- no hook point needed.
# Mods should hook the call sites instead of wrapping the data class.
const RTV_RESOURCE_DATA_SKIP: Array[String] = [
	"AIWeaponData.gd", "AttachmentData.gd", "AudioEvent.gd", "AudioLibrary.gd",
	"CasetteData.gd", "CatData.gd", "EventData.gd", "Events.gd",
	"FishingData.gd", "FurnitureData.gd", "GrenadeData.gd",
	"InstrumentData.gd", "ItemData.gd", "KnifeData.gd", "LootTable.gd",
	"RecipeData.gd", "Recipes.gd",
	"SpawnerChunkData.gd", "SpawnerData.gd", "SpawnerSceneData.gd",
	"SpineData.gd", "TaskData.gd", "TrackData.gd",
	"TraderData.gd", "WeaponData.gd",
]

# Engine lifecycle methods are always void; codegen uses this list to pick
# the void template regardless of return-type detection.
const RTV_ENGINE_VOID_METHODS: Array[String] = [
	"_ready", "_process", "_physics_process", "_input",
	"_unhandled_input", "_unhandled_key_input",
	"_enter_tree", "_exit_tree", "_notification",
]

var _mods_dir: String = ""
var _developer_mode := false
var _active_profile := "Default"
var _ui_window: Window = null
# Bottom-bar label used as a makeshift status hint because Godot's native
# tooltips get layered behind our always_on_top launcher and aren't visible.
var _ui_hint_label: Label = null
# Launch button kept on self so refresh_launch_button_label can reach it
# from the mod-enable toggle handler.
var _ui_launch_btn: Button = null
var _has_loaded := false
var _last_mod_txt_status := "none"
# Detailed parse-failure diagnostic written by _parse_mod_txt when ConfigFile
# rejects mod.txt. Plumbed into UI warnings + boot-log messages so authors
# see *which* line/section broke instead of a generic "Invalid mod" prompt.
# Empty when status != "parse_error".
var _last_mod_txt_error := ""
var _database_replaced_by := ""
# Post-boot UI re-open state. _boot_complete flips true once Pass 1 / Pass 2 /
# single-pass finish paths finalize. Once true, any mutation of mod_config.cfg
# via the launcher UI sets _dirty_since_boot, which the main-menu reopen flow
# uses to decide whether to restart on UI close.
var _boot_complete: bool = false
var _dirty_since_boot: bool = false

var _ui_mod_entries: Array[Dictionary] = []
# profile_keys for folder mods that exist on disk but were skipped from entries
# because developer mode is off. Orphan-scan treats these as present so
# disabling dev mode doesn't spam the UI with false "missing" rows for dev
# mods the user still has installed.
var _hidden_folder_profile_keys: Dictionary = {}
var _hidden_folder_ids: Dictionary = {}
var _pending_autoloads: Array[Dictionary] = []
var _report_lines: Array[String] = []
var _loaded_mod_ids: Dictionary = {}
var _registered_autoload_names: Dictionary = {}
var _override_registry: Dictionary = {}
var _mod_script_analysis: Dictionary = {}
var _archive_file_sets: Dictionary = {}

# Hook registry. Hook names are "<scriptname>-<methodname>[-pre|-post|-callback]",
# lowercase. A bare name (no suffix) is a replace hook (first-wins).
signal frameworks_ready
var _hooks: Dictionary = {}              # hook_name -> Array of {callback, priority, id}
# Dev-mode-only: per-hook_base dispatch counter. Incremented inside each
# wrapper AFTER the _any_mod_hooked short-circuit when _developer_mode is
# true. Summary at 30s timer in _activate_rewritten_scripts pinpoints
# runaway method calls (e.g. connect-already-connected error spam from a
# _ready firing thousands of times).
var _dispatch_counts: Dictionary = {}
# Fast-path short-circuit: flipped true the first time any mod calls hook().
# Dispatch wrappers skip the full _wrapper_active/_caller/_dispatch path
# when no mod has hooked anything at all. Sticky -- stays true once set.
# Same approach as godot-mod-loader's `_ModLoaderHooks.any_mod_hooked`.
var _any_mod_hooked: bool = false
var _next_id: int = 1
var _skip_super: bool = false
var _seq: int = 0
var _caller: Node = null                 # public: source node of the current dispatch
var _is_ready: bool = false              # public: true once frameworks_ready has emitted
# Step C re-entry guard: Set of hook_base currently executing a dispatch
# wrapper. When a rewritten mod script's wrapper fires, then its body calls
# super() into vanilla's wrapper, the vanilla wrapper sees the base already
# active and skips dispatch (just runs its body). Prevents double-fire when
# rewritten subclass scripts chain into rewritten vanilla.
var _wrapper_active: Dictionary = {}

# Class + script enumeration state (populated from PCK parse at boot).
var _class_name_to_path: Dictionary = {} # "Camera" -> "res://Scripts/Camera.gd"
var _all_game_script_paths: Array[String] = []  # populated by _enumerate_game_scripts from PCK parse; DirAccess can't list PCK contents in 4.6
var _pck_zero_byte_paths: Dictionary = {}  # res_path -> true for entries the base game PCK ships as 0-byte (e.g. CasettePlayer.gd in RTV 4.6.1). Populated by _parse_pck_file_list; checked by detokenize + hook-gen to skip silently. These files are not hookable and any vanilla or mod preload() of them will fail at engine level -- not a modloader bug.
var _scripts_with_scene_preloads: Dictionary = {}  # filename -> PackedStringArray of scene paths; scripts listed here are deferred from eager load+reload in _activate_rewritten_scripts. Rationale: their module-scope preload() fires at parse time; if we force-load them before mod autoloads run overrideScript(), scenes bake Script ext_resources to the pre-override vanilla. take_over_path then orphans those refs and instantiate() produces nodes with vanilla body, not mod body. Deferring to lazy-compile lets mod overrides run first -- the preload chain fires via extends resolution during mod's own overrideScript call, AFTER take_over_path took effect for prior targets. VFS mount precedence still serves our rewrite on lazy-load.

# Script overrides
var _pending_script_overrides: Array[Dictionary] = []  # {vanilla_path, mod_script_path, mod_name, priority}
var _applied_script_overrides: Dictionary = {}         # vanilla_path -> true

# Opt-in declarations (v3.0.1 cutover). Populated by the [hooks] parser in
# mod_loading.gd and by .hook() call scanning. Drives the wrap surface in
# _generate_hook_pack. If both are empty AND _any_mod_declared_registry is
# false, _generate_hook_pack early-returns and no hook pack is produced --
# the modlist behaves byte-identical to pre-hook-system (v2.1.0) behavior.
var _hooked_methods: Dictionary = {}             # res_path -> {method_name: true}
var _any_mod_declared_registry: bool = false     # set by [registry] parser

var _re_take_over: RegEx
var _re_extends: RegEx
var _re_extends_classname: RegEx
var _re_class_name: RegEx
var _re_func: RegEx
var _re_preload: RegEx
var _re_filename_priority: RegEx
var _re_hook_call: RegEx

# Rewriter regex (compiled in _rtv_compile_codegen_regex)
var _rtv_re_extends: RegEx
var _rtv_re_class_name: RegEx
var _rtv_re_func: RegEx
var _rtv_re_static_func: RegEx
var _rtv_re_var: RegEx

# Mounts previous session's archives at file-scope (before _ready) so autoloads
# that load after ModLoader can resolve their res:// paths.
# Returns a dict keyed by the archive path as it appears in pass state -- used
# by _process_mod_candidate to skip redundant re-mounts that would clobber our
# own overlay overrides applied at static init (e.g. hook pack for mod scripts).
var _filescope_mounted: Dictionary = _mount_previous_session()

## ----- logging.gd -----
## Thin logging helpers used by every domain. Each helper both emits via
## Godot's print/push_* and appends to _report_lines for the conflict report.

func _log_info(msg: String) -> void:
	var line := "[ModLoader][Info] " + msg
	print(line)
	_report_lines.append(line)

func _log_warning(msg: String) -> void:
	var line := "[ModLoader][Warning] " + msg
	push_warning(line)
	_report_lines.append(line)

func _log_critical(msg: String) -> void:
	var line := "[ModLoader][Critical] " + msg
	push_error(line)
	_report_lines.append(line)

func _log_debug(msg: String) -> void:
	if not _developer_mode:
		return
	var line := "[ModLoader][Debug] " + msg
	print(line)
	_report_lines.append(line)

## ----- fs_archive.gd -----
## File and archive helpers. No game-specific logic; just disk I/O, zip
## packing/unpacking, mod.txt parsing, and path normalization. Used by most
## other domains.

# Copies a .vmz to the cache dir as .zip (same content, different extension)
# so ZIPReader can open it. Returns the cached zip path, or "" on failure.
# Re-extracts if the source .vmz is newer than the cache.
static func _static_vmz_to_zip(vmz_path: String) -> String:
	var cache_dir := ProjectSettings.globalize_path(TMP_DIR)
	if not DirAccess.dir_exists_absolute(cache_dir):
		DirAccess.make_dir_recursive_absolute(cache_dir)
	var zip_name := vmz_path.get_file().get_basename() + ".zip"
	var zip_path := cache_dir.path_join(zip_name)
	if FileAccess.file_exists(zip_path):
		# Re-extract if source is newer than cache (mod was updated in-place).
		var src_time := FileAccess.get_modified_time(vmz_path)
		var zip_time := FileAccess.get_modified_time(zip_path)
		if src_time <= zip_time:
			return zip_path
	var src := FileAccess.open(vmz_path, FileAccess.READ)
	if src == null:
		return ""
	var dst := FileAccess.open(zip_path, FileAccess.WRITE)
	if dst == null:
		src.close()
		return ""
	while src.get_position() < src.get_length():
		dst.store_buffer(src.get_buffer(65536))
	src.close()
	dst.close()
	return zip_path

# Prints all log lines AND dumps them to user://modloader_filescope.log for
# post-mortem inspection. Called from _mount_previous_session after the static
# init pass finishes (before any normal logging is wired up).
static func _write_filescope_log(lines: PackedStringArray) -> void:
	for line in lines:
		print(line)
	var f := FileAccess.open("user://modloader_filescope.log", FileAccess.WRITE)
	if f:
		for line in lines:
			f.store_line(line)
		f.close()

# Reads override.cfg and returns lines for sections OTHER than [autoload] and
# [autoload_prepend]. Used to preserve user/game settings ([display], [input],
# etc.) when rewriting autoload sections.
static func _read_preserved_cfg_sections(cfg_path: String) -> String:
	if not FileAccess.file_exists(cfg_path):
		return ""
	var f := FileAccess.open(cfg_path, FileAccess.READ)
	if f == null:
		return ""
	var text := f.get_as_text()
	f.close()
	var result := PackedStringArray()
	var in_autoload_section := false
	for line in text.split("\n"):
		var stripped := line.strip_edges()
		if stripped.begins_with("["):
			var section := stripped.to_lower()
			in_autoload_section = section == "[autoload]" or section == "[autoload_prepend]"
			if not in_autoload_section:
				result.append(line)
			continue
		if not in_autoload_section and stripped != "":
			result.append(line)
	var preserved := "\n".join(result).strip_edges()
	if preserved.is_empty():
		return ""
	return "\n" + preserved + "\n"

# Converts zip-relative paths to res:// paths for tracked file extensions.
# Returns "" for paths we don't want to track (hidden files, mod.txt, etc.)
func _normalize_to_res_path(zip_path: String) -> String:
	var path := zip_path.replace("\\", "/")
	if path.begins_with("res://"):   return path
	if path.begins_with("/"):        return "res:/" + path
	if path.begins_with(".") or path == "mod.txt": return ""
	if path.get_extension().to_lower() in TRACKED_EXTENSIONS:
		return "res://" + path
	return ""

# Mount a .pck or .vmz archive via ProjectSettings.load_resource_pack, with
# vmz->zip caching for .vmz files. Resolves .remap entries in the archive
# after a successful mount so preload()/load() calls targeting original
# .tscn/.tres paths work (load_resource_pack doesn't follow remaps).
func _try_mount_pack(path: String) -> bool:
	if ProjectSettings.load_resource_pack(path):
		_resolve_remaps(path)
		return true
	if path.get_extension().to_lower() != "vmz":
		return false
	var zip_path := _static_vmz_to_zip(path)
	if not zip_path.is_empty() and ProjectSettings.load_resource_pack(zip_path):
		_resolve_remaps(zip_path)
		return true
	return false

# Resolve .remap files in a mounted archive so preload()/load() work with
# the original .tscn/.tres paths (load_resource_pack doesn't follow remaps).
func _resolve_remaps(archive_path: String) -> void:
	var remap_count := _static_resolve_remaps(archive_path)
	if remap_count > 0:
		_log_debug("  Resolved %d .remap file(s)" % remap_count)

# Same as _resolve_remaps but static, callable from _mount_previous_session
# at static-init time (before the node has instance state).
static func _static_resolve_remaps(archive_path: String) -> int:
	var zr := ZIPReader.new()
	if zr.open(archive_path) != OK:
		return 0

	var count := 0
	for f: String in zr.get_files():
		if not f.ends_with(".remap"):
			continue
		var remap_bytes := zr.read_file(f)
		if remap_bytes.is_empty():
			continue
		var cfg := ConfigFile.new()
		if cfg.parse(remap_bytes.get_string_from_utf8()) != OK:
			continue
		var target: String = cfg.get_value("remap", "path", "")
		if target.is_empty():
			continue
		# Skip remaps pointing to .godot/exported/ bakes. Mods like MCM ship
		# their own .godot cache with pre-compiled scenes; eagerly take_over_path'ing
		# those before mod scripts register causes UID resolution failures that
		# break the mod's UI. Godot resolves these lazily via the .remap files
		# when actually needed. Credit: tetrahydroc.
		if target.begins_with("res://.godot/exported/"):
			continue
		var original_path := f.trim_suffix(".remap")
		if not original_path.begins_with("res://"):
			original_path = "res://" + original_path
		var res: Resource = load(target)
		if res != null:
			res.take_over_path(original_path)
			count += 1
	zr.close()
	return count

# mod.txt parser

func read_mod_config(path: String) -> ConfigFile:
	_last_mod_txt_status = "none"
	# Reset diagnostic alongside status: paths below (empty mod.txt, missing
	# mod.txt, ZIPReader open failure) set parse_error without going through
	# _parse_mod_txt, so without this reset the prior mod's error message
	# would leak into the next mod's launcher warning.
	_last_mod_txt_error = ""
	var zr := ZIPReader.new()
	if zr.open(path) != OK:
		return null
	if not zr.file_exists("mod.txt"):
		# Nested mod.txt (e.g. "SubFolder/mod.txt") means bad packaging.
		for f: String in zr.get_files():
			if f.get_file() == "mod.txt":
				_last_mod_txt_status = "nested:" + f
				zr.close()
				return null
		zr.close()
		return null
	var raw := zr.read_file("mod.txt")
	zr.close()
	if raw.size() == 0:
		_last_mod_txt_status = "parse_error"
		return null
	var text := raw.get_string_from_utf8()
	var cfg := _parse_mod_txt(text)
	if cfg == null:
		_last_mod_txt_status = "parse_error"
		return null
	_last_mod_txt_status = "ok"
	return cfg

func read_mod_config_folder(folder_path: String) -> ConfigFile:
	_last_mod_txt_status = "none"
	_last_mod_txt_error = ""  # see read_mod_config for rationale
	var mod_txt_path := folder_path.path_join("mod.txt")
	if not FileAccess.file_exists(mod_txt_path):
		return null
	var f := FileAccess.open(mod_txt_path, FileAccess.READ)
	if f == null:
		return null
	var text := f.get_as_text()
	f.close()
	var cfg := _parse_mod_txt(text)
	if cfg == null:
		_last_mod_txt_status = "parse_error"
		return null
	_last_mod_txt_status = "ok"
	return cfg

func _parse_mod_txt(text: String) -> ConfigFile:
	_last_mod_txt_error = ""
	if text.begins_with("\uFEFF"):
		text = text.substr(1)
	# Tolerate the wiki-documented [hooks] form. The Hooks/Mod-Format wiki
	# pages show entries like:
	#     res://Scripts/Interface.gd = _ready, update_tooltip
	#     res://Scripts/Controller.gd = *
	# but Godot's ConfigFile uses the Variant parser for values, which
	# rejects unquoted identifier lists, bare `*`, and top-level commas.
	# Authors following the docs verbatim hit a parse_error and the generic
	# "Invalid mod -- try re-downloading" prompt. Quote-wrapping the values
	# before cfg.parse() lets the wiki form land as a string and downstream
	# code (mod_loading.gd's [hooks] reader) handles the comma-split itself.
	# Already-quoted entries pass through unchanged.
	var preprocessed := _quote_unquoted_hooks_values(text)
	var cfg := ConfigFile.new()
	if cfg.parse(preprocessed) != OK:
		# The Variant-parser failure code from cfg.parse() doesn't carry the
		# offending line number. Walk the source per-line to locate it; the
		# diagnostic flows through _last_mod_txt_error -> mod_txt_error on
		# the entry -> launcher warning + boot log so authors see the broken
		# section/line instead of a generic "re-download" hint.
		_last_mod_txt_error = _diagnose_parse_failure(preprocessed)
		return null
	# Godot's ConfigFile drops empty sections, so a bare `[registry]` header
	# with no body gets silently dropped and cfg.has_section("registry") returns
	# false. [registry] is a presence-signal section (its body is parsed by
	# per-kind registry handlers at call sites, not mod-load time), so an empty
	# header is the common legitimate form. Scan the raw text for the header
	# and stash a sentinel key so has_section picks it up downstream.
	for line in text.split("\n"):
		var stripped := line.strip_edges()
		if stripped == "[registry]" and not cfg.has_section("registry"):
			cfg.set_value("registry", "_modloader_header_present", true)
			break
	return cfg

# Quote the values of unquoted entries inside [hooks] sections. Wiki examples
# document `path = method1, method2` / `path = *` / `path =` -- all rejected
# by ConfigFile's Variant parser. Wrap unquoted right-hand-sides in double
# quotes so they parse as plain strings; mod_loading.gd's [hooks] handler
# already comma-splits and lowercases the result.
#
# Already-quoted values (the AI Overhaul pattern) pass through verbatim so
# we don't change behavior for mods that got the syntax right. Inline
# `# comment` / `; comment` on these lines is stripped before wrapping --
# Variant parser eats it natively for raw values, but once we quote the
# right-hand side a trailing comment becomes part of the string.
func _quote_unquoted_hooks_values(text: String) -> String:
	var lines := text.split("\n")
	var out := PackedStringArray()
	var in_hooks := false
	for line in lines:
		var stripped := line.strip_edges()
		# Section header: track whether we just entered/left [hooks].
		if stripped.begins_with("[") and stripped.ends_with("]"):
			in_hooks = stripped.to_lower() == "[hooks]"
			out.append(line)
			continue
		if not in_hooks:
			out.append(line)
			continue
		if stripped.is_empty() or stripped.begins_with("#") or stripped.begins_with(";"):
			out.append(line)
			continue
		var eq_pos := line.find("=")
		if eq_pos < 0:
			out.append(line)
			continue
		var key_part := line.substr(0, eq_pos)
		var val_part := line.substr(eq_pos + 1)
		if val_part.strip_edges(true, false).begins_with("\""):
			# Already quoted -- ConfigFile + Variant parser handle it.
			out.append(line)
			continue
		var comment := ""
		var comment_pos := -1
		for j in val_part.length():
			var ch := val_part[j]
			if ch == "#" or ch == ";":
				comment_pos = j
				break
		if comment_pos >= 0:
			comment = val_part.substr(comment_pos)
			val_part = val_part.substr(0, comment_pos)
		var val_trim := val_part.strip_edges()
		var escaped := val_trim.replace("\\", "\\\\").replace("\"", "\\\"")
		var rebuilt := "%s= \"%s\"" % [key_part, escaped]
		if not comment.is_empty():
			rebuilt += "  " + comment
		out.append(rebuilt)
	return "\n".join(out)

# Locate the first line that ConfigFile.parse() would reject. Used only on
# the failure path -- the per-line probe is O(N) parses but only fires when
# the mod is already broken, and the result lets the launcher tell authors
# *which* line/section to look at instead of "Invalid mod, re-download".
func _diagnose_parse_failure(text: String) -> String:
	var current_section := ""
	var line_num := 0
	for line in text.split("\n"):
		line_num += 1
		var stripped := line.strip_edges()
		if stripped.is_empty() or stripped.begins_with("#") or stripped.begins_with(";"):
			continue
		if stripped.begins_with("[") and stripped.ends_with("]"):
			current_section = stripped.substr(1, stripped.length() - 2)
			continue
		var probe := ConfigFile.new()
		var header := ""
		if current_section != "":
			header = "[%s]\n" % current_section
		if probe.parse(header + line + "\n") != OK:
			var section_label := ("[%s]" % current_section) if current_section != "" else "(no section)"
			return "line %d %s: %s" % [line_num, section_label, _truncate_for_log(stripped)]
	# Fall-through: per-line probes all passed but the full parse failed.
	# Could happen with a section-header / multi-line value interaction we
	# don't model. Return a generic locator so the user at least knows we
	# detected the failure but couldn't pin the line.
	return "could not pin line (full parse failed but per-line probes passed)"

func _truncate_for_log(s: String) -> String:
	if s.length() <= 80:
		return s
	return s.substr(0, 77) + "..."

# Folder -> temp zip (developer mode). Zips a mod's source folder to a temp
# .zip in the cache dir so it can be mounted like any other archive.

func zip_folder_to_temp(folder_path: String) -> String:
	var folder_name := folder_path.get_file()
	var tmp_zip_path := ProjectSettings.globalize_path(TMP_DIR).path_join(
			folder_name + "_dev.zip")
	var zp := ZIPPacker.new()
	if zp.open(tmp_zip_path) != OK:
		_log_critical("Failed to create temp zip: " + tmp_zip_path)
		return ""
	# Zip contents without a top-level wrapper -- the folder's internal structure
	# already mirrors the res:// paths the mod expects.
	_zip_folder_recursive(zp, folder_path, "")
	zp.close()
	return tmp_zip_path

func _zip_folder_recursive(zp: ZIPPacker, disk_path: String, archive_prefix: String) -> void:
	var dir := DirAccess.open(disk_path)
	if dir == null:
		return
	dir.list_dir_begin()
	while true:
		var entry := dir.get_next()
		if entry == "":
			break
		if entry.begins_with("."):
			continue
		var full := disk_path.path_join(entry)
		var arc_path := entry if archive_prefix == "" else archive_prefix.path_join(entry)
		if dir.current_is_dir():
			_zip_folder_recursive(zp, full, arc_path)
		else:
			var data := FileAccess.get_file_as_bytes(full)
			zp.start_file(arc_path)
			zp.write_file(data)
			zp.close_file()
	dir.list_dir_end()

## ----- boot.gd -----
## Static-init boot layer. Runs at script load time (before _ready) via
## _mount_previous_session. Owns the two-pass archive mount, override.cfg
## rewriting, pass state persistence, heartbeat + crash recovery, safe mode,
## and the hook-pack preload that preempts Godot's PCK-bytecode pinning for
## class_name scripts.

static func _is_modloader_disabled() -> bool:
	# Check for sentinel file in the game exe directory. When present, ModLoader
	# skips all work: no archives mount, no UI shows, no autoloads instantiate.
	# Use this as a nuclear escape hatch when modloader itself is broken or the
	# user wants guaranteed vanilla behavior without navigating the UI.
	var exe_dir := OS.get_executable_path().get_base_dir()
	return FileAccess.file_exists(exe_dir.path_join(DISABLED_FILE))

# Force all persistent state back to a vanilla baseline: clean override.cfg,
# delete pass state, wipe the hook pack directory. Safe to call when any of
# these artifacts are missing. Shared cleanup for the disabled sentinel,
# crashed-Pass-2 recovery, and (via instance wrapper) the UI reset button.
static func _static_force_vanilla_state(reason: String, log_lines: PackedStringArray) -> void:
	log_lines.append("[FileScope] RESET (" + reason + "): forcing vanilla state")
	_static_reset_override_cfg(log_lines)
	if FileAccess.file_exists(PASS_STATE_PATH):
		DirAccess.remove_absolute(ProjectSettings.globalize_path(PASS_STATE_PATH))
		log_lines.append("[FileScope] RESET (" + reason + "): wiped pass state")
	if FileAccess.file_exists(PASS2_DIRTY_PATH):
		DirAccess.remove_absolute(ProjectSettings.globalize_path(PASS2_DIRTY_PATH))
		log_lines.append("[FileScope] RESET (" + reason + "): cleared pass2 dirty marker")
	_static_wipe_hook_cache()
	log_lines.append("[FileScope] RESET (" + reason + "): wiped hook pack")

static func _mount_previous_session() -> Dictionary:
	var mounted: Dictionary = {}
	var log_lines: PackedStringArray = []
	log_lines.append("[FileScope] _mount_previous_session() starting")

	# Nuclear escape hatch: sentinel file in game dir skips everything and
	# resets persistent state so next launch is clean vanilla. This boot may
	# log errors about failed mod autoloads (override.cfg was read before we
	# got here), but the reset takes effect for the NEXT launch.
	if _is_modloader_disabled():
		_static_force_vanilla_state("modloader_disabled sentinel", log_lines)
		_write_filescope_log(log_lines)
		return mounted

	# Crashed Pass 2 recovery: if the dirty marker survived, the previous
	# Pass 2 was interrupted before cleanup (force-quit, crash, power loss).
	# Hook pack may be half-written; pass state + override.cfg reference a
	# state we can't trust. Full wipe forces Pass 1 to regenerate cleanly.
	if FileAccess.file_exists(PASS2_DIRTY_PATH):
		_static_force_vanilla_state("pass 2 crashed mid-run", log_lines)
		_write_filescope_log(log_lines)
		return mounted

	# Pinned probes narrowed (v3.0.1): previously a hardcoded list of 16
	# class_name scripts the game pre-compiles at boot. Now read from the
	# pass_state's hook_pack_wrapped_paths key -- only scripts this modlist
	# actually wrapped get CACHE_MODE_IGNORE preempt. Populated further
	# down after pass_state loads; the cache-snapshot diagnostic now logs
	# whatever the prior session wrapped.

	var cfg := ConfigFile.new()
	if cfg.load(PASS_STATE_PATH) != OK:
		log_lines.append("[FileScope] No pass state file -- skipping")
		_write_filescope_log(log_lines)
		return mounted
	# Wipe stale state from a different modloader version (format may have changed).
	# Also reset override.cfg -- prior version may have written [autoload_prepend]
	# entries for mods that are no longer enabled, causing Godot to fail loading
	# their scripts before modloader's _ready even runs.
	var saved_ver: String = cfg.get_value("state", "modloader_version", "")
	if saved_ver != MODLOADER_VERSION:
		log_lines.append("[FileScope] Version mismatch: saved=%s current=%s -- wiping" % [saved_ver, MODLOADER_VERSION])
		# Wipe hook cache along with pass state. Rewriter output semantics
		# may have changed across versions (e.g. 3.0.0 -> 3.0.1 changed the
		# opt-in gate + per-method wrap mask shape); any stale framework_pack
		# still on disk must not get mounted. Pass 1 regenerates a fresh
		# pack from the current modlist. Mirrors the exe_mtime wipe below.
		_static_wipe_hook_cache()
		DirAccess.remove_absolute(ProjectSettings.globalize_path(PASS_STATE_PATH))
		_static_reset_override_cfg(log_lines)
		_write_filescope_log(log_lines)
		return mounted
	# Detect game updates -- exe mtime change means vanilla scripts may have changed.
	var saved_exe_mtime: int = cfg.get_value("state", "exe_mtime", 0)
	if saved_exe_mtime != 0:
		var current_exe_mtime := FileAccess.get_modified_time(OS.get_executable_path())
		if current_exe_mtime != saved_exe_mtime:
			log_lines.append("[FileScope] Game exe mtime changed -- wiping hook cache")
			# Game updated -- wipe hook cache so Pass 1 regenerates from fresh vanilla.
			_static_wipe_hook_cache()
			DirAccess.remove_absolute(ProjectSettings.globalize_path(PASS_STATE_PATH))
			_static_reset_override_cfg(log_lines)
			_write_filescope_log(log_lines)
			return mounted
	var paths: PackedStringArray = cfg.get_value("state", "archive_paths", PackedStringArray())
	if paths.is_empty():
		log_lines.append("[FileScope] Pass state has no archive paths -- skipping")
		_write_filescope_log(log_lines)
		return mounted

	log_lines.append("[FileScope] %d archive path(s) in pass state" % paths.size())

	# Were any archives deleted since last session?
	var any_missing := false
	var any_stale := false
	for path in paths:
		var abs_path := path if not path.begins_with("res://") and not path.begins_with("user://") \
				else ProjectSettings.globalize_path(path)
		if FileAccess.file_exists(abs_path):
			log_lines.append("[FileScope]   EXISTS: " + abs_path)
			continue
		# VMZ source gone -- check if the zip cache survived.
		if abs_path.get_extension().to_lower() == "vmz":
			var cache_dir := ProjectSettings.globalize_path(TMP_DIR)
			var cached := cache_dir.path_join(abs_path.get_file().get_basename() + ".zip")
			if FileAccess.file_exists(cached):
				log_lines.append("[FileScope]   STALE (vmz gone, cache ok): " + abs_path)
				any_stale = true
				continue
		log_lines.append("[FileScope]   MISSING: " + abs_path)
		any_missing = true

	if any_missing:
		log_lines.append("[FileScope] Archive(s) missing -- resetting to clean state")
		# Archive gone, no cache. Wipe override.cfg autoload sections so the next
		# boot is clean, but preserve any non-autoload settings ([display], etc.).
		var exe_dir := OS.get_executable_path().get_base_dir()
		var cfg_path := exe_dir.path_join("override.cfg")
		var preserved := _read_preserved_cfg_sections(cfg_path)
		var f := FileAccess.open(cfg_path, FileAccess.WRITE)
		if f:
			f.store_string("[autoload_prepend]\nModLoader=\"*" + MODLOADER_RES_PATH + "\"\n\n[autoload]\n\n" + preserved)
			f.close()
		var state_path := ProjectSettings.globalize_path(PASS_STATE_PATH)
		if FileAccess.file_exists(state_path):
			DirAccess.remove_absolute(state_path)
		_write_filescope_log(log_lines)
		return mounted

	if any_stale:
		# Source gone but cache works -- invalidate hash so Pass 1 rewrites state.
		cfg.set_value("state", "mods_hash", "")
		cfg.save(PASS_STATE_PATH)

	for path in paths:
		if ProjectSettings.load_resource_pack(path):
			var remaps := _static_resolve_remaps(path)
			log_lines.append("[FileScope]   MOUNTED: " + path
					+ (" (%d remaps)" % remaps if remaps > 0 else ""))
			mounted[path] = true
		elif path.get_extension().to_lower() == "vmz":
			var zip_path := _static_vmz_to_zip(path)
			if not zip_path.is_empty() and ProjectSettings.load_resource_pack(zip_path):
				var remaps := _static_resolve_remaps(zip_path)
				log_lines.append("[FileScope]   MOUNTED (vmz->zip): " + path
						+ (" (%d remaps)" % remaps if remaps > 0 else ""))
				mounted[path] = true
			else:
				log_lines.append("[FileScope]   MOUNT FAILED (vmz): " + path + " zip_path=" + zip_path)
		else:
			log_lines.append("[FileScope]   MOUNT FAILED: " + path)

	# Step D: mount the hook pack (Scripts/<Name>.gd + .gd.remap + empty
	# .gdc for each rewritten vanilla) at static init -- BEFORE any game
	# autoload compiles a class_name script. This is the only way to
	# rewire scripts Godot pre-compiles during class_cache population
	# (Camera, WeaponRig in the current mod set); source_code+reload and
	# CACHE_MODE_IGNORE+take_over_path both fail after class_cache pins
	# a compiled reference. Must mount AFTER mod archives so our
	# Scripts/*.gd entries win via replace_files=true.
	#
	# First-ever session: no pass_state entry, skip. Pass 1 will generate
	# and activate this session -- Camera/WeaponRig fall back to PCK
	# bytecode that first run. Second session onward: pre-mount works.
	# No fallback by filename: per-session filenames mean a lost pass_state
	# entry leaves us with orphan files we can't distinguish. Let Pass 1
	# regenerate from scratch; the orphan-cleanup pass below sweeps them.
	var hook_pack: String = cfg.get_value("state", "hook_pack_path", "") as String
	var wrapped_paths: PackedStringArray = cfg.get_value("state", "hook_pack_wrapped_paths", PackedStringArray())
	# Orphan cleanup: previous sessions may have left framework_pack_*.zip
	# files behind (Windows can't delete the currently-mounted one mid-session).
	# At static-init the engine has mounted nothing yet, so deleting every pack
	# EXCEPT the one pass_state points at is safe. Prevents unbounded growth
	# for users cycling large mod sets over many sessions.
	_static_cleanup_orphan_hook_packs(hook_pack, log_lines)
	# Cache-snapshot diagnostic -- shows which wrapped scripts were already
	# loaded into ResourceLoader by Godot's eager class_cache pass before
	# we get a chance to preempt them. Useful for diagnosing "why didn't
	# my hook fire" on pinned paths. Skipped when no wrapped_paths exist.
	if wrapped_paths.size() > 0:
		var pre_cached_count := 0
		var pre_cached_tokenized: PackedStringArray = []
		var pre_cached_source: PackedStringArray = []
		var pre_notloaded: PackedStringArray = []
		for path in wrapped_paths:
			if ResourceLoader.has_cached(path):
				pre_cached_count += 1
				var s := load(path) as GDScript
				if s != null and s.source_code.length() > 0:
					pre_cached_source.append(path.get_file())
				else:
					pre_cached_tokenized.append(path.get_file())
			else:
				pre_notloaded.append(path.get_file())
		log_lines.append("[FileScope] PRE-INIT cache: %d/%d wrapped scripts already cached at static init" \
				% [pre_cached_count, wrapped_paths.size()])
		if pre_cached_tokenized.size() > 0:
			log_lines.append("[FileScope]   tokenized (PCK-compiled already): " + ", ".join(pre_cached_tokenized))
		if pre_cached_source.size() > 0:
			log_lines.append("[FileScope]   source-loaded (our take_over_path from prev session): " + ", ".join(pre_cached_source))
		if pre_notloaded.size() > 0:
			log_lines.append("[FileScope]   NOT YET LOADED (preempt window open): " + ", ".join(pre_notloaded))
	if hook_pack != "":
		var hook_abs: String = hook_pack if not hook_pack.begins_with("user://") \
				else ProjectSettings.globalize_path(hook_pack)
		if FileAccess.file_exists(hook_abs):
			if ProjectSettings.load_resource_pack(hook_abs, true):
				log_lines.append("[FileScope] HOOK PACK mounted at static init: " + hook_pack)
				# Preempt ONLY the scripts this modlist declared + wrapped
				# (v3.0.1). Previous behavior was to preempt a hardcoded list
				# of 16 class_name scripts regardless of whether a mod
				# touched them. Narrowing to wrapped_paths ensures legacy
				# modlists (zero declarations) never see static-init
				# preemption at all -- Godot's native lazy-compile runs
				# unmodified, byte-identical to v2.1.0 behavior.
				var hzr := ZIPReader.new()
				if hzr.open(hook_abs) == OK:
					var wrapped_set: Dictionary = {}
					for wp in wrapped_paths:
						wrapped_set[wp] = true
					var preloaded := 0
					var preload_failed := 0
					var skipped_lenient := 0
					for f: String in hzr.get_files():
						if not f.begins_with("Scripts/") or not f.ends_with(".gd"):
							continue
						var rpath := "res://" + f
						if not wrapped_set.has(rpath):
							# Not declared as a wrapped target -- skip strict
							# preempt. VFS mount (replace_files=true) still
							# serves our rewrite to Godot's lenient lazy-
							# compile when game code first loads the path.
							skipped_lenient += 1
							continue
						var scr := ResourceLoader.load(rpath, "", ResourceLoader.CACHE_MODE_IGNORE) as GDScript
						if scr == null or scr.source_code.is_empty():
							preload_failed += 1
							continue
						scr.take_over_path(rpath)
						preloaded += 1
					hzr.close()
					log_lines.append("[FileScope] HOOK PACK preempted %d wrapped script(s) at static init (%d failed, %d other vanilla left to lenient lazy-compile)" \
							% [preloaded, preload_failed, skipped_lenient])
			else:
				log_lines.append("[FileScope] HOOK PACK mount FAILED: " + hook_pack)
		else:
			log_lines.append("[FileScope] HOOK PACK path in pass_state but file missing: " + hook_abs)

	# TEST HOOK: mount the test pack here (static-init, before any autoload
	# runs) so VFS serves our rewritten scripts to the FIRST compilation.
	# Mount it AFTER mod archives so our entries win via replace_files=true.
	var test_pack_path := ProjectSettings.globalize_path("user://test_pack_precedence.zip")
	if FileAccess.file_exists(test_pack_path):
		if ProjectSettings.load_resource_pack(test_pack_path, true):
			log_lines.append("[FileScope] TEST: mounted test_pack_precedence.zip at static init")
		else:
			log_lines.append("[FileScope] TEST: FAILED to mount test_pack_precedence.zip")

	log_lines.append("[FileScope] Done -- %d archive(s) mounted" % mounted.size())
	_write_filescope_log(log_lines)
	return mounted

# Reset override.cfg to a clean state -- just [autoload] ModLoader + any
# preserved non-autoload sections. Used when pass state is wiped so stale
# [autoload_prepend] entries from prior launches don't crash the next boot
# by referencing scripts whose archive isn't file-scope-mounted.
static func _static_reset_override_cfg(log_lines: PackedStringArray) -> void:
	var exe_dir := OS.get_executable_path().get_base_dir()
	var cfg_path := exe_dir.path_join("override.cfg")
	if not FileAccess.file_exists(cfg_path):
		return
	var preserved := _read_preserved_cfg_sections(cfg_path)
	var f := FileAccess.open(cfg_path, FileAccess.WRITE)
	if f == null:
		log_lines.append("[FileScope] WARNING: could not rewrite override.cfg (read-only?)")
		return
	f.store_string("[autoload_prepend]\nModLoader=\"*" + MODLOADER_RES_PATH + "\"\n\n[autoload]\n\n" + preserved)
	f.close()
	log_lines.append("[FileScope] override.cfg reset to clean [autoload_prepend] state")

static func _static_cleanup_orphan_hook_packs(keep_path: String, log_lines: PackedStringArray) -> void:
	# Delete every framework_pack_*.zip in HOOK_PACK_DIR except keep_path.
	# Called at static-init BEFORE any hook-pack mount, so the VFS holds no
	# handles to these files. Safe to delete them on every platform. If
	# keep_path is empty (no pass_state entry, or no hook pack yet) every
	# file matching the pattern is treated as orphan.
	var pack_dir := ProjectSettings.globalize_path(HOOK_PACK_DIR)
	if not DirAccess.dir_exists_absolute(pack_dir):
		return
	var keep_abs := ProjectSettings.globalize_path(keep_path) if keep_path != "" else ""
	var dir := DirAccess.open(pack_dir)
	if dir == null:
		return
	dir.list_dir_begin()
	var removed := 0
	while true:
		var fname := dir.get_next()
		if fname == "":
			break
		if not fname.begins_with(HOOK_PACK_PREFIX) or not fname.ends_with(".zip"):
			continue
		var full := pack_dir.path_join(fname)
		if keep_abs != "" and full == keep_abs:
			continue
		DirAccess.remove_absolute(full)
		removed += 1
	dir.list_dir_end()
	if removed > 0:
		log_lines.append("[FileScope] Cleaned %d orphan hook pack(s) from prior session(s)" % removed)

static func _static_wipe_hook_cache() -> void:
	# Wipe every Framework*.gd we previously generated (cheap to regenerate)
	# and every framework_pack_*.zip (per-session hook packs). On Windows,
	# a zip currently mounted by Godot's VFS may refuse deletion (open handle);
	# the orphan-cleanup pass in _mount_previous_session catches stragglers
	# on the next fresh-engine launch.
	var pack_dir := ProjectSettings.globalize_path(HOOK_PACK_DIR)
	if DirAccess.dir_exists_absolute(pack_dir):
		var pdir := DirAccess.open(pack_dir)
		if pdir != null:
			pdir.list_dir_begin()
			while true:
				var pname := pdir.get_next()
				if pname == "":
					break
				if pname.begins_with("Framework") and pname.ends_with(".gd"):
					DirAccess.remove_absolute(pack_dir.path_join(pname))
				elif pname.begins_with(HOOK_PACK_PREFIX) and pname.ends_with(".zip"):
					DirAccess.remove_absolute(pack_dir.path_join(pname))
			pdir.list_dir_end()
	var cache_dir := ProjectSettings.globalize_path(VANILLA_CACHE_DIR)
	if not DirAccess.dir_exists_absolute(cache_dir):
		return
	var dir := DirAccess.open(cache_dir)
	if dir == null:
		return
	dir.list_dir_begin()
	var file_name := dir.get_next()
	while file_name != "":
		var full := cache_dir.path_join(file_name)
		if dir.current_is_dir():
			# Shallow -- vanilla cache is only Scripts/*.gd (one level deep)
			var sub := DirAccess.open(full)
			if sub:
				sub.list_dir_begin()
				var sub_file := sub.get_next()
				while sub_file != "":
					DirAccess.remove_absolute(full.path_join(sub_file))
					sub_file = sub.get_next()
				sub.list_dir_end()
			DirAccess.remove_absolute(full)
		else:
			DirAccess.remove_absolute(full)
		file_name = dir.get_next()
	dir.list_dir_end()
	DirAccess.remove_absolute(cache_dir)

func _build_autoload_sections() -> Dictionary:
	# Wipe previous early-autoload extractions so stale scripts don't linger.
	_clean_early_autoload_dir()
	var prepend: Array[Dictionary] = []
	var append: Array[Dictionary] = []
	for entry in _pending_autoloads:
		if entry.get("is_early", false):
			var path: String = entry["path"]
			var disk_path := _ensure_early_autoload_on_disk(path, entry.get("mod_name", ""))
			prepend.append({ "name": entry["name"], "path": disk_path })
		else:
			append.append({ "name": entry["name"], "path": entry["path"] })
	return { "prepend": prepend, "append": append }

const EARLY_AUTOLOAD_DIR := "user://modloader_early"

func _clean_early_autoload_dir() -> void:
	var dir_path := ProjectSettings.globalize_path(EARLY_AUTOLOAD_DIR)
	if not DirAccess.dir_exists_absolute(dir_path):
		return
	var dir := DirAccess.open(dir_path)
	if dir == null:
		return
	# Simple recursive wipe -- this directory is entirely modloader-managed.
	dir.list_dir_begin()
	while true:
		var entry := dir.get_next()
		if entry == "":
			break
		var full: String = dir_path.path_join(entry)
		if dir.current_is_dir():
			var sub := DirAccess.open(full)
			if sub:
				sub.list_dir_begin()
				var sub_file := sub.get_next()
				while sub_file != "":
					DirAccess.remove_absolute(full.path_join(sub_file))
					sub_file = sub.get_next()
				sub.list_dir_end()
			DirAccess.remove_absolute(full)
		else:
			DirAccess.remove_absolute(full)
	dir.list_dir_end()

# Extract an early autoload .gd script to disk if it only exists inside a
# mounted archive.  Godot opens [autoload_prepend] scripts before file-scope
# code runs, so archive-only scripts must be on disk for the restart.
# Scene autoloads (.tscn) are handled by file-scope mounting -- returned as-is.
func _ensure_early_autoload_on_disk(res_path: String, mod_name: String) -> String:
	var global := ProjectSettings.globalize_path(res_path)
	if FileAccess.file_exists(global):
		return res_path

	# Only .gd scripts need extraction -- scenes resolve via file-scope mount.
	var script := load(res_path) as GDScript
	if script == null or not script.has_source_code():
		return res_path

	var rel := res_path.trim_prefix("res://")
	var disk_dir := ProjectSettings.globalize_path(EARLY_AUTOLOAD_DIR)
	var target := disk_dir.path_join(rel)
	DirAccess.make_dir_recursive_absolute(target.get_base_dir())
	var f := FileAccess.open(target, FileAccess.WRITE)
	if f == null:
		_log_critical("Cannot write early autoload to disk: " + target + " [" + mod_name + "]")
		return res_path
	f.store_string(script.source_code)
	f.close()

	# Return as user:// path so Godot finds it without archive mounting.
	var user_path := EARLY_AUTOLOAD_DIR.path_join(rel)
	_log_info("  Extracted early autoload to disk: " + user_path + " [" + mod_name + "]")
	return user_path

func _collect_enabled_archive_paths() -> PackedStringArray:
	var paths := PackedStringArray()
	var candidates: Array[Dictionary] = []
	for entry in _ui_mod_entries:
		if not entry["enabled"]:
			continue
		candidates.append(entry.duplicate())
	candidates.sort_custom(_compare_load_order)
	for c in candidates:
		if c["ext"] == "folder":
			# Folder mods are zipped to a temp cache during load_all_mods().
			# Store the temp zip path -- the folder itself can't be mounted.
			var folder_name: String = c["full_path"].get_file()
			var tmp_zip := ProjectSettings.globalize_path(TMP_DIR).path_join(
					folder_name + "_dev.zip")
			if FileAccess.file_exists(tmp_zip):
				paths.append(tmp_zip)
			else:
				_log_warning("Folder mod '%s' has no cached zip -- skipping from pass state"
						% c["mod_name"])
			continue
		paths.append(c["full_path"])
	return paths

# Uses FileAccess instead of ConfigFile (which erases null keys).
# ModLoader listed last in [autoload_prepend] = loaded first (reverse insertion).
func _write_override_cfg(prepend_autoloads: Array[Dictionary]) -> Error:
	var exe_dir := OS.get_executable_path().get_base_dir()
	var path := exe_dir.path_join("override.cfg")
	var tmp := path + ".tmp"
	var preserved := _read_preserved_cfg_sections(path)
	var lines := PackedStringArray()
	# Always put ModLoader in [autoload_prepend] (last = loaded first via
	# reverse insertion). Without this, when no mods use the "!" prefix,
	# ModLoader falls into plain [autoload] and some game autoloads
	# (Database, GameData, Loader, Simulation) load before our class-level
	# static init runs -- pinning their .gdc bytecode before our hook pack
	# can preempt them.
	lines.append("[autoload_prepend]")
	for entry in prepend_autoloads:
		lines.append('%s="*%s"' % [entry["name"], entry["path"]])
	lines.append('ModLoader="*' + MODLOADER_RES_PATH + '"')
	lines.append("")
	lines.append("[autoload]")
	lines.append("")
	var f := FileAccess.open(tmp, FileAccess.WRITE)
	if f == null:
		return FileAccess.get_open_error()
	f.store_string("\n".join(lines) + "\n" + preserved)
	f.close()
	# Windows DirAccess.rename() won't overwrite -- remove target first.
	if FileAccess.file_exists(path):
		DirAccess.remove_absolute(path)
	var dir := DirAccess.open(exe_dir)
	if dir == null:
		DirAccess.remove_absolute(tmp)
		return ERR_CANT_OPEN
	var err := dir.rename(tmp.get_file(), path.get_file())
	if err != OK:
		DirAccess.remove_absolute(tmp)
	return err

func _persist_hook_pack_state(pack_path: String, wrapped_paths: PackedStringArray = PackedStringArray()) -> void:
	# Write hook_pack_path + wrapped_paths to pass_state so the next session
	# (1) mounts the pack at static init and (2) preempts ONLY the declared
	# scripts in _mount_previous_session's class_cache-pinning path.
	# Piggybacks on the existing pass_state ConfigFile -- doesn't overwrite
	# other keys.
	var cfg := ConfigFile.new()
	cfg.load(PASS_STATE_PATH)  # OK if missing; we populate below
	cfg.set_value("state", "hook_pack_path", pack_path)
	cfg.set_value("state", "hook_pack_wrapped_paths", wrapped_paths)
	# Store exe mtime alongside so _mount_previous_session's existing
	# exe-mtime check also invalidates the hook pack on game updates.
	cfg.set_value("state", "hook_pack_exe_mtime", FileAccess.get_modified_time(OS.get_executable_path()))
	if cfg.get_value("state", "modloader_version", "") == "":
		cfg.set_value("state", "modloader_version", MODLOADER_VERSION)
	if cfg.save(PASS_STATE_PATH) == OK:
		_log_info("[RTVCodegen] Persisted hook pack path for next-session static-init mount: %s (%d wrapped path(s))" \
				% [pack_path.get_file(), wrapped_paths.size()])

func _write_pass_state(archive_paths: PackedStringArray, state_hash: String = "") -> Error:
	var cfg := ConfigFile.new()
	cfg.load(PASS_STATE_PATH)
	var count: int = cfg.get_value("state", "restart_count", 0)
	cfg.set_value("state", "restart_count", count + 1)
	cfg.set_value("state", "mods_hash", state_hash)
	cfg.set_value("state", "archive_paths", archive_paths)
	cfg.set_value("state", "modloader_version", MODLOADER_VERSION)
	cfg.set_value("state", "exe_mtime", FileAccess.get_modified_time(OS.get_executable_path()))
	cfg.set_value("state", "timestamp", Time.get_unix_time_from_system())
	# Persist script overrides so Pass 2 can apply them without re-parsing mods.
	var override_data: Array = []
	for entry in _pending_script_overrides:
		override_data.append(entry.duplicate())
	cfg.set_value("state", "script_overrides", override_data)
	var err := cfg.save(PASS_STATE_PATH)
	if err != OK:
		_log_critical("Failed to save pass state (error %d)" % err)
	return err

func _compute_state_hash(archive_paths: PackedStringArray, prepend_autoloads: Array[Dictionary]) -> String:
	if archive_paths.is_empty() and prepend_autoloads.is_empty():
		return ""
	var parts := PackedStringArray()
	var sorted_paths := Array(archive_paths)
	sorted_paths.sort()
	for p in sorted_paths:
		# Include mtime so replacing a file with the same name triggers a restart.
		parts.append("a:%s@%d" % [p, FileAccess.get_modified_time(p)])
	for entry in prepend_autoloads:
		parts.append("p:%s=%s" % [entry["name"], entry["path"]])
	for entry in _ui_mod_entries:
		if entry["enabled"] and entry.get("cfg") != null:
			var ver: String = (entry["cfg"] as ConfigFile).get_value("mod", "version", "")
			if not ver.is_empty():
				parts.append("v:%s=%s" % [entry["mod_id"], ver])
	for entry in _pending_script_overrides:
		parts.append("so:%s=%s" % [entry["vanilla_path"], entry["mod_script_path"]])
	parts.append("ml:" + MODLOADER_VERSION)
	# Include modloader.gd's mtime so any rebuild of the loader itself
	# triggers a restart, even when the mod set is unchanged. Rationale:
	# _finish_with_existing_mounts regenerates the hook pack in place on
	# a process that already has the old pack mounted. ZIPPacker.open
	# rewrites the file but ProjectSettings.load_resource_pack dedupes by
	# path (see lifecycle.gd comment), so the re-mount is a no-op and the
	# VFS keeps the OLD mount's cached file offsets. If the new pack's
	# entry layout differs from the old pack's (common when the rewriter
	# changes between builds), every read of a moved entry fails at
	# file_access_zip.cpp:141 (unzGoToFilePos on a stale offset). Forcing
	# a restart on modloader rebuild means Pass 2's fresh engine mounts
	# the new pack with a fresh index -- no stale cache to fight.
	var self_mtime: int = FileAccess.get_modified_time("res://modloader.gd")
	if self_mtime > 0:
		parts.append("ml_mtime:%d" % self_mtime)
	return "\n".join(parts).md5_text()

func _write_heartbeat() -> void:
	var f := FileAccess.open(HEARTBEAT_PATH, FileAccess.WRITE)
	if f:
		f.store_string("started:%d" % Time.get_unix_time_from_system())
		f.close()

func _delete_heartbeat() -> void:
	if FileAccess.file_exists(HEARTBEAT_PATH):
		DirAccess.remove_absolute(ProjectSettings.globalize_path(HEARTBEAT_PATH))

func _check_crash_recovery() -> void:
	if not FileAccess.file_exists(HEARTBEAT_PATH):
		return
	_log_warning("Heartbeat detected -- previous launch may have crashed")
	var cfg := ConfigFile.new()
	if cfg.load(PASS_STATE_PATH) == OK:
		var count: int = cfg.get_value("state", "restart_count", 0)
		if count >= MAX_RESTART_COUNT:
			_log_critical("Restart loop (%d crashes) -- resetting to clean state" % count)
			_restore_clean_override_cfg()
			DirAccess.remove_absolute(ProjectSettings.globalize_path(PASS_STATE_PATH))
			_delete_heartbeat()
			return
	_delete_heartbeat()

func _check_safe_mode() -> void:
	var exe_dir := OS.get_executable_path().get_base_dir()
	var safe_path := exe_dir.path_join(SAFE_MODE_FILE)
	if not FileAccess.file_exists(safe_path):
		return
	_log_warning("Safe mode file detected -- resetting to clean state")
	_restore_clean_override_cfg()
	if FileAccess.file_exists(PASS_STATE_PATH):
		DirAccess.remove_absolute(ProjectSettings.globalize_path(PASS_STATE_PATH))
	_delete_heartbeat()
	DirAccess.remove_absolute(safe_path)

func _clean_stale_cache() -> void:
	# Remove cached zips whose source .vmz / folder no longer exists in the mods dir.
	var cache_dir := ProjectSettings.globalize_path(TMP_DIR)
	if not DirAccess.dir_exists_absolute(cache_dir):
		return
	var dir := DirAccess.open(cache_dir)
	if dir == null:
		return
	dir.list_dir_begin()
	while true:
		var fname := dir.get_next()
		if fname == "":
			break
		if fname.get_extension().to_lower() != "zip":
			continue
		var base := fname.get_basename()
		if base.ends_with("_dev"):
			# Folder mod cache -- check if the source folder still exists.
			var folder_name := base.substr(0, base.length() - 4)
			if DirAccess.dir_exists_absolute(_mods_dir.path_join(folder_name)):
				continue
		else:
			# VMZ cache -- check if the source .vmz still exists.
			var vmz_name := base + ".vmz"
			if FileAccess.file_exists(_mods_dir.path_join(vmz_name)):
				continue
		DirAccess.remove_absolute(cache_dir.path_join(fname))
		_log_debug("Removed stale cache: " + fname)
	dir.list_dir_end()

func _restore_clean_override_cfg() -> void:
	var exe_dir := OS.get_executable_path().get_base_dir()
	var path := exe_dir.path_join("override.cfg")
	var preserved := _read_preserved_cfg_sections(path)
	var f := FileAccess.open(path, FileAccess.WRITE)
	if f == null:
		_log_critical("Cannot write override.cfg -- game dir may be read-only: " + exe_dir)
		return
	f.store_string("[autoload_prepend]\nModLoader=\"*" + MODLOADER_RES_PATH + "\"\n\n[autoload]\n\n" + preserved)
	f.close()

func _clear_restart_counter() -> void:
	var cfg := ConfigFile.new()
	if cfg.load(PASS_STATE_PATH) == OK:
		cfg.set_value("state", "restart_count", 0)
		cfg.save(PASS_STATE_PATH)

## ----- security_scan.gd -----
## Lightweight guardrail. Reads each file inside a candidate mod
## (zip/vmz, pck, or developer-mode folder) WITHOUT mounting it and looks
## for combinations of GDScript patterns that are nearly diagnostic of
## known malware: obfuscated string decoding paired with process
## spawning, anti-debug crashes, ransomware-setup calls.
##
## This is NOT a virus scanner. It catches the lazy / copy-paste attacks
## (see the Road to Vostok dropper that motivated this branch); a
## determined attacker with the modloader source can evade specific
## patterns. Loading is never blocked. Only mods that hit a red trigger
## get a "suspicious code" tag in the launcher and a confirmation
## dialog at Launch time.

# Source files we run regex content scans on (GDScript text + text-form
# Godot resources that can embed inline GDScript).
const _TEXT_SCAN_EXTS: Dictionary = {
	"gd": true, "tscn": true, "tres": true, "gdshader": true,
}

# Binary resources that may embed compiled GDScript or string payloads.
# Byte-scanned for ASCII substrings of binary-safe rule patterns.
const _BINARY_SCAN_EXTS: Dictionary = {
	"scn": true, "res": true,
}

# Cap findings per mod so a deliberately-noisy archive can't bury the UI.
const _MAX_FINDINGS_PER_MOD: int = 50

# Cap individual file size we will fully scan. Almost no legitimate mod
# script is over a couple hundred KB.
const _MAX_TEXT_SCAN_BYTES: int = 8 * 1024 * 1024

# Rules. Each rule's individual presence is not a warning by itself --
# the user only sees a badge when compute_risk_level() returns RISK_RED,
# which fires for solo red triggers (os_crash, disable_save_safety) or
# for combinations (obfuscation + process spawn, runtime-code-build +
# process spawn, both obfuscation patterns together).
const _SECURITY_RULES: Array = [
	# --- Process spawning (combo with obfuscation/runtime_code -> red) ----
	{
		"id": "os_execute",
		"pattern": "\\bOS\\.execute\\s*\\(",
		"description": "Runs a system command via OS.execute.",
		"binary": true,
	},
	{
		"id": "os_create_process",
		"pattern": "\\bOS\\.create_process\\s*\\(",
		"description": "Spawns a process via OS.create_process.",
		"binary": true,
	},
	{
		"id": "os_create_instance",
		"pattern": "\\bOS\\.create_instance\\s*\\(",
		"description": "Spawns another copy of the game via OS.create_instance, with custom CLI args.",
		"binary": true,
	},
	{
		"id": "os_shell_open",
		# Skip http/https URL literals -- those go through the browser
		# (e.g. mods linking to their modworkshop page).
		"pattern": "\\bOS\\.shell_open\\s*\\((?!\\s*\"https?://)",
		"description": "Calls OS.shell_open on a path or URI (not an http(s) URL). The OS handler decides what to launch.",
		"binary": false,
	},
	{
		"id": "os_kill",
		"pattern": "\\bOS\\.kill\\s*\\(",
		"description": "Terminates a process by PID via OS.kill.",
		"binary": true,
	},
	# --- Solo red triggers (almost zero legit use) ------------------------
	{
		"id": "os_crash",
		"pattern": "\\bOS\\.crash\\s*\\(",
		"description": "Forces an engine crash via OS.crash. Used by malware as anti-debug or to defeat scanners.",
		"binary": true,
	},
	{
		"id": "disable_save_safety",
		"pattern": "\\bOS\\.set_use_file_access_save_and_swap\\s*\\(\\s*false\\b",
		"description": "Disables Godot's atomic-write save protection. No legitimate use in a mod.",
		"binary": true,
	},
	# --- Runtime code build (combo with process spawn -> red) -------------
	{
		"id": "expression_eval",
		"pattern": "\\bExpression\\.new\\s*\\(",
		"description": "Parses and runs GDScript-flavored code from a string at runtime via Expression.new().",
		"binary": true,
	},
	{
		"id": "script_from_string",
		"pattern": "\\.set_source_code\\s*\\(",
		"description": "Builds a GDScript from a runtime string via .set_source_code(). The source isn't part of the shipped files.",
		"binary": true,
	},
	{
		"id": "deserialize_objects",
		"pattern": "\\b(?:bytes_to_var_with_objects|var_to_bytes_with_objects|str_to_var)\\s*\\(",
		"description": "Uses bytes_to_var_with_objects / str_to_var. These rebuild Object instances from bytes (including any attached scripts).",
		"binary": true,
	},
	{
		"id": "marshalls_objects_decode",
		"pattern": "\\bMarshalls\\.base64_to_variant\\s*\\([^)]*\\btrue\\b",
		"description": "Uses Marshalls.base64_to_variant with allow_objects=true. Reconstructs Object instances from a base64 string.",
		"binary": true,
	},
	# --- Obfuscation signatures (combo with each other or spawn -> red) ---
	{
		"id": "byte_decode_loop",
		# `for <ident> in <expr>:` ... `<acc> += char(<ident>)` -- the
		# string-decoding loop attackers use to hide the real argument
		# passed to OS.execute or FileAccess.
		"pattern": "for\\s+\\w+\\s+in[^:]{1,200}:[\\s\\S]{0,200}?\\+=\\s*(?:char|String\\.chr)\\s*\\(",
		"description": "Contains a byte-array decode loop (`for c in bytes: acc += char(c)`). This pattern is often used to obfuscate string literals.",
		"binary": false,
	},
	{
		"id": "large_int_array",
		# 16+ comma-separated numeric literals in a single literal -- the
		# encoded-payload shape that almost always pairs with byte_decode_loop.
		"pattern": "\\[\\s*\\d+(?:\\s*,\\s*\\d+){15,}",
		"description": "Contains a large integer literal (16+ entries). Often appears alongside obfuscated string-decoding loops.",
		"binary": false,
	},
]

# Risk level for a mod's combined findings: clean or red. There is no
# middle tier -- the UI shows nothing for clean findings (even if
# individual rules matched) and a red "suspicious code" tag with a
# launch-time confirmation dialog when red triggers fire.
const RISK_CLEAN := 0
const RISK_RED := 2

# Rules that, alone, push the mod to red. Almost zero legit use cases.
const _RED_SOLO_RULES: Dictionary = {
	"os_crash": true,
	"disable_save_safety": true,
}

# Rule families used in red combination logic.
const _PROCESS_SPAWN_RULES: Array = [
	"os_execute", "os_create_process", "os_create_instance",
	"os_shell_open", "os_kill",
]
const _OBFUSCATION_RULES: Array = [
	"byte_decode_loop", "large_int_array",
]
const _RUNTIME_CODE_RULES: Array = [
	"script_from_string", "marshalls_objects_decode",
	"deserialize_objects", "expression_eval",
]

# Returns RISK_CLEAN or RISK_RED.
#
# Red fires on:
#   - any RED_SOLO rule present
#   - both obfuscation rules together (encoded payload pattern)
#   - obfuscation + process spawn (decoded execute)
#   - runtime-code-build + process spawn (constructed execute)
# Otherwise: clean. Findings are still stored on the entry for logging
# but the user sees nothing.
func compute_risk_level(findings: Array) -> int:
	if findings.is_empty():
		return RISK_CLEAN
	var present: Dictionary = {}
	for f: Dictionary in findings:
		present[str(f.get("rule", ""))] = true
	for solo in _RED_SOLO_RULES:
		if present.has(solo):
			return RISK_RED
	if present.has("byte_decode_loop") and present.has("large_int_array"):
		return RISK_RED
	var has_obf := _any_present(present, _OBFUSCATION_RULES)
	var has_spawn := _any_present(present, _PROCESS_SPAWN_RULES)
	var has_runtime := _any_present(present, _RUNTIME_CODE_RULES)
	if has_obf and has_spawn:
		return RISK_RED
	if has_runtime and has_spawn:
		return RISK_RED
	return RISK_CLEAN

func _any_present(present: Dictionary, rules: Array) -> bool:
	for r in rules:
		if present.has(r):
			return true
	return false

# Compiled regex cache, indexed by rule id. Lazy-populated on first scan.
var _security_compiled: Dictionary = {}

func _security_compile_rules() -> void:
	if not _security_compiled.is_empty():
		return
	for rule: Dictionary in _SECURITY_RULES:
		var re := RegEx.new()
		if re.compile(str(rule["pattern"])) == OK:
			_security_compiled[rule["id"]] = re
		else:
			_log_warning("[SecurityScan] Failed to compile rule pattern: " + str(rule["id"]))

# Top-level entry. Returns Array[Dictionary] of findings for the mod.
# Empty array = clean. Each finding dict shape:
#   {rule, file, line, preview, description}
func scan_mod(full_path: String, ext: String) -> Array:
	_security_compile_rules()
	var findings: Array = []
	match ext:
		"vmz", "zip":
			_security_scan_zip(full_path, findings)
		"pck":
			_security_scan_pck(full_path, findings)
		"folder":
			_security_scan_folder(full_path, "", findings)
	findings.sort_custom(_security_sort_findings)
	return findings

# Sort by file then line so the log reads top-to-bottom.
func _security_sort_findings(a: Dictionary, b: Dictionary) -> bool:
	var fa: String = a.get("file", "")
	var fb: String = b.get("file", "")
	if fa != fb:
		return fa < fb
	return int(a.get("line", 0)) < int(b.get("line", 0))

func _security_scan_zip(zip_path: String, findings: Array) -> void:
	var zr := ZIPReader.new()
	if zr.open(zip_path) != OK:
		return
	for f: String in zr.get_files():
		if findings.size() >= _MAX_FINDINGS_PER_MOD:
			break
		if f == "mod.txt" or f.ends_with("/mod.txt"):
			continue
		var ext := f.get_extension().to_lower()
		if _TEXT_SCAN_EXTS.has(ext):
			var bytes := zr.read_file(f)
			if bytes.size() > _MAX_TEXT_SCAN_BYTES:
				continue
			_security_scan_text(f, bytes.get_string_from_utf8(), findings)
			continue
		if _BINARY_SCAN_EXTS.has(ext) or ext == "gdc":
			_security_scan_binary(f, zr.read_file(f), findings)
	zr.close()

func _security_scan_folder(root: String, rel: String, findings: Array) -> void:
	var dir := DirAccess.open(root.path_join(rel))
	if dir == null:
		return
	dir.list_dir_begin()
	while findings.size() < _MAX_FINDINGS_PER_MOD:
		var entry := dir.get_next()
		if entry == "":
			break
		if entry.begins_with("."):
			continue
		var rel_path := entry if rel == "" else rel.path_join(entry)
		if dir.current_is_dir():
			_security_scan_folder(root, rel_path, findings)
			continue
		if entry == "mod.txt":
			continue
		var ext := entry.get_extension().to_lower()
		var disk_path := root.path_join(rel_path)
		if _TEXT_SCAN_EXTS.has(ext):
			var bytes := FileAccess.get_file_as_bytes(disk_path)
			if bytes.size() > _MAX_TEXT_SCAN_BYTES:
				continue
			_security_scan_text(rel_path, bytes.get_string_from_utf8(), findings)
			continue
		if _BINARY_SCAN_EXTS.has(ext) or ext == "gdc":
			_security_scan_binary(rel_path,
					FileAccess.get_file_as_bytes(disk_path), findings)
	dir.list_dir_end()

func _security_scan_pck(pck_path: String, findings: Array) -> void:
	var entries := _security_pck_list_with_offsets(pck_path)
	if entries.is_empty():
		return
	var f := FileAccess.open(pck_path, FileAccess.READ)
	if f == null:
		return
	for entry: Dictionary in entries:
		if findings.size() >= _MAX_FINDINGS_PER_MOD:
			break
		var path: String = entry["path"]
		var ext := path.get_extension().to_lower()
		if not (_TEXT_SCAN_EXTS.has(ext) or _BINARY_SCAN_EXTS.has(ext) or ext == "gdc"):
			continue
		f.seek(int(entry["offset"]))
		var bytes := f.get_buffer(int(entry["size"]))
		if _TEXT_SCAN_EXTS.has(ext):
			if bytes.size() > _MAX_TEXT_SCAN_BYTES:
				continue
			_security_scan_text(path, bytes.get_string_from_utf8(), findings)
		else:
			_security_scan_binary(path, bytes, findings)
	f.close()

func _security_scan_text(file: String, text: String, findings: Array) -> void:
	if text.is_empty():
		return
	# Strip GDScript line comments before matching so docstrings mentioning
	# API names don't false-positive. Newlines preserved so line numbers
	# stay accurate; preview is pulled from the original (uncommented) text.
	var stripped := _strip_gdscript_comments(text)
	var orig_lines := text.split("\n")
	for rule: Dictionary in _SECURITY_RULES:
		if findings.size() >= _MAX_FINDINGS_PER_MOD:
			return
		var re: RegEx = _security_compiled.get(rule["id"], null)
		if re == null:
			continue
		var m := re.search(stripped)
		if m == null:
			continue
		var pre := stripped.substr(0, m.get_start())
		var line := pre.count("\n") + 1
		var preview := ""
		if line - 1 < orig_lines.size():
			preview = (orig_lines[line - 1] as String).strip_edges()
			if preview.length() > 120:
				preview = preview.substr(0, 117) + "..."
		findings.append({
			"rule": rule["id"],
			"file": file,
			"line": line,
			"preview": preview,
			"description": rule["description"],
		})

# Strip GDScript line comments (# ...) before pattern matching. Tracks
# string-literal state so a # inside "..." isn't treated as a comment
# start. Newlines preserved so line-number arithmetic stays correct.
func _strip_gdscript_comments(text: String) -> String:
	if text.is_empty():
		return text
	var out := PackedStringArray()
	for line in text.split("\n"):
		out.append(_strip_line_comment(line))
	return "\n".join(out)

func _strip_line_comment(line: String) -> String:
	var in_str := ""
	var prev := ""
	for i in line.length():
		var c := line[i]
		if in_str != "":
			if c == in_str and prev != "\\":
				in_str = ""
		elif c == "\"" or c == "'":
			in_str = c
		elif c == "#":
			return line.substr(0, i)
		prev = c
	return line

# Byte-search inside binary resources or .gdc files. Only runs rules
# marked `binary: true` -- those whose match is unique enough not to
# false-positive on legit binary serialized content.
func _security_scan_binary(file: String, bytes: PackedByteArray, findings: Array) -> void:
	if bytes.is_empty():
		return
	var as_text := bytes.get_string_from_utf8()
	if as_text.is_empty():
		var out := PackedByteArray()
		for b in bytes:
			if b >= 32 and b < 127:
				out.append(b)
			else:
				out.append(0x20)
		as_text = out.get_string_from_ascii()
	for rule: Dictionary in _SECURITY_RULES:
		if not bool(rule.get("binary", false)):
			continue
		if findings.size() >= _MAX_FINDINGS_PER_MOD:
			return
		var re: RegEx = _security_compiled.get(rule["id"], null)
		if re == null:
			continue
		if re.search(as_text) == null:
			continue
		findings.append({
			"rule": rule["id"],
			"file": file,
			"line": 0,
			"preview": "(matched in binary file)",
			"description": rule["description"],
		})

# PCK file-table parser that also returns per-entry offset+size so the
# scanner can read individual blobs without mounting the pck.
func _security_pck_list_with_offsets(pck_path: String) -> Array:
	const MAGIC_GDPC: int = 0x43504447  # "GDPC"
	const PACK_DIR_ENCRYPTED := 1
	const PACK_FORMAT_V2 := 2
	const PACK_FORMAT_V3 := 3
	var result: Array = []
	var f := FileAccess.open(pck_path, FileAccess.READ)
	if f == null:
		return result
	var magic: int = f.get_32()
	if magic != MAGIC_GDPC:
		f.close()
		return result
	var version: int = f.get_32()
	if version < PACK_FORMAT_V2 or version > PACK_FORMAT_V3:
		f.close()
		return result
	f.get_32(); f.get_32(); f.get_32()
	var pack_flags: int = f.get_32()
	f.get_64()
	if version == PACK_FORMAT_V3:
		f.seek(f.get_64())
	else:
		for i in 16:
			f.get_32()
	if pack_flags & PACK_DIR_ENCRYPTED:
		f.close()
		return result
	var file_count: int = f.get_32()
	for i in file_count:
		var path_len: int = f.get_32()
		if path_len == 0 or path_len > 4096:
			break
		var path := f.get_buffer(path_len).get_string_from_utf8()
		var offset: int = f.get_64()
		var size: int = f.get_64()
		f.get_buffer(16)
		f.get_32()
		if not path.is_empty():
			result.append({"path": path, "offset": offset, "size": size})
	f.close()
	return result

## ----- mod_discovery.gd -----
## Scans the mods directory, parses mod.txt metadata, builds the ordered list
## of mod entries, and handles ModWorkshop update checking + downloads.
## Everything here feeds the UI (which renders the list) and the loading
## phase (which mounts + applies them).

func collect_mod_metadata() -> Array[Dictionary]:
	var entries: Array[Dictionary] = []
	_mods_dir = OS.get_executable_path().get_base_dir().path_join(MOD_DIR)
	_log_info("Scanning mods dir: " + _mods_dir)
	DirAccess.make_dir_recursive_absolute(_mods_dir)
	var dir := DirAccess.open(_mods_dir)
	if dir == null:
		_log_critical("Failed to open mods dir: " + _mods_dir
				+ " (error " + str(DirAccess.get_open_error()) + ")")
		return entries
	var seen: Dictionary = {}
	var skipped_files: Array[String] = []
	_hidden_folder_profile_keys.clear()
	_hidden_folder_ids.clear()
	dir.list_dir_begin()
	while true:
		var entry_name := dir.get_next()
		if entry_name == "":
			break
		if dir.current_is_dir():
			if entry_name.begins_with("."):
				continue
			if _developer_mode:
				if not seen.has(entry_name):
					seen[entry_name] = true
					entries.append(_build_folder_entry(_mods_dir, entry_name))
			else:
				# Record the folder's profile_key so orphan-detection knows the
				# mod is still on disk, just filtered. Without this, disabling
				# dev mode would flag every installed dev folder as "missing".
				_record_hidden_folder(_mods_dir, entry_name)
			continue
		var ext := entry_name.get_extension().to_lower()
		if ext not in ["vmz", "zip", "pck"]:
			skipped_files.append(entry_name)
			continue
		if seen.has(entry_name):
			continue
		seen[entry_name] = true
		entries.append(_build_archive_entry(_mods_dir, entry_name, ext))
	dir.list_dir_end()
	if skipped_files.size() > 0:
		_log_debug("Skipped " + str(skipped_files.size()) + " non-mod file(s) in mods dir:")
		for sf in skipped_files:
			_log_debug("  " + sf + "  (not .vmz/.pck)")
	if entries.size() == 0:
		_log_warning("No mods found in: " + _mods_dir)
	else:
		_log_info("Found " + str(entries.size()) + " mod(s):")
		for e in entries:
			var tag := " [folder]" if e["ext"] == "folder" else ""
			_log_info("  " + e["file_name"] + " (" + e["mod_name"] + ")" + tag)
	return entries

func _build_archive_entry(mods_dir: String, file_name: String, ext: String) -> Dictionary:
	# Breadcrumb: identifies the mod Godot's UTF-8 C++ warning (printed
	# unconditionally for non-UTF8 bytes in mod.txt / .gd / pck paths) is
	# about to complain about. Without this the user sees "Unicode parsing
	# error, some characters were replaced with ..." and can't tell which
	# mod tripped it.
	_log_info("[ModScan] inspecting " + file_name)
	var full_path := mods_dir.path_join(file_name)
	if ext == "pck":
		_last_mod_txt_status = "pck"
	var cfg: ConfigFile = read_mod_config(full_path) if ext != "pck" else null
	var entry := _entry_from_config(cfg, file_name, full_path, ext)
	entry["warnings"] = _build_entry_warnings(entry)
	entry["security_findings"] = scan_mod(full_path, ext)
	entry["risk_level"] = compute_risk_level(entry["security_findings"])
	_log_security_findings(entry)
	return entry

func _build_folder_entry(mods_dir: String, dir_name: String) -> Dictionary:
	# See _build_archive_entry for rationale.
	_log_info("[ModScan] inspecting " + dir_name + " [folder]")
	var folder_path := mods_dir.path_join(dir_name)
	var cfg: ConfigFile = read_mod_config_folder(folder_path)
	var entry := _entry_from_config(cfg, dir_name, folder_path, "folder")
	entry["warnings"] = _build_entry_warnings(entry)
	entry["security_findings"] = scan_mod(folder_path, "folder")
	entry["risk_level"] = compute_risk_level(entry["security_findings"])
	_log_security_findings(entry)
	return entry

# Track a folder mod that's on disk but excluded from _ui_mod_entries because
# developer mode is off. Lets the orphan scan tell "dev-filtered" apart from
# "truly deleted" when rendering the missing-from-profile list.
func _record_hidden_folder(mods_dir: String, dir_name: String) -> void:
	var folder_path := mods_dir.path_join(dir_name)
	var cfg: ConfigFile = read_mod_config_folder(folder_path)
	var entry := _entry_from_config(cfg, dir_name, folder_path, "folder")
	_hidden_folder_profile_keys[entry["profile_key"]] = true
	if not entry["profile_key"].begins_with("zip:"):
		_hidden_folder_ids[entry["mod_id"]] = true

# Surface scanner findings in the boot log alongside the discovery summary.
# Logged at DEBUG -- findings are *disclosures* of "this mod uses these
# notable APIs", not warnings of malice. The UI surfaces the same data
# as a tappable "Uses N notable APIs" indicator on the mod row; the user
# decides whether the mod's stated purpose matches what it actually does.
# Dev mode enables this dump for deep investigation; regular users
# already see the relevant information in the UI.
func _log_security_findings(entry: Dictionary) -> void:
	var findings: Array = entry.get("security_findings", [])
	if findings.is_empty():
		return
	_log_debug("[ModScan] %s uses %d notable API(s)" \
			% [entry["file_name"], findings.size()])
	for f: Dictionary in findings:
		var loc: String = f["file"]
		if int(f.get("line", 0)) > 0:
			loc += ":" + str(f["line"])
		_log_debug("  %s @ %s -- %s" \
				% [f["rule"], loc, f.get("preview", "")])

func _entry_from_config(cfg: ConfigFile, file_name: String, full_path: String, ext: String) -> Dictionary:
	var mod_name := file_name
	var mod_id   := file_name
	var version  := ""
	var priority := 0
	var has_mod_id := false

	# VostokMods compat: parse "100-ModName.vmz" filename priority prefix.
	# The prefix is stripped from mod_name/mod_id defaults and used as fallback priority.
	var base_name := file_name.get_basename()  # strip extension
	var filename_priority := 0
	var has_filename_priority := false
	if _re_filename_priority:
		var m := _re_filename_priority.search(base_name)
		if m:
			filename_priority = int(m.get_string(1))
			base_name = m.get_string(2)
			has_filename_priority = true
			mod_name = base_name
			mod_id   = base_name

	if cfg:
		mod_name = str(cfg.get_value("mod", "name", mod_name))
		if cfg.has_section_key("mod", "id"):
			mod_id = str(cfg.get_value("mod", "id"))
			has_mod_id = true
		version = str(cfg.get_value("mod", "version", ""))
		if cfg.has_section_key("mod", "priority"):
			priority = int(str(cfg.get_value("mod", "priority")))
		elif has_filename_priority:
			priority = filename_priority
	elif has_filename_priority:
		priority = filename_priority
	priority = clampi(priority, PRIORITY_MIN, PRIORITY_MAX)

	# Profile key identifies the mod across ZIP renames. Uses "<id>@<version>"
	# when mod.txt declares an id (empty version allowed, yielding "<id>@"),
	# otherwise falls back to "zip:<file_name>" -- without a declared id the
	# filename is all we have and renames still orphan those profile entries.
	var profile_key := ("zip:" + file_name) if not has_mod_id else (mod_id + "@" + version)

	var entry := {
		"file_name": file_name, "full_path": full_path, "ext": ext,
		"mod_name": mod_name, "mod_id": mod_id, "version": version,
		"profile_key": profile_key,
		"priority": priority, "enabled": true,
		"cfg": cfg, "mod_txt_status": _last_mod_txt_status,
		"mod_txt_error": _last_mod_txt_error,
	}
	return entry

func _build_entry_warnings(entry: Dictionary) -> Array[String]:
	var warnings: Array[String] = []
	var ext: String = entry["ext"]
	if ext == "pck" or ext == "folder":
		return warnings
	var status: String = entry.get("mod_txt_status", "none")
	if status == "none":
		warnings.append("Invalid mod -- may not work correctly. Try re-downloading.")
	elif status == "parse_error":
		# Surface the line/section the parser tripped on so authors can
		# self-correct instead of staring at a generic "re-download" hint
		# when the real problem is a typo in their own mod.txt.
		var detail: String = entry.get("mod_txt_error", "")
		if detail.is_empty():
			warnings.append("Invalid mod -- mod.txt failed to parse. Try re-downloading.")
		else:
			warnings.append("mod.txt parse error at " + detail)
	elif status.begins_with("nested:"):
		warnings.append("Invalid mod -- packaged incorrectly. Try re-downloading.")
	return warnings

# Config persistence


func _compare_load_order(a: Dictionary, b: Dictionary) -> bool:
	if a["priority"] != b["priority"]:
		return a["priority"] < b["priority"]
	var a_name := (a["mod_name"] as String).to_lower()
	var b_name := (b["mod_name"] as String).to_lower()
	if a_name != b_name:
		return a_name < b_name
	# Filename tiebreaker for stable sort.
	return (a["file_name"] as String).to_lower() < (b["file_name"] as String).to_lower()

# Returns -1/0/1 for version comparison (a < b, equal, a > b).
func compare_versions(a: String, b: String) -> int:
	if a.is_empty() or b.is_empty():
		return 0 if a == b else (-1 if a.is_empty() else 1)
	var pa := a.lstrip("vV").split(".")
	var pb := b.lstrip("vV").split(".")
	var n: int = max(pa.size(), pb.size())
	for i in n:
		var sa := pa[i] if i < pa.size() else "0"
		var sb := pb[i] if i < pb.size() else "0"
		var va := int(sa) if sa.is_valid_int() else 0
		var vb := int(sb) if sb.is_valid_int() else 0
		if va < vb: return -1
		if va > vb: return 1
	return 0

func fetch_latest_modworkshop_versions(ids: Array[int]) -> Dictionary:
	var latest_versions := {}
	for chunk_ids in _chunk_int_array(ids, MODWORKSHOP_BATCH_SIZE):
		var req := HTTPRequest.new()
		req.timeout = API_CHECK_TIMEOUT
		add_child(req)
		var err := req.request(MODWORKSHOP_VERSIONS_URL,
			PackedStringArray(["Content-Type: application/json", "Accept: application/json"]),
			HTTPClient.METHOD_GET, JSON.stringify({"mod_ids": chunk_ids}))
		if err != OK:
			req.queue_free()
			continue

		var res: Array = await req.request_completed
		req.queue_free()
		if res[0] != HTTPRequest.RESULT_SUCCESS or res[1] < 200 or res[1] >= 300:
			continue
		var parsed = JSON.parse_string((res[3] as PackedByteArray).get_string_from_utf8())
		if parsed is Dictionary:
			latest_versions.merge(parsed, true)
	return latest_versions

func download_and_replace_mod(target_path: String, modworkshop_id: int) -> bool:
	var req := HTTPRequest.new()
	req.timeout = API_DOWNLOAD_TIMEOUT
	req.download_body_size_limit = 256 * 1024 * 1024
	add_child(req)
	var err := req.request(MODWORKSHOP_DOWNLOAD_URL_TEMPLATE % str(modworkshop_id))
	if err != OK:
		req.queue_free()
		return false
	# request_completed -> [result, http_code, headers, body]
	var res: Array = await req.request_completed
	req.queue_free()

	if res[0] != HTTPRequest.RESULT_SUCCESS or res[1] < 200 or res[1] >= 300:
		return false
	var response_body: PackedByteArray = res[3]
	if response_body.is_empty():
		return false

	var temp_path   := target_path + ".download"
	var backup_path := target_path + ".bak"
	if FileAccess.file_exists(temp_path):   DirAccess.remove_absolute(temp_path)
	if FileAccess.file_exists(backup_path): DirAccess.remove_absolute(backup_path)

	var out := FileAccess.open(temp_path, FileAccess.WRITE)
	if out == null:
		return false
	out.store_buffer(response_body)
	out.close()

	if read_mod_config(temp_path) == null:
		DirAccess.remove_absolute(temp_path)
		return false

	var dir_access := DirAccess.open(target_path.get_base_dir())
	if dir_access == null:
		DirAccess.remove_absolute(temp_path)
		return false

	if FileAccess.file_exists(target_path):
		if dir_access.rename(target_path.get_file(), backup_path.get_file()) != OK:
			DirAccess.remove_absolute(temp_path)
			return false

	if dir_access.rename(temp_path.get_file(), target_path.get_file()) != OK:
		if FileAccess.file_exists(backup_path):
			dir_access.rename(backup_path.get_file(), target_path.get_file())
		DirAccess.remove_absolute(temp_path)
		return false

	if FileAccess.file_exists(backup_path):
		DirAccess.remove_absolute(backup_path)
	return true

func _chunk_int_array(arr: Array[int], chunk_size: int) -> Array:
	var result: Array = []
	for i in range(0, arr.size(), chunk_size):
		result.append(arr.slice(i, i + chunk_size))
	return result

## ----- mod_loading.gd -----
## Runtime loading: mounts mod archives, scans their .gd files for safety
## issues, registers file-claims, instantiates autoloads, and applies
## [script_overrides] from mod.txt. Runs after mod_discovery has built the
## ordered list.

func load_all_mods(pass_label: String = "") -> void:
	_pending_autoloads.clear()
	_loaded_mod_ids.clear()
	_registered_autoload_names.clear()
	_override_registry.clear()
	_report_lines.clear()
	_database_replaced_by = ""
	_mod_script_analysis.clear()
	_archive_file_sets.clear()
	_hooks.clear()
	_pending_script_overrides.clear()
	_applied_script_overrides.clear()
	_hooked_methods.clear()
	_any_mod_declared_registry = false

	DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(TMP_DIR))

	var candidates: Array[Dictionary] = []
	for entry in _ui_mod_entries:
		if not entry["enabled"]:
			continue
		candidates.append(entry.duplicate())
	candidates.sort_custom(_compare_load_order)

	if candidates.is_empty():
		_log_info("No mods enabled.")
		return

	# Warn about duplicate mod names -- likely a packaging mistake or fork.
	# The sort is still deterministic (file_name tiebreaker), but users should know.
	for i in range(1, candidates.size()):
		if (candidates[i]["mod_name"] as String).to_lower() \
				== (candidates[i - 1]["mod_name"] as String).to_lower():
			_log_warning("Duplicate mod name '" + candidates[i]["mod_name"]
					+ "' -- archives '" + candidates[i - 1]["file_name"]
					+ "' and '" + candidates[i]["file_name"]
					+ "'. Load order tie broken by archive filename.")

	var header := "=== Load Order" + (" (" + pass_label + ")" if pass_label != "" else "") + " ==="
	_log_info(header)
	for i in candidates.size():
		var c: Dictionary = candidates[i]
		_log_info("  [" + str(i + 1) + "] " + c["mod_name"] + " | " + c["file_name"]
				+ " [priority=" + str(c["priority"]) + "]")
	_log_info("=" .repeat(header.length()))

	for load_index in candidates.size():
		_process_mod_candidate(candidates[load_index], load_index)

	# After every mod has been scanned + its mod.txt parsed, collapse the
	# per-mod .hook() call analysis into the global _hooked_methods map.
	# Resolves each prefix ("controller") to a vanilla path and records
	# the declared method name. _generate_hook_pack uses this + the
	# [hooks] static declarations as the per-path per-method wrap mask.
	_merge_hook_calls_into_wrap_mask()

func _merge_hook_calls_into_wrap_mask() -> void:
	if _mod_script_analysis.is_empty():
		return
	# Build a prefix -> res://Scripts/<File>.gd map. Script enumeration is
	# driven by the PCK list (populated for the rewriter), but the class-name
	# lookup covers the hot-path names (Camera, Controller, Interface, etc.)
	# and is cheap enough to rebuild here. Fall back to lowercased filename
	# match for scripts without class_name.
	var prefix_to_path: Dictionary = {}
	for cn: String in _class_name_to_path:
		var p: String = _class_name_to_path[cn]
		prefix_to_path[p.get_file().get_basename().to_lower()] = p
	for sp: String in _all_game_script_paths:
		var key := sp.get_file().get_basename().to_lower()
		if not prefix_to_path.has(key):
			prefix_to_path[key] = sp
	for mod_name: String in _mod_script_analysis:
		var analysis: Dictionary = _mod_script_analysis[mod_name]
		for entry: Dictionary in (analysis.get("hook_calls", []) as Array):
			var prefix: String = entry["prefix"]
			var method: String = entry["method"]
			if not prefix_to_path.has(prefix):
				# Source-scan saw a .hook("<prefix>-<method>-...") call but
				# we can't resolve <prefix> to any vanilla script. After the
				# pass-1/pass-2 reorder this should only fire for genuine
				# typos or hooks targeting a renamed/removed script -- log
				# loudly so the mod author isn't debugging a silent miss.
				_log_warning("[Hooks] %s calls .hook(\"%s-%s-...\") but no vanilla script matches prefix '%s' -- check spelling, or declare the path in [hooks] in mod.txt" \
						% [mod_name, prefix, method, prefix])
				continue
			var path: String = prefix_to_path[prefix]
			if not _hooked_methods.has(path):
				_hooked_methods[path] = {}
			# Mask keys lowercase (hook_pack.gd compares fe["name"].to_lower()).
			# Hook names are lowercase by convention but mods occasionally
			# write mixed case like .hook("Interface-UpdateToolTip-pre", ...);
			# normalize so the wrap surface picks those up too.
			(_hooked_methods[path] as Dictionary)[method.to_lower()] = true

func _process_mod_candidate(c: Dictionary, load_index: int) -> void:
	var file_name: String = c["file_name"]
	var full_path: String = c["full_path"]
	var ext:       String = c["ext"]
	var mod_name:  String = c["mod_name"]
	var mod_id:    String = c["mod_id"]
	var cfg               = c["cfg"]

	_log_info("--- [" + str(load_index + 1) + "] " + mod_name + " (" + file_name + ")")

	if ext != "pck" and _loaded_mod_ids.has(mod_id):
		_log_warning("Duplicate mod id '" + mod_id + "' -- skipped: " + file_name)
		return

	var mount_path := full_path
	if ext == "folder":
		mount_path = zip_folder_to_temp(full_path)
		if mount_path == "":
			_log_critical("Failed to zip folder: " + file_name)
			return

	# If this archive was already file-scope-mounted at static init, skip the
	# redundant re-mount. ProjectSettings.load_resource_pack with
	# replace_files=true (default in _try_mount_pack) would otherwise clobber
	# any overlay pack we mounted AFTER this archive -- e.g. an inline-hooks
	# overlay whose entries overlap with this mod's archive paths.
	if _filescope_mounted.has(full_path):
		_log_debug("  File-scope mount active -- skipping re-mount")
		_log_debug("  Mount path: " + mount_path)
	elif not _try_mount_pack(mount_path):
		_log_critical("Failed to mount: " + file_name + " (path: " + mount_path + ")")
		return
	else:
		_log_debug("  Mounted OK")
		_log_debug("  Mount path: " + mount_path)

	if ext != "pck":
		var scan_path := mount_path if ext == "folder" else full_path
		scan_and_register_archive_claims(scan_path, mod_name, file_name, load_index)

	if ext == "pck" or cfg == null:
		if cfg == null and ext != "pck":
			var status: String = c.get("mod_txt_status", "none")
			if status.begins_with("nested:"):
				_log_warning("  Invalid mod -- packaged incorrectly (nested mod.txt at " + status.substr(7) + ")")
			elif status == "parse_error":
				var detail: String = c.get("mod_txt_error", "")
				if detail.is_empty():
					_log_warning("  Invalid mod -- mod.txt failed to parse")
				else:
					_log_warning("  Invalid mod -- mod.txt parse error at " + detail)
			else:
				_log_warning("  No mod.txt -- autoloads skipped")
		return

	_loaded_mod_ids[mod_id] = true

	# [hooks] static declaration (v3.0.1 opt-in model). Escape hatch for
	# mods that can't get auto-enrolled via the .hook("prefix-method-...",
	# cb) scanner -- e.g. godot-mod-loader-compat mods using add_hook()
	# from a runtime autoload, or mods registering hooks via callbacks
	# passed in from elsewhere. Most mods using .hook() directly need no
	# section here. Formats:
	#   res://Scripts/Interface.gd = _ready, update_tooltip   # named methods
	#   res://Scripts/Interface.gd = *                        # all methods
	#   res://Scripts/Interface.gd =                          # empty = all
	# Populates _hooked_methods[path][method]; an empty inner dict is read
	# by _generate_hook_pack as apply_mask=false -> wrap every hookable
	# method. Method names are lowercased on write because hook_pack.gd
	# compares vanilla fn names via .to_lower() against the mask.
	if cfg != null and cfg.has_section("hooks"):
		for key in cfg.get_section_keys("hooks"):
			var script_path := str(key).strip_edges()
			var methods_str := str(cfg.get_value("hooks", key, "")).strip_edges()
			if script_path.is_empty():
				continue
			if not _hooked_methods.has(script_path):
				_hooked_methods[script_path] = {}
			# Parse method list. "*" anywhere (including mixed with specific
			# methods) promotes to whole-script wildcard -- the mixed form
			# used to silently ignore the wildcard and wrap only the listed
			# methods, a silent miss for mod authors who wanted "these for
			# sure, plus anything else I might hook dynamically."
			var specific_methods: Array[String] = []
			var has_wildcard := methods_str == ""
			for raw_method in methods_str.split(","):
				var method_name: String = raw_method.strip_edges()
				if method_name == "":
					continue
				if method_name == "*":
					has_wildcard = true
					continue
				specific_methods.append(method_name)
			if has_wildcard:
				if not specific_methods.is_empty():
					_log_warning("  [hooks] %s mixes '*' with specific methods (%s); '*' wins, all methods wrapped [%s]" \
							% [script_path, ", ".join(specific_methods), mod_name])
				else:
					_log_info("  Hooks declared: %s :: * (all methods) [%s]" % [script_path, mod_name])
				# Leave the inner dict empty; hook_pack.gd treats that as wrap-all.
				continue
			for method_name in specific_methods:
				(_hooked_methods[script_path] as Dictionary)[method_name.to_lower()] = true
				_log_info("  Hook declared: %s :: %s [%s]" % [script_path, method_name, mod_name])

	# [registry] opt-in (v3.0.1). Gates Database.gd wrapping + const-to-dict
	# transform on explicit mod declaration. Without this, Database.gd stays
	# unwrapped and lib.register()/override() will not work. The presence
	# of the section is sufficient -- body content is parsed by the
	# registry handlers per registry kind (currently only SCENES).
	if cfg != null and cfg.has_section("registry"):
		_any_mod_declared_registry = true
		_log_info("  Registry declared [%s]" % mod_name)

	# [script_extend] / [script_overrides] -- full script replacements that
	# chain via Godot's extends resolution. Both section names accepted
	# (script_extend is the preferred v3.0.1 name; script_overrides is the
	# legacy alias kept for backward compat with mods written pre-cutover).
	# Each entry: vanilla_path = mod_script_path. Higher-priority mods land
	# last in the chain (most recent take_over_path wins, extends resolves
	# to the prior chain tip).
	var _extend_sections: Array[String] = ["script_extend", "script_overrides"]
	if cfg != null:
		for section in _extend_sections:
			if not cfg.has_section(section):
				continue
			for key in cfg.get_section_keys(section):
				var vanilla_path := str(key).strip_edges()
				var mod_script_path := str(cfg.get_value(section, key)).strip_edges()
				if vanilla_path.is_empty() or mod_script_path.is_empty():
					_log_warning("  Empty [%s] entry -- skipped" % section)
					continue
				_pending_script_overrides.append({
					"vanilla_path": vanilla_path,
					"mod_script_path": mod_script_path,
					"mod_name": mod_name,
					"priority": c.get("priority", 0),
				})
				_log_info("  [%s] %s -> %s" % [section, vanilla_path, mod_script_path])

	if cfg == null or not cfg.has_section("autoload"):
		return

	var keys: PackedStringArray = cfg.get_section_keys("autoload")
	for key in keys:
		var autoload_name := str(key)
		var raw_path := str(cfg.get_value("autoload", key)).lstrip("*").strip_edges()
		var is_early := raw_path.begins_with("!")
		if is_early:
			raw_path = raw_path.lstrip("!")
		var res_path := raw_path

		if res_path == "":
			_log_warning("  Empty autoload path for '" + autoload_name + "' -- skipped")
			continue

		if _registered_autoload_names.has(autoload_name):
			_log_warning("Duplicate autoload name '" + autoload_name + "' -- skipped")
			continue
		_registered_autoload_names[autoload_name] = true

		if _archive_file_sets.has(file_name) and not _archive_file_sets[file_name].has(res_path):
			_log_critical("  Autoload path not found in archive: " + res_path)
			_log_critical("    Declared in mod.txt but missing from: " + file_name)
			# Log similar paths to help mod authors diagnose typos / case mismatches.
			var similar: Array[String] = []
			var target_file := res_path.get_file().to_lower()
			for p: String in _archive_file_sets[file_name]:
				if p.get_file().to_lower() == target_file:
					similar.append(p)
			if similar.size() > 0:
				_log_critical("    Similar paths in archive: " + ", ".join(similar))
			continue

		_pending_autoloads.append({
			"mod_name": mod_name, "name": autoload_name, "path": res_path,
			"is_early": is_early,
		})
		var early_tag := " [EARLY]" if is_early else ""
		_log_debug("  Autoload queued: " + autoload_name + " -> " + res_path + early_tag)
		_register_claim(res_path, mod_name, file_name, load_index)

# Logging


func _register_claim(res_path: String, mod_name: String, archive: String,
		load_index: int) -> void:
	if not _override_registry.has(res_path):
		_override_registry[res_path] = []
	for existing in _override_registry[res_path]:
		if existing["mod_name"] == mod_name and existing["archive"] == archive:
			return
	_override_registry[res_path].append({
		"mod_name": mod_name, "archive": archive, "load_index": load_index,
	})


# Apply [script_overrides] / [script_extend] entries via take_over_path.
# Each override is a mod script that extends the vanilla script. Processing
# in priority order (lowest first) means each subsequent override's extends
# resolves to the previous one, forming a natural chain: ModB -> ModA -> vanilla.
#
# Legacy-syntax autofix runs on each mod source before reload() so Godot 4's
# strict parser accepts Godot-3-era patterns (bodyless blocks, `tool`,
# `onready var`, `export var`, bare `base()` calls). Narrow, semantically-
# equivalent transform. Chain composition survives -- every intermediate
# script in the chain goes through the same autofix.
func _apply_script_overrides() -> void:
	if _pending_script_overrides.is_empty():
		return
	# Sort by priority (lowest first, highest last wins take_over_path).
	_pending_script_overrides.sort_custom(func(a, b): return a["priority"] < b["priority"])
	var applied := 0
	for entry in _pending_script_overrides:
		var vanilla_path: String = entry["vanilla_path"]
		var mod_path: String = entry["mod_script_path"]
		var mod_name: String = entry["mod_name"]

		# Read source and compile fresh so extends resolves to current occupant
		# of the vanilla path (which may be a previous mod's override).
		var src_script := load(mod_path) as GDScript
		if src_script == null:
			_log_critical("[Overrides] Failed to load: %s [%s]" % [mod_path, mod_name])
			continue
		var source := src_script.source_code
		if source.is_empty():
			_log_critical("[Overrides] Empty source: %s [%s]" % [mod_path, mod_name])
			continue

		# Normalize line endings + autofix legacy syntax. GDScript's strict
		# reload parser rejects CRLF/LF mixes, bodyless blocks, and Godot-3
		# annotations; v2.1.0-era mods commonly ship with these and break
		# take_over_path when loaded as-is. Autofix is a no-op for clean
		# source so modern mods pay no transform cost.
		var normalized: String = source.replace("\r\n", "\n").replace("\r", "\n")
		var af := _rtv_autofix_legacy_syntax(normalized)
		var fixed_src: String = af["source"]
		var af_total: int = int(af["bodyless"]) + int(af["tool"]) + int(af["onready"]) \
				+ int(af["export"]) + int(af.get("base", 0))
		if af_total > 0:
			_log_info("[Overrides] Autofix %s: %d bodyless, %d tool, %d onready, %d export, %d base() -> super" \
					% [mod_path, af["bodyless"], af["tool"], af["onready"], af["export"], af.get("base", 0)])

		var new_script := GDScript.new()
		new_script.source_code = fixed_src
		var err := new_script.reload()
		if err != OK:
			_log_critical("[Overrides] Compile failed for %s (error %d) [%s]" % [mod_path, err, mod_name])
			continue
		new_script.take_over_path(vanilla_path)
		_applied_script_overrides[vanilla_path] = true
		applied += 1
		_log_info("[Overrides] Applied: %s -> %s [%s]" % [vanilla_path, mod_path, mod_name])
	if applied > 0:
		_log_info("[Overrides] Applied %d script override(s)" % applied)

func scan_and_register_archive_claims(archive_path: String, mod_name: String,
		archive_file: String, load_index: int) -> void:
	var zr := ZIPReader.new()
	if zr.open(archive_path) != OK:
		_log_warning("  Could not scan archive: " + archive_file)
		return

	var files := zr.get_files()

	# Archives repacked on Windows via ZipFile.CreateFromDirectory() write backslash
	# separators. Godot mounts the pack but can't resolve those paths.
	var backslash_count := 0
	var example_bad := ""
	for f: String in files:
		if "\\" in f:
			backslash_count += 1
			if example_bad == "":
				example_bad = f
	if backslash_count > 0:
		_log_critical("  BAD ZIP: " + str(backslash_count) + " entries use Windows backslash paths.")
		_log_critical("    Re-pack with 7-Zip. Example bad entry: '" + example_bad + "'")

	var tracked_count := 0
	var path_set: Dictionary = {}
	var gd_analysis: Dictionary = {
		"take_over_literal_paths": [],
		"extends_paths":           [],
		"uses_dynamic_override":   false,
		"lifecycle_no_super":      [],
		"calls_update_tooltip":    false,
		"class_names":             [],
		"extends_class_names":     [],
		"override_methods":        {},   # extends_path -> Array[method_name]
		"preload_paths":           [],
		"calls_base":              false, # uses base() instead of super() -- Godot 3 or removed method
		"total_gd_files":          0,
		# .hook("<prefix>-<method>[-suffix]") declarations found in source.
		# Each entry is {prefix, method}; _generate_hook_pack uses this to
		# populate the per-path per-method wrap mask. A mod declaring zero
		# hooks AND zero [hooks]/[registry] mod.txt sections leaves the
		# wrap surface empty and skips hook pack generation entirely.
		"hook_calls":              [],  # Array of {prefix, method}
	}

	for f in files:
		if f.get_extension().to_lower() == "gd":
			gd_analysis["total_gd_files"] = gd_analysis["total_gd_files"] + 1
			var gd_bytes := zr.read_file(f)
			if gd_bytes.size() > 0:
				var gd_text := gd_bytes.get_string_from_utf8()
				# Scan is unconditional. _generate_hook_pack reads hook_calls
				# to build the wrap mask; the other fields feed diagnostics
				# (conflict report, override verification).
				_scan_gd_source(gd_text, gd_analysis)
				if _class_name_to_path.size() > 0:
					_check_class_name_safety(gd_text, f, mod_name)

		var res_path := _normalize_to_res_path(f)
		if res_path == "" and f.ends_with(".remap"):
			res_path = _normalize_to_res_path(f.trim_suffix(".remap"))
		if res_path == "":
			continue

		path_set[res_path] = true
		tracked_count += 1
		_register_claim(res_path, mod_name, archive_file, load_index)

		var bare_name := res_path.get_file().get_basename().to_lower()
		var is_db_file := bare_name == "database" and res_path.get_extension().to_lower() == "gd"

		if is_db_file:
			if _database_replaced_by == "":
				_database_replaced_by = mod_name
				_log_info("  DATABASE OVERRIDE: " + mod_name + " replaces Database.gd")
			else:
				_log_warning("  DATABASE COPY: " + mod_name + " bundles a private Database.gd at " + res_path)
				_log_warning("    Hardcoded preload() paths may break if companion mods aren't present.")

	zr.close()
	_mod_script_analysis[mod_name] = gd_analysis
	_archive_file_sets[archive_file] = path_set

	_log_debug("  " + str(tracked_count) + " resource path(s)")

	if gd_analysis["total_gd_files"] > 0:
		var override_count: int = (gd_analysis["take_over_literal_paths"] as Array).size() \
				+ (gd_analysis["extends_paths"] as Array).size()
		var dynamic_tag := " [uses overrideScript()]" if gd_analysis["uses_dynamic_override"] else ""
		_log_debug("  " + str(gd_analysis["total_gd_files"]) + " .gd file(s), "
				+ str(override_count) + " override target(s)" + dynamic_tag)

# GDScript source analysis

func _scan_gd_source(text: String, analysis: Dictionary) -> void:
	for m in _re_take_over.search_all(text):
		var path := m.get_string(1)
		if path not in (analysis["take_over_literal_paths"] as Array):
			(analysis["take_over_literal_paths"] as Array).append(path)

	var m_ext := _re_extends.search(text)
	if m_ext:
		var path := m_ext.get_string(1)
		if path not in (analysis["extends_paths"] as Array):
			(analysis["extends_paths"] as Array).append(path)

	# Detect extends via class_name (e.g. "extends Weapon") -- breaks override chains.
	var m_ext_cn := _re_extends_classname.search(text)
	if m_ext_cn:
		var cn := m_ext_cn.get_string(1)
		if cn not in (analysis["extends_class_names"] as Array):
			(analysis["extends_class_names"] as Array).append(cn)

	# Detect class_name declarations -- Godot bug #83542: can only be overridden once.
	for m_cn in _re_class_name.search_all(text):
		var cn := m_cn.get_string(1)
		if cn not in (analysis["class_names"] as Array):
			(analysis["class_names"] as Array).append(cn)

	if not analysis["uses_dynamic_override"]:
		# Previous narrow match (get_base_script() + take_over_path(parentScript)
		# missed RTVCoop's pattern: script.take_over_path(gamePath) where gamePath
		# is a literal arg that doesn't start with "parentScript". Matching any
		# take_over_path() call is a superset that still catches AI Overhaul /
		# MCM / other parentScript-style callers.
		analysis["uses_dynamic_override"] = "take_over_path(" in text

	# UpdateTooltip() is inventory-UI only. World-item tooltips are written directly
	# by HUD._physics_process from gameData.tooltip -- this override has no effect there.
	if not analysis["calls_update_tooltip"]:
		analysis["calls_update_tooltip"] = "UpdateTooltip" in text

	# Detect base() calls -- Godot 3 pattern or removed parent method.
	if not analysis["calls_base"]:
		analysis["calls_base"] = "base(" in text

	# preload() paths -- used for stale-cache detection.
	for m_pl in _re_preload.search_all(text):
		var pl_path := m_pl.get_string(1)
		if pl_path not in (analysis["preload_paths"] as Array):
			(analysis["preload_paths"] as Array).append(pl_path)

	# .hook("<prefix>-<method>[-suffix]") calls. Capture (prefix, method)
	# pairs so _generate_hook_pack can build a per-path, per-method wrap
	# mask -- only methods a mod actually hooks get dispatch wrappers,
	# matching godot-mod-loader's method_mask semantics. Prefix is the
	# lowercase script stem ("controller", "camera"); method is the method
	# name without the -pre/-post/-callback dispatch-variant suffix.
	for m_hk in _re_hook_call.search_all(text):
		var prefix := m_hk.get_string(1).to_lower()
		var method := m_hk.get_string(2)
		var already: bool = false
		for existing: Dictionary in (analysis["hook_calls"] as Array):
			if existing["prefix"] == prefix and existing["method"] == method:
				already = true
				break
		if not already:
			(analysis["hook_calls"] as Array).append({"prefix": prefix, "method": method})

	# Method declarations -- needed for mod collision detection.
	var func_matches := _re_func.search_all(text)

	# Determine the extends target for this file (if any).
	var ext_target := ""
	if m_ext:
		ext_target = m_ext.get_string(1)

	for i in func_matches.size():
		var func_name := func_matches[i].get_string(1)

		# Track method names per extends target for collision detection.
		if ext_target != "":
			if not (analysis["override_methods"] as Dictionary).has(ext_target):
				(analysis["override_methods"] as Dictionary)[ext_target] = []
			var method_list: Array = (analysis["override_methods"] as Dictionary)[ext_target]
			if func_name not in method_list:
				method_list.append(func_name)

		# Warn if lifecycle methods lack super() in scripts that extend game scripts.
		if ext_target == "":
			continue
		const _LIFECYCLE := ["_ready", "_process", "_physics_process",
				"_input", "_unhandled_input", "_unhandled_key_input"]
		if func_name not in _LIFECYCLE:
			continue
		var body_start := func_matches[i].get_end()
		var body_end := text.length() if i + 1 >= func_matches.size() \
				else func_matches[i + 1].get_start()
		var body := text.substr(body_start, body_end - body_start)
		if "super(" not in body and "super." not in body:
			if func_name not in (analysis["lifecycle_no_super"] as Array):
				(analysis["lifecycle_no_super"] as Array).append(func_name)

func _check_class_name_safety(text: String, file_path: String, mod_name: String) -> void:
	for m_cn in _re_class_name.search_all(text):
		var cn := m_cn.get_string(1)
		if _class_name_to_path.has(cn):
			var res_path := _normalize_to_res_path(file_path)
			var game_path: String = _class_name_to_path[cn]
			if res_path != game_path:
				_log_critical("  CONFLICT: %s re-declares class_name %s (game has it at %s)" % [file_path, cn, game_path])
	for m_to in _re_take_over.search_all(text):
		var to_path := m_to.get_string(1)
		for cn: String in _class_name_to_path:
			if _class_name_to_path[cn] == to_path:
				_log_critical("  DANGER: %s calls take_over_path on class_name script %s (%s) -- this will crash" % [file_path, to_path, cn])
				break

# Override diagnostics (developer mode)


func _instantiate_autoload(mod_name: String, autoload_name: String, res_path: String) -> void:
	var resource: Resource = load(res_path)
	if resource == null:
		_log_critical("Autoload failed: %s -> %s [%s]" % [autoload_name, res_path, mod_name])
		if _developer_mode:
			_log_debug("  FileAccess=%s  ResourceLoader=%s"
					% [str(FileAccess.file_exists(res_path)), str(ResourceLoader.exists(res_path))])
		return

	if get_tree().root.has_node(autoload_name):
		_log_warning("Autoload name '" + autoload_name + "' conflicts with existing node at /root/"
				+ autoload_name + " -- Godot will rename it. [" + mod_name + "]")

	if resource is PackedScene:
		var instance: Node = (resource as PackedScene).instantiate()
		if instance == null:
			_log_critical("PackedScene.instantiate() returned null: " + autoload_name
					+ " -> " + res_path + " [" + mod_name + "]")
			return
		instance.name = autoload_name
		get_tree().root.add_child(instance)
		_log_debug("Autoload instantiated (scene): " + autoload_name + " [" + mod_name + "]")
		return

	if resource is GDScript:
		var gdscript := resource as GDScript
		if not gdscript.can_instantiate():
			_log_critical("Autoload script failed to compile: " + autoload_name
					+ " -> " + res_path + " [" + mod_name + "]")
			_log_critical("  can_instantiate() returned false. Check the Godot log above for parse errors.")
			return
		var inst: Variant = gdscript.new()
		if inst == null:
			_log_warning("Autoload script returned null: " + autoload_name)
			return
		if inst is Node:
			(inst as Node).name = autoload_name
			get_tree().root.add_child(inst as Node)
			_log_debug("Autoload instantiated (script): " + autoload_name + " [" + mod_name + "]")
			return
		_log_warning("Autoload is not a Node -- not added to tree: " + autoload_name
				+ " [" + mod_name + "]")
		return

	_log_warning("Autoload is not a PackedScene or GDScript: " + autoload_name
			+ " -> " + res_path + " [" + mod_name + "]")

## ----- conflict_report.gd -----
## Developer-mode diagnostics: verify script_overrides took effect, probe the
## scene tree for mismatches, log override timing issues, and produce the
## conflict report written to user://. Loaded alongside the normal loading
## path but only runs when developer_mode=true.
##
## v3.0.1 cutover note: mod subclass scripts are no longer rewritten (old
## Step C removed), so the _rtv_mod_ method-prefix signal is gone. Detection
## now classifies by "does the instance's script extend the wrapped vanilla"
## rather than "does it carry a _rtv_mod_ prefix." Script path + extends-
## chain walk are the reliable signals for override-took-effect.

# Log which mods use overrideScript() -- overrides apply after scene reload.
func _log_override_timing_warnings() -> void:
	for mod_name: String in _mod_script_analysis:
		var analysis: Dictionary = _mod_script_analysis[mod_name]
		if not analysis["uses_dynamic_override"]:
			continue
		var targets: Array = analysis["extends_paths"]
		if targets.is_empty():
			continue
		var target_list := ", ".join(targets.map(func(p): return (p as String).get_file()))
		_log_debug(mod_name + " uses overrideScript() on: " + target_list
				+ " -- applies after scene reload")

# [OverrideVerify]: Post-frameworks_ready sanity check on dynamic overrides.
#
# v3.0.1: classification is path-based, not method-prefix-based. For each
# mod that calls take_over_path() dynamically, load() the declared target
# path and log its resource_path + source head. Operators can eyeball
# whether the take_over_path took effect. Full STALE/BROKEN diagnosis
# that v3.0.0's _rtv_mod_ prefix enabled is gone with Step C -- without
# rewriting mod sources there is no reliable in-source signal to test.

func _verify_script_overrides() -> void:
	var printed_header: bool = false
	for mod_name: String in _mod_script_analysis:
		var analysis: Dictionary = _mod_script_analysis[mod_name]
		if not analysis.get("uses_dynamic_override", false):
			continue
		var targets: Array = analysis.get("extends_paths", [])
		if targets.is_empty():
			continue
		if not printed_header:
			_log_info("[OverrideVerify] === Post-autoload cache check ===")
			printed_header = true
		for vanilla_path in targets:
			var vp: String = String(vanilla_path)
			var scr := load(vp) as Script
			if scr == null:
				_log_warning("[OverrideVerify] %s | %s | FAIL: load() returned null" % [mod_name, vp])
				continue
			var src: String = scr.source_code
			var src_head: String = src.substr(0, 60).replace("\n", " | ").replace("\t", " ")
			_log_info("[OverrideVerify] %s | %s | resource_path=%s src_head=[%s]" \
					% [mod_name, vp, scr.resource_path, src_head])

# Two-pass helpers

func _print_conflict_summary() -> void:
	_log_info("")
	_log_info("============================================")
	_log_info("=== ModLoader Compatibility Summary      ===")
	_log_info("============================================")
	_log_info("Mods loaded:  " + str(_loaded_mod_ids.size()))

	var conflicted_paths: Array[String] = []
	for res_path: String in _override_registry:
		var claims: Array = _override_registry[res_path]
		if claims.size() > 1:
			conflicted_paths.append(res_path)

	_log_info("Conflicting resource paths: " + str(conflicted_paths.size()))

	if conflicted_paths.is_empty():
		_log_info("No resource path conflicts -- all mods appear compatible.")
	else:
		_log_info("")
		_log_info("--- Conflicted Paths (last loader wins) ---")
		for res_path in conflicted_paths:
			var claims: Array = _override_registry[res_path]
			var winner: Dictionary = claims[claims.size() - 1]
			_log_warning("CONFLICT: " + res_path)
			for claim in claims:
				var marker := " <-- wins" if claim == winner else ""
				_log_info("    [" + str(claim["load_index"] + 1) + "] "
						+ claim["mod_name"] + " via " + claim["archive"] + marker)

	if not _hooks.is_empty():
		_log_info("")
		_log_info("--- Hook Registrations ---")
		for hook_name: String in _hooks:
			var arr: Array = _hooks[hook_name]
			if arr.size() > 0:
				_log_info("  %s (%d callback(s))" % [hook_name, arr.size()])

	_log_info("============================================")
	_log_info("")

func _write_conflict_report() -> void:
	var f := FileAccess.open(CONFLICT_REPORT_PATH, FileAccess.WRITE)
	if f == null:
		_log_warning("Could not write report to: " + CONFLICT_REPORT_PATH)
		return
	for line in _report_lines:
		f.store_line(line)
	f.close()
	_log_info("Conflict report written to: " + CONFLICT_REPORT_PATH)

## ----- ui.gd -----
## The launcher window shown before the game starts.
##   - Mods tab: per-mod enable checkbox + load-order spin, profile selector
##     (switch / create / delete) with a Vanilla entry that confirms then
##     resets + restarts, and a live load-order preview.
##   - Updates tab: ModWorkshop version checking + downloads.
##   - Profiles live in UI_CONFIG_PATH under `profile.<name>.enabled` and
##     `profile.<name>.priority`; the active profile is stored in
##     `[settings] active_profile`. VANILLA_PROFILE is a sentinel meaning
##     "all mods off" and keeps stored profiles untouched on reset.
## Closing the window (or clicking Launch Game) hands control back to
## _run_pass_1.

func _load_developer_mode_setting() -> void:
	var cfg := ConfigFile.new()
	if cfg.load(UI_CONFIG_PATH) != OK:
		return
	_developer_mode = bool(cfg.get_value("settings", "developer_mode", false))
	if _developer_mode:
		_log_info("Developer mode: ON")

func _load_ui_config() -> void:
	_active_profile = "Default"
	var cfg := ConfigFile.new()
	if cfg.load(UI_CONFIG_PATH) != OK:
		# Fresh install, no config file yet. Materialize the placeholder
		# Default profile so it's a real on-disk profile from the first UI
		# render (see comment at the tail of this function for rationale).
		_save_ui_config()
		return

	# Migrate legacy flat [enabled]/[priority] layout into profile.Default.* on
	# the first post-upgrade load. The next _save_ui_config writes the file back
	# without the flat sections, so the migration only runs once per install.
	var has_any_profile := false
	for sec: String in cfg.get_sections():
		if sec.begins_with("profile."):
			has_any_profile = true
			break
	if not has_any_profile:
		if cfg.has_section("enabled"):
			for key: String in cfg.get_section_keys("enabled"):
				cfg.set_value("profile.Default.enabled", key, cfg.get_value("enabled", key))
		if cfg.has_section("priority"):
			for key: String in cfg.get_section_keys("priority"):
				cfg.set_value("profile.Default.priority", key, cfg.get_value("priority", key))

	var stored := str(cfg.get_value("settings", "active_profile", "Default"))
	var profiles := _list_profiles_in_cfg(cfg)
	if stored == VANILLA_PROFILE:
		_active_profile = VANILLA_PROFILE
	elif stored in profiles:
		_active_profile = stored
	elif not profiles.is_empty():
		_active_profile = profiles[0]
	else:
		_active_profile = "Default"

	_apply_profile_to_entries(cfg, _active_profile)

	# Materialize the placeholder Default profile when it's the resolved
	# active and wasn't on disk at load time. Without this, "Default"
	# appears in the dropdown only as a UI-level placeholder (see the
	# profile selector build in build_mods_tab) and vanishes the first
	# time the user creates a named profile -- confusing, and also leaves
	# a silent-overwrite gap where an imported profile named "Default"
	# would write without the overwrite confirm (since _list_profiles()
	# wouldn't yet include the untoggled placeholder). Writing the section
	# here makes Default a persistent profile like every other launcher
	# (Firefox, Minecraft, Steam). Users can rename or delete it if they
	# want.
	#
	# Uses the has_any_profile flag captured BEFORE migration rather than
	# cfg.has_section, because the legacy [enabled]/[priority] migration
	# populates profile.Default.* in-memory -- cfg.has_section would
	# return true from the in-memory state and we'd skip the save,
	# leaving disk still without the section.
	if _active_profile == "Default" and not has_any_profile:
		_save_ui_config()

func _apply_profile_to_entries(cfg: ConfigFile, profile: String) -> void:
	# VANILLA_PROFILE has no stored sections -- treating it as "all mods off"
	# lets Reset to Vanilla avoid touching the user's other profiles.
	var is_vanilla := profile == VANILLA_PROFILE
	var en_sec := "profile." + profile + ".enabled"
	var pr_sec := "profile." + profile + ".priority"
	for entry in _ui_mod_entries:
		var pk: String = entry["profile_key"]
		entry.erase("profile_version_mismatch")
		# Resolve once, reuse for both enabled and priority lookups. Exact
		# profile_key match first; if missing, fall back to id-prefix match
		# ("<mod_id>@*") so a version bump doesn't silently drop the entry --
		# we carry over the stored state and flag the mismatch for the UI.
		var resolved_key := ""
		if cfg.has_section_key(en_sec, pk) or cfg.has_section_key(pr_sec, pk):
			resolved_key = pk
		elif not pk.begins_with("zip:"):
			resolved_key = _find_stored_key_for_mod_id(cfg, profile, entry["mod_id"])
			if resolved_key != "" and resolved_key != pk:
				entry["profile_version_mismatch"] = {
					"stored":  _version_from_profile_key(resolved_key),
					"current": entry["version"],
				}
		if is_vanilla:
			entry["enabled"] = false
		elif resolved_key != "" and cfg.has_section_key(en_sec, resolved_key):
			entry["enabled"] = bool(cfg.get_value(en_sec, resolved_key))
		else:
			entry["enabled"] = true
		if resolved_key != "" and cfg.has_section_key(pr_sec, resolved_key):
			entry["priority"] = int(str(cfg.get_value(pr_sec, resolved_key)))

# Find a stored profile key matching an entry's mod_id but with a different
# version, so a version bump doesn't orphan the profile entry. Returns "" if
# no such key exists. The "@" sentinel guards against partial-id collisions
# (e.g., "foo" matching "foobar@1.0").
func _find_stored_key_for_mod_id(cfg: ConfigFile, profile: String, mod_id: String) -> String:
	var prefix := mod_id + "@"
	for suffix: String in [".enabled", ".priority"]:
		var sec := "profile." + profile + suffix
		if cfg.has_section(sec):
			for key: String in cfg.get_section_keys(sec):
				if key.begins_with(prefix):
					return key
	return ""

func _version_from_profile_key(key: String) -> String:
	var at := key.find("@")
	if at < 0:
		return ""
	return key.substr(at + 1)

func _list_profiles_in_cfg(cfg: ConfigFile) -> Array[String]:
	var names: Array[String] = []
	var prefix := "profile."
	var suffix := ".enabled"
	for sec: String in cfg.get_sections():
		if sec.begins_with(prefix) and sec.ends_with(suffix):
			var name: String = sec.substr(prefix.length(), sec.length() - prefix.length() - suffix.length())
			# Skip VANILLA_PROFILE -- it's a sentinel, not a real profile, and
			# leaked ghost sections (e.g. from pre-guard auto-save bugs) must
			# not appear in the dropdown.
			if name != "" and name != VANILLA_PROFILE and not (name in names):
				names.append(name)
	# Also include profiles that only have a priority section (shouldn't happen
	# in practice, but guards against partial state).
	var pr_suffix := ".priority"
	for sec: String in cfg.get_sections():
		if sec.begins_with(prefix) and sec.ends_with(pr_suffix):
			var name: String = sec.substr(prefix.length(), sec.length() - prefix.length() - pr_suffix.length())
			if name != "" and name != VANILLA_PROFILE and not (name in names):
				names.append(name)
	names.sort()
	return names

func _list_profiles() -> Array[String]:
	var cfg := ConfigFile.new()
	if cfg.load(UI_CONFIG_PATH) != OK:
		return []
	return _list_profiles_in_cfg(cfg)

func _save_ui_config() -> void:
	var cfg := ConfigFile.new()
	cfg.load(UI_CONFIG_PATH)

	# Drop legacy flat sections if they linger after migration.
	if cfg.has_section("enabled"):
		cfg.erase_section("enabled")
	if cfg.has_section("priority"):
		cfg.erase_section("priority")

	# Skip profile-section writes while the Vanilla sentinel is active -- it's
	# not a real profile and must not materialize stored sections, even from
	# the Launch-time save in lifecycle.gd.
	if _active_profile != VANILLA_PROFILE:
		# Rewrite the active profile's sections fresh so removed mods don't linger.
		var en_sec := "profile." + _active_profile + ".enabled"
		var pr_sec := "profile." + _active_profile + ".priority"
		# Snapshot stored state for folder mods that dev-mode-off filtered out
		# of _ui_mod_entries -- otherwise the erase+rewrite below would drop
		# their enabled/priority entries and the user loses those settings the
		# moment they save with dev mode disabled.
		var preserved_enabled: Dictionary = {}
		var preserved_priority: Dictionary = {}
		if not _hidden_folder_profile_keys.is_empty() and cfg.has_section(en_sec):
			for key: String in cfg.get_section_keys(en_sec):
				if _hidden_folder_profile_keys.has(key):
					preserved_enabled[key] = cfg.get_value(en_sec, key)
		if not _hidden_folder_profile_keys.is_empty() and cfg.has_section(pr_sec):
			for key: String in cfg.get_section_keys(pr_sec):
				if _hidden_folder_profile_keys.has(key):
					preserved_priority[key] = cfg.get_value(pr_sec, key)
		if cfg.has_section(en_sec):
			cfg.erase_section(en_sec)
		if cfg.has_section(pr_sec):
			cfg.erase_section(pr_sec)
		for entry in _ui_mod_entries:
			var pk: String = entry["profile_key"]
			cfg.set_value(en_sec, pk, entry["enabled"])
			cfg.set_value(pr_sec, pk, entry["priority"])
		for k in preserved_enabled.keys():
			cfg.set_value(en_sec, k, preserved_enabled[k])
		for k in preserved_priority.keys():
			cfg.set_value(pr_sec, k, preserved_priority[k])

	cfg.set_value("settings", "developer_mode", _developer_mode)
	cfg.set_value("settings", "active_profile", _active_profile)
	cfg.save(UI_CONFIG_PATH)
	if _boot_complete:
		_dirty_since_boot = true

# Profile management: snapshot the current in-memory state to a new profile
# and switch to it. Caller is responsible for validating `name` (unique,
# non-empty, not "Vanilla").
func _create_profile(name: String) -> void:
	_active_profile = name
	_save_ui_config()

# Delete the active profile's sections and switch to whichever profile remains
# first in alphabetical order. Caller must ensure at least one other profile
# exists before calling this.
func _delete_active_profile() -> void:
	var cfg := ConfigFile.new()
	if cfg.load(UI_CONFIG_PATH) != OK:
		return
	var target := _active_profile
	for suffix: String in [".enabled", ".priority"]:
		var sec := "profile." + target + suffix
		if cfg.has_section(sec):
			cfg.erase_section(sec)
	var remaining := _list_profiles_in_cfg(cfg)
	if remaining.is_empty():
		_active_profile = "Default"
	else:
		_active_profile = remaining[0]
	cfg.set_value("settings", "active_profile", _active_profile)
	cfg.save(UI_CONFIG_PATH)
	_apply_profile_to_entries(cfg, _active_profile)
	if _boot_complete:
		_dirty_since_boot = true

# Swap in-memory mod state to an existing profile. Does not write to disk
# beyond updating the active_profile pointer -- mod enabled/priority values
# already live in the profile sections.
func _switch_profile(name: String) -> void:
	_active_profile = name
	var cfg := ConfigFile.new()
	cfg.load(UI_CONFIG_PATH)
	cfg.set_value("settings", "active_profile", _active_profile)
	cfg.save(UI_CONFIG_PATH)
	_apply_profile_to_entries(cfg, _active_profile)
	if _boot_complete:
		_dirty_since_boot = true

# Rename the active profile. We just save under the new name (which materializes
# the sections from current in-memory state, matching what the old profile
# held), then erase the old sections. Handles fresh-install placeholder cleanly
# since _save_ui_config doesn't care whether sections existed previously.
func _rename_profile(new_name: String) -> void:
	var old := _active_profile
	if old == new_name:
		return
	_active_profile = new_name
	_save_ui_config()
	var cfg := ConfigFile.new()
	if cfg.load(UI_CONFIG_PATH) != OK:
		return
	for suffix: String in [".enabled", ".priority"]:
		var sec := "profile." + old + suffix
		if cfg.has_section(sec):
			cfg.erase_section(sec)
	cfg.save(UI_CONFIG_PATH)

# Parse a shared payload back into the fields needed to reconstruct a profile.
# Returns either {"error": "..."} on failure or the parsed metroprofile dict
# on success. Validates the MTRPRF1 magic, checksum, and JSON shape.
func _parse_profile_payload(raw: String) -> Dictionary:
	var parts := raw.strip_edges().split(".")
	if parts.size() != 3:
		return {"error": "Invalid format -- expected MTRPRF1.<body>.<checksum>"}
	if parts[0] != "MTRPRF1":
		return {"error": "Unknown payload type \"" + parts[0] + "\""}
	var body: String = parts[1]
	var check: String = parts[2]
	var ctx := HashingContext.new()
	ctx.start(HashingContext.HASH_SHA256)
	ctx.update(body.to_utf8_buffer())
	if check != ctx.finish().hex_encode().substr(0, 8):
		return {"error": "Payload is corrupted -- checksum mismatch"}
	var json_str := Marshalls.base64_to_utf8(body)
	if json_str == "":
		return {"error": "Payload body is not valid base64"}
	var obj = JSON.parse_string(json_str)
	if typeof(obj) != TYPE_DICTIONARY:
		return {"error": "Payload JSON is not an object"}
	var d: Dictionary = obj
	if int(d.get("metroprofile", 0)) != 1:
		return {"error": "Unsupported metroprofile schema version"}
	if not (d.get("name") is String):
		return {"error": "Payload missing name"}
	if not (d.get("enabled") is Dictionary):
		return {"error": "Payload missing enabled data"}
	return d

# Apply a parsed payload as a profile. Overwrites any existing profile with
# the same name (caller is expected to have confirmed), switches to it, and
# syncs in-memory entries.
func _import_profile_from_parsed(parsed: Dictionary) -> void:
	var name := _sanitize_profile_name(parsed["name"])
	if name == "" or name.to_lower() == "vanilla" or name == VANILLA_PROFILE:
		return
	var cfg := ConfigFile.new()
	cfg.load(UI_CONFIG_PATH)
	var en_sec := "profile." + name + ".enabled"
	var pr_sec := "profile." + name + ".priority"
	if cfg.has_section(en_sec):
		cfg.erase_section(en_sec)
	if cfg.has_section(pr_sec):
		cfg.erase_section(pr_sec)
	var enabled_dict: Dictionary = parsed["enabled"]
	for key in enabled_dict.keys():
		cfg.set_value(en_sec, str(key), bool(enabled_dict[key]))
	var priority_dict: Dictionary = parsed.get("priority", {})
	for key in priority_dict.keys():
		# Clamp defensively -- payload came from the clipboard and a crafted
		# or corrupted entry could set an out-of-range priority that breaks
		# load-order invariants (UI spinbox is [-999, 999]; anything outside
		# that range couldn't have been authored through the UI anyway).
		var pv := int(priority_dict[key])
		cfg.set_value(pr_sec, str(key), clampi(pv, PRIORITY_MIN, PRIORITY_MAX))
	# Explicit manifest: any local mod NOT in the imported payload is written
	# as disabled. Without this, _apply_profile_to_entries falls through to
	# its default-true branch for unknown keys (ergonomic for "newly-dropped
	# mod in existing profile") and imports would silently enable every
	# local mod the exporter didn't have -- including dev folders, which is
	# the opposite of what a shared profile means. Handles id-prefix matches
	# (foo@2.0 local resolving to foo@1.0 in payload) so version bumps
	# inherit the payload's state rather than getting disabled.
	var payload_mod_ids: Dictionary = {}
	for key in enabled_dict.keys():
		var key_str := str(key)
		var at := key_str.find("@")
		if at > 0:
			payload_mod_ids[key_str.substr(0, at)] = true
	for entry in _ui_mod_entries:
		var pk: String = entry["profile_key"]
		if enabled_dict.has(pk):
			continue
		if not pk.begins_with("zip:") and payload_mod_ids.has(entry["mod_id"]):
			continue
		cfg.set_value(en_sec, pk, false)
	_active_profile = name
	cfg.set_value("settings", "active_profile", _active_profile)
	cfg.save(UI_CONFIG_PATH)
	_apply_profile_to_entries(cfg, _active_profile)
	if _boot_complete:
		_dirty_since_boot = true

# Metroprofile v1 schema is LOCKED at 3.0.1. Full spec (wrapper format, JSON
# shape, profile key format, forward-compat rules, round-trip guarantees) is
# in the wiki: docs/wiki/Profile-Format.md. Changes to the export/import
# shape require bumping the schema version so old parsers reject cleanly.

# Build the shareable opaque payload for the given profile. Shape:
#     MTRPRF1.<base64-encoded JSON>.<first 8 hex chars of SHA-256(body)>
# The magic prefix identifies the schema version, the body is the profile's
# JSON, and the suffix lets a future import path detect copy/paste corruption
# without full cryptographic verification. Empty string if the profile has
# nothing to export.
func _profile_to_payload(profile_name: String) -> String:
	var json := _profile_to_json_string(profile_name)
	if json == "":
		return ""
	var body := Marshalls.utf8_to_base64(json)
	var ctx := HashingContext.new()
	ctx.start(HashingContext.HASH_SHA256)
	ctx.update(body.to_utf8_buffer())
	var check := ctx.finish().hex_encode().substr(0, 8)
	return "MTRPRF1." + body + "." + check

# Serialize the named profile to a JSON string. Used as the inner layer of
# _profile_to_payload; exposed separately in case we need it for debugging
# or tests. Empty string if the profile has no stored sections.
func _profile_to_json_string(profile_name: String) -> String:
	var src := ConfigFile.new()
	if src.load(UI_CONFIG_PATH) != OK:
		return ""
	var en_sec := "profile." + profile_name + ".enabled"
	var pr_sec := "profile." + profile_name + ".priority"
	if not src.has_section(en_sec):
		return ""
	var enabled: Dictionary = {}
	for key: String in src.get_section_keys(en_sec):
		enabled[key] = bool(src.get_value(en_sec, key))
	var priority: Dictionary = {}
	if src.has_section(pr_sec):
		for key: String in src.get_section_keys(pr_sec):
			priority[key] = int(str(src.get_value(pr_sec, key)))
	return JSON.stringify({
		"metroprofile":      1,
		"name":              profile_name,
		"modloader_version": MODLOADER_VERSION,
		"exported_at":       Time.get_datetime_string_from_system(),
		"enabled":           enabled,
		"priority":          priority,
	}, "  ")

# Profile keys that the active profile references but whose mod isn't in
# _ui_mod_entries (archives deleted, or renamed ZIPs for mods without a
# mod.txt id). Keys whose id prefix matches an installed mod with a different
# version are treated as present -- _apply_profile_to_entries resolves those
# via id-prefix fallback and flags the mismatch. Rendered as red stub rows.
func _missing_mods_in_active_profile() -> Array[String]:
	var cfg := ConfigFile.new()
	if cfg.load(UI_CONFIG_PATH) != OK:
		return []
	var en_sec := "profile." + _active_profile + ".enabled"
	if not cfg.has_section(en_sec):
		return []
	var present: Dictionary = {}
	var ids_installed: Dictionary = {}
	for entry in _ui_mod_entries:
		present[entry["profile_key"]] = true
		if not entry["profile_key"].begins_with("zip:"):
			ids_installed[entry["mod_id"]] = true
	# Folder mods filtered out by dev-mode-off are on disk but hidden from
	# _ui_mod_entries; treat them as present so the user doesn't see every
	# dev mod flagged as deleted when they toggle the setting.
	for key in _hidden_folder_profile_keys.keys():
		present[key] = true
	for mid in _hidden_folder_ids.keys():
		ids_installed[mid] = true
	var missing: Array[String] = []
	for key: String in cfg.get_section_keys(en_sec):
		if present.has(key):
			continue
		var at := key.find("@")
		if at > 0 and ids_installed.has(key.substr(0, at)):
			continue
		missing.append(key)
	missing.sort()
	return missing

# Strip an orphaned stored key from the active profile's sections. Called
# from the "Remove" button on a missing-mod stub row.
func _remove_missing_entry_from_profile(stored_key: String) -> void:
	var cfg := ConfigFile.new()
	if cfg.load(UI_CONFIG_PATH) != OK:
		return
	for suffix: String in [".enabled", ".priority"]:
		var sec := "profile." + _active_profile + suffix
		if cfg.has_section(sec) and cfg.has_section_key(sec, stored_key):
			cfg.erase_section_key(sec, stored_key)
	cfg.save(UI_CONFIG_PATH)

# Keep only letters, digits, space, underscore, hyphen. Strip edges. Reject
# dots (they would collide with the `profile.<name>.enabled` section path).
func _sanitize_profile_name(raw: String) -> String:
	var trimmed := raw.strip_edges()
	var out := ""
	for i in trimmed.length():
		var c := trimmed.substr(i, 1)
		var u := trimmed.unicode_at(i)
		var is_alpha := (u >= 65 and u <= 90) or (u >= 97 and u <= 122)
		var is_digit := u >= 48 and u <= 57
		if is_alpha or is_digit or c == " " or c == "-" or c == "_":
			out += c
	return out

# Reset to Vanilla: switch the active profile to VANILLA_PROFILE, wipe the
# hook pack + override.cfg, and restart the game clean. The active profile
# pointer is the ONLY thing we touch in the config -- stored profiles survive
# so the user can switch back and restore their selection.
#
# Important: we do NOT call _save_ui_config here. That would rewrite the
# currently-active profile's sections from the in-memory _ui_mod_entries
# state, which callers often set to all-disabled before invoking us.

func _reset_to_vanilla_and_restart(win: Window) -> void:
	_log_info("[Reset] User triggered Reset to Vanilla")
	var cfg := ConfigFile.new()
	cfg.load(UI_CONFIG_PATH)
	cfg.set_value("settings", "active_profile", VANILLA_PROFILE)
	cfg.set_value("settings", "developer_mode", _developer_mode)
	cfg.save(UI_CONFIG_PATH)
	var log_lines := PackedStringArray()
	_static_force_vanilla_state("UI reset button", log_lines)
	for line in log_lines:
		_log_info(line)
	if is_instance_valid(win):
		win.queue_free()
	# Strip --modloader-restart so the relaunch is a clean Pass 1, not a Pass 2
	# that would expect pass state we just deleted.
	_modloader_restart(true)

# Tear down and rebuild the Mods tab in place. Called whenever profile state
# changes (switch, create, delete) or Developer Mode toggles, so rows and the
# profile bar reflect fresh _ui_mod_entries + _active_profile state.
func _rebuild_mods_tab(tabs: TabContainer) -> void:
	var old := tabs.get_node_or_null("Mods")
	if old == null:
		return
	var idx := old.get_index()
	tabs.remove_child(old)
	old.queue_free()
	var new_tab := build_mods_tab(tabs)
	new_tab.name = "Mods"
	tabs.add_child(new_tab)
	tabs.move_child(new_tab, idx)
	tabs.current_tab = idx
	# Profile switches / dev-mode toggles change enable state without
	# hitting the per-row checkbox handler.
	refresh_launch_button_label()

# Parent a dialog on the launcher window (fallback: tree root) so it layers
# over our always_on_top Window, and copy our dark theme onto it since theme
# lookup doesn't cross Window boundaries reliably.
func _attach_ui_dialog(d: Window) -> void:
	var parent: Node = _ui_window if _ui_window != null else get_tree().root
	parent.add_child(d)
	if _ui_window != null and _ui_window.theme != null:
		d.theme = _ui_window.theme

# Connect the same handler to both signals and a shared free-and-forget exit
# path. ConfirmationDialog fires `canceled` on Cancel and `close_requested` on
# ESC / window-X -- callers want both to behave the same.
func _connect_dialog_exits(d: ConfirmationDialog, on_confirm: Callable, on_dismiss: Callable) -> void:
	d.confirmed.connect(on_confirm)
	d.canceled.connect(on_dismiss)
	d.close_requested.connect(on_dismiss)

# Make a Control swap the bottom-bar hint label to `text` while hovered and
# restore the original on exit. Stand-in for Godot tooltips, which are popups
# that render behind our always_on_top launcher window.
func _wire_hint(c: Control, text: String) -> void:
	if _ui_hint_label == null:
		return
	var default_text := _ui_hint_label.text
	c.mouse_entered.connect(func():
		if is_instance_valid(_ui_hint_label):
			_ui_hint_label.text = text
	)
	c.mouse_exited.connect(func():
		if is_instance_valid(_ui_hint_label):
			_ui_hint_label.text = default_text
	)

# Modal opens from the red "suspicious code" tag on a mod row. Lists the
# specific patterns the scanner matched. Dismiss-only -- the actual
# launch-time gate lives in _confirm_red_launch.
func _show_security_findings_dialog(entry: Dictionary) -> void:
	var findings: Array = entry.get("security_findings", [])
	if findings.is_empty():
		return
	var d := AcceptDialog.new()
	var mod_name := str(entry.get("mod_name", "?"))
	d.title = "Suspicious code in " + mod_name
	d.ok_button_text = "Close"
	d.min_size = Vector2(580, 420)

	var scroll := ScrollContainer.new()
	scroll.custom_minimum_size = Vector2(560, 380)
	scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
	d.add_child(scroll)

	var body := VBoxContainer.new()
	body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	body.add_theme_constant_override("separation", 12)
	scroll.add_child(body)

	var intro := Label.new()
	intro.text = "The scanner found patterns in this mod's code that are commonly used by malware " \
			+ "(obfuscated string decoding combined with process spawning, anti-debug calls, etc.). " \
			+ "If you don't trust this mod, do not enable it."
	intro.modulate = Color(0.95, 0.6, 0.6)
	intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	intro.add_theme_font_size_override("font_size", 11)
	body.add_child(intro)

	body.add_child(HSeparator.new())

	var rule_color := Color(0.95, 0.4, 0.4)
	for f: Dictionary in findings:
		var card := VBoxContainer.new()
		card.add_theme_constant_override("separation", 4)
		body.add_child(card)

		var rule_lbl := Label.new()
		rule_lbl.text = str(f.get("rule", "?"))
		rule_lbl.modulate = rule_color
		rule_lbl.add_theme_font_size_override("font_size", 13)
		card.add_child(rule_lbl)

		var desc_lbl := Label.new()
		desc_lbl.text = str(f.get("description", ""))
		desc_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
		desc_lbl.add_theme_font_size_override("font_size", 11)
		card.add_child(desc_lbl)

		var loc := str(f.get("file", "?"))
		if int(f.get("line", 0)) > 0:
			loc += ":" + str(f.get("line"))
		var loc_lbl := Label.new()
		loc_lbl.text = loc
		loc_lbl.modulate = Color(0.55, 0.55, 0.55)
		loc_lbl.add_theme_font_size_override("font_size", 10)
		card.add_child(loc_lbl)

		var preview := str(f.get("preview", ""))
		if not preview.is_empty():
			var pre_lbl := Label.new()
			pre_lbl.text = "  " + preview
			pre_lbl.modulate = Color(0.78, 0.85, 0.6)
			pre_lbl.add_theme_font_size_override("font_size", 11)
			pre_lbl.autowrap_mode = TextServer.AUTOWRAP_OFF
			pre_lbl.clip_text = true
			card.add_child(pre_lbl)

		body.add_child(HSeparator.new())

	_attach_ui_dialog(d)
	d.confirmed.connect(d.queue_free)
	d.close_requested.connect(d.queue_free)
	d.popup_centered()

# Mod entries that are currently enabled AND scored RED by the scanner.
# Used to gate Launch when the user has any of these toggled on.
func _enabled_red_mods() -> Array:
	var out: Array = []
	for entry in _ui_mod_entries:
		if entry.get("enabled", false) and int(entry.get("risk_level", 0)) == 2:
			out.append(entry)
	return out

# Launch-time confirmation when one or more enabled mods are scored RED.
# Returns true if the user confirms launch, false if they go back. Loading
# is never silently bypassed; the user must explicitly acknowledge.
#
# Uses plain dialog_text so Godot auto-sizes the window to its content.
# A custom VBoxContainer body would let the window grow off-screen.
func _confirm_red_launch(red_mods: Array) -> bool:
	var d := ConfirmationDialog.new()
	d.title = "Suspicious mods enabled"
	d.ok_button_text = "Launch anyway"
	d.cancel_button_text = "Go back"
	d.dialog_autowrap = true
	# Width floor so the autowrap doesn't squeeze the text into a narrow
	# column; height left to grow with the mod list.
	d.min_size = Vector2(560, 120)

	var lines := PackedStringArray()
	lines.append("The scanner found patterns in the following mod(s) that are commonly used by malware. If you don't trust them, go back and disable them before launching.")
	lines.append("")
	for entry: Dictionary in red_mods:
		lines.append("    " + str(entry.get("mod_name", "?")))
	d.dialog_text = "\n".join(lines)

	_attach_ui_dialog(d)
	# Force dialog above the always_on_top launcher. Without this, clicking
	# the launcher's X (which routes to the same Launch handler) sometimes
	# parents-off the dialog behind the launcher and leaves input frozen.
	d.exclusive = true
	d.always_on_top = true
	# Red text on the destructive button so "Launch anyway" reads as the
	# risky option. Same modulate trick as _show_vanilla_confirm.
	d.get_ok_button().modulate = Color(1.0, 0.55, 0.55)

	# Single-result polling: lambdas mark done + capture the choice.
	# Array used because GDScript closures hold object references.
	var state := [false, false]  # [done, confirmed]
	d.confirmed.connect(func():
		state[0] = true
		state[1] = true)
	d.canceled.connect(func(): state[0] = true)
	d.close_requested.connect(func(): state[0] = true)
	d.popup_centered()
	d.grab_focus()
	while not state[0]:
		await get_tree().process_frame
	d.queue_free()
	return state[1]

# Vanilla dropdown entry: confirm, then run the full reset-and-restart flow.
# Cancel rebuilds the Mods tab so the dropdown reverts from "Vanilla" back to
# the currently-active profile.
func _show_vanilla_confirm(tabs: TabContainer) -> void:
	var d := ConfirmationDialog.new()
	d.title = "Reset to Vanilla"
	d.dialog_text = "This will disable all mods, wipe the hook cache and override.cfg, and restart the game clean.\n\nYour saved profiles are kept -- switch back to any of them later to re-enable those mods.\n\nContinue?"
	d.ok_button_text = "Reset and Restart"
	_attach_ui_dialog(d)
	# Red text on the destructive button. theme_color_override on the OK
	# button didn't take effect for reasons I haven't chased; modulate works
	# because dark bg (~0.06) tints imperceptibly while light text (~0.84)
	# multiplies into a clear red.
	d.get_ok_button().modulate = Color(1.0, 0.55, 0.55)
	var win_ref := _ui_window
	_connect_dialog_exits(d,
		func():
			d.queue_free()
			_reset_to_vanilla_and_restart(win_ref),
		func():
			d.queue_free()
			_rebuild_mods_tab(tabs))
	d.popup_centered()

# New Profile dialog: prompt for a name, validate, snapshot current state
# into the new profile, switch to it. Cancel leaves everything unchanged.
func _show_new_profile_dialog(tabs: TabContainer) -> void:
	var d := ConfirmationDialog.new()
	d.title = "New Profile"
	d.ok_button_text = "Create"
	d.dialog_hide_on_ok = false  # keep open until we validate the name

	var form := VBoxContainer.new()
	form.custom_minimum_size = Vector2(320, 0)
	form.add_theme_constant_override("separation", 6)
	d.add_child(form)

	var prompt := Label.new()
	prompt.text = "Profile name (letters, digits, spaces, _-):"
	form.add_child(prompt)

	var name_edit := LineEdit.new()
	name_edit.custom_minimum_size.x = 280
	form.add_child(name_edit)

	var err_lbl := Label.new()
	err_lbl.modulate = Color(1.0, 0.5, 0.5)
	err_lbl.add_theme_font_size_override("font_size", 11)
	form.add_child(err_lbl)

	_attach_ui_dialog(d)

	var existing := _list_profiles()
	var try_create := func():
		var name := _sanitize_profile_name(name_edit.text)
		if name == "":
			err_lbl.text = "Name cannot be empty or all invalid characters."
		elif name.to_lower() == "vanilla" or name == VANILLA_PROFILE:
			err_lbl.text = "That name is reserved."
		elif name in existing:
			err_lbl.text = "Profile \"" + name + "\" already exists."
		else:
			d.queue_free()
			_create_profile(name)
			_rebuild_mods_tab(tabs)

	name_edit.text_submitted.connect(func(_t): try_create.call())
	_connect_dialog_exits(d, try_create, func(): d.queue_free())
	d.popup_centered()
	name_edit.grab_focus()

# Rename dialog. Same validation rules as New (letters/digits/space/_-, not
# empty, not "Vanilla", not colliding with another profile). Renaming to the
# same name is a silent no-op.
func _show_rename_profile_dialog(tabs: TabContainer) -> void:
	var current := _active_profile
	var d := ConfirmationDialog.new()
	d.title = "Rename Profile"
	d.ok_button_text = "Rename"
	d.dialog_hide_on_ok = false

	var form := VBoxContainer.new()
	form.custom_minimum_size = Vector2(320, 0)
	form.add_theme_constant_override("separation", 6)
	d.add_child(form)

	var prompt := Label.new()
	prompt.text = "New name for \"" + current + "\":"
	form.add_child(prompt)

	var name_edit := LineEdit.new()
	name_edit.custom_minimum_size.x = 280
	name_edit.text = current
	form.add_child(name_edit)

	var err_lbl := Label.new()
	err_lbl.modulate = Color(1.0, 0.5, 0.5)
	err_lbl.add_theme_font_size_override("font_size", 11)
	form.add_child(err_lbl)

	_attach_ui_dialog(d)

	var existing := _list_profiles()
	var try_rename := func():
		var name := _sanitize_profile_name(name_edit.text)
		if name == "":
			err_lbl.text = "Name cannot be empty or all invalid characters."
		elif name.to_lower() == "vanilla" or name == VANILLA_PROFILE:
			err_lbl.text = "That name is reserved."
		elif name == current:
			d.queue_free()  # no-op
		elif name in existing:
			err_lbl.text = "Profile \"" + name + "\" already exists."
		else:
			d.queue_free()
			_rename_profile(name)
			_rebuild_mods_tab(tabs)

	name_edit.text_submitted.connect(func(_t): try_rename.call())
	_connect_dialog_exits(d, try_rename, func(): d.queue_free())
	d.popup_centered()
	name_edit.select_all()
	name_edit.grab_focus()

# Combined Import / Export dialog. Top half shows the active profile's
# checksummed payload with a Copy button; bottom half is a paste area +
# Import button. Name collisions prompt for an overwrite confirm.
func _show_share_profile_dialog(tabs: TabContainer) -> void:
	var current := _active_profile
	var payload := _profile_to_payload(current)

	var d := AcceptDialog.new()
	d.title = "Import / Export Profile"
	d.ok_button_text = "Close"

	var box := VBoxContainer.new()
	box.custom_minimum_size = Vector2(560, 500)
	box.add_theme_constant_override("separation", 8)
	d.add_child(box)

	# -- Export half -----------------------------------------------------------
	var export_lbl := Label.new()
	if payload != "":
		export_lbl.text = "Profile \"" + current + "\" -- copy and share this payload:"
	else:
		export_lbl.text = "Nothing to export (active profile has no saved data yet)."
		export_lbl.modulate = Color(0.55, 0.55, 0.55)
	box.add_child(export_lbl)

	var export_text := TextEdit.new()
	export_text.text = payload
	export_text.editable = false
	export_text.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
	export_text.custom_minimum_size.y = 120
	box.add_child(export_text)

	var copy_btn := Button.new()
	copy_btn.text = "Copy to Clipboard"
	copy_btn.disabled = payload == ""
	box.add_child(copy_btn)
	copy_btn.pressed.connect(func():
		DisplayServer.clipboard_set(payload)
		copy_btn.text = "Copied!"
	)

	box.add_child(HSeparator.new())

	# -- Import half -----------------------------------------------------------
	var import_lbl := Label.new()
	import_lbl.text = "Or paste someone else's payload to import their profile:"
	box.add_child(import_lbl)

	var import_text := TextEdit.new()
	import_text.editable = true
	import_text.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
	import_text.custom_minimum_size.y = 100
	box.add_child(import_text)

	var err_lbl := Label.new()
	err_lbl.modulate = Color(1.0, 0.5, 0.5)
	err_lbl.add_theme_font_size_override("font_size", 11)
	box.add_child(err_lbl)

	var import_btn := Button.new()
	import_btn.text = "Import"
	box.add_child(import_btn)

	_attach_ui_dialog(d)

	# Import flow: parse, validate, prompt-to-overwrite if name collides,
	# otherwise apply + switch + rebuild in one step.
	var do_import := func():
		var parsed := _parse_profile_payload(import_text.text)
		if parsed.has("error"):
			err_lbl.text = parsed["error"]
			return
		var name := _sanitize_profile_name(parsed["name"])
		if name == "" or name.to_lower() == "vanilla" or name == VANILLA_PROFILE:
			err_lbl.text = "Payload contains an invalid profile name."
			return
		var apply := func():
			_import_profile_from_parsed(parsed)
			d.queue_free()
			_rebuild_mods_tab(tabs)
		if name in _list_profiles():
			var cd := ConfirmationDialog.new()
			cd.title = "Overwrite Profile"
			cd.dialog_text = "Profile \"" + name + "\" already exists. Overwrite it with the pasted payload?"
			cd.ok_button_text = "Overwrite"
			_attach_ui_dialog(cd)
			_connect_dialog_exits(cd,
				func():
					cd.queue_free()
					apply.call(),
				func(): cd.queue_free())
			cd.popup_centered()
		else:
			apply.call()

	import_btn.pressed.connect(do_import)

	var dismiss := func(): d.queue_free()
	d.confirmed.connect(dismiss)
	d.close_requested.connect(dismiss)
	d.popup_centered()

# Delete-profile confirmation. The trash button is already disabled when the
# active profile is Vanilla or the last remaining user profile; the guard in
# _delete_active_profile is belt-and-suspenders.
func _show_delete_confirm(tabs: TabContainer) -> void:
	var target := _active_profile
	var d := ConfirmationDialog.new()
	d.title = "Delete Profile"
	d.dialog_text = "Delete profile \"" + target + "\"?\n\nThe mod selection stored in this profile will be discarded. Your other profiles are not affected."
	d.ok_button_text = "Delete"
	_attach_ui_dialog(d)
	_connect_dialog_exits(d,
		func():
			d.queue_free()
			_delete_active_profile()
			_rebuild_mods_tab(tabs),
		func(): d.queue_free())
	d.popup_centered()

# UI

func show_mod_ui() -> void:
	var win := Window.new()
	win.title = "Road to Vostok -- Mod Loader"
	win.size = Vector2i(960, 640)
	win.min_size = Vector2i(640, 420)
	win.wrap_controls = false
	win.always_on_top = true
	win.transparent = true
	win.transparent_bg = true
	get_tree().root.add_child(win)
	win.popup_centered()
	# Stash for dialogs triggered by profile-bar controls. Cleared on close.
	_ui_window = win

	# Kill the default Godot gray on the Window itself (embedded_border is the
	# stylebox that paints the window's own background area).
	var win_style := StyleBoxFlat.new()
	win_style.bg_color = Color(0.0, 0.0, 0.0)
	win.add_theme_stylebox_override("panel",                    win_style)
	win.add_theme_stylebox_override("embedded_border",          win_style.duplicate())
	win.add_theme_stylebox_override("embedded_unfocused_border", win_style.duplicate())

	# Solid dark background so Godot's default gray theme doesn't show through.
	var bg := Panel.new()
	bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	var bg_s := StyleBoxFlat.new()
	bg_s.bg_color = Color(0.0, 0.0, 0.0, 0.6)
	bg_s.border_color = Color(1.0, 1.0, 1.0)
	bg_s.border_width_top    = 1
	bg_s.border_width_bottom = 1
	bg_s.border_width_left   = 1
	bg_s.border_width_right  = 1
	bg.add_theme_stylebox_override("panel", bg_s)
	win.add_child(bg)

	# Assign the dark theme on the Window itself so child Windows (OptionButton
	# popup + dialogs spawned from the profile bar) inherit it via the scene
	# tree. Setting it only on the MarginContainer misses sub-Windows.
	var dark_theme := make_dark_theme()
	win.theme = dark_theme

	var margin := MarginContainer.new()
	margin.add_theme_constant_override("margin_left", 10)
	margin.add_theme_constant_override("margin_right", 10)
	margin.add_theme_constant_override("margin_top", 8)
	margin.add_theme_constant_override("margin_bottom", 10)
	margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	margin.theme = dark_theme
	win.add_child(margin)

	var root := VBoxContainer.new()
	root.add_theme_constant_override("separation", 6)
	margin.add_child(root)

	var tabs := TabContainer.new()
	tabs.size_flags_vertical = Control.SIZE_EXPAND_FILL
	root.add_child(tabs)

	root.add_child(HSeparator.new())

	# Bottom bar: instructions + launch button
	var bottom := HBoxContainer.new()
	bottom.add_theme_constant_override("separation", 10)
	root.add_child(bottom)

	var hint := Label.new()
	hint.text = "Higher number loads later and wins when mods share files.\n" \
			+ "Developer Mode: verbose logging, conflict report, and loose folder loading."
	hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	hint.add_theme_font_size_override("font_size", 11)
	hint.modulate = Color(0.45, 0.45, 0.45)
	bottom.add_child(hint)
	# Expose for _wire_hint so toolbar/dropdown hovers can temporarily repurpose
	# this label as a status-line substitute for broken Godot tooltips.
	_ui_hint_label = hint

	var launch_btn := Button.new()
	launch_btn.text = "  Launch Game  "
	launch_btn.custom_minimum_size = Vector2(130, 36)
	var ls_n := StyleBoxFlat.new()
	ls_n.bg_color = Color(0.05, 0.05, 0.05)
	ls_n.border_color = Color(0.28, 0.28, 0.28)
	ls_n.border_width_top = 1; ls_n.border_width_bottom = 1
	ls_n.border_width_left = 1; ls_n.border_width_right = 1
	ls_n.content_margin_left = 10; ls_n.content_margin_right = 10
	launch_btn.add_theme_stylebox_override("normal", ls_n)
	var ls_h := ls_n.duplicate()
	ls_h.bg_color = Color(0.10, 0.10, 0.10)
	ls_h.border_color = Color(0.55, 0.55, 0.55)
	launch_btn.add_theme_stylebox_override("hover", ls_h)
	var ls_p := ls_n.duplicate()
	ls_p.bg_color = Color(0.03, 0.03, 0.03)
	launch_btn.add_theme_stylebox_override("pressed", ls_p)
	launch_btn.add_theme_color_override("font_color", Color(0.88, 0.88, 0.88))
	launch_btn.add_theme_color_override("font_hover_color", Color(1.0, 1.0, 1.0))
	bottom.add_child(launch_btn)
	_ui_launch_btn = launch_btn

	# Closing the window with X should behave the same as clicking Launch.
	win.close_requested.connect(func(): launch_btn.pressed.emit())

	var mods_tab := build_mods_tab(tabs)
	mods_tab.name = "Mods"
	tabs.add_child(mods_tab)

	var updates_tab := build_updates_tab()
	updates_tab.name = "Updates"
	tabs.add_child(updates_tab)

	refresh_launch_button_label()

	# Launch loop. If any enabled mod has the scanner's RED risk_level,
	# show a confirmation dialog before proceeding. Cancel returns the
	# user to the launcher so they can disable the flagged mod or
	# reconsider; confirm proceeds. No gate when no red mods are enabled.
	while true:
		await launch_btn.pressed
		var red_mods := _enabled_red_mods()
		if red_mods.is_empty():
			break
		var proceed: bool = await _confirm_red_launch(red_mods)
		if proceed:
			break
		# else: loop and wait for Launch again
	_ui_window = null
	_ui_hint_label = null
	_ui_launch_btn = null
	win.queue_free()

# Pessimistic label: any enabled mod -> "(Restart)". Over-warn beats a
# surprise close/reopen; the rare hash-match no-restart case just launches
# faster than promised.
func refresh_launch_button_label() -> void:
	if not is_instance_valid(_ui_launch_btn):
		return
	var any_enabled := false
	for entry in _ui_mod_entries:
		if entry.get("enabled", false):
			any_enabled = true
			break
	if any_enabled:
		_ui_launch_btn.text = "  Launch with Mods (Restart)  "
	else:
		_ui_launch_btn.text = "  Launch Unmodded  "

# Runtime-generated 16x16 pencil icon. Monochrome outline in button-text
# gray so it matches the rest of the UI -- a colored pencil looks like an
# emoji in this context.
func _make_pencil_icon() -> ImageTexture:
	var img := Image.create(16, 16, false, Image.FORMAT_RGBA8)
	img.fill(Color(0, 0, 0, 0))
	var line := Color(0.84, 0.84, 0.84)  # matches C_TEXT in make_dark_theme
	# Body outline: rectangle from (1,5) to (12,9).
	for x in range(1, 13):
		img.set_pixel(x, 5, line)
		img.set_pixel(x, 9, line)
	for y in range(5, 10):
		img.set_pixel(1, y, line)
		img.set_pixel(12, y, line)
	# Divider between eraser compartment and main body.
	for y in range(5, 10):
		img.set_pixel(4, y, line)
	# Triangular tip sticking off the right side.
	img.set_pixel(13, 6, line)
	img.set_pixel(13, 7, line)
	img.set_pixel(13, 8, line)
	img.set_pixel(14, 7, line)
	return ImageTexture.create_from_image(img)

# Runtime-generated 16x16 trashcan: lid handle on top, rectangular body with
# three vertical slots.
func _make_trashcan_icon() -> ImageTexture:
	var img := Image.create(16, 16, false, Image.FORMAT_RGBA8)
	img.fill(Color(0, 0, 0, 0))
	var line := Color(0.84, 0.84, 0.84)  # matches C_TEXT in make_dark_theme
	# Lid handle (short bar on top).
	for x in range(6, 10):
		img.set_pixel(x, 2, line)
	# Lid (wider bar).
	for x in range(3, 13):
		img.set_pixel(x, 4, line)
	# Body sides + floor.
	for y in range(5, 14):
		img.set_pixel(4, y, line)
		img.set_pixel(11, y, line)
	for x in range(5, 11):
		img.set_pixel(x, 13, line)
	# Three vertical slots for texture.
	for y in range(6, 12):
		img.set_pixel(6, y, line)
		img.set_pixel(8, y, line)
		img.set_pixel(10, y, line)
	return ImageTexture.create_from_image(img)

func make_dark_theme() -> Theme:
	var t := Theme.new()

	const C_PANEL := Color(0.04, 0.04, 0.04)
	const C_BTN   := Color(0.07, 0.07, 0.07)
	const C_BORD  := Color(0.18, 0.18, 0.18)
	const C_HI    := Color(0.90, 0.90, 0.90)
	const C_TEXT  := Color(0.84, 0.84, 0.84)
	const C_DIM   := Color(0.42, 0.42, 0.42)

	# -- Button ----------------------------------------------------------------
	var bn := StyleBoxFlat.new()
	bn.bg_color = C_BTN
	bn.border_color = C_BORD
	bn.border_width_top = 1; bn.border_width_bottom = 1
	bn.border_width_left = 1; bn.border_width_right = 1
	bn.content_margin_left = 8; bn.content_margin_right = 8
	bn.content_margin_top = 3; bn.content_margin_bottom = 3
	var bh := bn.duplicate()
	bh.bg_color = Color(0.10, 0.10, 0.10); bh.border_color = C_HI
	var bp := bn.duplicate(); bp.bg_color = Color(0.03, 0.03, 0.03)
	var bd := bn.duplicate()
	bd.bg_color = Color(0.04, 0.04, 0.04); bd.border_color = Color(0.12, 0.12, 0.12)
	t.set_stylebox("normal",   "Button", bn)
	t.set_stylebox("hover",    "Button", bh)
	t.set_stylebox("pressed",  "Button", bp)
	t.set_stylebox("disabled", "Button", bd)
	t.set_stylebox("focus",    "Button", StyleBoxEmpty.new())
	t.set_color("font_color",          "Button", C_TEXT)
	t.set_color("font_hover_color",    "Button", Color(1.0, 1.0, 1.0))
	t.set_color("font_pressed_color",  "Button", C_TEXT)
	t.set_color("font_disabled_color", "Button", C_DIM)

	# -- CheckBox (font only -- box glyph needs texture to restyle) -------------
	t.set_color("font_color",       "CheckBox", C_TEXT)
	t.set_color("font_hover_color", "CheckBox", Color(1.0, 1.0, 1.0))

	# -- Label -----------------------------------------------------------------
	t.set_color("font_color", "Label", C_TEXT)

	# -- Panel / PanelContainer ------------------------------------------------
	var ps := StyleBoxFlat.new(); ps.bg_color = C_PANEL
	t.set_stylebox("panel", "Panel",          ps)
	t.set_stylebox("panel", "PanelContainer", ps.duplicate())

	# -- TabContainer ----------------------------------------------------------
	var ts := StyleBoxFlat.new()   # selected tab
	ts.bg_color = C_PANEL
	ts.border_color = C_BORD
	ts.border_width_top = 1; ts.border_width_left = 1; ts.border_width_right = 1
	ts.border_width_bottom = 0
	ts.content_margin_left = 12; ts.content_margin_right = 12
	ts.content_margin_top = 5;   ts.content_margin_bottom = 5
	var tu := ts.duplicate()      # unselected tab
	tu.bg_color = Color(0.02, 0.02, 0.02)
	tu.border_color = Color(0.12, 0.12, 0.12)
	tu.border_width_bottom = 1
	var tc_panel := StyleBoxFlat.new(); tc_panel.bg_color = C_PANEL
	tc_panel.content_margin_left   = 10
	tc_panel.content_margin_right  = 10
	tc_panel.content_margin_top    = 8
	tc_panel.content_margin_bottom = 8
	t.set_stylebox("tab_selected",   "TabContainer", ts)
	t.set_stylebox("tab_unselected", "TabContainer", tu)
	t.set_stylebox("tab_hovered",    "TabContainer", tu.duplicate())
	t.set_stylebox("panel",          "TabContainer", tc_panel)
	t.set_color("font_selected_color",   "TabContainer", C_HI)
	t.set_color("font_unselected_color", "TabContainer", C_DIM)
	t.set_color("font_hovered_color",    "TabContainer", C_TEXT)

	# -- HSeparator ------------------------------------------------------------
	var sep := StyleBoxFlat.new(); sep.bg_color = Color(0.14, 0.14, 0.14)
	t.set_stylebox("separator", "HSeparator", sep)
	t.set_constant("separation", "HSeparator", 1)

	# -- LineEdit (SpinBox uses this internally) --------------------------------
	var le := StyleBoxFlat.new()
	le.bg_color = Color(0.04, 0.04, 0.04)
	le.border_color = C_BORD
	le.border_width_top = 1; le.border_width_bottom = 1
	le.border_width_left = 1; le.border_width_right = 1
	le.content_margin_left = 6; le.content_margin_right = 6
	le.content_margin_top = 3; le.content_margin_bottom = 3
	t.set_stylebox("normal", "LineEdit", le)
	t.set_stylebox("focus",  "LineEdit", le.duplicate())
	t.set_color("font_color", "LineEdit", C_TEXT)

	# -- ScrollContainer (transparent, scrollbars inherit) ---------------------
	t.set_stylebox("panel", "ScrollContainer", StyleBoxEmpty.new())

	# -- PopupMenu (OptionButton dropdown) -------------------------------------
	var pm_panel := StyleBoxFlat.new()
	pm_panel.bg_color = Color(0.06, 0.06, 0.06)
	pm_panel.border_color = C_BORD
	pm_panel.border_width_top = 1; pm_panel.border_width_bottom = 1
	pm_panel.border_width_left = 1; pm_panel.border_width_right = 1
	pm_panel.content_margin_left = 4; pm_panel.content_margin_right = 4
	pm_panel.content_margin_top = 4;  pm_panel.content_margin_bottom = 4
	t.set_stylebox("panel", "PopupMenu", pm_panel)
	var pm_hover := StyleBoxFlat.new()
	pm_hover.bg_color = Color(0.14, 0.14, 0.14)
	t.set_stylebox("hover", "PopupMenu", pm_hover)
	var pm_sep := StyleBoxFlat.new()
	pm_sep.bg_color = C_BORD
	pm_sep.content_margin_top = 1; pm_sep.content_margin_bottom = 1
	t.set_stylebox("separator", "PopupMenu", pm_sep)
	t.set_color("font_color",           "PopupMenu", C_TEXT)
	t.set_color("font_hover_color",     "PopupMenu", Color(1.0, 1.0, 1.0))
	t.set_color("font_disabled_color",  "PopupMenu", C_DIM)
	t.set_color("font_separator_color", "PopupMenu", C_DIM)

	# -- OptionButton (themed like Button but needs its own panel stylebox
	#    because OptionButton uses a separate theme type from Button) ----------
	t.set_stylebox("normal",   "OptionButton", bn.duplicate())
	t.set_stylebox("hover",    "OptionButton", bh.duplicate())
	t.set_stylebox("pressed",  "OptionButton", bp.duplicate())
	t.set_stylebox("disabled", "OptionButton", bd.duplicate())
	t.set_stylebox("focus",    "OptionButton", StyleBoxEmpty.new())
	t.set_color("font_color",         "OptionButton", C_TEXT)
	t.set_color("font_hover_color",   "OptionButton", Color(1.0, 1.0, 1.0))
	t.set_color("font_pressed_color", "OptionButton", C_TEXT)

	# -- Tooltip (hover hint panel) --------------------------------------------
	# Without these our tooltips render with the default light theme and get
	# lost behind the always_on_top launcher window.
	var tt_panel := StyleBoxFlat.new()
	tt_panel.bg_color = Color(0.10, 0.10, 0.10)
	tt_panel.border_color = C_BORD
	tt_panel.border_width_top = 1; tt_panel.border_width_bottom = 1
	tt_panel.border_width_left = 1; tt_panel.border_width_right = 1
	tt_panel.content_margin_left = 8; tt_panel.content_margin_right = 8
	tt_panel.content_margin_top = 4;  tt_panel.content_margin_bottom = 4
	t.set_stylebox("panel", "TooltipPanel", tt_panel)
	t.set_color("font_color", "TooltipLabel", C_TEXT)

	# -- AcceptDialog / ConfirmationDialog -------------------------------------
	# The dialog's own panel background + embedded-window border styleboxes.
	var dlg_panel := StyleBoxFlat.new()
	dlg_panel.bg_color = Color(0.06, 0.06, 0.06)
	dlg_panel.border_color = C_BORD
	dlg_panel.border_width_top = 1; dlg_panel.border_width_bottom = 1
	dlg_panel.border_width_left = 1; dlg_panel.border_width_right = 1
	dlg_panel.content_margin_left = 10; dlg_panel.content_margin_right = 10
	dlg_panel.content_margin_top = 8;   dlg_panel.content_margin_bottom = 8
	t.set_stylebox("panel", "AcceptDialog", dlg_panel)
	t.set_stylebox("panel", "ConfirmationDialog", dlg_panel.duplicate())
	t.set_stylebox("embedded_border",           "Window", dlg_panel.duplicate())
	t.set_stylebox("embedded_unfocused_border", "Window", dlg_panel.duplicate())
	t.set_color("title_color", "Window", C_HI)

	return t

func build_mods_tab(tabs: TabContainer) -> Control:
	var outer := VBoxContainer.new()
	outer.size_flags_vertical = Control.SIZE_EXPAND_FILL

	# -- Toolbar (profile selector + folder shortcut + dev toggle) ------------
	# Single row: Open Mods Folder | Profile: [dropdown] [+] [pencil] [trash] [Share] | ... | Developer Mode

	var toolbar := HBoxContainer.new()
	toolbar.add_theme_constant_override("separation", 8)
	outer.add_child(toolbar)

	var open_btn := Button.new()
	open_btn.text = "Open Mods Folder"
	toolbar.add_child(open_btn)
	open_btn.pressed.connect(func():
		OS.shell_open(ProjectSettings.globalize_path(_mods_dir))
	)
	_wire_hint(open_btn, "Open the game's mods folder in your file manager.")

	# Small visual gap between folder button and profile controls.
	var pre_profile_gap := Control.new()
	pre_profile_gap.custom_minimum_size.x = 12
	toolbar.add_child(pre_profile_gap)

	var profile_lbl := Label.new()
	profile_lbl.text = "Profile:"
	toolbar.add_child(profile_lbl)

	var profile_opt := OptionButton.new()
	profile_opt.custom_minimum_size.x = 180
	toolbar.add_child(profile_opt)

	# The dropdown popup is a sub-Window. Our modloader Window is always_on_top,
	# which leaves the popup stranded behind it (invisible on click). Mark the
	# popup always_on_top and transient so it layers over us correctly. Theme
	# assignment is explicit -- theme lookup doesn't always cross Window boundaries.
	var profile_popup := profile_opt.get_popup()
	profile_popup.always_on_top = true
	profile_popup.transient = true
	if _ui_window != null and _ui_window.theme != null:
		profile_popup.theme = _ui_window.theme

	# Item 0 is Vanilla; selecting it shows the reset-and-restart confirm.
	# Godot 4 PopupMenu has no per-item text color, so the danger cue lives
	# in the confirmation dialog's red OK button rather than on this entry.
	profile_opt.add_item("Vanilla")
	profile_opt.set_item_metadata(0, VANILLA_PROFILE)

	# Fresh install has no profile sections yet -- show Default as a placeholder
	# that gets materialized on the first _save_ui_config (Launch or any toggle).
	var profiles := _list_profiles()
	if profiles.is_empty():
		profiles = ["Default"]
	var active_idx := 0  # fall back to Vanilla if no user profile matches
	for name: String in profiles:
		profile_opt.add_item(name)
		var idx := profile_opt.item_count - 1
		profile_opt.set_item_metadata(idx, name)
		if name == _active_profile:
			active_idx = idx
	profile_opt.selected = active_idx

	# Profile-mutation buttons. Rename/Delete guard against Vanilla (sentinel
	# has no underlying profile); Delete additionally needs at least one
	# other profile to switch to.
	var on_vanilla := _active_profile == VANILLA_PROFILE

	var new_profile_btn := Button.new()
	new_profile_btn.text = "+"
	new_profile_btn.tooltip_text = "New profile from current mod selection"
	new_profile_btn.custom_minimum_size.x = 28
	toolbar.add_child(new_profile_btn)
	_wire_hint(new_profile_btn, "New profile from current mod selection.")

	var rename_btn := Button.new()
	rename_btn.icon = _make_pencil_icon()
	rename_btn.tooltip_text = "Rename the active profile"
	rename_btn.disabled = on_vanilla
	rename_btn.custom_minimum_size.x = 28
	toolbar.add_child(rename_btn)
	_wire_hint(rename_btn, "Rename the active profile.")

	# Delete is disabled on Vanilla (nothing concrete to delete) and when only
	# one user profile exists (we always need at least one to switch to).
	var del_profile_btn := Button.new()
	del_profile_btn.icon = _make_trashcan_icon()
	del_profile_btn.tooltip_text = "Delete the active profile"
	del_profile_btn.disabled = on_vanilla or profiles.size() <= 1
	del_profile_btn.custom_minimum_size.x = 28
	toolbar.add_child(del_profile_btn)
	_wire_hint(del_profile_btn, "Delete the active profile.")

	# Always enabled: even on Vanilla or an empty profile, users may want to
	# paste in someone else's shared payload.
	var share_btn := Button.new()
	share_btn.text = "Share"
	share_btn.tooltip_text = "Copy this profile to share, or paste one from someone else"
	toolbar.add_child(share_btn)
	_wire_hint(share_btn, "Copy this profile to share, or paste one from someone else.")

	var spacer := Control.new()
	spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	toolbar.add_child(spacer)

	var dev_check := CheckBox.new()
	dev_check.text = "Developer Mode"
	dev_check.tooltip_text = "Enables verbose logging, conflict report, and loose folder loading"
	dev_check.button_pressed = _developer_mode
	dev_check.add_theme_font_size_override("font_size", 11)
	dev_check.modulate = Color(0.45, 0.45, 0.45)
	toolbar.add_child(dev_check)
	_wire_hint(dev_check, "Developer Mode: verbose logging, conflict report, and loose folder loading.")

	profile_opt.item_selected.connect(func(idx: int):
		var meta = profile_opt.get_item_metadata(idx)
		if meta == VANILLA_PROFILE:
			_show_vanilla_confirm(tabs)
		else:
			_switch_profile(str(meta))
			_rebuild_mods_tab(tabs)
	)
	new_profile_btn.pressed.connect(func(): _show_new_profile_dialog(tabs))
	rename_btn.pressed.connect(func(): _show_rename_profile_dialog(tabs))
	del_profile_btn.pressed.connect(func(): _show_delete_confirm(tabs))
	share_btn.pressed.connect(func(): _show_share_profile_dialog(tabs))

	dev_check.toggled.connect(func(on: bool):
		_developer_mode = on
		_ui_mod_entries = collect_mod_metadata()
		_load_ui_config()
		_rebuild_mods_tab(tabs)
	)

	outer.add_child(HSeparator.new())

	var split := HSplitContainer.new()
	split.split_offset = 560
	split.size_flags_vertical = Control.SIZE_EXPAND_FILL
	outer.add_child(split)

	# -- Left: mod list --------------------------------------------------------

	var left_scroll := ScrollContainer.new()
	left_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	left_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
	split.add_child(left_scroll)

	# Right padding keeps the load-order SpinBox arrows from sitting flush
	# against the vertical scrollbar -- users were hitting the spin arrows
	# while trying to drag the scrollbar handle.
	var list_pad := MarginContainer.new()
	list_pad.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	list_pad.add_theme_constant_override("margin_right", 16)
	left_scroll.add_child(list_pad)

	var list := VBoxContainer.new()
	list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	list_pad.add_child(list)

	# -- Right: live load order preview ----------------------------------------

	var right := VBoxContainer.new()
	right.custom_minimum_size.x = 220
	split.add_child(right)

	var order_header := Label.new()
	order_header.text = "Load Order"
	order_header.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	right.add_child(order_header)
	right.add_child(HSeparator.new())

	# Dark panel behind the load order list for visual separation.
	var order_panel := PanelContainer.new()
	order_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
	var panel_style := StyleBoxFlat.new()
	panel_style.bg_color = Color(0.09, 0.09, 0.09)
	panel_style.content_margin_left = 8
	panel_style.content_margin_right = 8
	panel_style.content_margin_top = 6
	panel_style.content_margin_bottom = 6
	order_panel.add_theme_stylebox_override("panel", panel_style)
	right.add_child(order_panel)

	var order_scroll := ScrollContainer.new()
	order_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
	order_panel.add_child(order_scroll)

	var order_list := VBoxContainer.new()
	order_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	order_scroll.add_child(order_list)

	# Rebuilds the right-side order list from current entry state.
	var refresh_order := func():
		for child in order_list.get_children():
			child.queue_free()
		var sorted := _ui_mod_entries.filter(func(e): return e["enabled"])
		sorted.sort_custom(_compare_load_order)
		if sorted.is_empty():
			var lbl := Label.new()
			lbl.text = "(none enabled)"
			lbl.modulate = Color(0.5, 0.5, 0.5)
			order_list.add_child(lbl)
			return
		for i in sorted.size():
			var e: Dictionary = sorted[i]
			var lbl := Label.new()
			lbl.text = str(i + 1) + ".  " + e["mod_name"]
			lbl.add_theme_font_size_override("font_size", 12)
			lbl.modulate = Color(0.80, 0.80, 0.80)
			lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
			order_list.add_child(lbl)

	# -- Missing from this profile --------------------------------------------
	# Mods the active profile references but that aren't on disk. Shown at the
	# top of the list so they get attention before the regular mod rows; each
	# has a Remove button to strip the orphaned keys from the profile. Future:
	# offer to download via modworkshop if an id is stored.
	var missing_files := _missing_mods_in_active_profile()
	if not missing_files.is_empty():
		var missing_hdr := Label.new()
		missing_hdr.text = "Missing from this profile"
		missing_hdr.modulate = Color(1.0, 0.55, 0.55)
		missing_hdr.add_theme_font_size_override("font_size", 11)
		list.add_child(missing_hdr)
		list.add_child(HSeparator.new())
		for fn: String in missing_files:
			var miss_row := HBoxContainer.new()
			list.add_child(miss_row)
			var miss_lbl := Label.new()
			var display := fn.trim_prefix("zip:")
			miss_lbl.text = display + "  --  not installed"
			miss_lbl.modulate = Color(1.0, 0.45, 0.45)
			miss_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
			miss_row.add_child(miss_lbl)
			var remove_btn := Button.new()
			remove_btn.text = "Remove"
			remove_btn.tooltip_text = "Strip this entry from the active profile"
			miss_row.add_child(remove_btn)
			var captured := fn
			remove_btn.pressed.connect(func():
				_remove_missing_entry_from_profile(captured)
				_rebuild_mods_tab(tabs)
			)
			list.add_child(HSeparator.new())

	# -- Column headers --------------------------------------------------------

	var header_row := HBoxContainer.new()
	list.add_child(header_row)

	var h_on := Label.new()
	h_on.text = "On"
	h_on.custom_minimum_size.x = 30
	header_row.add_child(h_on)

	var h_name := Label.new()
	h_name.text = "Mod"
	h_name.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	header_row.add_child(h_name)

	var h_prio := Label.new()
	h_prio.text = "Load Order"
	h_prio.custom_minimum_size.x = 100
	h_prio.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
	header_row.add_child(h_prio)

	list.add_child(HSeparator.new())

	# -- One row per mod -------------------------------------------------------

	# Vanilla has no editable state; hint users to pick a profile before they
	# try to toggle anything. Controls below are disabled alongside this note.
	if on_vanilla and not _ui_mod_entries.is_empty():
		var vanilla_note := Label.new()
		vanilla_note.text = "Vanilla active -- switch to a profile to edit mods."
		vanilla_note.modulate = Color(1.0, 0.55, 0.55)
		vanilla_note.add_theme_font_size_override("font_size", 11)
		list.add_child(vanilla_note)
		list.add_child(HSeparator.new())

	if _ui_mod_entries.is_empty():
		var empty := Label.new()
		empty.text = "No mods found.\n\nPlace .vmz or .pck files in:\n" \
				+ ProjectSettings.globalize_path(_mods_dir)
		empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
		empty.modulate = Color(0.5, 0.5, 0.5)
		empty.add_theme_font_size_override("font_size", 12)
		list.add_child(empty)

	for entry in _ui_mod_entries:
		var row := HBoxContainer.new()
		list.add_child(row)

		var check := CheckBox.new()
		check.button_pressed = entry["enabled"]
		check.custom_minimum_size.x = 30
		row.add_child(check)

		var name_col := VBoxContainer.new()
		name_col.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		row.add_child(name_col)

		var name_lbl := Label.new()
		name_lbl.text = entry["mod_name"]
		name_lbl.clip_text = true
		name_lbl.modulate = Color(0.58, 0.82, 0.38) if entry["enabled"] else Color(0.5, 0.5, 0.5)
		name_col.add_child(name_lbl)

		if entry["ext"] == "folder":
			var dev_lbl := Label.new()
			dev_lbl.text = "[dev folder]"
			dev_lbl.modulate = Color(0.9, 0.3, 0.3)
			dev_lbl.add_theme_font_size_override("font_size", 11)
			name_col.add_child(dev_lbl)
		for warn_text: String in entry.get("warnings", []):
			var warn := Label.new()
			warn.text = warn_text
			warn.modulate = Color(1.0, 0.6, 0.2)
			warn.add_theme_font_size_override("font_size", 11)
			name_col.add_child(warn)

		# Profile was saved with a different version of this mod. Surface the
		# change so the user knows their enabled/priority state was carried
		# over across the upgrade/downgrade rather than silently re-defaulted.
		var vm: Dictionary = entry.get("profile_version_mismatch", {})
		if not vm.is_empty():
			var stored_v: String = str(vm.get("stored", ""))
			var current_v: String = str(vm.get("current", ""))
			var stored_disp := stored_v if stored_v != "" else "(unset)"
			var current_disp := current_v if current_v != "" else "(unset)"
			var vm_lbl := Label.new()
			vm_lbl.text = "profile version: " + stored_disp + " -> " + current_disp
			vm_lbl.modulate = Color(1.0, 0.6, 0.2)
			vm_lbl.add_theme_font_size_override("font_size", 11)
			name_col.add_child(vm_lbl)

		# Scanner indicator. Only renders for RED risk -- mods whose source
		# combines patterns that are nearly diagnostic of malware (dropper
		# trinity, anti-debug crash, ransomware setup). Yellow ("uses
		# notable APIs") is computed and logged but deliberately not shown
		# in the UI: most legit mods have at least one elevated API and
		# surfacing every one would just generate help-channel noise.
		# Loading is never blocked either way; the user judges.
		var risk: int = int(entry.get("risk_level", 0))
		if risk == 2:
			var sec_btn := Button.new()
			sec_btn.text = "suspicious code"
			sec_btn.flat = true
			sec_btn.modulate = Color(0.95, 0.4, 0.4)
			sec_btn.add_theme_font_size_override("font_size", 11)
			sec_btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
			sec_btn.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
			name_col.add_child(sec_btn)
			var captured_entry := entry
			sec_btn.pressed.connect(func(): _show_security_findings_dialog(captured_entry))

		# Vanilla has no stored profile; disable editing so auto-save can't
		# create a ghost `profile.__vanilla__.*` section.
		if on_vanilla:
			check.disabled = true

		var spin := SpinBox.new()
		spin.min_value = PRIORITY_MIN
		spin.max_value = PRIORITY_MAX
		spin.value = entry["priority"]
		spin.custom_minimum_size.x = 100
		if on_vanilla:
			spin.editable = false
		row.add_child(spin)

		list.add_child(HSeparator.new())

		# Capture entry by reference (Dictionaries are reference types in GDScript)
		var e := entry
		var nlbl := name_lbl
		check.toggled.connect(func(on: bool):
			e["enabled"] = on
			nlbl.modulate = Color(0.58, 0.82, 0.38) if on else Color(0.5, 0.5, 0.5)
			refresh_order.call()
			refresh_launch_button_label()
			_save_ui_config()
		)
		spin.value_changed.connect(func(val: float):
			e["priority"] = int(val)
			refresh_order.call()
			_save_ui_config()
		)

	refresh_order.call()
	return outer

func build_updates_tab() -> Control:
	var margin := MarginContainer.new()
	margin.add_theme_constant_override("margin_left", 8)
	margin.add_theme_constant_override("margin_right", 8)
	margin.add_theme_constant_override("margin_top", 6)
	margin.add_theme_constant_override("margin_bottom", 6)

	var container := VBoxContainer.new()
	container.add_theme_constant_override("separation", 6)
	margin.add_child(container)

	var toolbar := HBoxContainer.new()
	toolbar.add_theme_constant_override("separation", 8)
	container.add_child(toolbar)

	var spacer := Control.new()
	spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	toolbar.add_child(spacer)

	var check_btn := Button.new()
	check_btn.text = "Check for Updates"
	toolbar.add_child(check_btn)

	container.add_child(HSeparator.new())

	# Column headers
	var header_row := HBoxContainer.new()
	container.add_child(header_row)

	var h_mod := Label.new()
	h_mod.text = "Mod"
	h_mod.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	header_row.add_child(h_mod)

	var h_ver := Label.new()
	h_ver.text = "Version"
	h_ver.custom_minimum_size.x = 90
	header_row.add_child(h_ver)

	var h_status := Label.new()
	h_status.text = "Status"
	h_status.custom_minimum_size.x = 160
	header_row.add_child(h_status)

	var h_action := Label.new()
	h_action.text = "Action"
	h_action.custom_minimum_size.x = 90
	header_row.add_child(h_action)

	container.add_child(HSeparator.new())

	var scroll := ScrollContainer.new()
	scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
	container.add_child(scroll)

	var list := VBoxContainer.new()
	list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	scroll.add_child(list)

	# { label, version, mw_id, dl_btn, full_path, mod_name }
	var status_info: Dictionary = {}

	for entry in _ui_mod_entries:
		var cfg: ConfigFile = entry["cfg"]
		if cfg == null:
			continue
		var version := str(cfg.get_value("mod", "version", ""))
		var mw_id := 0
		if cfg.has_section_key("updates", "modworkshop"):
			mw_id = int(str(cfg.get_value("updates", "modworkshop", "")))

		var row := HBoxContainer.new()
		list.add_child(row)

		# Name column: mod name + last-modified date sub-label.
		var name_col := VBoxContainer.new()
		name_col.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		row.add_child(name_col)

		var name_lbl := Label.new()
		name_lbl.text = entry["mod_name"]
		name_lbl.clip_text = true
		name_col.add_child(name_lbl)

		var mtime := FileAccess.get_modified_time(entry["full_path"])
		if mtime > 0:
			var dt := Time.get_datetime_dict_from_unix_time(mtime)
			var date_str := "%04d-%02d-%02d" % [dt["year"], dt["month"], dt["day"]]
			var mod_lbl := Label.new()
			mod_lbl.text = "modified " + date_str
			mod_lbl.add_theme_font_size_override("font_size", 11)
			mod_lbl.modulate = Color(0.5, 0.5, 0.5)
			name_col.add_child(mod_lbl)

		var ver_lbl := Label.new()
		ver_lbl.text = "v" + version if version != "" else "--"
		ver_lbl.custom_minimum_size.x = 90
		row.add_child(ver_lbl)

		var status_lbl := Label.new()
		status_lbl.custom_minimum_size.x = 160
		status_lbl.text = "no update info" if mw_id == 0 or version == "" else "--"
		row.add_child(status_lbl)

		# Always add dl_btn to preserve column width. Use modulate.a to
		# hide it visually without collapsing its layout slot.
		var dl_btn := Button.new()
		dl_btn.text = "Download"
		dl_btn.custom_minimum_size.x = 90
		dl_btn.modulate.a = 0.0
		dl_btn.disabled = true
		dl_btn.mouse_filter = Control.MOUSE_FILTER_IGNORE
		row.add_child(dl_btn)

		list.add_child(HSeparator.new())

		if mw_id > 0 and version != "":
			status_info[entry["file_name"]] = {
				"label": status_lbl, "ver_lbl": ver_lbl, "version": version, "mw_id": mw_id,
				"dl_btn": dl_btn, "full_path": entry["full_path"],
				"mod_name": entry["mod_name"],
			}

	if list.get_child_count() == 0:
		var lbl := Label.new()
		lbl.text = "No mods with update information found.\nAdd [updates] modworkshop=<id> and version=<x.y.z> to mod.txt to enable this."
		lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
		list.add_child(lbl)

	# -- Activity log ----------------------------------------------------------

	container.add_child(HSeparator.new())

	var log_hdr := Label.new()
	log_hdr.text = "Activity"
	log_hdr.add_theme_font_size_override("font_size", 11)
	log_hdr.modulate = Color(0.65, 0.65, 0.65)
	container.add_child(log_hdr)

	var log_bg := PanelContainer.new()
	log_bg.custom_minimum_size.y = 72
	var log_style := StyleBoxFlat.new()
	log_style.bg_color = Color(0.09, 0.09, 0.09)
	log_style.content_margin_left = 6
	log_style.content_margin_right = 6
	log_style.content_margin_top = 4
	log_style.content_margin_bottom = 4
	log_bg.add_theme_stylebox_override("panel", log_style)
	container.add_child(log_bg)

	var log_scroll := ScrollContainer.new()
	log_bg.add_child(log_scroll)

	var log_list := VBoxContainer.new()
	log_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	log_scroll.add_child(log_list)

	var add_log := func(msg: String):
		var t := Time.get_time_string_from_system()
		var lbl := Label.new()
		lbl.text = "[" + t + "] " + msg
		lbl.add_theme_font_size_override("font_size", 11)
		lbl.modulate = Color(0.8, 0.8, 0.8)
		lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
		log_list.add_child(lbl)
		log_scroll.scroll_vertical = 999999

	check_btn.pressed.connect(func():
		check_btn.disabled = true
		check_btn.text = "Checking..."
		for fn in status_info:
			var info: Dictionary = status_info[fn]
			(info["label"] as Label).text = "checking..."
			(info["label"] as Label).modulate = Color(1.0, 1.0, 1.0)
			var btn: Button = info["dl_btn"]
			btn.modulate.a = 0.0
			btn.disabled = true
			btn.mouse_filter = Control.MOUSE_FILTER_IGNORE
			btn.text = "Download"
		await check_updates_for_ui(status_info, add_log, check_btn)
		check_btn.disabled = false
		check_btn.text = "Check for Updates"
	)

	return margin

func check_updates_for_ui(status_info: Dictionary, add_log: Callable, check_btn: Button) -> void:
	var ids: Array[int] = []
	for fn in status_info:
		ids.append(status_info[fn]["mw_id"])
	if ids.is_empty():
		return

	var latest := await fetch_latest_modworkshop_versions(ids)

	if not is_instance_valid(check_btn):
		return

	for fn: String in status_info:
		var info: Dictionary = status_info[fn]
		var lbl: Label = info["label"]
		var dl_btn: Button = info["dl_btn"]
		var latest_v = latest.get(str(info["mw_id"]), null)
		if latest_v == null:
			lbl.text = "no data"
			lbl.modulate = Color(1.0, 1.0, 1.0)
			continue

		var cmp := compare_versions(info["version"], str(latest_v))
		if cmp >= 0:
			# Local is same version or newer than what's on the server.
			lbl.text = "up to date"
			lbl.modulate = Color(0.6, 0.6, 0.6)
		else:
			# Server has a newer version.
			lbl.text = "update: v" + str(latest_v)
			lbl.modulate = Color(0.90, 0.90, 0.90)
			dl_btn.modulate.a = 1.0
			dl_btn.disabled = false
			dl_btn.mouse_filter = Control.MOUSE_FILTER_STOP
			var full_path: String = info["full_path"]
			var mw_id: int = info["mw_id"]
			var mod_name: String = info["mod_name"]
			var new_ver: String = str(latest_v)
			# Disconnect previous connections so repeated checks don't stack callbacks.
			for c in dl_btn.pressed.get_connections():
				dl_btn.pressed.disconnect(c["callable"])
			dl_btn.pressed.connect(func():
				dl_btn.disabled = true
				dl_btn.text = "Downloading..."
				lbl.text = "downloading..."
				check_btn.disabled = true
				var ok := await download_and_replace_mod(full_path, mw_id)
				if not is_instance_valid(check_btn):
					return
				if not is_instance_valid(dl_btn):
					return
				check_btn.disabled = false
				if ok:
					lbl.text = "updated -- restart to apply"
					lbl.modulate = Color(0.80, 0.80, 0.80)
					dl_btn.modulate.a = 0.0
					dl_btn.disabled = true
					dl_btn.mouse_filter = Control.MOUSE_FILTER_IGNORE
					dl_btn.text = "Download"
					# Update cached version so next Check won't re-flag this mod.
					info["version"] = new_ver
					(info["ver_lbl"] as Label).text = "v" + new_ver
					add_log.call(mod_name + " -- updated to v" + new_ver + ". Restart game to apply.")
				else:
					lbl.text = "download failed"
					lbl.modulate = Color(1.0, 0.4, 0.4)
					dl_btn.disabled = false
					dl_btn.text = "Retry"
					add_log.call(mod_name + " -- download failed.")
			)

## ----- hooks_api.gd -----
## Public surface that mods call via Engine.get_meta("RTVModLib"):
## hook/unhook/has_hooks/has_replace/get_replace_owner/skip_super/seq, the
## version accessors, plus the internal dispatch helpers. Also owns
## frameworks_ready emission.

# Version accessors. Mods call these to gate features on modloader version:
#   if lib.major_version() >= 3: use_new_api()
static func version() -> String:
	return MODLOADER_VERSION

static func major_version() -> int:
	return int(MODLOADER_VERSION.split(".")[0])

static func minor_version() -> int:
	return int(MODLOADER_VERSION.split(".")[1])

static func patch_version() -> int:
	return int(MODLOADER_VERSION.split(".")[2])

func _register_rtv_modlib_meta() -> void:
	if Engine.has_meta("RTVModLib"):
		_log_warning("[RTVModLib] Engine.meta 'RTVModLib' already set -- not overwriting")
		return
	Engine.set_meta("RTVModLib", self)
	_log_info("[RTVModLib] modloader registered as Engine.meta('RTVModLib')")

# Mods that await Engine.get_meta("RTVModLib").frameworks_ready block until
# we fire this.
func _emit_frameworks_ready() -> void:
	_is_ready = true
	_register_core_hooks()
	_scene_nodes_connect_listener()
	frameworks_ready.emit()
	_log_info("[RTVModLib] frameworks_ready emitted")
	# All mod autoloads have now finished their _ready() calls, which is
	# where overrideScript() calls typically fire take_over_path. Verify
	# each declared override actually landed in the ResourceCache and
	# subscribe to node_added so we can report whether NEW instances
	# spawn with the mod's script vs. vanilla (catches PackedScene
	# ext_resource staleness that take_over_path can't fix).
	_verify_script_overrides()

func hook(hook_name: String, callback: Callable, priority: int = 100) -> int:
	var is_replace := not (hook_name.ends_with("-pre") \
			or hook_name.ends_with("-post") \
			or hook_name.ends_with("-callback"))
	if is_replace and _hooks.has(hook_name) and (_hooks[hook_name] as Array).size() > 0:
		var owner_id: int = (_hooks[hook_name] as Array)[0]["id"]
		# Info-level, not warning: rejection is normal API behavior (replace
		# slots are single-owner by design). Caller checks the -1 return
		# code. Promoting this to push_warning() made every test assertion
		# and every mod-conflict check spam Godot's stderr even though it's
		# expected. Debug-gated so verbose logs still show it when needed.
		_log_debug("[RTVModLib] replace hook '%s' already owned (id=%d), registration rejected" \
				% [hook_name, owner_id])
		return -1
	if not _hooks.has(hook_name):
		_hooks[hook_name] = []
	var entry := { "callback": callback, "priority": priority, "id": _next_id }
	(_hooks[hook_name] as Array).append(entry)
	(_hooks[hook_name] as Array).sort_custom(func(a, b): return a["priority"] < b["priority"])
	# Flip the global short-circuit so dispatch wrappers stop skipping.
	_any_mod_hooked = true
	var id := _next_id
	_next_id += 1
	return id

## godot-mod-loader compat shim. Mods written against the upstream
## godot-mod-loader convention call `ModLoader.add_hook(path, method, cb,
## before)` to register a hook. Translate that into our native
## `hook("<stem>-<method>-pre/post", cb)`. Enroll the path into
## _hooked_methods so the wrap surface picks it up on pack generation.
##
## Limitation 1 -- timing: pack generation reads _hooked_methods up front.
## An add_hook() call that arrives AFTER `_generate_hook_pack` has already
## run won't get its vanilla script wrapped (the hook name registers fine,
## but there's no wrapper to dispatch it). To be wrapped, add_hook() must
## run before _generate_hook_pack -- in practice, from a `!`-prefixed early
## autoload's _init, or the mod must declare the path in [hooks] in mod.txt
## so the wrap mask is populated statically before any mod code runs.
##
## Limitation 2 -- path shape: bare filenames are normalized to
## `res://Scripts/<file>` to match RTV's game-script layout. If the actual
## vanilla lives elsewhere (`res://Scripts/Framework/X.gd`,
## `res://SomeOther/Y.gd`, etc.) the normalized path won't match any enumerated
## vanilla and the wrap silently no-ops. Pass a fully-qualified `res://` path
## when your target isn't directly under `res://Scripts/`.
func add_hook(script_path: String, method_name: String, callback: Callable, is_before: bool = true) -> int:
	var stem := script_path.get_file().get_basename().to_lower()
	var suffix := "pre" if is_before else "post"
	var hook_name := "%s-%s-%s" % [stem, method_name.to_lower(), suffix]
	# Enroll the path into _hooked_methods so the wrap surface picks it up.
	# Upstream godot-mod-loader accepts both fully-qualified res:// paths and
	# bare filenames; normalize bare filenames to res://Scripts/<file> to
	# match the game's script layout. Mask keys are lowercase (hook_pack.gd
	# checks `fe["name"].to_lower()` against the mask), so lowercase the
	# method name on write -- godot-mod-loader callers pass vanilla method
	# names preserving source casing (e.g. "UpdateToolTip").
	var res_path := script_path
	if not res_path.begins_with("res://"):
		res_path = "res://Scripts/" + script_path.get_file()
	if not _hooked_methods.has(res_path):
		_hooked_methods[res_path] = {}
	(_hooked_methods[res_path] as Dictionary)[method_name.to_lower()] = true
	return hook(hook_name, callback, 100)

## Remove a hook by ID.
func unhook(hook_id: int) -> void:
	for hook_name in _hooks:
		var arr: Array = _hooks[hook_name]
		for i in range(arr.size() - 1, -1, -1):
			if arr[i]["id"] == hook_id:
				arr.remove_at(i)
				return

## Any hooks registered at this name?
func has_hooks(hook_name: String) -> bool:
	return _hooks.has(hook_name) and (_hooks[hook_name] as Array).size() > 0

## Is a replace hook registered at this bare name (no -pre/-post/-callback)?
func has_replace(hook_name: String) -> bool:
	return _hooks.has(hook_name) and (_hooks[hook_name] as Array).size() > 0

## ID of the current replace owner, or -1 if none. Lets a mod detect a
## pre-existing replace and fall back to pre/post rather than getting rejected.
func get_replace_owner(hook_name: String) -> int:
	if not _hooks.has(hook_name) or (_hooks[hook_name] as Array).size() == 0:
		return -1
	return (_hooks[hook_name] as Array)[0]["id"]

## From inside a replace hook: prevent super() from running on return.
func skip_super() -> void:
	_skip_super = true

## Monotonic dispatch counter, for tests + debug logging.
func seq() -> int:
	return _seq

# Internal dispatch -- called from the generated framework wrappers.

func _dispatch(hook_name: String, args: Array) -> void:
	if not _hooks.has(hook_name):
		return
	# Snapshot before iterating so callbacks that hook()/unhook() mid-dispatch
	# see consistent semantics: hooks registered during dispatch don't fire
	# in the CURRENT dispatch (they join the next one), and the new hook()'s
	# sort_custom on the live array can't re-enter our iteration. Matches
	# C03/C16/C17/C18 test expectations.
	var entries: Array = (_hooks[hook_name] as Array).duplicate()
	for entry in entries:
		_seq += 1
		var cb: Callable = entry["callback"]
		cb.callv(args)

func _dispatch_deferred(hook_name: String, args: Array) -> void:
	if not _hooks.has(hook_name):
		return
	var entries: Array = (_hooks[hook_name] as Array).duplicate()
	for entry in entries:
		_seq += 1
		var cb: Callable = entry["callback"]
		cb.bindv(args).call_deferred()

func _get_hooks(hook_name: String) -> Array:
	if not _hooks.has(hook_name):
		return []
	var callbacks := []
	for entry in _hooks[hook_name]:
		callbacks.append(entry["callback"])
	return callbacks

## ----- registry.gd -----
## Public registry API for mods to add/override/edit vanilla game content.
##
## Usage:
##   lib.register(lib.Registry.SCENES, "my_item", preload("res://mymod/item.tscn"))
##   lib.override(lib.Registry.SCENES, "Potato", preload("res://mymod/better_potato.tscn"))
##   lib.remove(lib.Registry.SCENES, "my_item")
##   lib.revert(lib.Registry.SCENES, "Potato")
##
## This file owns:
##   - the Registry const (canonical registry names)
##   - rollback tracking dicts shared across sections
##   - the five public verb dispatchers + get_entry() read API
##
## Per-registry handlers live in src/registry/*.gd. Adding a new registry is:
##   1. Add a Registry.FOO constant below
##   2. Add a match-arm per verb in this file
##   3. Create src/registry/foo.gd with _register_foo / _override_foo etc.
##   4. List the new file in build.sh's FILES array
## No other file changes.
##
## Timing constraint: Trader / LootContainer / LootSimulation fill local
## buckets from LootTables in their `_ready()` and never re-read. Mod authors
## MUST register loot during their own mod `_ready()`; earlier in the
## autoload order; or their entries won't propagate to world loot and
## traders. Runtime re-registration after scene load is invisible.

# Registry name constants. Mods use lib.Registry.SCENES etc. instead of raw
# strings so typos surface at parse time.
const Registry := {
	SCENES = "scenes",
	ITEMS = "items",
	LOOT = "loot",
	SOUNDS = "sounds",
	RECIPES = "recipes",
	EVENTS = "events",
	TRADER_POOLS = "trader_pools",
	TRADER_TASKS = "trader_tasks",
	INPUTS = "inputs",
	SCENE_PATHS = "scene_paths",
	SHELTERS = "shelters",
	RANDOM_SCENES = "random_scenes",
	AI_TYPES = "ai_types",
	FISH_SPECIES = "fish_species",
	RESOURCES = "resources",
	SCENE_NODES = "scene_nodes",
	# Future sections populate the rest:
	# TRADER_POOLS = "trader_pools",
	# TRADER_TASKS = "trader_tasks",
	# INPUTS = "inputs",
	# SHELTERS = "shelters",
	# SCENE_PATHS = "scene_paths",
	# AI_TYPES = "ai_types",
	# FISH_SPECIES = "fish_species",
	# TRACKS = "tracks",
	# RESOURCES = "resources",
}

# Rollback tracking. Populated by register/override/patch, consumed by
# remove/revert. Structure: reg -> {id -> {...per-verb data}}.
#   _registry_registered: reg -> {id -> data}         (newly created entries)
#   _registry_overridden: reg -> {id -> original}     (full-entry replacements)
#   _registry_patched:    reg -> {id -> {field -> original_value}}
# Per-field patch tracking stores the value as it was BEFORE the first patch
# to that field; subsequent patches to the same field don't overwrite the
# stash, so revert restores true original state.
var _registry_registered: Dictionary = {}
var _registry_overridden: Dictionary = {}
var _registry_patched: Dictionary = {}

# ---- Public verbs ----

## Register a NEW entry. Fails if the id already exists (in vanilla or prior
## mod registrations). Returns true on success.
func register(registry: String, id: String, data: Variant) -> bool:
	if id == "":
		push_warning("[Registry] register(%s, ...) called with empty id" % registry)
		return false
	match registry:
		"scenes": return _register_scene(id, data)
		"items": return _register_item(id, data)
		"loot": return _register_loot(id, data)
		"sounds": return _register_sound(id, data)
		"recipes": return _register_recipe(id, data)
		"events": return _register_event(id, data)
		"trader_pools": return _register_trader_pool(id, data)
		"trader_tasks": return _register_trader_task(id, data)
		"inputs": return _register_input(id, data)
		"scene_paths": return _register_scene_path(id, data)
		"shelters": return _register_shelter(id, data)
		"random_scenes": return _register_random_scene(id, data)
		"ai_types": return _register_ai_type(id, data)
		"fish_species": return _register_fish_species(id, data)
		"resources":
			push_warning("[Registry] register: 'resources' doesn't support register (the target .tres already exists in vanilla; use patch to mutate its fields)")
			return false
		"scene_nodes":
			push_warning("[Registry] register: 'scene_nodes' doesn't support register (nodes are positional inside a scene; use override('scenes', ...) to replace the whole scene or patch('scene_nodes', ...) to mutate node properties)")
			return false
		_:
			push_warning("[Registry] register: unknown registry '%s'" % registry)
			return false

## Replace an existing entry. Preserves the original so revert() can restore.
## Fails if the id doesn't currently resolve.
func override(registry: String, id: String, data: Variant) -> bool:
	if id == "":
		push_warning("[Registry] override(%s, ...) called with empty id" % registry)
		return false
	match registry:
		"scenes": return _override_scene(id, data)
		"items": return _override_item(id, data)
		"loot": return _override_loot(id, data)
		"sounds": return _override_sound(id, data)
		"recipes": return _override_recipe(id, data)
		"events": return _override_event(id, data)
		"trader_pools":
			push_warning("[Registry] override: 'trader_pools' doesn't support override (pool entries are boolean flags on ItemData; just register/remove)")
			return false
		"trader_tasks": return _override_trader_task(id, data)
		"inputs": return _override_input(id, data)
		"scene_paths": return _override_scene_path(id, data)
		"shelters":
			push_warning("[Registry] override: 'shelters' doesn't support override (it's an append-only list; use register/remove)")
			return false
		"random_scenes":
			push_warning("[Registry] override: 'random_scenes' doesn't support override (append-only list; use register/remove)")
			return false
		"ai_types": return _override_ai_type(id, data)
		"fish_species":
			push_warning("[Registry] override: 'fish_species' doesn't support override (append-only list; use register/remove)")
			return false
		"resources":
			push_warning("[Registry] override: 'resources' doesn't support override (vanilla .tres already exists; use patch to mutate fields)")
			return false
		"scene_nodes":
			push_warning("[Registry] override: 'scene_nodes' doesn't support override (whole-scene swap goes through override('scenes', ...); scene_nodes is patch-only)")
			return false
		_:
			push_warning("[Registry] override: unknown registry '%s'" % registry)
			return false

## Partial update: merge `fields` into the entry at `id`. Not every registry
## supports patch; scenes are monolithic PackedScenes, loot entries are
## bare ItemData references (patch via the items registry instead). Returns
## false with guidance on unsupported registries.
##
## `id` is String for most registries. The 'recipes' and 'events' registries
## also accept a direct Resource ref (RecipeData / EventData) so mods can
## patch vanilla entries without first registering a handle; same
## semantics, just skips the indirection when the mod already holds the ref.
func patch(registry: String, id: Variant, fields: Dictionary) -> bool:
	if id is String and id == "":
		push_warning("[Registry] patch(%s, ...) called with empty id" % registry)
		return false
	match registry:
		"scenes":
			push_warning("[Registry] patch: 'scenes' registry doesn't support patch (scenes are monolithic PackedScenes; use override instead)")
			return false
		"items":
			if not (id is String):
				push_warning("[Registry] patch('items', ...): id must be a String")
				return false
			return _patch_item(id, fields)
		"loot":
			push_warning("[Registry] patch: 'loot' registry doesn't support patch (loot entries are ItemData references; patch the ItemData via the 'items' registry instead)")
			return false
		"sounds":
			if not (id is String):
				push_warning("[Registry] patch('sounds', ...): id must be a String")
				return false
			return _patch_sound(id, fields)
		"recipes": return _patch_recipe(id, fields)
		"events": return _patch_event(id, fields)
		"trader_pools":
			push_warning("[Registry] patch: 'trader_pools' doesn't support patch (entries are boolean flags; use register/remove)")
			return false
		"trader_tasks": return _patch_trader_task(id, fields)
		"inputs":
			if not (id is String):
				push_warning("[Registry] patch('inputs', ...): id must be a String")
				return false
			return _patch_input(id, fields)
		"scene_paths":
			if not (id is String):
				push_warning("[Registry] patch('scene_paths', ...): id must be a String")
				return false
			return _patch_scene_path(id, fields)
		"shelters":
			push_warning("[Registry] patch: 'shelters' doesn't support patch (entries are bare strings)")
			return false
		"random_scenes":
			push_warning("[Registry] patch: 'random_scenes' doesn't support patch (entries are bare paths)")
			return false
		"ai_types":
			push_warning("[Registry] patch: 'ai_types' doesn't support patch (entries are {scene, zone} refs; use override to swap the scene)")
			return false
		"fish_species":
			push_warning("[Registry] patch: 'fish_species' doesn't support patch (entries are {scene, pool_id} refs)")
			return false
		"resources":
			if not (id is String):
				push_warning("[Registry] patch('resources', ...): id must be a res:// path String")
				return false
			return _patch_resource(id, fields)
		"scene_nodes":
			if not (id is String):
				push_warning("[Registry] patch('scene_nodes', ...): id must be a String in the form '<scene_path>#<node_path>'")
				return false
			return _patch_scene_node(id, fields)
		_:
			push_warning("[Registry] patch: unknown registry '%s'" % registry)
			return false

## Undo a register(). Fails if the id wasn't registered by a mod (can't
## remove vanilla entries via this API; use override with a disabled
## equivalent, or rely on the game's own toggle mechanisms).
func remove(registry: String, id: String) -> bool:
	match registry:
		"scenes": return _remove_scene(id)
		"items": return _remove_item(id)
		"loot": return _remove_loot(id)
		"sounds": return _remove_sound(id)
		"recipes": return _remove_recipe(id)
		"events": return _remove_event(id)
		"trader_pools": return _remove_trader_pool(id)
		"trader_tasks": return _remove_trader_task(id)
		"inputs": return _remove_input(id)
		"scene_paths": return _remove_scene_path(id)
		"shelters": return _remove_shelter(id)
		"random_scenes": return _remove_random_scene(id)
		"ai_types": return _remove_ai_type(id)
		"fish_species": return _remove_fish_species(id)
		"resources":
			push_warning("[Registry] remove: 'resources' doesn't support remove (use revert to undo patches)")
			return false
		"scene_nodes":
			push_warning("[Registry] remove: 'scene_nodes' doesn't support remove (use revert to undo a property patch)")
			return false
		_:
			push_warning("[Registry] remove: unknown registry '%s'" % registry)
			return false

## Undo an override() or patch(). `fields` is for per-field revert on patch
## registries; leave empty to revert everything (both override and all
## accumulated patches on the id).
##
## `id` widens to Variant for symmetry with patch(): the 'recipes' and
## 'events' registries accept either a String handle or a Resource ref.
## Other registries require String.
func revert(registry: String, id: Variant, fields: Array = []) -> bool:
	match registry:
		"scenes":
			if not (id is String):
				push_warning("[Registry] revert('scenes', ...): id must be a String")
				return false
			return _revert_scene(id)
		"items":
			if not (id is String):
				push_warning("[Registry] revert('items', ...): id must be a String")
				return false
			return _revert_item(id, fields)
		"loot":
			if not (id is String):
				push_warning("[Registry] revert('loot', ...): id must be a String")
				return false
			return _revert_loot(id)
		"sounds":
			if not (id is String):
				push_warning("[Registry] revert('sounds', ...): id must be a String")
				return false
			return _revert_sound(id, fields)
		"recipes": return _revert_recipe(id, fields)
		"events": return _revert_event(id, fields)
		"trader_pools":
			if not (id is String):
				push_warning("[Registry] revert('trader_pools', ...): id must be a String")
				return false
			return _revert_trader_pool(id)
		"trader_tasks": return _revert_trader_task(id, fields)
		"inputs":
			if not (id is String):
				push_warning("[Registry] revert('inputs', ...): id must be a String")
				return false
			return _revert_input(id, fields)
		"scene_paths":
			if not (id is String):
				push_warning("[Registry] revert('scene_paths', ...): id must be a String")
				return false
			return _revert_scene_path(id, fields)
		"shelters":
			if not (id is String):
				push_warning("[Registry] revert('shelters', ...): id must be a String")
				return false
			return _remove_shelter(id)
		"random_scenes":
			if not (id is String):
				push_warning("[Registry] revert('random_scenes', ...): id must be a String")
				return false
			return _remove_random_scene(id)
		"ai_types":
			if not (id is String):
				push_warning("[Registry] revert('ai_types', ...): id must be a String")
				return false
			return _revert_ai_type(id)
		"fish_species":
			if not (id is String):
				push_warning("[Registry] revert('fish_species', ...): id must be a String")
				return false
			return _remove_fish_species(id)
		"resources":
			if not (id is String):
				push_warning("[Registry] revert('resources', ...): id must be a res:// path String")
				return false
			return _revert_resource(id, fields)
		"scene_nodes":
			if not (id is String):
				push_warning("[Registry] revert('scene_nodes', ...): id must be a String in the form '<scene_path>#<node_path>'")
				return false
			return _revert_scene_node(id, fields)
		_:
			push_warning("[Registry] revert: unknown registry '%s'" % registry)
			return false

## Read API: resolve an id to its current value (vanilla, mod-registered, or
## mod-overridden, in the same priority the game itself sees). Useful for
## debugging and for mods that want to introspect what's registered. Returns
## null if the id doesn't resolve.
func get_entry(registry: String, id: String) -> Variant:
	match registry:
		"scenes":
			var db := _database_node()
			return null if db == null else db.get(id)
		"items":
			return _lookup_item(id)
		"loot":
			# Returns the {item, table} dict the mod registered under id, or
			# null if the id isn't a mod loot registration. Reads through
			# overrides first (matches lookup precedence elsewhere).
			var reg: Dictionary = _registry_registered.get("loot", {})
			return reg.get(id)
		"sounds":
			return _lookup_sound(id)
		"recipes":
			var reg: Dictionary = _registry_registered.get("recipes", {})
			return reg.get(id)
		"events":
			var reg: Dictionary = _registry_registered.get("events", {})
			return reg.get(id)
		"trader_pools":
			var reg: Dictionary = _registry_registered.get("trader_pools", {})
			return reg.get(id)
		"trader_tasks":
			var reg: Dictionary = _registry_registered.get("trader_tasks", {})
			return reg.get(id)
		"inputs":
			var reg: Dictionary = _registry_registered.get("inputs", {})
			return reg.get(id)
		"scene_paths":
			var reg: Dictionary = _registry_registered.get("scene_paths", {})
			return reg.get(id)
		"shelters":
			var reg: Dictionary = _registry_registered.get("shelters", {})
			return reg.get(id)
		"random_scenes":
			var reg: Dictionary = _registry_registered.get("random_scenes", {})
			return reg.get(id)
		"ai_types":
			var reg: Dictionary = _registry_registered.get("ai_types", {})
			return reg.get(id)
		"fish_species":
			var reg: Dictionary = _registry_registered.get("fish_species", {})
			return reg.get(id)
		"resources":
			# For the raw-resource escape hatch, `id` is a res:// path.
			# Returns the loaded Resource (with any live patches applied),
			# or null if the path doesn't resolve.
			if not (id is String):
				return null
			return load(id)
		_:
			push_warning("[Registry] get_entry: unknown registry '%s'" % registry)
			return null

## ----- registry/shared.gd -----
## Cross-section registry helpers. Used by more than one section handler, so
## they live here instead of duplicating in each registry file.

# Bump a reg's rollback-registered map with a bare marker entry. Section
# handlers that need to store per-id payload (items, loot) write the payload
# directly instead of calling this.
func _track_registered(registry: String, id: String) -> void:
	var reg: Dictionary = _registry_registered.get(registry, {})
	reg[id] = true
	_registry_registered[registry] = reg

# True if `res` has a declared property named `prop`. Used by patch() to
# reject typos and by shape-checks that test for a specific field.
func _resource_has_property(res: Resource, prop: String) -> bool:
	for p in res.get_property_list():
		if p.get("name") == prop:
			return true
	return false

# Heuristic: does this Resource carry the ItemData shape? We don't use `is
# ItemData` because that requires the class_name to be registered in our
# loader's script scope, which it isn't (ItemData is a game class). Instead
# check for the canonical `file` field every ItemData (and subclass) defines.
func _looks_like_item_data(res: Resource) -> bool:
	return _resource_has_property(res, "file")

# Typed-array validation. Array[ItemData] only accepts instances whose script
# chain inherits from the ItemData script. Raw Resource + inline GDScript
# won't pass. Walks item's script chain looking for a script whose resource
# path matches the array's declared type.
#
# Returns true if either the array is untyped, or the item inherits from the
# array's declared type. Lets handlers fail fast with a clear warning rather
# than letting Godot spit cryptic TypedArray errors.
func _typed_array_accepts(arr: Array, item: Variant) -> bool:
	if not arr.is_typed():
		return true
	# For typed arrays of Objects, get_typed_script() returns the required
	# script. Walk item's script chain; any match means it's a valid subclass.
	var required = arr.get_typed_script()
	if required == null:
		# Typed by built-in type, not a script class; fall back to duck check.
		return true
	if not (item is Object):
		return false
	var s = item.get_script()
	while s != null:
		if s == required:
			return true
		s = s.get_base_script()
	return false

## ----- registry/scenes.gd -----
##
## The rewriter injects _rtv_mod_scenes + _rtv_override_scenes + _get() into
## Database.gd at build time. Registration writes into those dicts on the
## live Database autoload node. Vanilla game code doing Database.get(name)
## hits the injected _get() and resolves through the mod dicts before falling
## back to vanilla constants (now rewritten into _rtv_vanilla_scenes).

func _database_node() -> Node:
	var db = get_tree().root.get_node_or_null("Database")
	if db == null:
		push_warning("[Registry] Database autoload not in tree yet; is the loader still booting?")
	return db

func _register_scene(id: String, data: Variant) -> bool:
	if not (data is PackedScene):
		push_warning("[Registry] register('scenes', '%s', ...) expects a PackedScene, got %s" % [id, typeof(data)])
		return false
	var db := _database_node()
	if db == null:
		return false
	# The rewriter injects _rtv_mod_scenes + _rtv_override_scenes + _get() into
	# Database.gd only when at least one mod declares [registry]. Without that,
	# writing db._rtv_mod_scenes[id] = data silently creates an ad-hoc property
	# (Godot's Object.set accepts it) but Database.get(id) never routes through
	# the injected _get() -- callers get null and the registration is invisible.
	# Fail loud so mod authors see the real cause instead of "item registered
	# but won't spawn."
	if not ("_rtv_mod_scenes" in db):
		push_warning("[Registry] register('scenes', '%s'): Database.gd is missing injected scene fields (rewriter didn't fire). Does your mod.txt include a [registry] section?" % id)
		return false
	# Collision check: vanilla const, prior mod registration, or mod override.
	if _scene_exists_in_vanilla(db, id):
		push_warning("[Registry] register('scenes', '%s'): id collides with vanilla constant; use override instead" % id)
		return false
	if db._rtv_mod_scenes.has(id):
		push_warning("[Registry] register('scenes', '%s'): already registered by a mod" % id)
		return false
	db._rtv_mod_scenes[id] = data
	_track_registered("scenes", id)
	_log_debug("[Registry] registered scene '%s'" % id)
	return true

func _override_scene(id: String, data: Variant) -> bool:
	if not (data is PackedScene):
		push_warning("[Registry] override('scenes', '%s', ...) expects a PackedScene, got %s" % [id, typeof(data)])
		return false
	var db := _database_node()
	if db == null:
		return false
	# The rewriter converts Database's `const X = preload(...)` into entries
	# in _rtv_vanilla_scenes, so db.get(id) routes through _get(); which
	# checks _rtv_override_scenes first. Writing to that dict is enough to
	# replace the scene a vanilla id resolves to.
	var original = db.get(id)
	if original == null:
		push_warning("[Registry] override('scenes', '%s'): no existing entry to override" % id)
		return false
	# Reject second override of the same scene id to match every other
	# registry's behavior. Without this guard, a later mod silently displaces
	# an earlier mod's override and the earlier mod has no signal that their
	# work was clobbered. The second mod should revert first (if it wants to
	# drop the earlier override) or target the registered id explicitly.
	var ov: Dictionary = _registry_overridden.get("scenes", {})
	if ov.has(id):
		push_warning("[Registry] override('scenes', '%s'): already overridden (revert first to re-override)" % id)
		return false
	ov[id] = original
	_registry_overridden["scenes"] = ov
	db._rtv_override_scenes[id] = data
	_log_debug("[Registry] overrode scene '%s'" % id)
	return true

func _remove_scene(id: String) -> bool:
	var db := _database_node()
	if db == null:
		return false
	if not db._rtv_mod_scenes.has(id):
		push_warning("[Registry] remove('scenes', '%s'): not registered by a mod" % id)
		return false
	db._rtv_mod_scenes.erase(id)
	var reg: Dictionary = _registry_registered.get("scenes", {})
	reg.erase(id)
	_registry_registered["scenes"] = reg
	_log_debug("[Registry] removed scene '%s'" % id)
	return true

func _revert_scene(id: String) -> bool:
	var db := _database_node()
	if db == null:
		return false
	if not db._rtv_override_scenes.has(id):
		push_warning("[Registry] revert('scenes', '%s'): no mod override to revert" % id)
		return false
	db._rtv_override_scenes.erase(id)
	var ov: Dictionary = _registry_overridden.get("scenes", {})
	ov.erase(id)
	_registry_overridden["scenes"] = ov
	_log_debug("[Registry] reverted scene '%s'" % id)
	return true

# A scene id collides with vanilla if Database's rewritten _rtv_vanilla_scenes
# dict contains it. The rewriter moves every `const X = preload(...)` from
# vanilla Database.gd into that dict; it's the canonical source of truth
# for "vanilla-shipped names."
func _scene_exists_in_vanilla(db: Node, id: String) -> bool:
	if not ("_rtv_vanilla_scenes" in db):
		return false
	var vs = db._rtv_vanilla_scenes as Dictionary
	return vs.has(id)

## ----- registry/items.gd -----
##
## Items are ItemData Resources (or subclasses: WeaponData, AttachmentData, etc).
## Godot's Resource caching means every `load("res://Items/.../Potato.tres")`
## returns the same instance, so mutating the loaded ItemData mutates it for
## every holder; including what saves deserialize on next load, because
## SlotData serializes ItemData references by-value.
##
## Vanilla has no central file-string -> ItemData lookup; code passes ItemData
## references around directly. So `register` doesn't need to inject anything
## global: we keep mod-registered items in a dict keyed by `file` and expose
## them via get_entry(). `file` is the primary id since vanilla code reads
## itemData.file directly.

# Vanilla item resolution: LT_Master.items is the authoritative list of every
# ItemData that ships with the game. Scan it for a matching `file` string.
# Cached on first hit since LT_Master is static for a given game install.
var _vanilla_item_cache: Dictionary = {}
var _vanilla_item_cache_built: bool = false

func _register_item(id: String, data: Variant) -> bool:
	if not (data is Resource) or not _looks_like_item_data(data):
		push_warning("[Registry] register('items', '%s', ...) expects an ItemData Resource, got %s" % [id, typeof(data)])
		return false
	# Collision: vanilla item with matching file, or prior mod registration.
	if _find_vanilla_item(id) != null:
		push_warning("[Registry] register('items', '%s'): id collides with vanilla item; use override or patch instead" % id)
		return false
	var reg: Dictionary = _registry_registered.get("items", {})
	if reg.has(id):
		push_warning("[Registry] register('items', '%s'): already registered by a mod" % id)
		return false
	# Keep ItemData.file in sync with the registry id; vanilla code reads
	# itemData.file directly and assumes it matches the item's canonical name.
	if data.get("file") != id:
		data.set("file", id)
	reg[id] = data
	_registry_registered["items"] = reg
	_log_debug("[Registry] registered item '%s'" % id)
	return true

func _override_item(id: String, data: Variant) -> bool:
	if not (data is Resource) or not _looks_like_item_data(data):
		push_warning("[Registry] override('items', '%s', ...) expects an ItemData Resource, got %s" % [id, typeof(data)])
		return false
	var existing := _lookup_item(id)
	if existing == null:
		push_warning("[Registry] override('items', '%s'): no existing item to override" % id)
		return false
	# Stash the original (by ref) once; subsequent overrides don't clobber.
	var ov: Dictionary = _registry_overridden.get("items", {})
	if not ov.has(id):
		ov[id] = existing
		_registry_overridden["items"] = ov
	# Overrides live in _registry_registered because lookup precedence is
	# override > register > vanilla, and we key both by id. Track in
	# overridden map so revert can restore; the live value in registered dict
	# is what lookups hit.
	var reg: Dictionary = _registry_registered.get("items", {})
	reg[id] = data
	_registry_registered["items"] = reg
	if data.get("file") != id:
		data.set("file", id)
	_log_debug("[Registry] overrode item '%s'" % id)
	return true

func _patch_item(id: String, fields: Dictionary) -> bool:
	if fields.is_empty():
		push_warning("[Registry] patch('items', '%s', ...): empty fields dict is a no-op" % id)
		return false
	var target := _lookup_item(id)
	if target == null:
		push_warning("[Registry] patch('items', '%s'): no item with that id" % id)
		return false
	var patched: Dictionary = _registry_patched.get("items", {})
	var stash: Dictionary = patched.get(id, {})
	for field in fields.keys():
		var field_name := String(field)
		# Resource.get() returns null both for "missing" and for legitimate
		# null values. Prefer property-list check so mistyped field names
		# surface as warnings instead of silently patching a phantom field.
		if not _resource_has_property(target, field_name):
			push_warning("[Registry] patch('items', '%s'): field '%s' doesn't exist on %s" \
					% [id, field_name, target.get_class()])
			continue
		# First-write-wins stash: keep pre-any-patch value so revert restores
		# the true original no matter how many patches have piled on.
		if not stash.has(field_name):
			stash[field_name] = target.get(field_name)
		target.set(field_name, fields[field])
	patched[id] = stash
	_registry_patched["items"] = patched
	_log_debug("[Registry] patched item '%s' fields %s" % [id, fields.keys()])
	return true

func _remove_item(id: String) -> bool:
	var reg: Dictionary = _registry_registered.get("items", {})
	if not reg.has(id):
		push_warning("[Registry] remove('items', '%s'): not registered by a mod" % id)
		return false
	# Block remove on entries that are actually overrides, not registrations.
	# An override lives in reg too (see _override_item rationale) but must be
	# reverted, not removed; remove implies "undo a register()".
	var ov: Dictionary = _registry_overridden.get("items", {})
	if ov.has(id):
		push_warning("[Registry] remove('items', '%s'): entry is an override, use revert instead" % id)
		return false
	reg.erase(id)
	_registry_registered["items"] = reg
	_log_debug("[Registry] removed item '%s'" % id)
	return true

func _revert_item(id: String, fields: Array) -> bool:
	# Two revert modes: (a) full-entry revert of an override(), (b) per-field
	# revert of patch()es. Caller distinguishes by presence of `fields`.
	var did_something := false
	var ov: Dictionary = _registry_overridden.get("items", {})
	var patched: Dictionary = _registry_patched.get("items", {})
	# Full revert: no fields specified -> undo override AND clear all patches.
	# Order matters: restore patch stash FIRST (onto the currently-resolving
	# entry, which may be an override), THEN drop the override so lookups
	# fall back to vanilla. Reversing would restore patch values onto vanilla
	# ItemData, mutating the base resource permanently.
	if fields.is_empty():
		if patched.has(id):
			var target := _lookup_item(id)
			if target != null:
				var stash: Dictionary = patched[id]
				for fname in stash.keys():
					target.set(fname, stash[fname])
			patched.erase(id)
			_registry_patched["items"] = patched
			did_something = true
		if ov.has(id):
			var reg: Dictionary = _registry_registered.get("items", {})
			reg.erase(id)
			_registry_registered["items"] = reg
			ov.erase(id)
			_registry_overridden["items"] = ov
			did_something = true
		if not did_something:
			push_warning("[Registry] revert('items', '%s'): nothing to revert" % id)
		return did_something
	# Per-field revert: only undo the named fields on a patch(). Overrides
	# are whole-entry and don't combine with field-level revert.
	if not patched.has(id):
		push_warning("[Registry] revert('items', '%s', %s): no patches on this id" % [id, fields])
		return false
	var target := _lookup_item(id)
	if target == null:
		push_warning("[Registry] revert('items', '%s', %s): id no longer resolves" % [id, fields])
		return false
	var stash: Dictionary = patched[id]
	for field in fields:
		var fname := String(field)
		if not stash.has(fname):
			push_warning("[Registry] revert('items', '%s'): field '%s' wasn't patched" % [id, fname])
			continue
		target.set(fname, stash[fname])
		stash.erase(fname)
		did_something = true
	if stash.is_empty():
		patched.erase(id)
	else:
		patched[id] = stash
	_registry_patched["items"] = patched
	return did_something

# Lookup precedence: mod registrations (which includes overrides) first, then
# vanilla. Matches the scenes registry's override > mod > vanilla shape;
# here overrides live inside the mod-registered dict, so the order collapses
# to "mod entries beat vanilla."
func _lookup_item(id: String) -> Resource:
	var reg: Dictionary = _registry_registered.get("items", {})
	if reg.has(id):
		return reg[id]
	return _find_vanilla_item(id)

func _find_vanilla_item(id: String) -> Resource:
	if not _vanilla_item_cache_built:
		_build_vanilla_item_cache()
	return _vanilla_item_cache.get(id)

func _build_vanilla_item_cache() -> void:
	_vanilla_item_cache_built = true
	var master = load("res://Loot/LT_Master.tres")
	if master == null or not ("items" in master):
		push_warning("[Registry] LT_Master.tres missing or unreadable; items registry lookups will only see mod entries")
		return
	for it in master.items:
		if it == null:
			continue
		var f = it.get("file")
		if f != null and String(f) != "":
			_vanilla_item_cache[String(f)] = it

## ----- registry/loot.gd -----
##
## Loot registrations mutate the `items: Array[ItemData]` on loaded LootTable
## Resources. Godot's Resource cache ensures every `load("res://Loot/X.tres")`
## returns the same instance, so appending to that array affects any later
## consumer that reads it; provided the consumer hasn't already copied it
## into a local bucket (see registry.gd module docstring for the timing
## constraint: mods must register during their own _ready()).
##
## Data shape (passed to register/override):
##   {item: ItemData, table: String}
## Override additionally requires `replaces: ItemData`; the existing entry
## to swap out. `table` is either a LootTable name like "LT_Master" (resolved
## to its .tres path) or an absolute res:// path. Named tables are the
## idiomatic form for vanilla; paths are the escape hatch.
##
## Rollback tracking is per-id: _registry_registered["loot"][id] = the data
## dict the mod registered. That lets remove() find the exact (table, item)
## pair to strip from the array. Override stashes the displaced entry as an
## ItemData ref so revert can put it back.

# Map short table names -> absolute res:// paths. If a mod passes an already-
# absolute path, we use it as-is.
const _LOOT_TABLE_PATHS := {
	"LT_Master": "res://Loot/LT_Master.tres",
	# Custom tables (event-driven): res://Loot/Custom/
	"LT_Airdrop": "res://Loot/Custom/LT_Airdrop.tres",
	"LT_Patient_Report": "res://Loot/Custom/LT_Patient_Report.tres",
	"LT_Punisher": "res://Loot/Custom/LT_Punisher.tres",
	"LT_Oil_Sample": "res://Loot/Custom/LT_Oil_Sample.tres",
	# Tutorial tables: res://Loot/Tutorial/
	"LT_Weapons_01": "res://Loot/Tutorial/LT_Weapons_01.tres",
	"LT_Weapons_02": "res://Loot/Tutorial/LT_Weapons_02.tres",
	"LT_Weapons_03": "res://Loot/Tutorial/LT_Weapons_03.tres",
	"LT_Weapons_04": "res://Loot/Tutorial/LT_Weapons_04.tres",
	"LT_Ammo": "res://Loot/Tutorial/LT_Ammo.tres",
	"LT_Medical": "res://Loot/Tutorial/LT_Medical.tres",
	"LT_Equipment": "res://Loot/Tutorial/LT_Equipment.tres",
	"LT_Armor": "res://Loot/Tutorial/LT_Armor.tres",
	"LT_Grenades": "res://Loot/Tutorial/LT_Grenades.tres",
	"LT_Attachments": "res://Loot/Tutorial/LT_Attachments.tres",
	"LT_Items": "res://Loot/Tutorial/LT_Items.tres",
	# Kit tables: res://Loot/Kits/
	"Kit_Colt": "res://Loot/Kits/Kit_Colt.tres",
	"Kit_Glock": "res://Loot/Kits/Kit_Glock.tres",
	"Kit_MP5K": "res://Loot/Kits/Kit_MP5K.tres",
	"Kit_Makarov": "res://Loot/Kits/Kit_Makarov.tres",
	"Kit_Mosin": "res://Loot/Kits/Kit_Mosin.tres",
	"Kit_Remington": "res://Loot/Kits/Kit_Remington.tres",
}

# Returns the loaded LootTable Resource or null + warning. Accepts bare names
# ("LT_Master") and absolute paths. Bare names let mods stay concise for the
# common case; absolute paths are the escape hatch for user-authored tables.
func _resolve_loot_table(table_ref: String) -> Resource:
	if table_ref == "":
		push_warning("[Registry] loot: empty table name")
		return null
	var path := table_ref
	if _LOOT_TABLE_PATHS.has(table_ref):
		path = _LOOT_TABLE_PATHS[table_ref]
	elif not table_ref.begins_with("res://"):
		push_warning("[Registry] loot: unknown table '%s' (not a known vanilla table name and not an absolute res:// path)" % table_ref)
		return null
	var res = load(path)
	if res == null:
		push_warning("[Registry] loot: couldn't load table at '%s'" % path)
		return null
	if not ("items" in res):
		push_warning("[Registry] loot: resource at '%s' has no `items` array (not a LootTable?)" % path)
		return null
	return res

# Validates the {item, table} payload. Returns [item, table_res] or
# [null, null] on error (with a warning already issued).
func _validate_loot_data(id: String, verb: String, data: Variant) -> Array:
	if not (data is Dictionary):
		push_warning("[Registry] %s('loot', '%s', ...) expects Dictionary {item, table}, got %s" % [verb, id, typeof(data)])
		return [null, null]
	var d: Dictionary = data
	if not d.has("item") or not d.has("table"):
		push_warning("[Registry] %s('loot', '%s', ...) data dict missing 'item' or 'table' key" % [verb, id])
		return [null, null]
	var item = d["item"]
	if not (item is Resource) or not _looks_like_item_data(item):
		push_warning("[Registry] %s('loot', '%s'): item is not an ItemData Resource" % [verb, id])
		return [null, null]
	var table = d["table"]
	if not (table is String):
		push_warning("[Registry] %s('loot', '%s'): table must be a String (name or res:// path)" % [verb, id])
		return [null, null]
	var table_res := _resolve_loot_table(table)
	if table_res == null:
		return [null, null]
	return [item, table_res]

func _register_loot(id: String, data: Variant) -> bool:
	var reg: Dictionary = _registry_registered.get("loot", {})
	if reg.has(id):
		push_warning("[Registry] register('loot', '%s'): already registered (ids are mod-chosen handles, pick a unique one)" % id)
		return false
	var parts := _validate_loot_data(id, "register", data)
	var item = parts[0]
	var table_res = parts[1]
	if item == null or table_res == null:
		return false
	if not _typed_array_accepts(table_res.items, item):
		push_warning("[Registry] register('loot', '%s'): item type doesn't match table's typed array (table wants ItemData or subclass; got a Resource with a different script)" % id)
		return false
	# Idempotent append: don't double-insert if the mod's item is already in
	# the table (e.g., after an editor rebuild). The existing entry isn't
	# ours to track, so we still fail the register; mod authors should use
	# override if they want to force a swap.
	if item in table_res.items:
		push_warning("[Registry] register('loot', '%s'): item is already present in table; use override to swap an existing entry" % id)
		return false
	table_res.items.append(item)
	# Stash what we registered so remove() can locate and strip it.
	reg[id] = {"item": item, "table": data["table"], "table_res": table_res}
	_registry_registered["loot"] = reg
	_log_debug("[Registry] registered loot '%s' (%s -> %s)" % [id, data["table"], item.get("file")])
	return true

# For loot, override swaps an ItemData already present in a table for a new
# one. The `id` is the mod's handle; the displaced entry is what gets
# restored on revert. Data payload extends the register shape with the entry
# to swap out: {item, table, replaces: ItemData}.
func _override_loot(id: String, data: Variant) -> bool:
	var ov: Dictionary = _registry_overridden.get("loot", {})
	if ov.has(id):
		push_warning("[Registry] override('loot', '%s'): already overridden (revert first to re-override)" % id)
		return false
	if not (data is Dictionary) or not data.has("replaces"):
		push_warning("[Registry] override('loot', '%s', ...) requires {item, table, replaces: ItemData}; 'replaces' is the existing entry to swap out" % id)
		return false
	var parts := _validate_loot_data(id, "override", data)
	var new_item = parts[0]
	var table_res = parts[1]
	if new_item == null or table_res == null:
		return false
	var old_item = data["replaces"]
	if not (old_item is Resource) or not _looks_like_item_data(old_item):
		push_warning("[Registry] override('loot', '%s'): 'replaces' is not an ItemData Resource" % id)
		return false
	if not _typed_array_accepts(table_res.items, new_item):
		push_warning("[Registry] override('loot', '%s'): item type doesn't match table's typed array (table wants ItemData or subclass)" % id)
		return false
	var idx: int = table_res.items.find(old_item)
	if idx < 0:
		push_warning("[Registry] override('loot', '%s'): 'replaces' item not present in table" % id)
		return false
	if new_item in table_res.items:
		push_warning("[Registry] override('loot', '%s'): new item is already in the table; would duplicate" % id)
		return false
	table_res.items[idx] = new_item
	ov[id] = {
		"item": new_item,
		"table": data["table"],
		"table_res": table_res,
		"replaced": old_item,
		"index": idx,
	}
	_registry_overridden["loot"] = ov
	# Also record in registered map so get_entry() can surface the override.
	var reg: Dictionary = _registry_registered.get("loot", {})
	reg[id] = {"item": new_item, "table": data["table"], "table_res": table_res}
	_registry_registered["loot"] = reg
	_log_debug("[Registry] overrode loot '%s' in %s (%s -> %s)" \
			% [id, data["table"], old_item.get("file"), new_item.get("file")])
	return true

func _remove_loot(id: String) -> bool:
	var reg: Dictionary = _registry_registered.get("loot", {})
	if not reg.has(id):
		push_warning("[Registry] remove('loot', '%s'): not a mod loot registration" % id)
		return false
	# Block remove on override-backed entries; revert is the correct verb.
	var ov: Dictionary = _registry_overridden.get("loot", {})
	if ov.has(id):
		push_warning("[Registry] remove('loot', '%s'): this id is an override, use revert instead" % id)
		return false
	var entry: Dictionary = reg[id]
	var table_res: Resource = entry["table_res"]
	var item: Resource = entry["item"]
	var idx: int = table_res.items.find(item)
	if idx >= 0:
		table_res.items.remove_at(idx)
	else:
		# The entry was registered but something (another mod? editor?)
		# stripped it from the table already. Clean up our tracking anyway.
		push_warning("[Registry] remove('loot', '%s'): item not found in table; tracking cleared" % id)
	reg.erase(id)
	_registry_registered["loot"] = reg
	_log_debug("[Registry] removed loot '%s'" % id)
	return true

func _revert_loot(id: String) -> bool:
	var ov: Dictionary = _registry_overridden.get("loot", {})
	if not ov.has(id):
		push_warning("[Registry] revert('loot', '%s'): no override to revert" % id)
		return false
	var entry: Dictionary = ov[id]
	var table_res: Resource = entry["table_res"]
	var current_item: Resource = entry["item"]
	var old_item: Resource = entry["replaced"]
	# Find the override in the table now (index may have shifted if other
	# registers/removes happened meanwhile) and put the original back.
	var idx: int = table_res.items.find(current_item)
	if idx >= 0:
		table_res.items[idx] = old_item
	else:
		# Override entry is gone; someone (another mod? sanitizer?) stripped
		# it. Put the original back at the end rather than nothing.
		push_warning("[Registry] revert('loot', '%s'): override entry missing from table, appending original at end" % id)
		table_res.items.append(old_item)
	ov.erase(id)
	_registry_overridden["loot"] = ov
	var reg: Dictionary = _registry_registered.get("loot", {})
	reg.erase(id)
	_registry_registered["loot"] = reg
	_log_debug("[Registry] reverted loot '%s'" % id)
	return true

## ----- registry/sounds.gd -----
##
## AudioLibrary is a plain Resource at res://Resources/AudioLibrary.tres, not
## an autoload. Every script that uses audio does `preload("res://Resources/
## AudioLibrary.tres")` and accesses events by direct property name, e.g.
## `audioLibrary.knifeSlash`. Godot's Resource cache means every preload
## returns the same instance, so mutating fields on that one instance
## propagates to every holder.
##
## Data shape accepted by register/override:
##   - AudioEvent Resource instance (direct)
##   - AudioStream directly (wrapped in a default AudioEvent: volume=0, randomPitch=false)
##   - Dictionary {audioClips: Array[AudioStream], volume: float, randomPitch: bool}
##     (missing keys default sensibly)
##
## patch() takes {volume, randomPitch, audioClips} subset.
##
## register() limitation (mirrors scenes): registrations live in a mod-only
## lookup dict. Vanilla scripts hardcode property names like `audioLibrary
## .knifeSlash`, so a newly registered id isn't reachable from vanilla code.
## Mods wanting to play their own sounds must fetch via
## `lib.get_entry(lib.Registry.SOUNDS, "mymod_pickup")` or, equivalently,
## `audioLibrary.get("mymod_pickup")`; both resolve through the same
## storage (see _lookup_sound). Use override to affect what vanilla plays.

const _AUDIO_LIBRARY_PATH := "res://Resources/AudioLibrary.tres"

var _audio_library_cache: Resource = null
var _audio_library_warned: bool = false

func _audio_library() -> Resource:
	if _audio_library_cache != null:
		return _audio_library_cache
	var lib = load(_AUDIO_LIBRARY_PATH)
	if lib == null:
		if not _audio_library_warned:
			push_warning("[Registry] sounds: AudioLibrary.tres missing at %s; sounds registry is inert" % _AUDIO_LIBRARY_PATH)
			_audio_library_warned = true
		return null
	_audio_library_cache = lib
	return lib

# Accept an AudioEvent, a bare AudioStream, or a Dictionary, and return an
# AudioEvent Resource (or null with warning on bad input).
#
# AudioEvent's class script is loaded dynamically from the existing library
#; we don't know its res:// path up front and don't want to hardcode it.
# Pull the class from any existing @export AudioEvent on the library. This
# also tolerates the game renaming or moving AudioEvent.gd.
func _coerce_audio_event(id: String, verb: String, data: Variant) -> Resource:
	# Direct pass-through: already an AudioEvent-shaped Resource.
	if data is Resource and _looks_like_audio_event(data):
		return data
	# Bare AudioStream: wrap in a default-constructed AudioEvent.
	if data is AudioStream:
		var ev_class := _audio_event_class()
		if ev_class == null:
			push_warning("[Registry] %s('sounds', '%s'): couldn't locate AudioEvent class (library may be empty or unmigrated)" % [verb, id])
			return null
		var ev = ev_class.new()
		ev.set("audioClips", [data])
		ev.set("volume", 0.0)
		ev.set("randomPitch", false)
		return ev
	# Dictionary shorthand.
	if data is Dictionary:
		var d: Dictionary = data
		var ev_class_d := _audio_event_class()
		if ev_class_d == null:
			push_warning("[Registry] %s('sounds', '%s'): couldn't locate AudioEvent class to construct from dict" % [verb, id])
			return null
		var ev = ev_class_d.new()
		if d.has("audioClips"):
			ev.set("audioClips", d["audioClips"])
		else:
			ev.set("audioClips", [])
		ev.set("volume", float(d.get("volume", 0.0)))
		ev.set("randomPitch", bool(d.get("randomPitch", false)))
		return ev
	push_warning("[Registry] %s('sounds', '%s', ...) expects AudioEvent / AudioStream / Dictionary, got %s" % [verb, id, typeof(data)])
	return null

# Walk the live AudioLibrary for the first non-null @export AudioEvent and
# use its script as the AudioEvent class reference. Avoids hardcoding the
# AudioEvent.gd path (which the game could move).
func _audio_event_class() -> GDScript:
	var lib := _audio_library()
	if lib == null:
		return null
	for p in lib.get_property_list():
		var pname = p.get("name")
		if not (p.get("usage") & PROPERTY_USAGE_SCRIPT_VARIABLE):
			continue
		var val = lib.get(pname)
		if val == null or not (val is Resource):
			continue
		var s = val.get_script()
		if s != null:
			return s
	return null

func _looks_like_audio_event(res: Resource) -> bool:
	# AudioEvent is identified by its three canonical fields. Same heuristic
	# shape as _looks_like_item_data for ItemData.
	return _resource_has_property(res, "audioClips") \
			and _resource_has_property(res, "volume") \
			and _resource_has_property(res, "randomPitch")

# True if the name is a declared @export property on AudioLibrary. Used to
# distinguish "override vanilla sound" from "register new sound".
func _sound_exists_in_vanilla(id: String) -> bool:
	var lib := _audio_library()
	if lib == null:
		return false
	return _resource_has_property(lib, id)

# Lookup precedence: mod overrides > mod registrations > vanilla library
# field. Overrides on vanilla names live as set() mutations on the library
# itself; lookups via audioLibrary.get(id) would find them there. To keep
# the registry self-contained and work for register-only ids too, we route
# through _registry_registered first, falling back to the library.
func _lookup_sound(id: String) -> Resource:
	var reg: Dictionary = _registry_registered.get("sounds", {})
	if reg.has(id):
		return reg[id]
	var lib := _audio_library()
	if lib == null:
		return null
	if _resource_has_property(lib, id):
		return lib.get(id)
	return null

func _register_sound(id: String, data: Variant) -> bool:
	if _sound_exists_in_vanilla(id):
		push_warning("[Registry] register('sounds', '%s'): id collides with vanilla AudioLibrary field; use override instead" % id)
		return false
	var reg: Dictionary = _registry_registered.get("sounds", {})
	if reg.has(id):
		push_warning("[Registry] register('sounds', '%s'): already registered by a mod" % id)
		return false
	var ev := _coerce_audio_event(id, "register", data)
	if ev == null:
		return false
	reg[id] = ev
	_registry_registered["sounds"] = reg
	_log_debug("[Registry] registered sound '%s'" % id)
	return true

func _override_sound(id: String, data: Variant) -> bool:
	var lib := _audio_library()
	if lib == null:
		return false
	if not _sound_exists_in_vanilla(id):
		# Mod-registered ids can't be overridden; that's what a second
		# register call would be conceptually, but we reject re-register.
		# Force mods to revert first.
		push_warning("[Registry] override('sounds', '%s'): no vanilla AudioLibrary field with that name (register can't be overridden; revert the register first)" % id)
		return false
	var ev := _coerce_audio_event(id, "override", data)
	if ev == null:
		return false
	# First-write-wins stash so multiple overrides on the same id still
	# restore to true vanilla on revert.
	var ov: Dictionary = _registry_overridden.get("sounds", {})
	if not ov.has(id):
		ov[id] = lib.get(id)
		_registry_overridden["sounds"] = ov
	lib.set(id, ev)
	_log_debug("[Registry] overrode sound '%s'" % id)
	return true

func _patch_sound(id: String, fields: Dictionary) -> bool:
	if fields.is_empty():
		push_warning("[Registry] patch('sounds', '%s', ...): empty fields dict is a no-op" % id)
		return false
	var target := _lookup_sound(id)
	if target == null:
		push_warning("[Registry] patch('sounds', '%s'): no sound with that id" % id)
		return false
	var patched: Dictionary = _registry_patched.get("sounds", {})
	var stash: Dictionary = patched.get(id, {})
	for field in fields.keys():
		var field_name := String(field)
		if not _resource_has_property(target, field_name):
			push_warning("[Registry] patch('sounds', '%s'): field '%s' doesn't exist on AudioEvent (valid: audioClips, volume, randomPitch)" \
					% [id, field_name])
			continue
		if not stash.has(field_name):
			stash[field_name] = target.get(field_name)
		target.set(field_name, fields[field])
	patched[id] = stash
	_registry_patched["sounds"] = patched
	_log_debug("[Registry] patched sound '%s' fields %s" % [id, fields.keys()])
	return true

func _remove_sound(id: String) -> bool:
	var reg: Dictionary = _registry_registered.get("sounds", {})
	if not reg.has(id):
		push_warning("[Registry] remove('sounds', '%s'): not registered by a mod" % id)
		return false
	# Sounds don't have the items-style override-lives-in-registered dual
	# storage; overrides mutate the library directly, not this dict. So
	# anything in reg is a plain register.
	reg.erase(id)
	_registry_registered["sounds"] = reg
	_log_debug("[Registry] removed sound '%s'" % id)
	return true

func _revert_sound(id: String, fields: Array) -> bool:
	var did_something := false
	var ov: Dictionary = _registry_overridden.get("sounds", {})
	var patched: Dictionary = _registry_patched.get("sounds", {})
	var lib := _audio_library()
	# Full revert: undo override AND clear patches. Order: patches first
	# (onto whatever's currently resolving, which may be an override), then
	# override (replaces whole entry with the stashed vanilla).
	if fields.is_empty():
		if patched.has(id):
			var target := _lookup_sound(id)
			if target != null:
				var stash: Dictionary = patched[id]
				for fname in stash.keys():
					target.set(fname, stash[fname])
			patched.erase(id)
			_registry_patched["sounds"] = patched
			did_something = true
		if ov.has(id) and lib != null:
			lib.set(id, ov[id])
			ov.erase(id)
			_registry_overridden["sounds"] = ov
			did_something = true
		if not did_something:
			push_warning("[Registry] revert('sounds', '%s'): nothing to revert" % id)
		return did_something
	# Per-field revert: only undo named fields on a patch.
	if not patched.has(id):
		push_warning("[Registry] revert('sounds', '%s', %s): no patches on this id" % [id, fields])
		return false
	var target := _lookup_sound(id)
	if target == null:
		push_warning("[Registry] revert('sounds', '%s', %s): id no longer resolves" % [id, fields])
		return false
	var stash: Dictionary = patched[id]
	for field in fields:
		var fname := String(field)
		if not stash.has(fname):
			push_warning("[Registry] revert('sounds', '%s'): field '%s' wasn't patched" % [id, fname])
			continue
		target.set(fname, stash[fname])
		stash.erase(fname)
		did_something = true
	if stash.is_empty():
		patched.erase(id)
	else:
		patched[id] = stash
	_registry_patched["sounds"] = patched
	return did_something

## ----- registry/recipes.gd -----
##
## Recipes.tres is a Resource with seven Array[RecipeData] fields: consumables,
## medical, equipment, weapons, electronics, misc, furniture. Interface.gd
## const-preloads Recipes.tres, then walks whichever category array matches
## the crafting tab the player opened. Godot's Resource cache means every
## `load()` (or const preload) of Recipes.tres returns the same instance, so
## appending a RecipeData to a category array propagates to the crafting UI
## provided the mod mutates before Interface._ready() fires; same timing
## constraint as loot.
##
## RecipeData has no unique id field (just name + input + output + proximity
## flags), so the registry's id is a mod-chosen handle the same way loot
## works. Payloads:
##   register: {recipe: RecipeData, category: "consumables"}
##   override: {recipe: RecipeData, category, replaces: RecipeData}
##   patch:    id can be a String handle OR a RecipeData Resource ref
##             directly; lets mods patch vanilla recipes in one call
##             without registering a handle first.
##
## Patch rollback keys on object identity (get_instance_id) when the caller
## passed a Resource ref, or on the mod's String handle otherwise. Either way
## revert can find the stash and restore original field values.

const _RECIPE_CATEGORIES := ["consumables", "medical", "equipment", "weapons", "electronics", "misc", "furniture"]
const _RECIPES_PATH := "res://Crafting/Recipes.tres"

var _recipes_cache: Resource = null
var _recipes_warned: bool = false

func _recipes_resource() -> Resource:
	if _recipes_cache != null:
		return _recipes_cache
	var res = load(_RECIPES_PATH)
	if res == null:
		if not _recipes_warned:
			push_warning("[Registry] recipes: Recipes.tres missing at %s; recipes registry is inert" % _RECIPES_PATH)
			_recipes_warned = true
		return null
	_recipes_cache = res
	return res

# Shape check: does this Resource carry the RecipeData fields? Consistent
# with _looks_like_item_data / _looks_like_audio_event.
func _looks_like_recipe_data(res: Resource) -> bool:
	return _resource_has_property(res, "name") \
			and _resource_has_property(res, "input") \
			and _resource_has_property(res, "output")

func _valid_category(category: String) -> bool:
	return category in _RECIPE_CATEGORIES

# Validates {recipe, category} payload for register / override. Returns
# [recipe, category_array, category_string] or [null, null, ""] on error.
func _validate_recipe_data(id: String, verb: String, data: Variant) -> Array:
	if not (data is Dictionary):
		push_warning("[Registry] %s('recipes', '%s', ...) expects Dictionary {recipe, category}, got %s" % [verb, id, typeof(data)])
		return [null, null, ""]
	var d: Dictionary = data
	if not d.has("recipe") or not d.has("category"):
		push_warning("[Registry] %s('recipes', '%s', ...) data dict missing 'recipe' or 'category' key" % [verb, id])
		return [null, null, ""]
	var recipe = d["recipe"]
	if not (recipe is Resource) or not _looks_like_recipe_data(recipe):
		push_warning("[Registry] %s('recipes', '%s'): recipe is not a RecipeData Resource" % [verb, id])
		return [null, null, ""]
	var category = d["category"]
	if not (category is String) or not _valid_category(category):
		push_warning("[Registry] %s('recipes', '%s'): category must be one of %s, got '%s'" \
				% [verb, id, _RECIPE_CATEGORIES, category])
		return [null, null, ""]
	var recipes := _recipes_resource()
	if recipes == null:
		return [null, null, ""]
	var arr = recipes.get(category)
	if not (arr is Array):
		push_warning("[Registry] %s('recipes', '%s'): Recipes.%s is not an Array" % [verb, id, category])
		return [null, null, ""]
	return [recipe, arr, String(category)]

func _register_recipe(id: String, data: Variant) -> bool:
	var reg: Dictionary = _registry_registered.get("recipes", {})
	if reg.has(id):
		push_warning("[Registry] register('recipes', '%s'): already registered (pick a unique handle)" % id)
		return false
	var parts := _validate_recipe_data(id, "register", data)
	var recipe = parts[0]
	var arr = parts[1]
	var category: String = parts[2]
	if recipe == null or arr == null:
		return false
	if not _typed_array_accepts(arr, recipe):
		push_warning("[Registry] register('recipes', '%s'): recipe type doesn't match Recipes.%s typed array" % [id, category])
		return false
	if recipe in arr:
		push_warning("[Registry] register('recipes', '%s'): recipe is already present in category '%s'; use override instead" % [id, category])
		return false
	arr.append(recipe)
	reg[id] = {"recipe": recipe, "category": category}
	_registry_registered["recipes"] = reg
	# Vanilla ships the Equipment and Misc category tabs disabled + faded
	# (Interface.tscn:2591, 2668) because those recipe arrays are empty.
	# Any mod adding a recipe to either category needs the tab clickable,
	# so auto-patch the buttons via the scene_nodes registry. Idempotent:
	# repeat calls from additional mods just re-set the same props.
	_unlock_crafting_category_button_if_needed(category)
	_log_debug("[Registry] registered recipe '%s' (category=%s, name=%s)" % [id, category, recipe.get("name")])
	return true

# Category -> node path inside Interface.tscn. Only equipment + misc ship
# locked in vanilla; the other categories are always clickable.
const _LOCKED_CATEGORY_BUTTONS := {
	"equipment": "Tools/Crafting/Types/Margin/Buttons/Equipment",
	"misc": "Tools/Crafting/Types/Margin/Buttons/Misc",
}
const _INTERFACE_SCENE_PATH := "res://UI/Interface.tscn"

func _unlock_crafting_category_button_if_needed(category: String) -> void:
	if not _LOCKED_CATEGORY_BUTTONS.has(category):
		return
	var node_path: String = _LOCKED_CATEGORY_BUTTONS[category]
	var id: String = "%s#%s" % [_INTERFACE_SCENE_PATH, node_path]
	# Reset the button's modulate to full alpha but leave the icon child
	# alone at its vanilla 0.5 alpha icons ship faded on every category
	# button, and matching sibling behavior is the right default.
	_patch_scene_node(id, {
		"disabled": false,
		"modulate": Color(1.0, 1.0, 1.0, 1.0),
	})

func _override_recipe(id: String, data: Variant) -> bool:
	var ov: Dictionary = _registry_overridden.get("recipes", {})
	if ov.has(id):
		push_warning("[Registry] override('recipes', '%s'): already overridden (revert first to re-override)" % id)
		return false
	if not (data is Dictionary) or not data.has("replaces"):
		push_warning("[Registry] override('recipes', '%s', ...) requires {recipe, category, replaces: RecipeData}" % id)
		return false
	var parts := _validate_recipe_data(id, "override", data)
	var new_recipe = parts[0]
	var arr = parts[1]
	var category: String = parts[2]
	if new_recipe == null or arr == null:
		return false
	var old_recipe = data["replaces"]
	if not (old_recipe is Resource) or not _looks_like_recipe_data(old_recipe):
		push_warning("[Registry] override('recipes', '%s'): 'replaces' is not a RecipeData Resource" % id)
		return false
	if not _typed_array_accepts(arr, new_recipe):
		push_warning("[Registry] override('recipes', '%s'): recipe type doesn't match Recipes.%s typed array" % [id, category])
		return false
	var idx: int = arr.find(old_recipe)
	if idx < 0:
		push_warning("[Registry] override('recipes', '%s'): 'replaces' not present in category '%s'" % [id, category])
		return false
	if new_recipe in arr:
		push_warning("[Registry] override('recipes', '%s'): new recipe already in category; would duplicate" % id)
		return false
	arr[idx] = new_recipe
	ov[id] = {
		"recipe": new_recipe,
		"category": category,
		"replaced": old_recipe,
		"index": idx,
	}
	_registry_overridden["recipes"] = ov
	var reg: Dictionary = _registry_registered.get("recipes", {})
	reg[id] = {"recipe": new_recipe, "category": category}
	_registry_registered["recipes"] = reg
	_log_debug("[Registry] overrode recipe '%s' in %s" % [id, category])
	return true

# Resolves whatever the mod passed (String handle or RecipeData Resource) to:
#   [recipe, patch_key]
# where patch_key is the stable Variant we use in _registry_patched to
# track per-field original values. For handles it's the String; for direct
# refs it's the object's instance_id (int), so distinct Resource instances
# don't collide.
func _resolve_patch_target(id: Variant) -> Array:
	if id is String:
		var reg: Dictionary = _registry_registered.get("recipes", {})
		if reg.has(id):
			return [reg[id]["recipe"], id]
		push_warning("[Registry] patch('recipes', '%s'): no registered handle with that id (register first, or pass a RecipeData Resource ref directly)" % id)
		return [null, null]
	if id is Resource and _looks_like_recipe_data(id):
		return [id, "ref:%d" % id.get_instance_id()]
	push_warning("[Registry] patch('recipes', ...): id must be a String handle or a RecipeData Resource")
	return [null, null]

func _patch_recipe(id: Variant, fields: Dictionary) -> bool:
	if fields.is_empty():
		push_warning("[Registry] patch('recipes', ...): empty fields dict is a no-op")
		return false
	var resolved := _resolve_patch_target(id)
	var target: Resource = resolved[0]
	var key = resolved[1]
	if target == null:
		return false
	var patched: Dictionary = _registry_patched.get("recipes", {})
	var stash: Dictionary = patched.get(key, {})
	for field in fields.keys():
		var fname := String(field)
		if not _resource_has_property(target, fname):
			push_warning("[Registry] patch('recipes'): field '%s' doesn't exist on RecipeData" % fname)
			continue
		if not stash.has(fname):
			stash[fname] = target.get(fname)
		target.set(fname, fields[field])
	patched[key] = stash
	_registry_patched["recipes"] = patched
	_log_debug("[Registry] patched recipe (key=%s) fields %s" % [key, fields.keys()])
	return true

func _remove_recipe(id: String) -> bool:
	var reg: Dictionary = _registry_registered.get("recipes", {})
	if not reg.has(id):
		push_warning("[Registry] remove('recipes', '%s'): not a mod recipe registration" % id)
		return false
	var ov: Dictionary = _registry_overridden.get("recipes", {})
	if ov.has(id):
		push_warning("[Registry] remove('recipes', '%s'): entry is an override, use revert instead" % id)
		return false
	var entry: Dictionary = reg[id]
	var category: String = entry["category"]
	var recipe: Resource = entry["recipe"]
	var recipes := _recipes_resource()
	if recipes != null:
		var arr = recipes.get(category)
		if arr is Array:
			var idx: int = arr.find(recipe)
			if idx >= 0:
				arr.remove_at(idx)
			else:
				push_warning("[Registry] remove('recipes', '%s'): recipe not found in %s; tracking cleared" % [id, category])
	reg.erase(id)
	_registry_registered["recipes"] = reg
	_log_debug("[Registry] removed recipe '%s'" % id)
	return true

func _revert_recipe(id: Variant, fields: Array) -> bool:
	var did_something := false
	var ov: Dictionary = _registry_overridden.get("recipes", {})
	var patched: Dictionary = _registry_patched.get("recipes", {})
	# Patch key computation matches _resolve_patch_target so we find the same
	# stash entry regardless of whether the caller patches by handle or by ref.
	var patch_key = null
	var patch_target: Resource = null
	if id is String:
		patch_key = id
		var reg: Dictionary = _registry_registered.get("recipes", {})
		if reg.has(id):
			patch_target = reg[id]["recipe"]
	elif id is Resource and _looks_like_recipe_data(id):
		patch_key = "ref:%d" % id.get_instance_id()
		patch_target = id
	# Full revert: patches first (so stash restores onto current entry),
	# then override (swaps back to the vanilla recipe instance).
	if fields.is_empty():
		if patch_key != null and patched.has(patch_key):
			if patch_target != null:
				var stash: Dictionary = patched[patch_key]
				for fname in stash.keys():
					patch_target.set(fname, stash[fname])
			patched.erase(patch_key)
			_registry_patched["recipes"] = patched
			did_something = true
		if id is String and ov.has(id):
			var entry: Dictionary = ov[id]
			var recipes := _recipes_resource()
			if recipes != null:
				var arr = recipes.get(entry["category"])
				if arr is Array:
					var current_idx: int = arr.find(entry["recipe"])
					if current_idx >= 0:
						arr[current_idx] = entry["replaced"]
					else:
						push_warning("[Registry] revert('recipes', '%s'): override's recipe missing from %s, appending original at end" % [id, entry["category"]])
						arr.append(entry["replaced"])
			ov.erase(id)
			_registry_overridden["recipes"] = ov
			var reg2: Dictionary = _registry_registered.get("recipes", {})
			reg2.erase(id)
			_registry_registered["recipes"] = reg2
			did_something = true
		if not did_something:
			push_warning("[Registry] revert('recipes'): nothing to revert for that id")
		return did_something
	# Per-field revert: only operates on patch stashes.
	if patch_key == null or not patched.has(patch_key):
		push_warning("[Registry] revert('recipes'): no patches found for that id")
		return false
	if patch_target == null:
		push_warning("[Registry] revert('recipes'): patch target no longer resolves")
		return false
	var stash2: Dictionary = patched[patch_key]
	for field in fields:
		var fname := String(field)
		if not stash2.has(fname):
			push_warning("[Registry] revert('recipes'): field '%s' wasn't patched" % fname)
			continue
		patch_target.set(fname, stash2[fname])
		stash2.erase(fname)
		did_something = true
	if stash2.is_empty():
		patched.erase(patch_key)
	else:
		patched[patch_key] = stash2
	_registry_patched["recipes"] = patched
	return did_something

## ----- registry/events.gd -----
##
## Events.tres holds a single `events: Array[EventData]` that EventSystem.gd
## const-preloads and filters into per-type buckets (dynamic/trader/special)
## at _ready(). Same timing constraint as loot / recipes: mods must register
## during their own _ready() for additions to propagate into EventSystem's
## buckets before the first GetAvailableEvents() call.
##
## EventData has no unique id (name isn't unique; vanilla can have duplicate
## names across days). Registry uses mod-chosen handles like loot / recipes.
##
## Data shapes:
##   register: {event: EventData}
##   override: {event: EventData, replaces: EventData}
##   patch:    id can be a String handle OR an EventData Resource ref
##             directly; lets mods patch vanilla events in one call
##             without registering a handle first.
##
## Caveat for mod authors: EventData.function is a method name resolved on
## EventSystem via Callable(self, event.function). Registering a new event
## whose function string refers to a method EventSystem doesn't have will
## make that event a no-op when it fires. Either reuse a vanilla function
## name (FighterJet, ActivateTrader, etc.) or hook EventSystem to inject
## your own handler.

const _EVENTS_PATH := "res://Events/Events.tres"

var _events_cache: Resource = null
var _events_warned: bool = false

func _events_resource() -> Resource:
	if _events_cache != null:
		return _events_cache
	var res = load(_EVENTS_PATH)
	if res == null:
		if not _events_warned:
			push_warning("[Registry] events: Events.tres missing at %s; events registry is inert" % _EVENTS_PATH)
			_events_warned = true
		return null
	_events_cache = res
	return res

# Shape check: consistent with other registries' _looks_like_* helpers.
# EventData's canonical fields are name, type, function, possibility.
func _looks_like_event_data(res: Resource) -> bool:
	return _resource_has_property(res, "function") \
			and _resource_has_property(res, "possibility") \
			and _resource_has_property(res, "day")

# Validates {event} payload. Returns [event, arr] or [null, null] on error.
func _validate_event_data(id: String, verb: String, data: Variant) -> Array:
	if not (data is Dictionary):
		push_warning("[Registry] %s('events', '%s', ...) expects Dictionary {event}, got %s" % [verb, id, typeof(data)])
		return [null, null]
	var d: Dictionary = data
	if not d.has("event"):
		push_warning("[Registry] %s('events', '%s', ...) data dict missing 'event' key" % [verb, id])
		return [null, null]
	var event = d["event"]
	if not (event is Resource) or not _looks_like_event_data(event):
		push_warning("[Registry] %s('events', '%s'): event is not an EventData Resource" % [verb, id])
		return [null, null]
	var events_res := _events_resource()
	if events_res == null:
		return [null, null]
	var arr = events_res.get("events")
	if not (arr is Array):
		push_warning("[Registry] %s('events', '%s'): Events.events is not an Array" % [verb, id])
		return [null, null]
	return [event, arr]

func _register_event(id: String, data: Variant) -> bool:
	var reg: Dictionary = _registry_registered.get("events", {})
	if reg.has(id):
		push_warning("[Registry] register('events', '%s'): already registered (pick a unique handle)" % id)
		return false
	var parts := _validate_event_data(id, "register", data)
	var event = parts[0]
	var arr = parts[1]
	if event == null or arr == null:
		return false
	if not _typed_array_accepts(arr, event):
		push_warning("[Registry] register('events', '%s'): event type doesn't match Events.events typed array" % id)
		return false
	if event in arr:
		push_warning("[Registry] register('events', '%s'): event is already present in the array; use override instead" % id)
		return false
	arr.append(event)
	reg[id] = {"event": event}
	_registry_registered["events"] = reg
	_log_debug("[Registry] registered event '%s' (name=%s)" % [id, event.get("name")])
	return true

func _override_event(id: String, data: Variant) -> bool:
	var ov: Dictionary = _registry_overridden.get("events", {})
	if ov.has(id):
		push_warning("[Registry] override('events', '%s'): already overridden (revert first to re-override)" % id)
		return false
	if not (data is Dictionary) or not data.has("replaces"):
		push_warning("[Registry] override('events', '%s', ...) requires {event, replaces: EventData}" % id)
		return false
	var parts := _validate_event_data(id, "override", data)
	var new_event = parts[0]
	var arr = parts[1]
	if new_event == null or arr == null:
		return false
	var old_event = data["replaces"]
	if not (old_event is Resource) or not _looks_like_event_data(old_event):
		push_warning("[Registry] override('events', '%s'): 'replaces' is not an EventData Resource" % id)
		return false
	if not _typed_array_accepts(arr, new_event):
		push_warning("[Registry] override('events', '%s'): event type doesn't match Events.events typed array" % id)
		return false
	var idx: int = arr.find(old_event)
	if idx < 0:
		push_warning("[Registry] override('events', '%s'): 'replaces' not present in Events.events" % id)
		return false
	if new_event in arr:
		push_warning("[Registry] override('events', '%s'): new event already in array; would duplicate" % id)
		return false
	arr[idx] = new_event
	ov[id] = {
		"event": new_event,
		"replaced": old_event,
		"index": idx,
	}
	_registry_overridden["events"] = ov
	var reg: Dictionary = _registry_registered.get("events", {})
	reg[id] = {"event": new_event}
	_registry_registered["events"] = reg
	_log_debug("[Registry] overrode event '%s'" % id)
	return true

# Resolves whatever the mod passed (String handle or EventData ref) to:
#   [event, patch_key]
# where patch_key is the stable Variant used in _registry_patched to track
# per-field original values. For handles it's the String; for direct refs
# it's "ref:<instance_id>" so distinct Resource instances don't collide.
func _resolve_event_patch_target(id: Variant) -> Array:
	if id is String:
		var reg: Dictionary = _registry_registered.get("events", {})
		if reg.has(id):
			return [reg[id]["event"], id]
		push_warning("[Registry] patch('events', '%s'): no registered handle with that id (register first, or pass an EventData Resource ref directly)" % id)
		return [null, null]
	if id is Resource and _looks_like_event_data(id):
		return [id, "ref:%d" % id.get_instance_id()]
	push_warning("[Registry] patch('events', ...): id must be a String handle or an EventData Resource")
	return [null, null]

func _patch_event(id: Variant, fields: Dictionary) -> bool:
	if fields.is_empty():
		push_warning("[Registry] patch('events', ...): empty fields dict is a no-op")
		return false
	var resolved := _resolve_event_patch_target(id)
	var target: Resource = resolved[0]
	var key = resolved[1]
	if target == null:
		return false
	var patched: Dictionary = _registry_patched.get("events", {})
	var stash: Dictionary = patched.get(key, {})
	for field in fields.keys():
		var fname := String(field)
		if not _resource_has_property(target, fname):
			push_warning("[Registry] patch('events'): field '%s' doesn't exist on EventData" % fname)
			continue
		if not stash.has(fname):
			stash[fname] = target.get(fname)
		target.set(fname, fields[field])
	patched[key] = stash
	_registry_patched["events"] = patched
	_log_debug("[Registry] patched event (key=%s) fields %s" % [key, fields.keys()])
	return true

func _remove_event(id: String) -> bool:
	var reg: Dictionary = _registry_registered.get("events", {})
	if not reg.has(id):
		push_warning("[Registry] remove('events', '%s'): not a mod event registration" % id)
		return false
	var ov: Dictionary = _registry_overridden.get("events", {})
	if ov.has(id):
		push_warning("[Registry] remove('events', '%s'): entry is an override, use revert instead" % id)
		return false
	var entry: Dictionary = reg[id]
	var event: Resource = entry["event"]
	var events_res := _events_resource()
	if events_res != null:
		var arr = events_res.get("events")
		if arr is Array:
			var idx: int = arr.find(event)
			if idx >= 0:
				arr.remove_at(idx)
			else:
				push_warning("[Registry] remove('events', '%s'): event not found in array; tracking cleared" % id)
	reg.erase(id)
	_registry_registered["events"] = reg
	_log_debug("[Registry] removed event '%s'" % id)
	return true

func _revert_event(id: Variant, fields: Array) -> bool:
	var did_something := false
	var ov: Dictionary = _registry_overridden.get("events", {})
	var patched: Dictionary = _registry_patched.get("events", {})
	# Resolve patch key + target (matches _resolve_event_patch_target).
	var patch_key = null
	var patch_target: Resource = null
	if id is String:
		patch_key = id
		var reg: Dictionary = _registry_registered.get("events", {})
		if reg.has(id):
			patch_target = reg[id]["event"]
	elif id is Resource and _looks_like_event_data(id):
		patch_key = "ref:%d" % id.get_instance_id()
		patch_target = id
	# Full revert: patches first, then override.
	if fields.is_empty():
		if patch_key != null and patched.has(patch_key):
			if patch_target != null:
				var stash: Dictionary = patched[patch_key]
				for fname in stash.keys():
					patch_target.set(fname, stash[fname])
			patched.erase(patch_key)
			_registry_patched["events"] = patched
			did_something = true
		if id is String and ov.has(id):
			var entry: Dictionary = ov[id]
			var events_res := _events_resource()
			if events_res != null:
				var arr = events_res.get("events")
				if arr is Array:
					var current_idx: int = arr.find(entry["event"])
					if current_idx >= 0:
						arr[current_idx] = entry["replaced"]
					else:
						push_warning("[Registry] revert('events', '%s'): override's event missing from array, appending original at end" % id)
						arr.append(entry["replaced"])
			ov.erase(id)
			_registry_overridden["events"] = ov
			var reg2: Dictionary = _registry_registered.get("events", {})
			reg2.erase(id)
			_registry_registered["events"] = reg2
			did_something = true
		if not did_something:
			push_warning("[Registry] revert('events'): nothing to revert for that id")
		return did_something
	# Per-field revert: patches only.
	if patch_key == null or not patched.has(patch_key):
		push_warning("[Registry] revert('events'): no patches found for that id")
		return false
	if patch_target == null:
		push_warning("[Registry] revert('events'): patch target no longer resolves")
		return false
	var stash2: Dictionary = patched[patch_key]
	for field in fields:
		var fname := String(field)
		if not stash2.has(fname):
			push_warning("[Registry] revert('events'): field '%s' wasn't patched" % fname)
			continue
		patch_target.set(fname, stash2[fname])
		stash2.erase(fname)
		did_something = true
	if stash2.is_empty():
		patched.erase(patch_key)
	else:
		patched[patch_key] = stash2
	_registry_patched["events"] = patched
	return did_something

## ----- registry/traders.gd -----
## Two related registries that both mutate trader-adjacent state:
##
## - trader_pools: enables an ItemData to appear in a named trader's supply
##   bucket. Trader.FillTraderBucket() walks LT_Master and filters by
##   boolean trader flags on each item (itemData.generalist / .doctor /
##   .gunsmith / .grandma). So "register an item into the Doctor's pool" =
##   set itemData.doctor = true. Register / remove only; no override or
##   patch (entries are single boolean flags, there's nothing to override).
##
## - trader_tasks: appends/overrides/patches TaskData entries in a
##   TraderData.tasks array. Structurally identical to recipes: register
##   adds, override swaps (requires `replaces`), patch mutates fields on a
##   TaskData (String handle OR direct TaskData ref).
##
## Timing: same as loot/recipes/events. Trader.gd fills its local bucket in
## _ready() from LT_Master's pre-mutated items and TraderData.tasks. Mods
## must register during their own _ready() or changes won't propagate into
## a trader that's already initialized.

const _TRADER_POOL_FLAGS := {
	"generalist": "generalist",
	"doctor": "doctor",
	"gunsmith": "gunsmith",
	"grandma": "grandma",
}

# Known TraderData paths. Mods can also pass an absolute res:// path for a
# custom trader (e.g., one added by another mod via a file-level addition).
const _TRADER_PATHS := {
	"Generalist": "res://Traders/Generalist/Generalist.tres",
	"Doctor": "res://Traders/Doctor/Doctor.tres",
	"Gunsmith": "res://Traders/Gunsmith/Gunsmith.tres",
}

# -------- trader_pools --------

func _normalize_trader_flag(trader_name: String) -> String:
	# Accepts "Generalist", "generalist", "GENERALIST" -> "generalist".
	# The ItemData flags are lowercased; the TraderData.name fields are
	# capitalized. trader_pools keys by the flag name.
	var lower := trader_name.to_lower()
	if _TRADER_POOL_FLAGS.has(lower):
		return lower
	return ""

func _register_trader_pool(id: String, data: Variant) -> bool:
	var reg: Dictionary = _registry_registered.get("trader_pools", {})
	if reg.has(id):
		push_warning("[Registry] register('trader_pools', '%s'): already registered (pick a unique handle)" % id)
		return false
	if not (data is Dictionary):
		push_warning("[Registry] register('trader_pools', '%s', ...) expects Dictionary {item, trader}, got %s" % [id, typeof(data)])
		return false
	var d: Dictionary = data
	if not d.has("item") or not d.has("trader"):
		push_warning("[Registry] register('trader_pools', '%s', ...) data dict missing 'item' or 'trader' key" % id)
		return false
	var item = d["item"]
	if not (item is Resource) or not _looks_like_item_data(item):
		push_warning("[Registry] register('trader_pools', '%s'): item is not an ItemData Resource" % id)
		return false
	var trader = d["trader"]
	if not (trader is String):
		push_warning("[Registry] register('trader_pools', '%s'): trader must be a String (e.g. 'Generalist')" % id)
		return false
	var flag := _normalize_trader_flag(trader)
	if flag == "":
		push_warning("[Registry] register('trader_pools', '%s'): unknown trader '%s' (valid: %s)" \
				% [id, trader, _TRADER_POOL_FLAGS.keys()])
		return false
	if not _resource_has_property(item, flag):
		push_warning("[Registry] register('trader_pools', '%s'): item has no '%s' flag field (not a standard ItemData?)" % [id, flag])
		return false
	# Stash the original flag value so remove/revert can restore it. Most
	# items default to false for a given trader flag, but we don't assume.
	var original_value = item.get(flag)
	item.set(flag, true)
	reg[id] = {
		"item": item,
		"trader": trader,
		"flag": flag,
		"original": original_value,
	}
	_registry_registered["trader_pools"] = reg
	_log_debug("[Registry] registered trader_pool '%s' (%s item.%s=true, was %s)" \
			% [id, item.get("file"), flag, original_value])
	return true

func _remove_trader_pool(id: String) -> bool:
	var reg: Dictionary = _registry_registered.get("trader_pools", {})
	if not reg.has(id):
		push_warning("[Registry] remove('trader_pools', '%s'): not registered by a mod" % id)
		return false
	var entry: Dictionary = reg[id]
	var item: Resource = entry["item"]
	var flag: String = entry["flag"]
	item.set(flag, entry["original"])
	reg.erase(id)
	_registry_registered["trader_pools"] = reg
	_log_debug("[Registry] removed trader_pool '%s'" % id)
	return true

# Revert on trader_pools is a straight alias for remove; there's no
# override layer. Accept it for API symmetry so mods can call revert
# uniformly across registries.
func _revert_trader_pool(id: String) -> bool:
	return _remove_trader_pool(id)

# -------- trader_tasks --------

var _trader_data_cache: Dictionary = {}  # name -> Resource

func _resolve_trader_data(trader_ref: String) -> Resource:
	if trader_ref == "":
		push_warning("[Registry] trader_tasks: empty trader name")
		return null
	if _trader_data_cache.has(trader_ref):
		return _trader_data_cache[trader_ref]
	var path := trader_ref
	if _TRADER_PATHS.has(trader_ref):
		path = _TRADER_PATHS[trader_ref]
	elif not trader_ref.begins_with("res://"):
		push_warning("[Registry] trader_tasks: unknown trader '%s' (valid: %s, or pass an absolute res:// path)" \
				% [trader_ref, _TRADER_PATHS.keys()])
		return null
	var res = load(path)
	if res == null:
		push_warning("[Registry] trader_tasks: couldn't load TraderData at '%s'" % path)
		return null
	if not ("tasks" in res):
		push_warning("[Registry] trader_tasks: resource at '%s' has no `tasks` array (not a TraderData?)" % path)
		return null
	_trader_data_cache[trader_ref] = res
	return res

# Shape check for TaskData.
func _looks_like_task_data(res: Resource) -> bool:
	return _resource_has_property(res, "trader") \
			and _resource_has_property(res, "deliver") \
			and _resource_has_property(res, "receive")

# Validates {task, trader} payload. Returns [task, tasks_arr, trader_name]
# or [null, null, ""] on error.
func _validate_trader_task(id: String, verb: String, data: Variant) -> Array:
	if not (data is Dictionary):
		push_warning("[Registry] %s('trader_tasks', '%s', ...) expects Dictionary {task, trader}, got %s" % [verb, id, typeof(data)])
		return [null, null, ""]
	var d: Dictionary = data
	if not d.has("task") or not d.has("trader"):
		push_warning("[Registry] %s('trader_tasks', '%s', ...) data dict missing 'task' or 'trader' key" % [verb, id])
		return [null, null, ""]
	var task = d["task"]
	if not (task is Resource) or not _looks_like_task_data(task):
		push_warning("[Registry] %s('trader_tasks', '%s'): task is not a TaskData Resource" % [verb, id])
		return [null, null, ""]
	var trader = d["trader"]
	if not (trader is String):
		push_warning("[Registry] %s('trader_tasks', '%s'): trader must be a String" % [verb, id])
		return [null, null, ""]
	var trader_res := _resolve_trader_data(trader)
	if trader_res == null:
		return [null, null, ""]
	var arr = trader_res.get("tasks")
	if not (arr is Array):
		push_warning("[Registry] %s('trader_tasks', '%s'): %s.tasks is not an Array" % [verb, id, trader])
		return [null, null, ""]
	return [task, arr, String(trader)]

func _register_trader_task(id: String, data: Variant) -> bool:
	var reg: Dictionary = _registry_registered.get("trader_tasks", {})
	if reg.has(id):
		push_warning("[Registry] register('trader_tasks', '%s'): already registered (pick a unique handle)" % id)
		return false
	var parts := _validate_trader_task(id, "register", data)
	var task = parts[0]
	var arr = parts[1]
	var trader: String = parts[2]
	if task == null or arr == null:
		return false
	if not _typed_array_accepts(arr, task):
		push_warning("[Registry] register('trader_tasks', '%s'): task type doesn't match %s.tasks typed array" % [id, trader])
		return false
	if task in arr:
		push_warning("[Registry] register('trader_tasks', '%s'): task already in %s.tasks; use override to swap" % [id, trader])
		return false
	arr.append(task)
	reg[id] = {"task": task, "trader": trader}
	_registry_registered["trader_tasks"] = reg
	_log_debug("[Registry] registered trader_task '%s' (trader=%s, name=%s)" % [id, trader, task.get("name")])
	return true

func _override_trader_task(id: String, data: Variant) -> bool:
	var ov: Dictionary = _registry_overridden.get("trader_tasks", {})
	if ov.has(id):
		push_warning("[Registry] override('trader_tasks', '%s'): already overridden (revert first)" % id)
		return false
	if not (data is Dictionary) or not data.has("replaces"):
		push_warning("[Registry] override('trader_tasks', '%s', ...) requires {task, trader, replaces: TaskData}" % id)
		return false
	var parts := _validate_trader_task(id, "override", data)
	var new_task = parts[0]
	var arr = parts[1]
	var trader: String = parts[2]
	if new_task == null or arr == null:
		return false
	var old_task = data["replaces"]
	if not (old_task is Resource) or not _looks_like_task_data(old_task):
		push_warning("[Registry] override('trader_tasks', '%s'): 'replaces' is not a TaskData Resource" % id)
		return false
	if not _typed_array_accepts(arr, new_task):
		push_warning("[Registry] override('trader_tasks', '%s'): task type doesn't match %s.tasks typed array" % [id, trader])
		return false
	var idx: int = arr.find(old_task)
	if idx < 0:
		push_warning("[Registry] override('trader_tasks', '%s'): 'replaces' not present in %s.tasks" % [id, trader])
		return false
	if new_task in arr:
		push_warning("[Registry] override('trader_tasks', '%s'): new task already in array; would duplicate" % id)
		return false
	arr[idx] = new_task
	ov[id] = {
		"task": new_task,
		"trader": trader,
		"replaced": old_task,
		"index": idx,
	}
	_registry_overridden["trader_tasks"] = ov
	var reg: Dictionary = _registry_registered.get("trader_tasks", {})
	reg[id] = {"task": new_task, "trader": trader}
	_registry_registered["trader_tasks"] = reg
	_log_debug("[Registry] overrode trader_task '%s' in %s" % [id, trader])
	return true

# Resolve a patch target (String handle or TaskData ref) -> [task, key].
func _resolve_trader_task_patch_target(id: Variant) -> Array:
	if id is String:
		var reg: Dictionary = _registry_registered.get("trader_tasks", {})
		if reg.has(id):
			return [reg[id]["task"], id]
		push_warning("[Registry] patch('trader_tasks', '%s'): no registered handle with that id (register first, or pass a TaskData Resource ref directly)" % id)
		return [null, null]
	if id is Resource and _looks_like_task_data(id):
		return [id, "ref:%d" % id.get_instance_id()]
	push_warning("[Registry] patch('trader_tasks', ...): id must be a String handle or a TaskData Resource")
	return [null, null]

func _patch_trader_task(id: Variant, fields: Dictionary) -> bool:
	if fields.is_empty():
		push_warning("[Registry] patch('trader_tasks', ...): empty fields dict is a no-op")
		return false
	var resolved := _resolve_trader_task_patch_target(id)
	var target: Resource = resolved[0]
	var key = resolved[1]
	if target == null:
		return false
	var patched: Dictionary = _registry_patched.get("trader_tasks", {})
	var stash: Dictionary = patched.get(key, {})
	for field in fields.keys():
		var fname := String(field)
		if not _resource_has_property(target, fname):
			push_warning("[Registry] patch('trader_tasks'): field '%s' doesn't exist on TaskData" % fname)
			continue
		if not stash.has(fname):
			stash[fname] = target.get(fname)
		target.set(fname, fields[field])
	patched[key] = stash
	_registry_patched["trader_tasks"] = patched
	_log_debug("[Registry] patched trader_task (key=%s) fields %s" % [key, fields.keys()])
	return true

func _remove_trader_task(id: String) -> bool:
	var reg: Dictionary = _registry_registered.get("trader_tasks", {})
	if not reg.has(id):
		push_warning("[Registry] remove('trader_tasks', '%s'): not a mod registration" % id)
		return false
	var ov: Dictionary = _registry_overridden.get("trader_tasks", {})
	if ov.has(id):
		push_warning("[Registry] remove('trader_tasks', '%s'): entry is an override, use revert instead" % id)
		return false
	var entry: Dictionary = reg[id]
	var trader: String = entry["trader"]
	var task: Resource = entry["task"]
	var trader_res := _resolve_trader_data(trader)
	if trader_res != null:
		var arr = trader_res.get("tasks")
		if arr is Array:
			var idx: int = arr.find(task)
			if idx >= 0:
				arr.remove_at(idx)
			else:
				push_warning("[Registry] remove('trader_tasks', '%s'): task not found in %s.tasks; tracking cleared" % [id, trader])
	reg.erase(id)
	_registry_registered["trader_tasks"] = reg
	_log_debug("[Registry] removed trader_task '%s'" % id)
	return true

func _revert_trader_task(id: Variant, fields: Array) -> bool:
	var did_something := false
	var ov: Dictionary = _registry_overridden.get("trader_tasks", {})
	var patched: Dictionary = _registry_patched.get("trader_tasks", {})
	var patch_key = null
	var patch_target: Resource = null
	if id is String:
		patch_key = id
		var reg: Dictionary = _registry_registered.get("trader_tasks", {})
		if reg.has(id):
			patch_target = reg[id]["task"]
	elif id is Resource and _looks_like_task_data(id):
		patch_key = "ref:%d" % id.get_instance_id()
		patch_target = id
	if fields.is_empty():
		if patch_key != null and patched.has(patch_key):
			if patch_target != null:
				var stash: Dictionary = patched[patch_key]
				for fname in stash.keys():
					patch_target.set(fname, stash[fname])
			patched.erase(patch_key)
			_registry_patched["trader_tasks"] = patched
			did_something = true
		if id is String and ov.has(id):
			var entry: Dictionary = ov[id]
			var trader_res := _resolve_trader_data(entry["trader"])
			if trader_res != null:
				var arr = trader_res.get("tasks")
				if arr is Array:
					var current_idx: int = arr.find(entry["task"])
					if current_idx >= 0:
						arr[current_idx] = entry["replaced"]
					else:
						push_warning("[Registry] revert('trader_tasks', '%s'): override's task missing from %s.tasks, appending original at end" % [id, entry["trader"]])
						arr.append(entry["replaced"])
			ov.erase(id)
			_registry_overridden["trader_tasks"] = ov
			var reg2: Dictionary = _registry_registered.get("trader_tasks", {})
			reg2.erase(id)
			_registry_registered["trader_tasks"] = reg2
			did_something = true
		if not did_something:
			push_warning("[Registry] revert('trader_tasks'): nothing to revert for that id")
		return did_something
	if patch_key == null or not patched.has(patch_key):
		push_warning("[Registry] revert('trader_tasks'): no patches found for that id")
		return false
	if patch_target == null:
		push_warning("[Registry] revert('trader_tasks'): patch target no longer resolves")
		return false
	var stash2: Dictionary = patched[patch_key]
	for field in fields:
		var fname := String(field)
		if not stash2.has(fname):
			push_warning("[Registry] revert('trader_tasks'): field '%s' wasn't patched" % fname)
			continue
		patch_target.set(fname, stash2[fname])
		stash2.erase(fname)
		did_something = true
	if stash2.is_empty():
		patched.erase(patch_key)
	else:
		patched[patch_key] = stash2
	_registry_patched["trader_tasks"] = patched
	return did_something

## ----- registry/inputs.gd -----
##
## Thin wrapper over InputMap.add_action / erase_action / action_add_event
## so mods can declare their own input actions with a default keybind.
## Registered actions are immediately usable via
##     Input.is_action_pressed("mymod_heal")
## everywhere in the mod's code.
##
## Data shape:
##   register: {display_label: String, default_event: InputEvent, deadzone: float (optional, default 0.5)}
##   override: same shape; replaces the default event on an existing action (vanilla or mod)
##   patch:    {display_label, default_event, deadzone} subset; mutates the
##             registry metadata (display_label + deadzone) and/or swaps the
##             event on InputMap.
##
## The `id` IS the action name. Mods use it directly with Input.is_action_*.
## So mods should namespace: "mymod_heal", not just "heal".
##
## Settings UI caveat: vanilla's Settings -> Keybinds panel reads from a
## hardcoded `inputs` Dictionary inside Inputs.gd (the Control script for
## that UI). Registering an action here makes it functional in-game, but it
## won't appear in the rebind menu until a hook on Inputs-createactions-pre
## merges lib.get_entry(INPUTS, id) results into the UI's dict. That hook
## isn't installed by this registry; mod authors can install it themselves,
## or we add it via a loader-installed hook pack later.
##
## User keybind persistence: vanilla stores rebinds in user://Preferences.tres
## (Preferences.actionEvents). Those persist across mod unload/reload as long
## as the mod re-registers its action on boot. If the mod is uninstalled
## entirely, orphan entries linger harmlessly in Preferences.tres.

# Default deadzone for InputMap actions. Matches vanilla's project.godot.
const _DEFAULT_DEADZONE := 0.5

func _validate_input_payload(id: String, verb: String, data: Variant) -> Dictionary:
	# Returns validated {display_label, default_event, deadzone} on success,
	# empty dict on error (with warning already pushed).
	if not (data is Dictionary):
		push_warning("[Registry] %s('inputs', '%s', ...) expects Dictionary {display_label, default_event, deadzone?}, got %s" \
				% [verb, id, typeof(data)])
		return {}
	var d: Dictionary = data
	if not d.has("default_event"):
		push_warning("[Registry] %s('inputs', '%s', ...) data dict missing 'default_event' key" % [verb, id])
		return {}
	var ev = d["default_event"]
	if not (ev is InputEvent):
		push_warning("[Registry] %s('inputs', '%s'): default_event is not an InputEvent (got %s)" % [verb, id, typeof(ev)])
		return {}
	var display_label = d.get("display_label", id)
	if not (display_label is String):
		push_warning("[Registry] %s('inputs', '%s'): display_label must be a String" % [verb, id])
		return {}
	var deadzone = d.get("deadzone", _DEFAULT_DEADZONE)
	if not (deadzone is float or deadzone is int):
		push_warning("[Registry] %s('inputs', '%s'): deadzone must be a number" % [verb, id])
		return {}
	return {
		"display_label": display_label,
		"default_event": ev,
		"deadzone": float(deadzone),
	}

func _register_input(id: String, data: Variant) -> bool:
	var reg: Dictionary = _registry_registered.get("inputs", {})
	if reg.has(id):
		push_warning("[Registry] register('inputs', '%s'): already registered by a mod" % id)
		return false
	if InputMap.has_action(id):
		push_warning("[Registry] register('inputs', '%s'): action already exists in InputMap (vanilla or another mod; use override instead)" % id)
		return false
	var payload := _validate_input_payload(id, "register", data)
	if payload.is_empty():
		return false
	InputMap.add_action(id, payload["deadzone"])
	InputMap.action_add_event(id, payload["default_event"])
	reg[id] = payload
	_registry_registered["inputs"] = reg
	_log_debug("[Registry] registered input '%s' (label=%s)" % [id, payload["display_label"]])
	return true

func _override_input(id: String, data: Variant) -> bool:
	if not InputMap.has_action(id):
		push_warning("[Registry] override('inputs', '%s'): no such action in InputMap" % id)
		return false
	var payload := _validate_input_payload(id, "override", data)
	if payload.is_empty():
		return false
	var ov: Dictionary = _registry_overridden.get("inputs", {})
	# Stash original event list + deadzone once so revert can restore.
	if not ov.has(id):
		var originals: Array = []
		for e in InputMap.action_get_events(id):
			originals.append(e)
		# InputMap has no deadzone getter pre-4.x; grab via project_settings
		# if available, else assume default. Action deadzone isn't routinely
		# inspected so we can accept approximation; only matters for revert.
		ov[id] = {
			"events": originals,
			"deadzone": _DEFAULT_DEADZONE,
		}
		_registry_overridden["inputs"] = ov
	InputMap.action_erase_events(id)
	InputMap.action_add_event(id, payload["default_event"])
	# Update the metadata dict too so get_entry/patch see the current label.
	var reg: Dictionary = _registry_registered.get("inputs", {})
	reg[id] = payload
	_registry_registered["inputs"] = reg
	_log_debug("[Registry] overrode input '%s' (label=%s)" % [id, payload["display_label"]])
	return true

func _patch_input(id: String, fields: Dictionary) -> bool:
	if fields.is_empty():
		push_warning("[Registry] patch('inputs', '%s', ...): empty fields dict is a no-op" % id)
		return false
	if not InputMap.has_action(id):
		push_warning("[Registry] patch('inputs', '%s'): no such action in InputMap" % id)
		return false
	# Patch works on the metadata we track, plus InputMap mutation for the
	# event field. Each patchable field maps to specific state:
	#   display_label -> reg[id]["display_label"]  (UI hint only)
	#   default_event -> replaces the first event in InputMap
	#   deadzone      -> InputMap.action_set_deadzone
	const _patchable := ["display_label", "default_event", "deadzone"]
	var reg: Dictionary = _registry_registered.get("inputs", {})
	# If the id isn't in our reg (vanilla action we haven't touched yet),
	# seed a stub so patch/revert have somewhere to stash label changes.
	if not reg.has(id):
		reg[id] = {
			"display_label": id,
			"default_event": null,
			"deadzone": _DEFAULT_DEADZONE,
		}
	var current: Dictionary = reg[id]
	var patched: Dictionary = _registry_patched.get("inputs", {})
	var stash: Dictionary = patched.get(id, {})
	var any_applied := false
	for field in fields.keys():
		var fname := String(field)
		if not (fname in _patchable):
			push_warning("[Registry] patch('inputs', '%s'): field '%s' not patchable (valid: %s)" % [id, fname, _patchable])
			continue
		var val = fields[field]
		# Per-field validation.
		match fname:
			"display_label":
				if not (val is String):
					push_warning("[Registry] patch('inputs', '%s'): display_label must be String" % id)
					continue
				if not stash.has(fname):
					stash[fname] = current.get("display_label", id)
				current["display_label"] = val
			"default_event":
				if not (val is InputEvent):
					push_warning("[Registry] patch('inputs', '%s'): default_event must be InputEvent" % id)
					continue
				if not stash.has(fname):
					# Stash the CURRENT first event from InputMap so revert
					# restores exactly what was active, even if vanilla +
					# override chains exist.
					var existing := InputMap.action_get_events(id)
					stash[fname] = existing[0] if existing.size() > 0 else null
				InputMap.action_erase_events(id)
				InputMap.action_add_event(id, val)
				current["default_event"] = val
			"deadzone":
				if not (val is float or val is int):
					push_warning("[Registry] patch('inputs', '%s'): deadzone must be a number" % id)
					continue
				if not stash.has(fname):
					stash[fname] = current.get("deadzone", _DEFAULT_DEADZONE)
				InputMap.action_set_deadzone(id, float(val))
				current["deadzone"] = float(val)
		any_applied = true
	if not any_applied:
		return false
	reg[id] = current
	_registry_registered["inputs"] = reg
	patched[id] = stash
	_registry_patched["inputs"] = patched
	_log_debug("[Registry] patched input '%s' fields %s" % [id, fields.keys()])
	return true

func _remove_input(id: String) -> bool:
	var reg: Dictionary = _registry_registered.get("inputs", {})
	if not reg.has(id):
		push_warning("[Registry] remove('inputs', '%s'): not registered by a mod" % id)
		return false
	var ov: Dictionary = _registry_overridden.get("inputs", {})
	if ov.has(id):
		push_warning("[Registry] remove('inputs', '%s'): entry is an override, use revert instead" % id)
		return false
	if InputMap.has_action(id):
		InputMap.erase_action(id)
	reg.erase(id)
	_registry_registered["inputs"] = reg
	_log_debug("[Registry] removed input '%s'" % id)
	return true

func _revert_input(id: String, fields: Array) -> bool:
	var did_something := false
	var ov: Dictionary = _registry_overridden.get("inputs", {})
	var patched: Dictionary = _registry_patched.get("inputs", {})
	# Full revert: restore patch stash, then restore override events.
	if fields.is_empty():
		if patched.has(id):
			var stash: Dictionary = patched[id]
			var reg2: Dictionary = _registry_registered.get("inputs", {})
			var current: Dictionary = reg2.get(id, {})
			for fname in stash.keys():
				match fname:
					"display_label":
						current["display_label"] = stash[fname]
					"default_event":
						InputMap.action_erase_events(id)
						if stash[fname] != null:
							InputMap.action_add_event(id, stash[fname])
						current["default_event"] = stash[fname]
					"deadzone":
						InputMap.action_set_deadzone(id, float(stash[fname]))
						current["deadzone"] = stash[fname]
			if not current.is_empty():
				reg2[id] = current
				_registry_registered["inputs"] = reg2
			patched.erase(id)
			_registry_patched["inputs"] = patched
			did_something = true
		if ov.has(id):
			var entry: Dictionary = ov[id]
			InputMap.action_erase_events(id)
			for e in entry["events"]:
				InputMap.action_add_event(id, e)
			InputMap.action_set_deadzone(id, float(entry["deadzone"]))
			ov.erase(id)
			_registry_overridden["inputs"] = ov
			# If the action was vanilla (no registered entry in reg beyond
			# an override-added stub), leave reg intact; reg now reflects
			# vanilla state. If the mod also registered the action itself,
			# reg stays pointing at the mod's registration.
			did_something = true
		if not did_something:
			push_warning("[Registry] revert('inputs', '%s'): nothing to revert" % id)
		return did_something
	# Per-field revert (patches only).
	if not patched.has(id):
		push_warning("[Registry] revert('inputs', '%s', %s): no patches on this id" % [id, fields])
		return false
	var stash2: Dictionary = patched[id]
	var reg3: Dictionary = _registry_registered.get("inputs", {})
	var current2: Dictionary = reg3.get(id, {})
	for field in fields:
		var fname := String(field)
		if not stash2.has(fname):
			push_warning("[Registry] revert('inputs', '%s'): field '%s' wasn't patched" % [id, fname])
			continue
		match fname:
			"display_label":
				current2["display_label"] = stash2[fname]
			"default_event":
				InputMap.action_erase_events(id)
				if stash2[fname] != null:
					InputMap.action_add_event(id, stash2[fname])
				current2["default_event"] = stash2[fname]
			"deadzone":
				InputMap.action_set_deadzone(id, float(stash2[fname]))
				current2["deadzone"] = stash2[fname]
		stash2.erase(fname)
		did_something = true
	if stash2.is_empty():
		patched.erase(id)
	else:
		patched[id] = stash2
	_registry_patched["inputs"] = patched
	if not current2.is_empty():
		reg3[id] = current2
		_registry_registered["inputs"] = reg3
	return did_something

## ----- registry/loader.gd -----
##
## Three related registries that all mutate state on the Loader autoload.
## The rewriter injects into Loader.gd:
##   - _rtv_mod_scene_paths / _rtv_override_scene_paths dicts
##   - _rtv_vanilla_shelters snapshot (for revert)
##   - const `shelters` rewritten to a var (so we can append)
##   - a prelude at the top of LoadScene that checks the dicts and sets
##     scenePath + gameData flags before the vanilla if-elif runs
##
## Timing: Loader is an autoload, so the prelude is active from engine boot.
## Mod registrations can happen any time after the Loader autoload is in
## the tree (usually mod _ready() is safe).
##
## - scene_paths: named scene lookups with optional gameData flags
##     register: {path: String, menu?: bool, shelter?: bool, permadeath?: bool, tutorial?: bool}
##     override: same shape as register; swaps the vanilla entry for a mod one
##     patch: mutate individual fields on a mod-registered entry
##     remove / revert: standard
##
## - shelters: append-only list of shelter NAMES (each name must also be a
##   resolvable scene; pass `{path, ...}` and we'll auto-register in
##   scene_paths too, or pass just {} if the name is already a vanilla
##   scene or was pre-registered via scene_paths)
##     register: {path?: String, menu?: bool, shelter?: bool, ...}
##     remove: strips from shelters list (and auto-cleans any auto-linked
##             scene_paths entry)
##
## - random_scenes: append-only list of res:// paths added to Loader's
##   randomScenes var (picked by LoadSceneRandom()).
##     register: {path: String}
##     remove: strips from randomScenes

func _loader_node() -> Node:
	var ldr = get_tree().root.get_node_or_null("Loader")
	if ldr == null:
		push_warning("[Registry] Loader autoload not in tree yet; is the loader still booting?")
	return ldr

# GDScript's has_script_constant() isn't a real API; script constants are
# read via get_script_constant_map() -> Dictionary. Check for `id` among
# the vanilla scene-path consts declared at module scope on Loader.gd.
var _vanilla_scene_const_cache: Dictionary = {}
var _vanilla_scene_const_built: bool = false
func _vanilla_scene_const_exists(ldr: Node, id: String) -> bool:
	if not _vanilla_scene_const_built:
		var script = ldr.get_script()
		if script != null:
			_vanilla_scene_const_cache = script.get_script_constant_map()
		_vanilla_scene_const_built = true
	return _vanilla_scene_const_cache.has(id)

# -------- scene_paths --------

func _register_scene_path(id: String, data: Variant) -> bool:
	if not (data is Dictionary):
		push_warning("[Registry] register('scene_paths', '%s', ...) expects Dictionary, got %s" % [id, typeof(data)])
		return false
	var d: Dictionary = data
	if not d.has("path") or not (d["path"] is String):
		push_warning("[Registry] register('scene_paths', '%s'): data requires string 'path' key" % id)
		return false
	var ldr := _loader_node()
	if ldr == null:
		return false
	if not ("_rtv_mod_scene_paths" in ldr):
		push_warning("[Registry] register('scene_paths'): Loader.gd is missing injected scene-path fields; rewriter didn't fire, is the hook pack installed?")
		return false
	# Collision: an existing mod registration, or a mod override on this id.
	if ldr._rtv_mod_scene_paths.has(id) or ldr._rtv_override_scene_paths.has(id):
		push_warning("[Registry] register('scene_paths', '%s'): already registered/overridden by a mod" % id)
		return false
	# Collision with vanilla: vanilla scene names are the top-level const
	# identifiers on Loader (Cabin, Attic, etc.). Reject so mods use
	# override() instead. Detect via script constant map; Loader.gd's
	# const declarations are still intact for scene paths.
	if _vanilla_scene_const_exists(ldr, id):
		push_warning("[Registry] register('scene_paths', '%s'): name collides with a vanilla scene const; use override instead" % id)
		return false
	ldr._rtv_mod_scene_paths[id] = d
	var reg: Dictionary = _registry_registered.get("scene_paths", {})
	reg[id] = d
	_registry_registered["scene_paths"] = reg
	_log_debug("[Registry] registered scene_path '%s' -> %s" % [id, d.get("path")])
	return true

func _override_scene_path(id: String, data: Variant) -> bool:
	if not (data is Dictionary):
		push_warning("[Registry] override('scene_paths', '%s', ...) expects Dictionary, got %s" % [id, typeof(data)])
		return false
	var d: Dictionary = data
	if not d.has("path") or not (d["path"] is String):
		push_warning("[Registry] override('scene_paths', '%s'): data requires string 'path' key" % id)
		return false
	var ldr := _loader_node()
	if ldr == null:
		return false
	if not ("_rtv_override_scene_paths" in ldr):
		push_warning("[Registry] override('scene_paths'): Loader.gd is missing injected fields")
		return false
	# Verify target exists: either a vanilla scene const or a mod scene_paths
	# registration. Overriding mod entries is allowed for same-id conflict
	# resolution between mods.
	var is_vanilla_const: bool = _vanilla_scene_const_exists(ldr, id)
	var is_mod_registration: bool = ldr._rtv_mod_scene_paths.has(id)
	if not is_vanilla_const and not is_mod_registration:
		push_warning("[Registry] override('scene_paths', '%s'): no vanilla scene const or mod registration with that name" % id)
		return false
	var ov: Dictionary = _registry_overridden.get("scene_paths", {})
	if not ov.has(id):
		# Stash the original. For vanilla, that's {path: <const value>} with
		# the appropriate flags (we don't know them without replicating the
		# if-elif, so stash minimally and on revert we just clear the
		# override; vanilla's if-elif handles the restore naturally).
		if is_vanilla_const:
			ov[id] = {"vanilla": true}
		else:
			ov[id] = {"vanilla": false, "data": ldr._rtv_mod_scene_paths[id]}
		_registry_overridden["scene_paths"] = ov
	ldr._rtv_override_scene_paths[id] = d
	_log_debug("[Registry] overrode scene_path '%s'" % id)
	return true

func _patch_scene_path(id: String, fields: Dictionary) -> bool:
	if fields.is_empty():
		push_warning("[Registry] patch('scene_paths', '%s'): empty fields is a no-op" % id)
		return false
	var ldr := _loader_node()
	if ldr == null:
		return false
	# Patch operates on the dict entry the mod registered/overrode. Walk
	# override first, then mod registration.
	var target_dict: Dictionary
	var target_store: String  # "override" or "mod"
	if ldr._rtv_override_scene_paths.has(id):
		target_dict = ldr._rtv_override_scene_paths[id]
		target_store = "override"
	elif ldr._rtv_mod_scene_paths.has(id):
		target_dict = ldr._rtv_mod_scene_paths[id]
		target_store = "mod"
	else:
		push_warning("[Registry] patch('scene_paths', '%s'): no mod registration or override to patch" % id)
		return false
	var patched: Dictionary = _registry_patched.get("scene_paths", {})
	var stash: Dictionary = patched.get(id, {})
	for field in fields.keys():
		var fname := String(field)
		if not stash.has(fname):
			# Capture whether the key existed at all so revert can erase vs
			# restore accurately.
			if target_dict.has(fname):
				stash[fname] = target_dict[fname]
			else:
				stash[fname] = "__rtv_missing__"
		target_dict[fname] = fields[field]
	# Write back since dicts are references in GDScript, but re-store to be
	# explicit (helps readers and matches our pattern).
	if target_store == "override":
		ldr._rtv_override_scene_paths[id] = target_dict
	else:
		ldr._rtv_mod_scene_paths[id] = target_dict
		# Mirror into the loader-side _registry_registered for get_entry.
		var reg: Dictionary = _registry_registered.get("scene_paths", {})
		reg[id] = target_dict
		_registry_registered["scene_paths"] = reg
	patched[id] = stash
	_registry_patched["scene_paths"] = patched
	return true

func _remove_scene_path(id: String) -> bool:
	var ldr := _loader_node()
	if ldr == null:
		return false
	var reg: Dictionary = _registry_registered.get("scene_paths", {})
	if not reg.has(id):
		push_warning("[Registry] remove('scene_paths', '%s'): not a mod registration" % id)
		return false
	var ov: Dictionary = _registry_overridden.get("scene_paths", {})
	if ov.has(id):
		push_warning("[Registry] remove('scene_paths', '%s'): entry is an override, use revert instead" % id)
		return false
	ldr._rtv_mod_scene_paths.erase(id)
	reg.erase(id)
	_registry_registered["scene_paths"] = reg
	_log_debug("[Registry] removed scene_path '%s'" % id)
	return true

func _revert_scene_path(id: String, fields: Array) -> bool:
	var ldr := _loader_node()
	if ldr == null:
		return false
	var did_something := false
	var ov: Dictionary = _registry_overridden.get("scene_paths", {})
	var patched: Dictionary = _registry_patched.get("scene_paths", {})
	# Single-scope var declarations: GDScript is function-scoped, so
	# declaring `target_dict` / `stash` in both the full-revert and
	# per-field branches below would shadow. Declare once up front.
	var target_dict: Dictionary
	var stash: Dictionary
	if fields.is_empty():
		# Patches first (onto whatever dict is current).
		if patched.has(id):
			stash = patched[id]
			if ldr._rtv_override_scene_paths.has(id):
				target_dict = ldr._rtv_override_scene_paths[id]
			elif ldr._rtv_mod_scene_paths.has(id):
				target_dict = ldr._rtv_mod_scene_paths[id]
			for fname in stash.keys():
				# Type-check before equality: comparing a bool to a String
				# with == raises a runtime error under strict GDScript, so
				# gate the sentinel check by type.
				var stashed_val = stash[fname]
				if stashed_val is String and stashed_val == "__rtv_missing__":
					target_dict.erase(fname)
				else:
					target_dict[fname] = stashed_val
			patched.erase(id)
			_registry_patched["scene_paths"] = patched
			did_something = true
		if ov.has(id):
			ldr._rtv_override_scene_paths.erase(id)
			ov.erase(id)
			_registry_overridden["scene_paths"] = ov
			did_something = true
		if not did_something:
			push_warning("[Registry] revert('scene_paths', '%s'): nothing to revert" % id)
		return did_something
	# Per-field patch revert.
	if not patched.has(id):
		push_warning("[Registry] revert('scene_paths', '%s', %s): no patches on this id" % [id, fields])
		return false
	if ldr._rtv_override_scene_paths.has(id):
		target_dict = ldr._rtv_override_scene_paths[id]
	elif ldr._rtv_mod_scene_paths.has(id):
		target_dict = ldr._rtv_mod_scene_paths[id]
	else:
		push_warning("[Registry] revert('scene_paths', '%s'): id no longer resolves" % id)
		return false
	stash = patched[id]
	for field in fields:
		var fname := String(field)
		if not stash.has(fname):
			push_warning("[Registry] revert('scene_paths', '%s'): field '%s' wasn't patched" % [id, fname])
			continue
		var stashed_val = stash[fname]
		if stashed_val is String and stashed_val == "__rtv_missing__":
			target_dict.erase(fname)
		else:
			target_dict[fname] = stashed_val
		stash.erase(fname)
		did_something = true
	if stash.is_empty():
		patched.erase(id)
	else:
		patched[id] = stash
	_registry_patched["scene_paths"] = patched
	return did_something

# -------- shelters --------
#
# The shelters list holds bare strings. Registering a shelter also requires
# a scene behind the name; if the mod provides `path`, we auto-register a
# paired scene_paths entry so LoadScene(name) works. Otherwise we assume
# the name is already resolvable (vanilla, or previously registered).

func _register_shelter(id: String, data: Variant) -> bool:
	var ldr := _loader_node()
	if ldr == null:
		return false
	if not (data is Dictionary):
		push_warning("[Registry] register('shelters', '%s', ...) expects Dictionary (can be empty if scene already registered)" % id)
		return false
	var d: Dictionary = data
	var reg: Dictionary = _registry_registered.get("shelters", {})
	if reg.has(id):
		push_warning("[Registry] register('shelters', '%s'): already registered" % id)
		return false
	if id in ldr.shelters:
		push_warning("[Registry] register('shelters', '%s'): name already in shelters list (vanilla?)" % id)
		return false
	# If a `path` is given, auto-register the scene_paths entry so the
	# shelter name resolves to a real scene. Force the shelter gameData
	# flag to true by default since it's a shelter.
	var auto_scene_path := false
	if d.has("path"):
		var sp_data: Dictionary = d.duplicate()
		# Default shelter flag to true; caller can explicitly pass shelter=false
		# to opt out.
		if not sp_data.has("shelter"):
			sp_data["shelter"] = true
		if not _register_scene_path(id, sp_data):
			# The scene_paths registration failed (probably a collision);
			# abort the shelter register too to keep state consistent.
			return false
		auto_scene_path = true
	ldr.shelters.append(id)
	reg[id] = {"auto_scene_path": auto_scene_path, "data": d}
	_registry_registered["shelters"] = reg
	_log_debug("[Registry] registered shelter '%s' (auto scene_path=%s)" % [id, auto_scene_path])
	return true

func _remove_shelter(id: String) -> bool:
	var ldr := _loader_node()
	if ldr == null:
		return false
	var reg: Dictionary = _registry_registered.get("shelters", {})
	if not reg.has(id):
		push_warning("[Registry] remove('shelters', '%s'): not a mod registration" % id)
		return false
	var entry: Dictionary = reg[id]
	var idx: int = ldr.shelters.find(id)
	if idx >= 0:
		ldr.shelters.remove_at(idx)
	# Clean up the auto-created scene_paths entry too (if any).
	if entry.get("auto_scene_path", false):
		_remove_scene_path(id)
	reg.erase(id)
	_registry_registered["shelters"] = reg
	_log_debug("[Registry] removed shelter '%s'" % id)
	return true

# -------- random_scenes --------

func _register_random_scene(id: String, data: Variant) -> bool:
	var ldr := _loader_node()
	if ldr == null:
		return false
	if not (data is Dictionary) or not data.has("path") or not (data["path"] is String):
		push_warning("[Registry] register('random_scenes', '%s', ...) expects Dictionary with 'path' key" % id)
		return false
	var reg: Dictionary = _registry_registered.get("random_scenes", {})
	if reg.has(id):
		push_warning("[Registry] register('random_scenes', '%s'): already registered" % id)
		return false
	var path: String = data["path"]
	if path in ldr.randomScenes:
		push_warning("[Registry] register('random_scenes', '%s'): path already in randomScenes" % id)
		return false
	ldr.randomScenes.append(path)
	reg[id] = {"path": path}
	_registry_registered["random_scenes"] = reg
	_log_debug("[Registry] registered random_scene '%s' -> %s" % [id, path])
	return true

func _remove_random_scene(id: String) -> bool:
	var ldr := _loader_node()
	if ldr == null:
		return false
	var reg: Dictionary = _registry_registered.get("random_scenes", {})
	if not reg.has(id):
		push_warning("[Registry] remove('random_scenes', '%s'): not a mod registration" % id)
		return false
	var path: String = reg[id]["path"]
	var idx: int = ldr.randomScenes.find(path)
	if idx >= 0:
		ldr.randomScenes.remove_at(idx)
	reg.erase(id)
	_registry_registered["random_scenes"] = reg
	_log_debug("[Registry] removed random_scene '%s'" % id)
	return true

## ----- registry/ai.gd -----
##
## Vanilla AISpawner.gd hardcodes a zone -> agent-scene mapping:
##   if zone == Zone.Area05: agent = bandit
##   elif zone == Zone.BorderZone: agent = guard
##   elif zone == Zone.Vostok: agent = military
##
## The rewriter transforms each `agent = <name>` into
##   agent = _rtv_resolve_ai_type(zone, <name>)
## where the resolver (injected into AISpawner.gd) reads
##   Engine.get_meta("_rtv_ai_overrides", {})
## and returns the mod-registered PackedScene for the current zone (or the
## vanilla scene if no override is registered).
##
## Design choices:
##   - AISpawner is a per-scene Node3D, not an autoload. There can be many
##     instances, each running _ready() independently. We use Engine.meta
##     to broadcast overrides to all of them.
##   - Zone keys are String names matching the Zone enum ("Area05",
##     "BorderZone", "Vostok"). The resolver uses Zone.keys()[zone_int] to
##     convert back at lookup time.
##   - Only override is meaningful here: a single agent scene per zone.
##     Register "adds a new entry" is semantically the same as override
##     for this registry, so we expose both verbs but they share a slot.
##
## Data shape:
##   {scene: PackedScene, zone: String}

const _VALID_ZONES := ["Area05", "BorderZone", "Vostok"]

# Keep the overrides we install in Engine meta, keyed by zone name, so the
# injected resolver on every AISpawner instance finds them. Each id in
# _registry_registered tracks a {scene, zone} payload; the engine-meta dict
# is derived from those registrations at each write.
const _AI_ENGINE_META_KEY := "_rtv_ai_overrides"

func _rebuild_ai_engine_meta() -> void:
	# Collapse all active id registrations into a single zone -> scene dict
	# for the resolver. If multiple mods register/override the same zone,
	# the last write wins; same semantics as other registries that share
	# a slot.
	var flat: Dictionary = {}
	var reg: Dictionary = _registry_registered.get("ai_types", {})
	for id in reg.keys():
		var entry: Dictionary = reg[id]
		flat[entry["zone"]] = entry["scene"]
	Engine.set_meta(_AI_ENGINE_META_KEY, flat)

func _validate_ai_type_data(id: String, verb: String, data: Variant) -> Array:
	# Returns [scene, zone] on success, [null, ""] on error.
	if not (data is Dictionary):
		push_warning("[Registry] %s('ai_types', '%s', ...) expects Dictionary {scene, zone}, got %s" % [verb, id, typeof(data)])
		return [null, ""]
	var d: Dictionary = data
	if not d.has("scene") or not d.has("zone"):
		push_warning("[Registry] %s('ai_types', '%s', ...) data dict missing 'scene' or 'zone' key" % [verb, id])
		return [null, ""]
	var scene = d["scene"]
	if not (scene is PackedScene):
		push_warning("[Registry] %s('ai_types', '%s'): scene is not a PackedScene" % [verb, id])
		return [null, ""]
	var zone = d["zone"]
	if not (zone is String):
		push_warning("[Registry] %s('ai_types', '%s'): zone must be a String (e.g. 'Area05')" % [verb, id])
		return [null, ""]
	if not (zone in _VALID_ZONES):
		push_warning("[Registry] %s('ai_types', '%s'): unknown zone '%s' (valid: %s)" % [verb, id, zone, _VALID_ZONES])
		return [null, ""]
	return [scene, zone]

func _register_ai_type(id: String, data: Variant) -> bool:
	var reg: Dictionary = _registry_registered.get("ai_types", {})
	if reg.has(id):
		push_warning("[Registry] register('ai_types', '%s'): already registered (pick a unique handle or use override)" % id)
		return false
	var parts := _validate_ai_type_data(id, "register", data)
	var scene = parts[0]
	var zone: String = parts[1]
	if scene == null:
		return false
	# Collision: another mod already claimed this zone. Register is one-per-
	# zone; use override to forcibly replace someone else's claim.
	for existing_id in reg.keys():
		if reg[existing_id]["zone"] == zone:
			push_warning("[Registry] register('ai_types', '%s'): zone '%s' already claimed by '%s'; use override to replace" % [id, zone, existing_id])
			return false
	reg[id] = {"scene": scene, "zone": zone}
	_registry_registered["ai_types"] = reg
	_rebuild_ai_engine_meta()
	_log_debug("[Registry] registered ai_type '%s' (zone=%s)" % [id, zone])
	return true

func _override_ai_type(id: String, data: Variant) -> bool:
	# Override is semantically "claim this zone even if another mod did."
	# It drops any conflicting registrations from other mods and installs
	# this one. On revert, the displaced registrations come back.
	var ov: Dictionary = _registry_overridden.get("ai_types", {})
	if ov.has(id):
		push_warning("[Registry] override('ai_types', '%s'): already overridden (revert first)" % id)
		return false
	var parts := _validate_ai_type_data(id, "override", data)
	var scene = parts[0]
	var zone: String = parts[1]
	if scene == null:
		return false
	var reg: Dictionary = _registry_registered.get("ai_types", {})
	# Stash displaced registrations so revert can restore them.
	var displaced: Array = []
	for existing_id in reg.keys():
		if reg[existing_id]["zone"] == zone:
			displaced.append({"id": existing_id, "entry": reg[existing_id]})
	for entry in displaced:
		reg.erase(entry["id"])
	reg[id] = {"scene": scene, "zone": zone}
	_registry_registered["ai_types"] = reg
	ov[id] = {"displaced": displaced, "zone": zone}
	_registry_overridden["ai_types"] = ov
	_rebuild_ai_engine_meta()
	_log_debug("[Registry] overrode ai_type '%s' (zone=%s, displaced %d entry/entries)" % [id, zone, displaced.size()])
	return true

func _remove_ai_type(id: String) -> bool:
	var reg: Dictionary = _registry_registered.get("ai_types", {})
	if not reg.has(id):
		push_warning("[Registry] remove('ai_types', '%s'): not registered by a mod" % id)
		return false
	var ov: Dictionary = _registry_overridden.get("ai_types", {})
	if ov.has(id):
		push_warning("[Registry] remove('ai_types', '%s'): entry is an override, use revert instead" % id)
		return false
	reg.erase(id)
	_registry_registered["ai_types"] = reg
	_rebuild_ai_engine_meta()
	_log_debug("[Registry] removed ai_type '%s'" % id)
	return true

func _revert_ai_type(id: String) -> bool:
	var ov: Dictionary = _registry_overridden.get("ai_types", {})
	if not ov.has(id):
		push_warning("[Registry] revert('ai_types', '%s'): no override to revert" % id)
		return false
	var entry: Dictionary = ov[id]
	var reg: Dictionary = _registry_registered.get("ai_types", {})
	# Drop the override entry and restore anything it displaced.
	reg.erase(id)
	var displaced: Array = entry["displaced"]
	for d in displaced:
		reg[d["id"]] = d["entry"]
	_registry_registered["ai_types"] = reg
	ov.erase(id)
	_registry_overridden["ai_types"] = ov
	_rebuild_ai_engine_meta()
	_log_debug("[Registry] reverted ai_type '%s' (restored %d displaced)" % [id, displaced.size()])
	return true

## ----- registry/fish.gd -----
##
## Vanilla FishPool.gd is a MeshInstance3D placed in scenes (several per map)
## with an `@export var species: Array[PackedScene]` populated in the editor.
## At _ready() it picks 1-10 random fish from species and instantiates them.
##
## The rewriter injects a prelude at the top of FishPool._ready() that
## reads Engine.get_meta("_rtv_fish_species", []) and appends matching
## entries to the local `species` array before the random-spawn loop. So
## mods register once and every pool picks it up without editor edits.
##
## Data shape:
##   {scene: PackedScene, pool_id: String}
## where pool_id is either "all" (every FishPool instance gets this scene)
## or a specific Node name like "FP_2" (only that one pool).
##
## Verbs: register, remove, revert (remove alias). Override/patch not
## meaningful for a flat list of {scene, pool_id} tuples.
##
## Timing: FishPool is a scene Node, not an autoload. Its _ready() fires
## when its containing scene loads. Mods must register before entering the
## map scene; mod autoload _ready() is fine, as the main menu loads
## first and any map scene comes later.

const _FISH_ENGINE_META_KEY := "_rtv_fish_species"

func _rebuild_fish_engine_meta() -> void:
	# Flatten all id registrations into a flat Array for the prelude loop.
	# Each entry is {scene, pool_id}. Preserving registration order keeps
	# behavior deterministic across mod load orders (prelude appends in
	# array order; dedupe by scene ensures same scene via multiple ids
	# doesn't multiply spawn weight).
	var flat: Array = []
	var reg: Dictionary = _registry_registered.get("fish_species", {})
	for id in reg.keys():
		flat.append(reg[id])
	Engine.set_meta(_FISH_ENGINE_META_KEY, flat)

func _register_fish_species(id: String, data: Variant) -> bool:
	var reg: Dictionary = _registry_registered.get("fish_species", {})
	if reg.has(id):
		push_warning("[Registry] register('fish_species', '%s'): already registered (pick a unique handle)" % id)
		return false
	if not (data is Dictionary):
		push_warning("[Registry] register('fish_species', '%s', ...) expects Dictionary {scene, pool_id}, got %s" % [id, typeof(data)])
		return false
	var d: Dictionary = data
	if not d.has("scene"):
		push_warning("[Registry] register('fish_species', '%s'): data missing 'scene' key" % id)
		return false
	var scene = d["scene"]
	if not (scene is PackedScene):
		push_warning("[Registry] register('fish_species', '%s'): scene is not a PackedScene" % id)
		return false
	# Default pool_id to "all" if not given; most mods want their fish
	# in every pool. Explicit pool names override for fine-grained placement.
	var pool_id: String = "all"
	if d.has("pool_id"):
		if not (d["pool_id"] is String):
			push_warning("[Registry] register('fish_species', '%s'): pool_id must be a String" % id)
			return false
		pool_id = d["pool_id"]
	reg[id] = {"scene": scene, "pool_id": pool_id}
	_registry_registered["fish_species"] = reg
	_rebuild_fish_engine_meta()
	_log_debug("[Registry] registered fish_species '%s' (pool_id=%s)" % [id, pool_id])
	return true

func _remove_fish_species(id: String) -> bool:
	var reg: Dictionary = _registry_registered.get("fish_species", {})
	if not reg.has(id):
		push_warning("[Registry] remove('fish_species', '%s'): not registered by a mod" % id)
		return false
	reg.erase(id)
	_registry_registered["fish_species"] = reg
	_rebuild_fish_engine_meta()
	_log_debug("[Registry] removed fish_species '%s'" % id)
	return true

## ----- registry/resources.gd -----
##
## Patches arbitrary field values on any vanilla .tres file. Intended as the
## generic fallback for Resources that don't have a dedicated registry: stat
## tuning files, config-like .tres, anything a mod author wants to tweak
## without us building a purpose-built registry.
##
## Usage:
##   lib.patch(RESOURCES, "res://Resources/GameData.tres", {"walk_speed": 5.0})
##   lib.revert(RESOURCES, "res://Resources/GameData.tres", ["walk_speed"])
##   lib.revert(RESOURCES, "res://Resources/GameData.tres")  # full revert
##
## The `id` is the absolute res:// path to the .tres. Godot's Resource cache
## ensures every `load()` of that path returns the same instance, so
## mutating fields on the loaded Resource propagates to all game-side
## holders. No register/override/remove; vanilla already defines the
## Resource; we only mutate fields and track rollback.
##
## Field stash keys per path, so the same path can be patched multiple
## times without losing the pre-first-patch value.

func _load_resource_at(path: String, verb: String) -> Resource:
	if path == "" or not path.begins_with("res://"):
		push_warning("[Registry] %s('resources', '%s'): id must be an absolute res:// path" % [verb, path])
		return null
	var res = load(path)
	if res == null:
		push_warning("[Registry] %s('resources', '%s'): couldn't load resource at path" % [verb, path])
		return null
	if not (res is Resource):
		push_warning("[Registry] %s('resources', '%s'): path doesn't resolve to a Resource" % [verb, path])
		return null
	return res

func _patch_resource(id: String, fields: Dictionary) -> bool:
	if fields.is_empty():
		push_warning("[Registry] patch('resources', '%s'): empty fields is a no-op" % id)
		return false
	var res := _load_resource_at(id, "patch")
	if res == null:
		return false
	var patched: Dictionary = _registry_patched.get("resources", {})
	var stash: Dictionary = patched.get(id, {})
	var any_applied := false
	for field in fields.keys():
		var fname := String(field)
		if not _resource_has_property(res, fname):
			push_warning("[Registry] patch('resources', '%s'): field '%s' doesn't exist on %s" % [id, fname, res.get_class()])
			continue
		# First-write-wins stash so subsequent patches to the same field
		# don't overwrite the original value.
		if not stash.has(fname):
			stash[fname] = res.get(fname)
		res.set(fname, fields[field])
		any_applied = true
	if not any_applied:
		return false
	patched[id] = stash
	_registry_patched["resources"] = patched
	_log_debug("[Registry] patched resource '%s' fields %s" % [id, fields.keys()])
	return true

func _revert_resource(id: String, fields: Array) -> bool:
	var patched: Dictionary = _registry_patched.get("resources", {})
	if not patched.has(id):
		push_warning("[Registry] revert('resources', '%s'): no patches on this path" % id)
		return false
	var res := _load_resource_at(id, "revert")
	if res == null:
		return false
	var stash: Dictionary = patched[id]
	# Full revert: restore every stashed field, clear the stash.
	if fields.is_empty():
		for fname in stash.keys():
			res.set(fname, stash[fname])
		patched.erase(id)
		_registry_patched["resources"] = patched
		return true
	# Per-field revert.
	var did_something := false
	for field in fields:
		var fname := String(field)
		if not stash.has(fname):
			push_warning("[Registry] revert('resources', '%s'): field '%s' wasn't patched" % [id, fname])
			continue
		res.set(fname, stash[fname])
		stash.erase(fname)
		did_something = true
	if stash.is_empty():
		patched.erase(id)
	else:
		patched[id] = stash
	_registry_patched["resources"] = patched
	return did_something

## ----- registry/scene_nodes.gd -----
##
## Patch-only registry for mutating node properties inside vanilla scenes
## without shipping a full-scene override. Mod calls:
##
##   lib.patch(lib.Registry.SCENE_NODES,
##             "res://UI/Interface.tscn#Tools/Crafting/Types/Margin/Buttons/Equipment",
##             {disabled = false, modulate = Color(1,1,1,1)})
##
## Id format: "<scene_path>#<node_path>" where scene_path is the res:// path
## of a PackedScene and node_path is relative to that scene's root. The two
## are split on the FIRST '#' (scene paths don't legally contain #, node
## names in Godot can't either).
##
## How it works: we subscribe to get_tree().node_added at frameworks_ready
## time. Godot sets `node.scene_file_path` on the ROOT of an instantiated
## scene (and only there); we use that as a cheap filter. When a match
## fires, we walk our registered node_paths for that scene, resolve each via
## get_node_or_null on the scene root, and apply the property patches. The
## signal fires BEFORE the node's _ready, so @onready values that depend on
## the patched props observe the patched state.
##
## The PackedScene resource is never mutated. Trade-off: patches apply per-instance at
## instantiation, not at resource load. Code that calls
## packed_scene.get_bundled_scene() directly still sees vanilla values
## (doesn't come up in RTV vanilla).
##
## What this registry CAN'T do (by design):
##   - Add or remove nodes (structural changes): use override('scenes', ...)
##   - Patch sub-resources embedded inside the scene
##   - Patch values on scenes loaded outside the tree (direct-load mutations)

# Per-scene patch state, keyed by scene_path -> node_path -> {prop: value}.
# Populated by _patch_scene_node, consumed by _apply_patches_for_scene_root.
var _scene_node_patches: Dictionary = {}

# Parallel stash for revert: same shape, holds the value the prop had BEFORE
# the first patch on that (scene, node, prop) triple. Subsequent patches to
# the same prop don't overwrite -- revert restores true original state.
# Populated inside _apply_patches_for_scene_root the first time a live
# instance gets a prop set, not at patch() call time (we don't hold the
# instance yet at that point).
var _scene_node_stash: Dictionary = {}

# Guard against connecting the node_added signal twice across
# frameworks_ready emissions (shouldn't happen, but belt-and-suspenders).
var _scene_nodes_listener_connected: bool = false

# Memoizes successful probe validations keyed by
# "<scene_path>#<node_path>|<sorted,field,names>". Keeps repeat patch()
# calls with the same id + field set (e.g. recipes.gd auto-unlocking the
# Equipment tab once per registered recipe) from instantiating +
# free-ing Interface.tscn N times. Runtime safety is unchanged --
# _apply_patches_for_scene_root still re-checks _node_has_property on
# every live instance, so the cache is strictly additive.
var _validated_patches: Dictionary = {}

# Entry point invoked from hooks_api._register_core_hooks after frameworks_ready.
# Idempotent.
func _scene_nodes_connect_listener() -> void:
	if _scene_nodes_listener_connected:
		return
	var tree := get_tree()
	if tree == null:
		_log_warning("[Registry] scene_nodes: no SceneTree at connect time; listener disabled")
		return
	tree.node_added.connect(_on_any_node_added)
	_scene_nodes_listener_connected = true

func _on_any_node_added(node: Node) -> void:
	# Cheap filter: Godot only sets scene_file_path on the ROOT of an
	# instantiated scene, so 99.9% of node_added events short-circuit here.
	var scene_path: String = node.scene_file_path
	if scene_path.is_empty():
		return
	if not _scene_node_patches.has(scene_path):
		return
	_apply_patches_for_scene_root(scene_path, node)

func _apply_patches_for_scene_root(scene_path: String, scene_root: Node) -> void:
	var per_node: Dictionary = _scene_node_patches[scene_path]
	var stash_per_scene: Dictionary = _scene_node_stash.get(scene_path, {})
	for node_path in per_node.keys():
		var target: Node = scene_root.get_node_or_null(NodePath(node_path))
		if target == null:
			_log_warning("[Registry] scene_nodes: node '%s' not found in instantiated '%s'; patch skipped for this instance" \
					% [node_path, scene_path])
			continue
		var props: Dictionary = per_node[node_path]
		var stash_per_node: Dictionary = stash_per_scene.get(node_path, {})
		for prop in props.keys():
			var fname: String = String(prop)
			if not _node_has_property(target, fname):
				_log_warning("[Registry] scene_nodes: property '%s' not found on node '%s' in '%s'; skipped" \
						% [fname, node_path, scene_path])
				continue
			if not stash_per_node.has(fname):
				stash_per_node[fname] = target.get(fname)
			target.set(fname, props[fname])
		stash_per_scene[node_path] = stash_per_node
	_scene_node_stash[scene_path] = stash_per_scene

# Split 'scene#node' id on the first '#'. Returns [scene_path, node_path] or
# [null, null] on malformed input.
func _split_scene_node_id(id: String) -> Array:
	var hash_idx: int = id.find("#")
	if hash_idx <= 0 or hash_idx == id.length() - 1:
		return [null, null]
	var scene_path: String = id.substr(0, hash_idx)
	var node_path: String = id.substr(hash_idx + 1)
	if not scene_path.begins_with("res://"):
		return [null, null]
	return [scene_path, node_path]

# Check property existence at patch-time against a freshly-instantiated
# probe. We don't require the scene to be in the tree at patch() time
# mods call this from _ready() before the UI scene loads. Instead we load
# the PackedScene and peek at the target node by instantiating and freeing.
# This is a per patch() call but only on cold paths (mod boot).
# Returns true if the (scene, node, props) triple is well-formed, false if
# any piece doesn't resolve (with a warn on each failure).
func _validate_scene_node_patch(scene_path: String, node_path: String, fields: Dictionary) -> bool:
	var field_keys: Array = []
	for k in fields.keys():
		field_keys.append(String(k))
	field_keys.sort()
	var cache_key: String = "%s#%s|%s" % [scene_path, node_path, ",".join(field_keys)]
	if _validated_patches.has(cache_key):
		return true
	var pscene := load(scene_path)
	if pscene == null or not (pscene is PackedScene):
		push_warning("[Registry] patch('scene_nodes'): scene '%s' failed to load (not a PackedScene)" % scene_path)
		return false
	var probe: Node = (pscene as PackedScene).instantiate()
	if probe == null:
		push_warning("[Registry] patch('scene_nodes'): scene '%s' failed to instantiate for validation" % scene_path)
		return false
	var target: Node = probe.get_node_or_null(NodePath(node_path))
	if target == null:
		push_warning("[Registry] patch('scene_nodes', '%s#%s'): node path doesn't resolve in the scene; check node hierarchy" \
				% [scene_path, node_path])
		probe.queue_free()
		return false
	for prop in fields.keys():
		if not _node_has_property(target, String(prop)):
			push_warning("[Registry] patch('scene_nodes', '%s#%s'): property '%s' not found on node (class=%s)" \
					% [scene_path, node_path, prop, target.get_class()])
			probe.queue_free()
			return false
	probe.queue_free()
	_validated_patches[cache_key] = true
	return true

# Does `node` have a declared property named `prop`? Mirrors
# _resource_has_property from shared.gd but for Node. (Nodes expose both
# class properties and script properties through get_property_list.)
func _node_has_property(node: Node, prop: String) -> bool:
	for p in node.get_property_list():
		if p.get("name") == prop:
			return true
	return false

func _patch_scene_node(id: String, fields: Dictionary) -> bool:
	if fields.is_empty():
		push_warning("[Registry] patch('scene_nodes', '%s'): empty fields dict is a no-op" % id)
		return false
	var parts := _split_scene_node_id(id)
	var scene_path = parts[0]
	var node_path = parts[1]
	if scene_path == null:
		push_warning("[Registry] patch('scene_nodes', '%s'): id must be '<res://scene_path>#<node_path>'" % id)
		return false
	if not _validate_scene_node_patch(scene_path, node_path, fields):
		return false
	# Connect the listener lazily, in case a mod patches before
	# frameworks_ready fires. Idempotent.
	_scene_nodes_connect_listener()
	var per_node: Dictionary = _scene_node_patches.get(scene_path, {})
	var props: Dictionary = per_node.get(node_path, {})
	for prop in fields.keys():
		props[String(prop)] = fields[prop]
	per_node[node_path] = props
	_scene_node_patches[scene_path] = per_node
	# Track into _registry_patched so the rest of the registry subsystem
	# sees a consistent shape: scene_nodes -> {id -> {prop -> value}}.
	# Note: the STASH (for revert) is populated at apply time, not here.
	var patched: Dictionary = _registry_patched.get("scene_nodes", {})
	var pat_entry: Dictionary = patched.get(id, {})
	for prop in fields.keys():
		pat_entry[String(prop)] = fields[prop]
	patched[id] = pat_entry
	_registry_patched["scene_nodes"] = patched
	# Apply immediately to any scene instance already in the tree. Covers
	# the case where a mod patches after the scene was instantiated (rare
	# but legal -- e.g. a config-menu toggle flipping a UI property live).
	_apply_patch_to_live_instances(scene_path)
	_log_debug("[Registry] patched scene node '%s' (%d field(s))" % [id, fields.size()])
	return true

# Scan the current tree for any live instance of `scene_path`, re-applying
# all registered patches for that scene. Called from _patch_scene_node so
# late patches land on already-instantiated scenes.
func _apply_patch_to_live_instances(scene_path: String) -> void:
	var tree := get_tree()
	if tree == null:
		return
	_walk_for_scene_roots(tree.root, scene_path)

func _walk_for_scene_roots(node: Node, scene_path: String) -> void:
	if node.scene_file_path == scene_path:
		_apply_patches_for_scene_root(scene_path, node)
		# Don't recurse into an already-matched root; its children can't
		# have the SAME scene_file_path unless they're nested instances
		# of the same scene, which is legal but exceedingly rare. A deeper
		# nested instance will also surface via node_added on its own.
		return
	for child in node.get_children():
		_walk_for_scene_roots(child, scene_path)

# Revert. Fields-empty: revert every prop on the id. Fields-nonempty:
# per-field revert. Restoration writes the stashed original value back to
# every live instance (found via tree walk) AND erases the patch so future
# instantiations see vanilla.
func _revert_scene_node(id: String, fields: Array) -> bool:
	var parts := _split_scene_node_id(id)
	var scene_path = parts[0]
	var node_path = parts[1]
	if scene_path == null:
		push_warning("[Registry] revert('scene_nodes', '%s'): id must be '<res://scene_path>#<node_path>'" % id)
		return false
	var patched: Dictionary = _registry_patched.get("scene_nodes", {})
	if not patched.has(id):
		push_warning("[Registry] revert('scene_nodes', '%s'): nothing patched at that id" % id)
		return false
	var pat_entry: Dictionary = patched[id]
	var per_node: Dictionary = _scene_node_patches.get(scene_path, {})
	var props: Dictionary = per_node.get(node_path, {})
	var stash_per_scene: Dictionary = _scene_node_stash.get(scene_path, {})
	var stash_per_node: Dictionary = stash_per_scene.get(node_path, {})
	var targets: Array[String] = []
	if fields.is_empty():
		for k in pat_entry.keys():
			targets.append(String(k))
	else:
		for k in fields:
			targets.append(String(k))
	# Restore stashed values on live instances.
	var live_roots: Array[Node] = []
	var tree := get_tree()
	if tree != null:
		_collect_scene_roots(tree.root, scene_path, live_roots)
	for fname in targets:
		if not stash_per_node.has(fname) and fields.is_empty() == false:
			# Per-field revert for a field that was never applied to any
			# live instance: it won't have a stashed original. We still
			# want to drop the patch, but there's nothing to restore on
			# live instances.
			push_warning("[Registry] revert('scene_nodes', '%s'): field '%s' wasn't patched (or never observed on a live instance)" % [id, fname])
			continue
		if stash_per_node.has(fname):
			for root in live_roots:
				var target: Node = root.get_node_or_null(NodePath(node_path))
				if target != null and _node_has_property(target, fname):
					target.set(fname, stash_per_node[fname])
			stash_per_node.erase(fname)
		props.erase(fname)
		pat_entry.erase(fname)
	# Prune empty nested dicts to keep state clean.
	if props.is_empty():
		per_node.erase(node_path)
	else:
		per_node[node_path] = props
	if per_node.is_empty():
		_scene_node_patches.erase(scene_path)
	else:
		_scene_node_patches[scene_path] = per_node
	if stash_per_node.is_empty():
		stash_per_scene.erase(node_path)
	else:
		stash_per_scene[node_path] = stash_per_node
	if stash_per_scene.is_empty():
		_scene_node_stash.erase(scene_path)
	else:
		_scene_node_stash[scene_path] = stash_per_scene
	if pat_entry.is_empty():
		patched.erase(id)
	else:
		patched[id] = pat_entry
	_registry_patched["scene_nodes"] = patched
	_log_debug("[Registry] reverted scene_nodes '%s' (fields=%s)" % [id, targets])
	return true

func _collect_scene_roots(node: Node, scene_path: String, out: Array[Node]) -> void:
	if node.scene_file_path == scene_path:
		out.append(node)
		return
	for child in node.get_children():
		_collect_scene_roots(child, scene_path, out)

## ----- framework_wrappers.gd -----
## Helper for walking the scene tree by class_name after source-rewrite hook
## packs apply. Used by hook_pack.gd's post-apply verification to find nodes
## whose attached script descends from a given class_name.
##
## The legacy extends-wrapper pipeline (node_added / Framework<X>.gd /
## _activate_hooked_scripts / _register_override / _connect_node_swap /
## _deferred_swap) was removed in 3.0.1 -- dead code under the
## source-rewrite model. The surviving helper below is the only live code
## still referenced from this file.

# Recursively walk the scene tree, collecting nodes whose attached script
# (or any ancestor in its extends chain) has the given class_name. Base
# chain walk is needed because mods that override vanilla via
# take_over_path typically use extends-by-path (no class_name of their
# own), so their instances report get_global_name() == "". Matching via
# base chain catches IXP's Controller which extends our class_name
# Controller rewrite.
func _rtv_collect_nodes_by_class(node: Node, cls_name: String, out: Array) -> void:
	var scr := node.get_script() as GDScript
	if scr != null:
		var matched := false
		var s: GDScript = scr
		var depth := 0
		while s != null and depth < 8:
			if str(s.get_global_name()) == cls_name:
				matched = true
				break
			s = s.get_base_script() as GDScript
			depth += 1
		if matched:
			out.append(node)
	for child in node.get_children():
		_rtv_collect_nodes_by_class(child, cls_name, out)

## ----- gdsc_detokenizer.gd -----
## Reads Godot's binary-tokenized .gdc scripts and reconstructs the source.
## Required because load().source_code is empty for scripts compiled via the
## tokenized export path. Covers TOKENIZER_VERSION 100 (Godot 4.0-4.4) and
## 101 (Godot 4.5-4.6). Also owns the vanilla-source cache helpers.

# --- GDSC Binary Token Detokenizer -------------------------------------------
# Reconstructs GDScript source from Godot's binary-tokenized .gdc format (GDSC).
# Used when the game exports with binary tokenization and load().source_code is
# empty.  Called for all class_name scripts during hook pack generation.
# Supports TOKENIZER_VERSION 100 (Godot 4.0-4.4) and 101 (Godot 4.5-4.6).

const _GDSC_MAGIC := "GDSC"
const _GDSC_TOKEN_BITS := 8
const _GDSC_TOKEN_MASK := (1 << (_GDSC_TOKEN_BITS - 1)) - 1  # 0x7F
const _GDSC_TOKEN_BYTE_MASK := 0x80

# Token type indices -- Godot 4.5-4.6 / TOKENIZER_VERSION 101.
# 0=EMPTY 1=ANNOTATION 2=IDENTIFIER 3=LITERAL
# 4-9: < <= > >= == !=   10-15: and or not && || !
# 16-21: & | ~ ^ << >>   22-27: + - * ** / %
# 28-39: = += -= *= **= /= %= <<= >>= &= |= ^=
# 40-50: if elif else for while break continue pass return match when
# 51-72: as assert await breakpoint class class_name const enum extends func
#        in is namespace preload self signal static super trait var void yield
# 73-78: [ ] { } ( )   79-87: , ; . .. ... : $ -> _
# 88-90: NEWLINE INDENT DEDENT   91-94: PI TAU INF NAN   99: EOF
#
# Raw int keys are used in dictionaries below because Godot does not allow
# enum references in const dictionary initializers.
const _TOKEN_TEXT := {
	4: "<", 5: "<=", 6: ">", 7: ">=", 8: "==", 9: "!=",
	10: "and", 11: "or", 12: "not", 13: "&&", 14: "||", 15: "!",
	16: "&", 17: "|", 18: "~", 19: "^", 20: "<<", 21: ">>",
	22: "+", 23: "-", 24: "*", 25: "**", 26: "/", 27: "%",
	28: "=", 29: "+=", 30: "-=", 31: "*=", 32: "**=", 33: "/=",
	34: "%=", 35: "<<=", 36: ">>=", 37: "&=", 38: "|=", 39: "^=",
	40: "if", 41: "elif", 42: "else", 43: "for", 44: "while",
	45: "break", 46: "continue", 47: "pass", 48: "return", 49: "match", 50: "when",
	51: "as", 52: "assert", 53: "await", 54: "breakpoint", 55: "class",
	56: "class_name", 57: "const", 58: "enum", 59: "extends", 60: "func",
	61: "in", 62: "is", 63: "namespace", 64: "preload", 65: "self",
	66: "signal", 67: "static", 68: "super", 69: "trait", 70: "var",
	71: "void", 72: "yield",
	73: "[", 74: "]", 75: "{", 76: "}", 77: "(", 78: ")",
	79: ",", 80: ";", 81: ".", 82: "..", 83: "...",
	84: ":", 85: "$", 86: "->", 87: "_",
	91: "PI", 92: "TAU", 93: "INF", 94: "NAN",
	96: "`", 97: "?",
}

# Tokens that want a space BEFORE them (binary operators, keywords after exprs).
const _SPACE_BEFORE := {
	4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1,      # < <= > >= == !=
	10: 1, 11: 1, 12: 1, 13: 1, 14: 1,         # and or not && ||
	16: 1, 17: 1, 19: 1, 20: 1, 21: 1,          # & | ^ << >>
	22: 1, 23: 1, 24: 1, 25: 1, 26: 1, 27: 1,  # + - * ** / %
	28: 1, 29: 1, 30: 1, 31: 1, 32: 1, 33: 1,  # = += -= *= **= /=
	34: 1, 35: 1, 36: 1, 37: 1, 38: 1, 39: 1,  # %= <<= >>= &= |= ^=
	40: 1, 42: 1, 51: 1, 61: 1, 62: 1,          # if else as in is
	86: 1,                                        # ->
}

# Tokens that want a space AFTER them.
const _SPACE_AFTER := {
	79: 1, 80: 1, 86: 1,                          # , ; ->
	4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1,          # < <= > >= == !=
	10: 1, 11: 1, 12: 1, 13: 1, 14: 1, 15: 1,    # and or not && || !
	16: 1, 17: 1, 19: 1, 20: 1, 21: 1,            # & | ^ << >>
	22: 1, 23: 1, 24: 1, 25: 1, 26: 1, 27: 1,    # + - * ** / %
	28: 1, 29: 1, 30: 1, 31: 1, 32: 1, 33: 1,    # = += -= *= **= /=
	34: 1, 35: 1, 36: 1, 37: 1, 38: 1, 39: 1,    # %= <<= >>= &= |= ^=
	84: 1,                                          # :
	1: 1,                                           # @ annotations
	# All keywords (40-72) need space after:
	40: 1, 41: 1, 42: 1, 43: 1, 44: 1,            # if elif else for while
	45: 1, 46: 1, 47: 1, 48: 1, 49: 1, 50: 1,    # break continue pass return match when
	51: 1, 52: 1, 53: 1, 54: 1, 55: 1,            # as assert await breakpoint class
	56: 1, 57: 1, 58: 1, 59: 1, 60: 1,            # class_name const enum extends func
	61: 1, 62: 1, 63: 1, 64: 1, 65: 1,            # in is namespace preload self
	66: 1, 67: 1, 68: 1, 69: 1, 70: 1,            # signal static super trait var
	71: 1, 72: 1,                                   # void yield
}

func _detokenize_script(script_path: String) -> String:
	# Zero-byte PCK entries (base game ships CasettePlayer.gd empty in RTV
	# 4.6.1) have nothing to decode. Return empty silently so callers don't
	# misread this as an IO failure.
	if _pck_zero_byte_paths.has(script_path):
		return ""
	# Try multiple methods to read raw bytes -- FileAccess on res:// can fail for
	# PCK-embedded files depending on the container format (RSCC, encryption, etc.).
	var raw := PackedByteArray()

	# Method 1: FileAccess.open() on res:// path directly.
	var f := FileAccess.open(script_path, FileAccess.READ)
	if f:
		raw = f.get_buffer(f.get_length())
		f.close()

	# Method 2: Try the globalized path.
	if raw.is_empty():
		var glob_path := ProjectSettings.globalize_path(script_path)
		f = FileAccess.open(glob_path, FileAccess.READ)
		if f:
			raw = f.get_buffer(f.get_length())
			f.close()

	# Method 3: Try loading as a generic Resource and check if it has raw data.
	# (GDScript objects loaded from tokenized files don't expose raw bytes, but
	# we can try get_file_as_bytes with .gdc extension in case Godot mapped it.)
	if raw.is_empty():
		var gdc_path := script_path.replace(".gd", ".gdc")
		raw = FileAccess.get_file_as_bytes(gdc_path)

	if raw.is_empty():
		_log_warning("[Detokenize] Cannot read bytes from: %s (tried res://, globalized, .gdc)" % script_path)
		return ""

	# -- Header (12 bytes) --
	if raw.size() < 12:
		return ""
	var magic := raw.slice(0, 4).get_string_from_ascii()
	if magic != _GDSC_MAGIC:
		# Not a GDSC file -- might be plain text that load() failed on for another reason.
		var text := raw.get_string_from_utf8()
		if not text.is_empty() and (text.begins_with("extends") or text.begins_with("class_name") or text.begins_with("@")):
			return text
		_log_warning("[Detokenize] Not a GDSC file: " + script_path)
		return ""

	var version := raw.decode_u32(4)
	if version != 100 and version != 101:
		_log_critical("[Detokenize] Unsupported GDSC version %d in %s (expected 100 or 101)" % [version, script_path])
		return ""

	var decompressed_size := raw.decode_u32(8)
	var buf: PackedByteArray
	if decompressed_size == 0:
		buf = raw.slice(12)
	else:
		var compressed := raw.slice(12)
		buf = compressed.decompress(decompressed_size, FileAccess.COMPRESSION_ZSTD)
		if buf.is_empty():
			_log_critical("[Detokenize] ZSTD decompression failed for: " + script_path)
			return ""

	# -- Metadata --
	var meta_size := 20 if version == 100 else 16  # v100 has 4-byte padding
	if buf.size() < meta_size:
		return ""
	var ident_count: int = buf.decode_u32(0)
	var const_count: int = buf.decode_u32(4)
	var line_count: int  = buf.decode_u32(8)
	var token_count: int
	if version == 100:
		token_count = buf.decode_u32(16)
	else:
		token_count = buf.decode_u32(12)

	var offset := meta_size

	# -- Identifiers (XOR 0xb6 encoded UTF-32) --
	var identifiers: Array[String] = []
	for _i in ident_count:
		if offset + 4 > buf.size():
			break
		var str_len: int = buf.decode_u32(offset)
		offset += 4
		var s := ""
		for _j in str_len:
			if offset + 4 > buf.size():
				break
			var b0: int = buf[offset] ^ 0xb6
			var b1: int = buf[offset + 1] ^ 0xb6
			var b2: int = buf[offset + 2] ^ 0xb6
			var b3: int = buf[offset + 3] ^ 0xb6
			var code_point: int = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)
			if code_point > 0:
				s += String.chr(code_point)
			offset += 4
		identifiers.append(s)

	# -- Constants (Variant-encoded, sequential) --
	var constants: Array = []
	for _i in const_count:
		if offset + 4 > buf.size():
			break
		# Decode next Variant from the stream.  We round-trip through
		# var_to_bytes() to determine consumed size since bytes_to_var()
		# doesn't report how many bytes it read.
		var remaining := buf.slice(offset)
		var val = bytes_to_var(remaining)
		constants.append(val)
		# Advance offset by the encoded size.
		var encoded := var_to_bytes(val)
		offset += encoded.size()

	# -- Line/column maps --
	var line_map := {}  # token_index -> line
	var col_map := {}   # token_index -> column
	for _i in line_count:
		if offset + 8 > buf.size():
			break
		var tok_idx: int = buf.decode_u32(offset)
		var line_val: int = buf.decode_u32(offset + 4)
		line_map[tok_idx] = line_val
		offset += 8
	for _i in line_count:
		if offset + 8 > buf.size():
			break
		var tok_idx: int = buf.decode_u32(offset)
		var col_val: int = buf.decode_u32(offset + 4)
		col_map[tok_idx] = col_val
		offset += 8

	# -- Token stream --
	var tokens: Array = []  # Array of [type: int, data_index: int]
	for _i in token_count:
		if offset >= buf.size():
			break
		var token_len := 8 if (buf[offset] & _GDSC_TOKEN_BYTE_MASK) else 5
		if offset + token_len > buf.size():
			break
		var raw_type: int = buf.decode_u32(offset)
		var tk_type: int = raw_type & _GDSC_TOKEN_MASK
		var data_idx: int = raw_type >> _GDSC_TOKEN_BITS
		tokens.append([tk_type, data_idx])
		offset += token_len

	var result := _gdsc_reconstruct(tokens, identifiers, constants, line_map, col_map)
	if result.is_empty():
		return ""
	_log_info("[Detokenize] Reconstructed: %s (%d tokens, %d lines) -- parse OK" \
			% [script_path, tokens.size(), result.count("\n") + 1])
	return result

func _gdsc_reconstruct(tokens: Array, identifiers: Array[String], constants: Array,
		line_map: Dictionary, col_map: Dictionary) -> String:
	var lines := PackedStringArray()
	var current_line := ""
	var current_line_num := 1
	var need_space := false
	var prev_tk := -1
	var line_started := false  # has any visible token been emitted on this line?

	for i in tokens.size():
		var tk: int = tokens[i][0]
		var idx: int = tokens[i][1]

		# Handle line changes via line_map.
		if line_map.has(i):
			var new_line: int = line_map[i]
			while current_line_num < new_line:
				lines.append(current_line)
				current_line = ""
				current_line_num += 1
				need_space = false
				line_started = false

		if tk == 99:  # EOF
			break

		if tk == 88:  # NEWLINE
			lines.append(current_line)
			current_line = ""
			current_line_num += 1
			need_space = false
			line_started = false
			prev_tk = tk
			continue

		if tk == 89 or tk == 90:  # INDENT / DEDENT -- skip, we use col_map instead
			prev_tk = tk
			continue

		# Build the text for this token.
		var text := ""
		if tk == 2:  # IDENTIFIER
			text = identifiers[idx] if idx < identifiers.size() else "<ident?>"
		elif tk == 1:  # ANNOTATION
			var aname: String = identifiers[idx] if idx < identifiers.size() else "?"
			text = aname if aname.begins_with("@") else ("@" + aname)
		elif tk == 3:  # LITERAL
			text = _gdsc_variant_to_source(constants[idx] if idx < constants.size() else null)
		elif _TOKEN_TEXT.has(tk):
			text = _TOKEN_TEXT[tk]
		else:
			text = "<tk%d>" % tk

		# Apply indentation from column data for the first visible token on a line.
		if not line_started:
			line_started = true
			if col_map.has(i):
				var col: int = col_map[i]
				# Convert column to tabs (Godot uses tab_size=4 for indentation).
				var tabs: int = col / 4
				for _t in tabs:
					current_line += "\t"

		# Spacing logic.
		var add_space_before := false
		if need_space and not current_line.is_empty() and not current_line.ends_with("\t"):
			if _SPACE_BEFORE.has(tk):
				add_space_before = true
			elif tk == 2 or tk == 3 or tk == 1 or (tk >= 40 and tk <= 72):
				# IDENTIFIER, LITERAL, ANNOTATION, or any keyword -- space before
				# unless prev was an opener, dot, $, ~, !, indent, newline.
				# Note: annotation (1) excluded only for identifiers (part of the
				# annotation name), NOT for keywords like var/func after @export.
				var skip_anno := (prev_tk == 1 and (tk == 2 or tk == 1))  # ident/anno after anno
				if not skip_anno \
						and prev_tk != 77 and prev_tk != 73 \
						and prev_tk != 81 and prev_tk != 85 \
						and prev_tk != 18 \
						and prev_tk != 15 and prev_tk != 89 \
						and prev_tk != 88 and prev_tk != -1:
					add_space_before = true
			elif tk == 77:  # PAREN_OPEN
				# Space before ( after control-flow keywords, but NOT after
				# function-like keywords (func, preload, super, assert, await).
				if prev_tk >= 40 and prev_tk <= 50:  # if..when (control flow)
					add_space_before = true
			elif tk == 12 or tk == 15:  # NOT, BANG
				add_space_before = true

		if add_space_before and not current_line.ends_with(" ") and not current_line.ends_with("\t"):
			current_line += " "

		current_line += text

		# Set need_space for next token.  _SPACE_AFTER covers operators,
		# keywords, and punctuation.  Also need space after identifiers (2),
		# literals (3), close-parens (78), close-bracket (74), close-brace (76),
		# constants (91-94 PI/TAU/INF/NAN), and underscore (87).
		need_space = _SPACE_AFTER.has(tk) or tk == 2 or tk == 3 \
				or tk == 78 or tk == 74 or tk == 76 \
				or tk == 91 or tk == 92 or tk == 93 \
				or tk == 94 or tk == 87

		prev_tk = tk

	# Flush last line.
	if not current_line.is_empty():
		lines.append(current_line)

	# GDScript files should end with newline.
	var result := "\n".join(lines)
	if not result.ends_with("\n"):
		result += "\n"
	return result

func _gdsc_variant_to_source(value: Variant) -> String:
	if value == null:
		return "null"
	match typeof(value):
		TYPE_BOOL:
			return "true" if value else "false"
		TYPE_INT:
			return str(value)
		TYPE_FLOAT:
			var s := str(value)
			if "." not in s and "e" not in s and "inf" not in s.to_lower() and "nan" not in s.to_lower():
				s += ".0"
			return s
		TYPE_STRING:
			return '"%s"' % str(value).c_escape()
		TYPE_STRING_NAME:
			return '&"%s"' % str(value).c_escape()
		TYPE_NODE_PATH:
			return '^"%s"' % str(value).c_escape()
		TYPE_VECTOR2:
			return "Vector2(%s, %s)" % [_gdsc_variant_to_source(value.x), _gdsc_variant_to_source(value.y)]
		TYPE_VECTOR2I:
			return "Vector2i(%s, %s)" % [value.x, value.y]
		TYPE_VECTOR3:
			return "Vector3(%s, %s, %s)" % [_gdsc_variant_to_source(value.x), _gdsc_variant_to_source(value.y), _gdsc_variant_to_source(value.z)]
		TYPE_VECTOR3I:
			return "Vector3i(%s, %s, %s)" % [value.x, value.y, value.z]
		TYPE_COLOR:
			return "Color(%s, %s, %s, %s)" % [_gdsc_variant_to_source(value.r), _gdsc_variant_to_source(value.g), _gdsc_variant_to_source(value.b), _gdsc_variant_to_source(value.a)]
		TYPE_ARRAY:
			var parts := PackedStringArray()
			for item in value:
				parts.append(_gdsc_variant_to_source(item))
			return "[%s]" % ", ".join(parts)
		TYPE_DICTIONARY:
			var parts := PackedStringArray()
			for k in value:
				parts.append("%s: %s" % [_gdsc_variant_to_source(k), _gdsc_variant_to_source(value[k])])
			return "{%s}" % ", ".join(parts)
		_:
			return str(value)

func _read_vanilla_source(script_path: String) -> String:
	# On-disk cache first (pristine vanilla from a prior session's detokenize).
	# IMPORTANT: do NOT call load(script_path) here, not even to "verify" the
	# live script. Any load() triggers ResourceFormatLoaderGDScript to read
	# the PCK's .gdc (via the PCK's stale .gd.remap) and cache the tokenized
	# result at script_path. Subsequent hook-pack mounts + loads hit that
	# cached entry instead of our rewrite. Cache must stay cold until the
	# hook pack is mounted.
	var cache_file := VANILLA_CACHE_DIR.path_join(script_path.trim_prefix("res://"))
	if FileAccess.file_exists(cache_file):
		var cached := FileAccess.get_file_as_string(cache_file)
		if not cached.is_empty():
			return cached

	# No cache: detokenize from raw .gdc bytes. This uses FileAccess only
	# (never ResourceLoader), so no cache entry is created.
	var source := _detokenize_script(script_path)
	if source.is_empty():
		return ""

	# Detect a rewrite accidentally served at the vanilla path (would mean
	# a prior session left a mount active that contaminated detokenize
	# input -- shouldn't happen in the current design, but catch it loudly
	# so we don't double-rewrite).
	if "_rtv_ready_done" in source or 'Engine.get_meta("RTVModLib"' in source:
		_log_critical("[Hooks] Detokenized source for %s already contains rewrite markers -- possible stale overlay. Delete %s and restart." \
				% [script_path, ProjectSettings.globalize_path(HOOK_PACK_DIR)])
		return ""
	_save_vanilla_source(script_path, source)
	return source

func _save_vanilla_source(script_path: String, source: String) -> void:
	if source.is_empty():
		return  # never write 0-byte cache files
	var cache_file := VANILLA_CACHE_DIR.path_join(script_path.trim_prefix("res://"))
	DirAccess.make_dir_recursive_absolute(
		ProjectSettings.globalize_path(cache_file.get_base_dir()))
	var f := FileAccess.open(cache_file, FileAccess.WRITE)
	if f:
		f.store_string(source)
		f.close()

func _probe_gdsc_version() -> int:
	var probe_paths := ["res://Scripts/Camera.gd", "res://Scripts/Controller.gd",
			"res://Scripts/Audio.gd", "res://Scripts/AI.gd"]
	for p in probe_paths:
		var raw := FileAccess.get_file_as_bytes(p)
		if raw.size() < 12:
			raw = FileAccess.get_file_as_bytes(p.replace(".gd", ".gdc"))
			if raw.size() < 12:
				continue
		if raw.slice(0, 4).get_string_from_ascii() != _GDSC_MAGIC:
			continue
		return int(raw.decode_u32(4))
	return -1

# Build the framework pack: enumerate res://Scripts/*.gd, detokenize each via
# _read_vanilla_source, parse + generate wrappers, zip them, mount the zip.
#
# The zip mounts at res://modloader_hooks/ and wrappers load from there. NOT
# from user:// -- Godot 4.6's extends-chain resolution for class_name parents
# breaks for scripts loaded from user://, which shows up as broken super()

## ----- pck_enumeration.gd -----
## PCK introspection. Parses the game's RTV.pck file table to enumerate
## every res://Scripts/*.gd at runtime (DirAccess can't list PCK contents in
## Godot 4.6). Also builds the class_name -> path lookup consumed by the
## rewriter when scanning mod subclasses.

func _build_class_name_lookup() -> void:
	_class_name_to_path.clear()
	var cache := ConfigFile.new()
	var load_err := cache.load("res://.godot/global_script_class_cache.cfg")
	if load_err == OK:
		var class_list: Array = cache.get_value("", "list", [])
		var skipped := 0
		for entry in class_list:
			var cn: String = str(entry.get("class", ""))
			var path: String = str(entry.get("path", ""))
			if cn != "" and path != "":
				_class_name_to_path[cn] = path
			else:
				skipped += 1
		if _class_name_to_path.size() < 10:
			# A mounted mod (e.g., MCM) may shadow the game's cache with its own
			# 1-entry version.  Fall back to the hardcoded map.
			_log_warning("Class cache has only %d entries (raw=%d) -- mod shadowing detected, using hardcoded fallback" \
					% [_class_name_to_path.size(), class_list.size()])
			_class_name_to_path = _get_hardcoded_class_map()
		else:
			_log_info("Loaded %d class_name mappings from game cache" % _class_name_to_path.size())
	else:
		_log_warning("Could not load global_script_class_cache.cfg -- using hardcoded fallback")
		_class_name_to_path = _get_hardcoded_class_map()

func _get_hardcoded_class_map() -> Dictionary:
	return {
		"AIWeaponData": "res://Scripts/AIWeaponData.gd",
		"Area": "res://Scripts/Area.gd",
		"AttachmentData": "res://Scripts/AttachmentData.gd",
		"AudioEvent": "res://Scripts/AudioEvent.gd",
		"AudioLibrary": "res://Scripts/AudioLibrary.gd",
		"Camera": "res://Scripts/Camera.gd",
		"CasetteData": "res://Scripts/CasetteData.gd",
		"CatData": "res://Scripts/CatData.gd",
		"CharacterSave": "res://Scripts/CharacterSave.gd",
		"ContainerSave": "res://Scripts/ContainerSave.gd",
		"Controller": "res://Scripts/Controller.gd",
		"Door": "res://Scripts/Door.gd",
		"EventData": "res://Scripts/EventData.gd",
		"Events": "res://Scripts/Events.gd",
		"Fish": "res://Scripts/Fish.gd",
		"FishingData": "res://Scripts/FishingData.gd",
		"Flash": "res://Scripts/MuzzleFlash.gd",
		"Furniture": "res://Scripts/Furniture.gd",
		"FurnitureSave": "res://Scripts/FurnitureSave.gd",
		"GameData": "res://Scripts/GameData.gd",
		"Grenade": "res://Scripts/Grenade.gd",
		"GrenadeData": "res://Scripts/GrenadeData.gd",
		"Grid": "res://Scripts/Grid.gd",
		"Hitbox": "res://Scripts/Hitbox.gd",
		"Inspect": "res://Scripts/Inspect.gd",
		"InstrumentData": "res://Scripts/InstrumentData.gd",
		"Item": "res://Scripts/Item.gd",
		"ItemData": "res://Scripts/ItemData.gd",
		"ItemSave": "res://Scripts/ItemSave.gd",
		"Knife": "res://Scripts/KnifeRig.gd",
		"KnifeData": "res://Scripts/KnifeData.gd",
		"LootContainer": "res://Scripts/LootContainer.gd",
		"LootTable": "res://Scripts/LootTable.gd",
		"Lure": "res://Scripts/Lure.gd",
		"Mine": "res://Scripts/Mine.gd",
		"Pickup": "res://Scripts/Pickup.gd",
		"Preferences": "res://Scripts/Preferences.gd",
		"RecipeData": "res://Scripts/RecipeData.gd",
		"Recipes": "res://Scripts/Recipes.gd",
		"Settings": "res://Scripts/Settings.gd",
		"ShelterSave": "res://Scripts/ShelterSave.gd",
		"Slot": "res://Scripts/Slot.gd",
		"SlotData": "res://Scripts/SlotData.gd",
		"SpawnerChunkData": "res://Scripts/SpawnerChunkData.gd",
		"SpawnerData": "res://Scripts/SpawnerData.gd",
		"SpawnerSceneData": "res://Scripts/SpawnerSceneData.gd",
		"SpineData": "res://Scripts/SpineData.gd",
		"Surface": "res://Scripts/Surface.gd",
		"SwitchSave": "res://Scripts/SwitchSave.gd",
		"TaskData": "res://Scripts/TaskData.gd",
		"TrackData": "res://Scripts/TrackData.gd",
		"Trader": "res://Scripts/Trader.gd",
		"TraderData": "res://Scripts/TraderData.gd",
		"TraderSave": "res://Scripts/TraderSave.gd",
		"Validator": "res://Scripts/Validator.gd",
		"WeaponData": "res://Scripts/WeaponData.gd",
		"WeaponRig": "res://Scripts/WeaponRig.gd",
		"WorldSave": "res://Scripts/WorldSave.gd",
	}

func _enumerate_game_scripts() -> Array[String]:
	# Memoized: PCK parsing is non-trivial and we call this from two sites
	# now (early in pass 1/2 so the .hook() merge can resolve class_name-less
	# stems, plus from _generate_hook_pack as before). Cache keeps the second
	# call free without forcing the caller to track lookup state.
	if not _all_game_script_paths.is_empty():
		return _all_game_script_paths
	var exe_dir := OS.get_executable_path().get_base_dir()
	var candidates := ["RTV.pck", OS.get_executable_path().get_file().get_basename() + ".pck"]
	for cand in candidates:
		var pck_path := exe_dir.path_join(cand)
		if not FileAccess.file_exists(pck_path):
			continue
		var paths := _parse_pck_file_list(pck_path)
		if paths.is_empty():
			continue
		var scripts: Array[String] = []
		for p in paths:
			# Packed paths lack the res:// prefix; Godot adds it on load.
			var normalized := p
			if not normalized.begins_with("res://"):
				normalized = "res://" + normalized.trim_prefix("/")
			if not normalized.begins_with("res://Scripts/"):
				continue
			# Canonicalize .gdc / .gd.remap / .remap to .gd.
			var canonical := normalized
			if canonical.ends_with(".gd.remap"):
				canonical = canonical.substr(0, canonical.length() - 6)
			elif canonical.ends_with(".remap"):
				canonical = canonical.substr(0, canonical.length() - 6)
			if canonical.ends_with(".gdc"):
				canonical = canonical.substr(0, canonical.length() - 4) + ".gd"
			if canonical.ends_with(".gd") and canonical not in scripts:
				scripts.append(canonical)
		_log_info("[RTVCodegen] parsed %s -- %d total file(s), %d .gd script(s) under res://Scripts/" \
				% [cand, paths.size(), scripts.size()])
		_all_game_script_paths = scripts
		return scripts
	return []

# Collect module-scope `preload("...tscn|scn")` paths from source. Module-scope
# = line starts at column 0 (no leading whitespace). Such preloads fire at
# script parse time, BEFORE mod autoloads run overrideScript(). If the
# preloaded scene has a Script ext_resource pointing to a path a mod intends
# to override, the scene bakes a Ref<> to the pre-override vanilla script.
# take_over_path later clears the vanilla's path_cache, leaving the scene
# holding an orphaned (empty-path) script. Subsequent instantiate() produces
# nodes with that orphan, and the mod's body never runs.
func _collect_module_scope_scene_preloads(source: String) -> PackedStringArray:
	var scenes := PackedStringArray()
	var re := RegEx.new()
	re.compile("preload\\(\"(res://[^\"]+\\.(?:tscn|scn))\"\\)")
	for line in source.split("\n"):
		if line.is_empty():
			continue
		var first := line[0]
		if first == "\t" or first == " ":
			continue  # indented -- inside function, block, or conditional
		var trimmed := line.strip_edges(true, false)
		if trimmed.is_empty() or trimmed.begins_with("#"):
			continue
		if "preload(" not in line:
			continue
		for m in re.search_all(line):
			var scene_path := m.get_string(1)
			if scene_path not in scenes:
				scenes.append(scene_path)
	return scenes

# Minimal PCK header + file-table parser. V2 (Godot 4.0-4.5) has 16 reserved
# dwords before the directory; V3 (Godot 4.6+) replaces them with an explicit
# 64-bit directory offset. Reference: core/io/file_access_pack.cpp.
func _parse_pck_file_list(pck_path: String) -> PackedStringArray:
	const MAGIC_GDPC: int = 0x43504447  # "GDPC" little-endian
	const PACK_DIR_ENCRYPTED := 1
	const PACK_FORMAT_V2 := 2
	const PACK_FORMAT_V3 := 3
	var result := PackedStringArray()
	var f := FileAccess.open(pck_path, FileAccess.READ)
	if f == null:
		_log_warning("[PCK] cannot open: %s" % pck_path)
		return result

	var magic: int = f.get_32()
	if magic != MAGIC_GDPC:
		# Would need footer-scan if embedded; not supported.
		_log_warning("[PCK] %s: not a standalone PCK (magic=0x%x)" % [pck_path, magic])
		f.close()
		return result

	var pack_format_version: int = f.get_32()
	if pack_format_version < PACK_FORMAT_V2 or pack_format_version > PACK_FORMAT_V3:
		_log_warning("[PCK] %s: unsupported format version %d" % [pck_path, pack_format_version])
		f.close()
		return result

	f.get_32()  # godot major
	f.get_32()  # godot minor
	f.get_32()  # godot patch
	var pack_flags: int = f.get_32()
	f.get_64()  # file_base

	if pack_format_version == PACK_FORMAT_V3:
		f.seek(f.get_64())  # explicit dir offset; absolute for standalone PCK
	else:
		for i in 16:
			f.get_32()  # reserved

	if pack_flags & PACK_DIR_ENCRYPTED:
		_log_warning("[PCK] %s: directory encrypted -- can't enumerate" % pck_path)
		f.close()
		return result

	var file_count: int = f.get_32()
	for i in file_count:
		var path_len: int = f.get_32()
		if path_len == 0 or path_len > 4096:
			_log_warning("[PCK] %s: suspicious path_len=%d at entry %d -- abort" \
					% [pck_path, path_len, i])
			break
		var path := f.get_buffer(path_len).get_string_from_utf8()
		f.get_64()              # offset
		var size: int = f.get_64()
		f.get_buffer(16)        # md5
		f.get_32()              # per-file flags (V2 and V3)
		if not path.is_empty():
			result.append(path)
			# Track zero-byte entries so downstream detokenize skips them
			# silently instead of logging misleading "Cannot read bytes"
			# warnings. The base game may ship empty .gd entries (e.g.
			# CasettePlayer.gd in RTV 4.6.1) that we cannot hook and that
			# any preload() call would fail regardless of modloader.
			if size == 0 and path.ends_with(".gd"):
				var res_path := path if path.begins_with("res://") else "res://" + path.trim_prefix("/")
				_pck_zero_byte_paths[res_path] = true

	f.close()
	return result

# STABILITY canary B: probe the first readable vanilla script for its GDSC
# tokenizer version. Returns 100 (Godot 4.0-4.4), 101 (Godot 4.5-4.6), or
# -1 if the file isn't binary-tokenized / unreadable. Used to bail out
# cleanly when Godot ships a new tokenizer format (v102+) rather than
# cascading "Empty detokenized source" warnings through every script.

## ----- rewriter.gd -----
## Source-rewrite codegen. Given detokenized vanilla source + a parse
## structure + an optional per-method mask, produces a rewritten script
## where each non-static method in the mask (or every non-static method,
## when the mask is empty) is renamed to _rtv_vanilla_<name> and a
## dispatch wrapper is appended at the original name. The wrappers fire
## pre/replace/post/callback hooks and call the renamed body.
##
## v3.0.1: mod-subclass rewrite removed (was the old Step C). Mods that
## extend wrapped vanilla now compose via Godot's native extends
## resolution -- no _rtv_mod_ prefix, no rewrite of mod source.
##
## Also owns: regex compilation, parse-script, autofix legacy syntax,
## indent detection, bare-super rewriting.

func _compile_regex() -> void:
	_re_take_over = RegEx.new()
	_re_take_over.compile('take_over_path\\s*\\(\\s*"(res://[^"]+)"')
	_re_extends = RegEx.new()
	_re_extends.compile('(?m)^extends\\s+"(res://[^"]+)"')
	_re_extends_classname = RegEx.new()
	_re_extends_classname.compile('(?m)^extends\\s+([A-Z]\\w+)\\s*$')
	_re_class_name = RegEx.new()
	_re_class_name.compile('(?m)^class_name\\s+(\\w+)')
	_re_func = RegEx.new()
	_re_func.compile('(?m)^(?:static\\s+)?func\\s+(\\w+)\\s*\\(')
	_re_preload = RegEx.new()
	_re_preload.compile('preload\\s*\\(\\s*"(res://[^"]+)"\\s*\\)')
	# VostokMods compat: "100-ModName.vmz" encodes priority in the filename.
	_re_filename_priority = RegEx.new()
	_re_filename_priority.compile('^(-?\\d+)-(.*)')
	# .hook("<prefix>-<method>[-pre|-post|-callback]") -- the first capture
	# is the lowercase script stem (e.g. "controller"), the second is the
	# declared method name. _generate_hook_pack uses the (prefix, method)
	# pair to build a per-path, per-method wrap mask so only the methods a
	# mod actually hooks get dispatch wrappers (matches godot-mod-loader's
	# per-path method_mask). Unknown-suffix fallbacks are treated as plain
	# methods (the -pre/-post/-callback suffix is a hook-dispatch variant,
	# not a method-name distinction).
	_re_hook_call = RegEx.new()
	_re_hook_call.compile('\\.hook\\s*\\(\\s*"([A-Za-z_][\\w]*)-([A-Za-z_][\\w]*?)(?:-(?:pre|post|callback))?"')

# Mod metadata collection (no mounting)


func _rtv_compile_codegen_regex() -> void:
	if _rtv_re_extends != null:
		return
	_rtv_re_extends = RegEx.new()
	_rtv_re_extends.compile('^extends\\s+"?([\\w/.:"]+)"?')
	_rtv_re_class_name = RegEx.new()
	_rtv_re_class_name.compile('^class_name\\s+(\\w+)')
	_rtv_re_func = RegEx.new()
	_rtv_re_func.compile('^func\\s+(\\w+)\\s*\\(([^)]*)\\)(\\s*->\\s*([\\w\\[\\]]+))?\\s*:')
	_rtv_re_static_func = RegEx.new()
	_rtv_re_static_func.compile('^static\\s+func\\s+(\\w+)\\s*\\(([^)]*)\\)(\\s*->\\s*([\\w\\[\\]]+))?\\s*:')
	_rtv_re_var = RegEx.new()
	_rtv_re_var.compile('^(?:@export\\s+)?var\\s+(\\w+)')

func _rtv_extract_param_names(params: String) -> Array:
	var names: Array = []
	if params.strip_edges().is_empty():
		return names
	for p in params.split(","):
		var trimmed := (p as String).strip_edges()
		var without_type := trimmed.split(":")[0]
		var without_default := (without_type as String).split("=")[0]
		var name := (without_default as String).strip_edges()
		if not name.is_empty():
			names.append(name)
	return names

func _rtv_script_hook_prefix(filename: String) -> String:
	var stem := filename
	if stem.ends_with(".gd"):
		stem = stem.substr(0, stem.length() - 3)
	return stem.to_lower()

# Returns:
#   { filename, path, extends, class_name, var_names, functions }
# Each function entry:
#   { name, params, param_names, line_number, is_static, return_type,
#     is_coroutine, has_return_value }

func _rtv_parse_script(filename: String, source: String) -> Dictionary:
	_rtv_compile_codegen_regex()
	var script := {
		"filename": filename,
		"path": "res://Scripts/" + filename,
		"extends": "",
		"class_name": null,
		"functions": [],
		"var_names": [],
	}
	var lines: PackedStringArray = source.split("\n")
	var func_starts: Array = []  # [line_num, name, params, param_names, is_static, return_type]

	for line_num in lines.size():
		var line: String = lines[line_num]
		var trimmed := line.strip_edges()

		var m_ext := _rtv_re_extends.search(trimmed)
		if m_ext != null:
			script["extends"] = m_ext.get_string(1)

		var m_cn := _rtv_re_class_name.search(trimmed)
		if m_cn != null:
			script["class_name"] = m_cn.get_string(1)

		# Top-level var names (line starts with "var" / "@export var" -- no leading indent).
		if not line.begins_with("\t") and not line.begins_with(" "):
			var m_var := _rtv_re_var.search(trimmed)
			if m_var != null:
				(script["var_names"] as Array).append(m_var.get_string(1))

		var m_sfunc := _rtv_re_static_func.search(trimmed)
		if m_sfunc != null:
			var ret_group = m_sfunc.get_string(4) if m_sfunc.get_start(4) != -1 else null
			func_starts.append([
				line_num, m_sfunc.get_string(1), m_sfunc.get_string(2),
				_rtv_extract_param_names(m_sfunc.get_string(2)), true,
				ret_group,
			])
			continue

		var m_func := _rtv_re_func.search(trimmed)
		if m_func != null:
			var ret_group2 = m_func.get_string(4) if m_func.get_start(4) != -1 else null
			func_starts.append([
				line_num, m_func.get_string(1), m_func.get_string(2),
				_rtv_extract_param_names(m_func.get_string(2)), false,
				ret_group2,
			])

	# Second pass: extract function bodies to detect await + return-with-value.
	for idx in func_starts.size():
		var fs: Array = func_starts[idx]
		var line_num: int = fs[0]
		var name: String = fs[1]
		var params: String = fs[2]
		var param_names: Array = fs[3]
		var is_static: bool = fs[4]
		var return_type = fs[5]  # String or null

		var body_start := line_num + 1
		var body_end := lines.size()
		if idx + 1 < func_starts.size():
			body_end = func_starts[idx + 1][0]

		var is_coroutine := false
		var has_return_value := false
		for i in range(body_start, body_end):
			if i >= lines.size():
				break
			var body_line := lines[i].strip_edges()
			if "await " in body_line:
				is_coroutine = true
			# "return <something>" (not bare "return").
			if body_line.begins_with("return ") and body_line.length() > 7:
				has_return_value = true

		# Explicit return type override (void -> no value; anything else -> has value).
		if return_type != null and return_type != "void":
			has_return_value = true
		if return_type != null and return_type == "void":
			has_return_value = false

		(script["functions"] as Array).append({
			"name": name,
			"params": params,
			"param_names": param_names,
			"line_number": line_num + 1,
			"is_static": is_static,
			"return_type": return_type,
			"is_coroutine": is_coroutine,
			"has_return_value": has_return_value,
		})

	return script

# Inline source-rewrite generator (Option C / Phase 1 Step A).
#
# Produces the full rewritten source of a vanilla script where each hookable
# method <name> is renamed to _rtv_vanilla_<name> and a new <name> method is
# appended that dispatches through RTVModLib hooks, then calls the renamed
# original.
#
# Rewrites the vanilla script itself rather than generating a separate
# wrapper class. When shipped at res://Scripts/<Name>.gd via a hook pack,
# it becomes the script Godot compiles for that path -- no extends chain,
# no class_name registry asymmetry, no bug #83542 regardless of what mods
# do with take_over_path.
#
# Caller MUST pass pristine vanilla source (e.g. from .gdc bytecode via
# _read_vanilla_source / _detokenize_script). Passing already-rewritten source
# produces duplicate-function parse errors.

func _rtv_rewrite_vanilla_source(source: String, parsed: Dictionary, method_mask: Dictionary = {}) -> String:
	# method_mask (v3.0.1): Dictionary[method_name, true] restricting which
	# methods get renamed + wrapped. Empty = wrap every non-static method
	# (used for REGISTRY_TARGETS where whole-script injection is needed).
	# Non-empty = wrap only declared methods; matches godot-mod-loader's
	# per-path method_mask. Other methods stay vanilla, no dispatch
	# overhead, no rename.
	var apply_mask: bool = not method_mask.is_empty()
	var hookable: Array = []
	for fe in parsed["functions"]:
		if fe["is_static"]:
			continue
		# Mask keys are lowercased (built from .hook() calls; see
		# rewriter.gd:211 dispatch-name lowering). Match case-insensitively
		# so "updatetooltip" matches vanilla "UpdateToolTip".
		if apply_mask and not method_mask.has(fe["name"].to_lower()):
			continue
		hookable.append(fe)
	if hookable.is_empty():
		return source

	# Build set of hookable method names for fast lookup during rename pass.
	var hookable_names: Dictionary = {}
	for fe in hookable:
		hookable_names[fe["name"]] = true

	# Normalize line endings. IXP ships CRLF-encoded source; our appended
	# wrappers use LF only. Mixing CRLF and LF in a single file confuses
	# GDScript's parser with a "tab character for indentation" error (even
	# when there are no tabs -- the misleading error is triggered by the
	# ending mismatch). Strip all CR so the whole file is pure LF.
	var src: String = source.replace("\r\n", "\n").replace("\r", "\n")

	# Maximum-compat pass: repair sloppy / Godot-3-era GDScript that the
	# parser would reject. Runs before our rename+wrapper pipeline so
	# every downstream step sees valid source. No-op for clean files.
	var autofix := _rtv_autofix_legacy_syntax(src)
	src = autofix["source"]
	var af_total: int = int(autofix["bodyless"]) + int(autofix["tool"]) \
			+ int(autofix["onready"]) + int(autofix["export"]) + int(autofix.get("base", 0))
	if af_total > 0:
		_log_info("[Autofix] %s: %d bodyless, %d @tool, %d @onready, %d @export, %d base()->super -- legacy syntax normalized" \
				% [parsed.get("filename", "?"), autofix["bodyless"], autofix["tool"], autofix["onready"], autofix["export"], autofix.get("base", 0)])

	# Per-script declaration-level transforms. Each case makes otherwise-
	# compile-time-immutable declarations runtime-mutable so the registry
	# can swap them under the hood.
	var fn: String = parsed.get("filename", "")
	# Database.gd: convert `const X = preload("...")` -> _rtv_vanilla_scenes
	# dict entries so Database.get(name) flows through _get() and sees mod
	# overrides instead of resolving via direct const lookup.
	if fn == "Database.gd":
		src = _rtv_rewrite_database_constants(src)
	# Loader.gd: convert `const shelters = [...]` -> var so the registry
	# can append mod shelter names. Scene-path consts (const Cabin = "..."
	# etc.) stay consts because LoadScene references them directly inside
	# its body; we inject a mod-lookup prelude into LoadScene instead.
	elif fn == "Loader.gd":
		src = _rtv_rewrite_loader_shelters(src)
	# AISpawner.gd: the vanilla if-elif that maps Zone -> agent is rewritten
	# so each `agent = <name>` becomes `agent = _rtv_resolve_ai_type(zone,
	# <name>)`. The helper (appended as part of registry injection) checks
	# the override dict and returns that or the vanilla scene. Lets mods
	# swap the agent spawned in any vanilla zone without touching _ready.
	elif fn == "AISpawner.gd":
		src = _rtv_rewrite_aispawner_agent_assignments(src)

	# Pass 1: rename top-level "func <name>(" to "func _rtv_vanilla_<name>("
	# AND rewrite bare super() calls inside that body to super.<name>().
	# Only matches at line start (static methods already filtered). Inner-class
	# methods (indented) keep their names. class_name stays intact -- scripts
	# ship at res://Scripts/<Name>.gd matching the PCK's class-cache
	# registration, and extends-by-path from other scripts needs the target
	# to carry a class_name to resolve.
	#
	# super() rewrite rationale: in IXP's Loader.gd `func CheckVersion(): ...
	# return super()`, `super()` means "parent's version of the current
	# function". After we rename the enclosing func to _rtv_vanilla_CheckVersion,
	# GDScript's strict reload parser looks for _rtv_vanilla_CheckVersion on the
	# parent -- which vanilla Loader doesn't have. Rewriting to
	# `super.CheckVersion()` keeps it resolving to the original method name on
	# parent (which is now our dispatch wrapper). `super.<explicit_method>()`
	# and `super.OtherMethod()` are already explicit and pass through untouched.
	var lines: PackedStringArray = src.split("\n")
	var current_hooked_method: String = ""
	for i in lines.size():
		var line: String = lines[i]
		# Top-level line (no indent): may open or close a method block.
		if not line.is_empty() and line[0] != "\t" and line[0] != " ":
			current_hooked_method = ""
			if line.begins_with("func "):
				var open_paren := line.find("(")
				if open_paren >= 0:
					var name_end := open_paren
					while name_end > 5 and line[name_end - 1] == " ":
						name_end -= 1
					var method_name := line.substr(5, name_end - 5)
					if hookable_names.has(method_name):
						lines[i] = "func _rtv_vanilla_" + method_name + line.substr(name_end)
						current_hooked_method = method_name
			continue
		# Indented line: inside some block. If inside a renamed method, rewrite
		# bare super( / super ( to super.<orig_name>( so it still resolves.
		if current_hooked_method.is_empty():
			continue
		if not ("super" in line):
			continue
		lines[i] = _rewrite_bare_super(line, current_hooked_method)

	# Pass 1.5: function-body prelude injection. Some registries need a
	# check at the TOP of a specific vanilla function body (e.g., Loader's
	# LoadScene needs to consult _rtv_mod_scene_paths before the if-elif
	# chain fires). The function was just renamed to _rtv_vanilla_<Name>,
	# so we inject right after its signature line.
	lines = _rtv_apply_prelude_injections(parsed.get("filename", ""), lines, "_rtv_vanilla_")

	# Pass 2: append dispatch wrappers at EOF. Match the source's indent
	# style -- GDScript rejects tab/space mixing in a single file. IXP uses
	# 4-space indent; vanilla RTV uses tabs.
	var indent := _detect_indent_style(src)
	var prefix := _rtv_script_hook_prefix(parsed["filename"])
	var appended := "\n\n# --- Metro mod loader inline hook dispatch wrappers ---\n"
	for fe in hookable:
		appended += _rtv_dispatch_inline_src(fe, prefix, indent) + "\n"

	# Per-script registry injections. The REGISTRY_TARGETS gate in
	# _generate_hook_pack already ensures these only fire for declared
	# registry-opt-in paths.
	appended += _rtv_registry_injection(parsed["filename"], indent)

	return "\n".join(lines) + appended

# Per-script registry injection. Scripts with a matching entry in the
# REGISTRY_INJECTIONS map below get extra code appended: a runtime dict for
# mod-registered entries and a _get() override that serves them transparently.
# Vanilla game code calling Node.get(name) falls through to _get() when the
# name isn't a declared property/const, which is how we expose mod data
# without modifying the vanilla lookup call sites.
func _rtv_registry_injection(filename: String, indent: String) -> String:
	match filename:
		"Database.gd":
			var inj := _rtv_inject_database_registry(indent)
			_log_info("[RTVCodegen] Injected registry into %s (%d chars)" % [filename, inj.length()])
			return inj
		"Loader.gd":
			var inj := _rtv_inject_loader_registry(indent)
			_log_info("[RTVCodegen] Injected registry into %s (%d chars)" % [filename, inj.length()])
			return inj
		"AISpawner.gd":
			var inj := _rtv_inject_aispawner_registry(indent)
			_log_info("[RTVCodegen] Injected registry into %s (%d chars)" % [filename, inj.length()])
			return inj
		_:
			return ""

func _rtv_inject_database_registry(indent: String) -> String:
	# Database.gd is just an appendix here. The REAL transform is done up
	# front in _rtv_rewrite_database_constants(): every vanilla `const X =
	# preload(...)` is converted to an entry in _rtv_vanilla_scenes, so
	# Database.get() can route through _get() and pick up mod overrides.
	#
	# What this appendix adds:
	#   _rtv_mod_scenes[name]      -> new scenes mods registered (lib.register)
	#   _rtv_override_scenes[name] -> scenes mods overrode (lib.override)
	#   _get()                     -> lookup order: override > mod > vanilla
	var I1 := indent
	return "\n\n# --- Metro mod loader registry injection ---\n" \
		+ "var _rtv_mod_scenes: Dictionary = {}\n" \
		+ "var _rtv_override_scenes: Dictionary = {}\n" \
		+ "\n" \
		+ "func _get(property: StringName):\n" \
		+ I1 + "var key := String(property)\n" \
		+ I1 + "if _rtv_override_scenes.has(key):\n" \
		+ I1 + I1 + "return _rtv_override_scenes[key]\n" \
		+ I1 + "if _rtv_mod_scenes.has(key):\n" \
		+ I1 + I1 + "return _rtv_mod_scenes[key]\n" \
		+ I1 + "if _rtv_vanilla_scenes.has(key):\n" \
		+ I1 + I1 + "return _rtv_vanilla_scenes[key]\n" \
		+ I1 + "return null\n"

# Walks Database.gd's source, moves every top-level `const X = preload("...")`
# into a single _rtv_vanilla_scenes dictionary var. All other content
# (extends, @export, @tool, functions, non-preload consts) stays put.
#
# Why: GDScript's compile-time const lookup bypasses _get(), so mods can't
# override what Database.get("Potato") returns. Consts can't be shadowed at
# runtime. Moving them into a dict lets _get() see every name and route
# through the mod override layer.
#
# ExecuteUpdate() in @tool mode reads get_script_constant_map() to build
# LT_Master at edit time. That's editor-only and irrelevant to runtime
# modding, but we also swap that call for an iteration over
# _rtv_vanilla_scenes so @tool still works if someone opens the script.
func _rtv_rewrite_database_constants(source: String) -> String:
	var lines: PackedStringArray = source.split("\n")
	var entries: PackedStringArray = []  # "KEY = PRELOAD"
	var out_lines: PackedStringArray = []
	# Regex: top-level `const NAME = preload("path")` with optional trailing
	# comment. Captures the name and the full preload expression verbatim so
	# we don't disturb whitespace/quoting.
	var re := RegEx.new()
	re.compile('^const\\s+(\\w+)\\s*=\\s*(preload\\s*\\(\\s*"[^"]+"\\s*\\))\\s*$')
	for line: String in lines:
		var m := re.search(line)
		if m != null:
			entries.append("\t\"%s\": %s," % [m.get_string(1), m.get_string(2)])
			continue
		out_lines.append(line)
	if entries.is_empty():
		return source
	# Inject the dict var right after the extends/script-annotation preamble.
	# Safe place: before any function. Walk until we find the first `func ` or
	# class_name and insert above it. If none found, append at end.
	var dict_block := "\n# --- Metro mod loader: vanilla scene dict (rewritten from const declarations) ---\n" \
		+ "var _rtv_vanilla_scenes: Dictionary = {\n" \
		+ "\n".join(entries) + "\n" \
		+ "}\n"
	var insert_at := -1
	for i in out_lines.size():
		var trimmed: String = (out_lines[i] as String).strip_edges()
		if trimmed.begins_with("func ") or trimmed.begins_with("static func "):
			insert_at = i
			break
	if insert_at < 0:
		return "\n".join(out_lines) + dict_block
	var before := out_lines.slice(0, insert_at)
	var after := out_lines.slice(insert_at)
	return "\n".join(before) + "\n" + dict_block + "\n" + "\n".join(after)

# Loader.gd transform: `const shelters = [...]` -> `var shelters = [...]`.
# GDScript consts can't be mutated, but the registry needs to append mod
# shelter names at runtime. Only the one shelters line is affected; the
# scene-path consts (const Cabin = "...", etc.) stay consts because
# LoadScene's body references them by name directly. The prelude injection
# handles mod scene paths without touching those consts.
#
# Also stashes a snapshot var `_rtv_vanilla_shelters` so the registry can
# compute "what's vanilla vs mod" for integrity checks / revert.
func _rtv_rewrite_loader_shelters(source: String) -> String:
	var lines: PackedStringArray = source.split("\n")
	var re := RegEx.new()
	# Match: optional leading whitespace (shouldn't happen at top level but
	# tolerate), "const shelters" followed by the rest of the declaration.
	re.compile('^(\\s*)const\\s+shelters\\s*(=.*)$')
	var changed := false
	for i in lines.size():
		var line: String = lines[i]
		var m := re.search(line)
		if m == null:
			continue
		lines[i] = m.get_string(1) + "var shelters " + m.get_string(2)
		changed = true
	if not changed:
		return source
	return "\n".join(lines)

# Function-body prelude injection dispatcher. Returns lines array (may be
# unchanged). Called after the rename pass -- the target function has
# already been renamed to _rtv_vanilla_<Name>, so we look for the renamed
# signature.
func _rtv_apply_prelude_injections(filename: String, lines: PackedStringArray, rename_prefix: String) -> PackedStringArray:
	match filename:
		"Loader.gd":
			return _rtv_inject_prelude(lines, rename_prefix + "LoadScene", _rtv_loader_loadscene_prelude())
		"FishPool.gd":
			return _rtv_inject_prelude(lines, rename_prefix + "_ready", _rtv_fishpool_ready_prelude())
		_:
			return lines

# Finds `func <func_name>(` at top level and inserts `prelude_lines` right
# after its signature (before any body code). The function was already
# renamed by the rewriter's pass 1, so callers pass the renamed name.
# If the function has multiple blank lines at the top of its body, the
# prelude slots in before them.
func _rtv_inject_prelude(lines: PackedStringArray, func_name: String, prelude_lines: PackedStringArray) -> PackedStringArray:
	var needle := "func " + func_name + "("
	var target := -1
	for i in lines.size():
		var line: String = lines[i]
		if line.begins_with(needle):
			target = i
			break
	if target < 0:
		_log_info("[RTVCodegen] prelude injection: func %s not found (was it not parsed as hookable?)" % func_name)
		return lines
	var out: Array = []
	for i in lines.size():
		out.append(lines[i])
		if i == target:
			for pl in prelude_lines:
				out.append(pl)
	var result := PackedStringArray()
	result.resize(out.size())
	for k in out.size():
		result[k] = out[k]
	return result

# The LoadScene prelude. Checks the mod + override scene-path dicts at
# the top of the function; on match, sets `scenePath` and applies the
# mod's gameData flag overrides. Does NOT early-return.
#
# Design rationale: vanilla LoadScene's structure is:
#     FadeInLoading(); gameData.freeze = true
#     <label visibility setup>
#     if scene == "Cabin": scenePath = Cabin; <flags>
#     elif ...
#     <tail> await timer; get_tree().change_scene_to_file(scenePath)
#
# We insert right after the func signature, so our prelude runs BEFORE
# the fade/label setup AND the if-elif. If the scene name is a mod
# registration, we set scenePath + flags here. The if-elif then falls
# through with no match (mod names aren't vanilla), and the tail code
# picks up our scenePath for change_scene_to_file. Vanilla fade/label
# setup still runs (harmless side-effects we want).
#
# This means mod scene_paths registrations:
#   - reuse vanilla's full loading flow (fade, label, timer, scene change)
#   - can override vanilla scenes (override takes precedence in the check)
#   - don't need to replicate any vanilla scene-change logic
func _rtv_loader_loadscene_prelude() -> PackedStringArray:
	var p := PackedStringArray()
	p.append("\t# --- Metro mod loader: scene_paths registry prelude ---")
	p.append("\tvar _rtv_scene_entry: Dictionary = {}")
	p.append("\tif _rtv_override_scene_paths.has(scene):")
	p.append("\t\t_rtv_scene_entry = _rtv_override_scene_paths[scene]")
	p.append("\telif _rtv_mod_scene_paths.has(scene):")
	p.append("\t\t_rtv_scene_entry = _rtv_mod_scene_paths[scene]")
	p.append("\tif not _rtv_scene_entry.is_empty():")
	p.append("\t\tscenePath = _rtv_scene_entry.get(\"path\", \"\")")
	# Apply gameData flags if the mod specified them. Defaults favor a
	# generic non-shelter non-tutorial non-permadeath zone.
	p.append("\t\tgameData.menu = _rtv_scene_entry.get(\"menu\", false)")
	p.append("\t\tgameData.shelter = _rtv_scene_entry.get(\"shelter\", false)")
	p.append("\t\tgameData.permadeath = _rtv_scene_entry.get(\"permadeath\", false)")
	p.append("\t\tgameData.tutorial = _rtv_scene_entry.get(\"tutorial\", false)")
	p.append("\t# Fall through: vanilla if-elif won't match mod names; the tail")
	p.append("\t# runs change_scene_to_file(scenePath) with our path set above.")
	return p

func _rtv_inject_loader_registry(indent: String) -> String:
	# Loader.gd registry appendix. Adds the two mod-scene-path dicts and a
	# snapshot of the vanilla shelters list for integrity/revert.
	#
	# Note: _rtv_vanilla_shelters is captured at @onready time from the
	# shelters var (which the const->var rewrite left populated with the
	# vanilla list). The registry can diff shelters against this snapshot
	# to tell vanilla entries apart from mod additions.
	var I1 := indent
	return "\n\n# --- Metro mod loader: Loader registry state ---\n" \
		+ "var _rtv_mod_scene_paths: Dictionary = {}\n" \
		+ "var _rtv_override_scene_paths: Dictionary = {}\n" \
		+ "@onready var _rtv_vanilla_shelters: Array = shelters.duplicate()\n"

# AISpawner.gd transform: rewrite each `agent = <name>` inside _ready() so
# the assignment goes through the _rtv_resolve_ai_type helper (defined in
# the registry appendix). That helper reads Engine.get_meta(
# "_rtv_ai_overrides", {}) to decide between the vanilla scene and any
# mod-registered replacement for the current zone.
#
# Pattern matched: the exact 5-line block `if zone == Zone.Foo: agent = bar`
# -- we search for leading-whitespace + `agent =` and rewrite it. Only
# vanilla fields (bandit/guard/military/punisher) should trigger this; any
# other `agent = <literal>` outside those cases is left alone.
func _rtv_rewrite_aispawner_agent_assignments(source: String) -> String:
	var lines: PackedStringArray = source.split("\n")
	# Regex: optional indent, "agent" "=" then an identifier (the vanilla
	# preloaded name) with optional trailing comment/whitespace.
	var re := RegEx.new()
	re.compile('^(\\s*)agent\\s*=\\s*(\\w+)\\s*(#.*)?$')
	for i in lines.size():
		var line: String = lines[i]
		var m := re.search(line)
		if m == null:
			continue
		var indent := m.get_string(1)
		var name := m.get_string(2)
		# Leave numeric / keyword RHS alone (won't happen in vanilla but be safe).
		if name in ["true", "false", "null"]:
			continue
		lines[i] = "%sagent = _rtv_resolve_ai_type(zone, %s)" % [indent, name]
	return "\n".join(lines)

# FishPool._ready() prelude: appends mod-registered species to the local
# `species: Array[PackedScene]` var BEFORE vanilla's random-spawn loop
# picks from it. The registry stores a flat list in Engine meta; each
# FishPool instance filters by its own node name (or "all" as a wildcard).
#
# Dedupe: if a mod registers the same scene twice (or another mod does too),
# we don't re-append. Keeps the random-pick weight stable when the same
# scene would otherwise multiply.
func _rtv_fishpool_ready_prelude() -> PackedStringArray:
	var p := PackedStringArray()
	p.append("\t# --- Metro mod loader: fish_species registry prelude ---")
	p.append("\tvar _rtv_mod_fish: Array = Engine.get_meta(\"_rtv_fish_species\", [])")
	p.append("\tfor _rtv_fe in _rtv_mod_fish:")
	p.append("\t\tif _rtv_fe.pool_id == \"all\" or _rtv_fe.pool_id == name:")
	p.append("\t\t\tif not (_rtv_fe.scene in species):")
	p.append("\t\t\t\tspecies.append(_rtv_fe.scene)")
	return p

func _rtv_inject_aispawner_registry(indent: String) -> String:
	# AISpawner.gd registry appendix. Adds the resolver helper used by the
	# rewritten `agent = _rtv_resolve_ai_type(zone, vanilla)` assignments.
	# The override lookup goes through Engine metadata rather than node
	# instance state because AISpawner is a per-scene Node3D -- there are
	# multiple instances, and mods write to one shared registry that every
	# spawner reads on _ready.
	#
	# Zone keys are the string form of the Zone enum (e.g. "Area05"). The
	# resolver uses Zone.keys()[zone_int] to convert the enum value to its
	# declared name, matching what the registry stores.
	var I1 := indent
	var out := "\n\n# --- Metro mod loader: AI type override resolver ---\n"
	out += "func _rtv_resolve_ai_type(z: int, vanilla: Variant) -> Variant:\n"
	out += I1 + "var overrides: Dictionary = Engine.get_meta(\"_rtv_ai_overrides\", {})\n"
	out += I1 + "if overrides.is_empty():\n"
	out += I1 + I1 + "return vanilla\n"
	# Zone.keys() returns Array (untyped), so `:=` can't infer. Type
	# explicitly for strict-mode GDScript parsers.
	out += I1 + "var key: String = Zone.keys()[z]\n"
	out += I1 + "if overrides.has(key):\n"
	out += I1 + I1 + "return overrides[key]\n"
	out += I1 + "return vanilla\n"
	return out

# Rewrite bare `super(` / `super (` in a line to `super.<method>(`. Preserves
# the rest of the line verbatim. Skips `super.<something>(` (already explicit),
# and skips occurrences inside strings or comments by stopping at the first
# `#` or quote. Called per-line within a renamed method's body.
func _rewrite_bare_super(line: String, method_name: String) -> String:
	# Strip inline comment/string content before matching to avoid false hits.
	# Simple heuristic: search the part of the line before the first # (not in
	# a string). Strings with # are rare enough that we accept false negatives.
	var scan_end := line.length()
	var comment_idx := line.find("#")
	if comment_idx >= 0:
		scan_end = comment_idx
	var out := line
	var cursor := 0
	while cursor < scan_end:
		var idx := out.find("super", cursor)
		if idx < 0 or idx >= scan_end:
			break
		# Must be a whole word: preceding char can't be alphanumeric or _ or .
		# (dot would mean this is `x.super...` -- not a super call).
		if idx > 0:
			var prev := out[idx - 1]
			if prev == "." or prev == "_" or prev.to_upper() != prev.to_lower() \
					or (prev >= "0" and prev <= "9"):
				cursor = idx + 5
				continue
		# After "super", skip optional whitespace, then require "(".
		var after := idx + 5
		while after < out.length() and out[after] == " ":
			after += 1
		if after >= out.length() or out[after] != "(":
			cursor = idx + 5
			continue
		# Rewrite "super(" segment to "super.<method>(", keeping the rest.
		var before := out.substr(0, idx)
		var rest := out.substr(after)  # from "("
		out = before + "super." + method_name + rest
		# Advance past the replaced region and update scan_end for shift.
		var delta := 1 + method_name.length()  # added ".<name>"
		cursor = idx + 5 + delta + 1  # past "super.<name>("
		scan_end += delta
	return out

# Scan source for the first indented (non-empty, non-comment) line and return
# its leading whitespace as the indent unit. Falls back to tab when the file
# has no indentation (rare -- empty method bodies use `pass` which is typically
# indented). Used to make generated wrappers match the file's existing style,
# since GDScript forbids mixing tabs and spaces.
func _detect_indent_style(source: String) -> String:
	for line: String in source.split("\n"):
		if line.is_empty():
			continue
		var ch: String = line[0]
		if ch != "\t" and ch != " ":
			continue
		# Skip pure-whitespace lines.
		var stripped := line.strip_edges()
		if stripped.is_empty() or stripped.begins_with("#"):
			continue
		if ch == "\t":
			return "\t"
		var n := 0
		while n < line.length() and line[n] == " ":
			n += 1
		if n > 0:
			return " ".repeat(n)
	return "\t"

# Returns the run of leading tabs+spaces on a line.
func _rtv_leading_indent(line: String) -> String:
	var n := 0
	while n < line.length() and (line[n] == "\t" or line[n] == " "):
		n += 1
	return line.substr(0, n)

# Detects whether a stripped line is a block-opening header (ends with ':'
# and starts with a block keyword). Used by _rtv_autofix_legacy_syntax to
# decide where to inject a `pass` when a block body is missing.
func _rtv_is_block_header(trimmed: String) -> bool:
	if not trimmed.ends_with(":"):
		return false
	if trimmed == "else:":
		return true
	for kw in ["if ", "elif ", "for ", "while ", "match ", "func ", "class "]:
		if trimmed.begins_with(kw):
			return true
	if trimmed.begins_with("static func "):
		return true
	return false

# MAXIMUM-COMPAT PASS: rewrite sloppy / Godot-3-era GDScript patterns that
# Godot 4's parser rejects outright. Runs before our dispatch-wrapper
# pipeline so every downstream step sees parser-acceptable source.
#
# Handles:
#   (1) Bodyless block headers (`if X:` with no indented body) -- the
#       dominant failure mode in real-world mods (Gotcha #5). Godot 4's
#       parser raises "Expected indented block after 'X' block". We scan
#       forward from each block header; if the next non-blank non-comment
#       line is NOT indented deeper than the header, we inject a `pass`
#       at header_indent + indent_unit. Semantically safe: the empty
#       block was already a no-op in the author's intent (or a latent
#       bug -- we preserve original semantics either way).
#   (2) `tool` first-line keyword -> `@tool` annotation (Godot 4 moved
#       it to the annotation namespace).
#   (3) `onready var` -> `@onready var` (same annotation move).
#   (4) `export var X = Y` (no type paren) -> `@export var X = Y`. Skips
#       `export(Type) var ...` -- that needs type-annotation transform
#       (risky, can break strict-typed references; leave for future
#       pass if a real mod trips it).
#
# Source must be LF-normalized by the caller.
func _rtv_autofix_legacy_syntax(source: String) -> Dictionary:
	var lines: PackedStringArray = source.split("\n")
	var out: PackedStringArray = PackedStringArray()
	var indent_unit := _detect_indent_style(source)
	var fix_bodyless := 0
	var fix_tool := 0
	var fix_onready := 0
	var fix_export := 0
	var fix_base := 0

	# Pre-pass: track which method a line belongs to, so `base(...)` inside
	# a method body can be rewritten to `super.<method>(...)`. Godot 3's
	# `base()` is no longer valid in Godot 4; parser fails with
	# `Function "base()" not found in base self` and the failure cascades
	# through chain-via-extends. Single autofix converts the common case.
	var current_method: String = ""
	var method_line_indent: String = ""

	for i in lines.size():
		var line: String = lines[i]

		# Track enclosing method for `base()` rewrite. Top-level line (no
		# indent) with `func <name>(` opens a method; top-level line without
		# that closes the prior method's scope.
		var lead := _rtv_leading_indent(line)
		if lead.is_empty() and not line.strip_edges().is_empty():
			var stripped_top := line.strip_edges()
			if stripped_top.begins_with("func "):
				var open_paren := stripped_top.find("(")
				if open_paren > 5:
					current_method = stripped_top.substr(5, open_paren - 5).strip_edges()
					method_line_indent = ""
			elif stripped_top.begins_with("static func ") or stripped_top.begins_with("@"):
				# Skip static funcs and annotations (they don't open a "self"
				# method where base() would resolve).
				current_method = ""
			else:
				current_method = ""

		# Rewrite `base(` / `base (` to `super.<method>(` when inside a
		# method body. Don't touch literal `.base(` calls (already qualified).
		if not current_method.is_empty() and line.find("base(") >= 0:
			var rewritten := _rtv_rewrite_bare_base(line, current_method)
			if rewritten != line:
				line = rewritten
				fix_base += 1

		# Annotation migrations (line-local rewrites).
		lead = _rtv_leading_indent(line)
		var body_text := line.substr(lead.length())
		if i == 0 and body_text.strip_edges() == "tool":
			line = lead + "@tool"
			fix_tool += 1
		elif body_text.begins_with("onready var "):
			line = lead + "@onready var " + body_text.substr(12)  # len("onready var ")
			fix_onready += 1
		elif body_text.begins_with("export var "):
			line = lead + "@export var " + body_text.substr(11)  # len("export var ")
			fix_export += 1

		out.append(line)

		# Bodyless-block detection on post-annotation line.
		var trimmed := line.strip_edges()
		if not _rtv_is_block_header(trimmed):
			continue
		var header_indent := _rtv_leading_indent(line)
		var j := i + 1
		var has_body := false
		while j < lines.size():
			var next_line: String = lines[j]
			var next_trimmed := next_line.strip_edges()
			if next_trimmed.is_empty():
				j += 1
				continue
			if next_trimmed.begins_with("#"):
				j += 1
				continue
			var next_indent := _rtv_leading_indent(next_line)
			if next_indent.length() > header_indent.length() \
					and next_indent.begins_with(header_indent):
				has_body = true
			break
		if not has_body:
			out.append(header_indent + indent_unit + "pass  # [Autofix] injected -- original block had no body")
			fix_bodyless += 1

	return {
		"source": "\n".join(out),
		"bodyless": fix_bodyless,
		"tool": fix_tool,
		"onready": fix_onready,
		"export": fix_export,
		"base": fix_base,
	}

# Rewrite bare `base(args)` or `base (args)` in a line to `super.<method>(args)`.
# Skips `self.base(`, `<ident>.base(`, etc. -- only rewrites standalone `base(`
# (possibly preceded by `=`, `+`, `(`, `[`, `,`, or whitespace). Per-line so
# strings/comments past a `#` stay unchanged.
#
# Chained-call form `base(...).<chained>(<args>)`: Godot 3's `base()` returned
# the parent instance, so mods wrote `base().Foo(x)` to call parent's Foo.
# A plain substitution ("super.<enclosing>") would yield
# "super.<enclosing>().Foo(x)" -- syntactically valid but chained onto the
# void return of enclosing's super call, which is wrong (parent's Foo never
# runs with the passed args, and the chained .Foo(x) fires on null). We
# detect the chain and rewrite to "super.<chained>(<args>)", which is how
# Godot 4 expresses "call parent's <chained> method" directly.
func _rtv_rewrite_bare_base(line: String, method_name: String) -> String:
	var comment_start := line.find("#")
	var head: String = line if comment_start < 0 else line.substr(0, comment_start)
	var tail: String = "" if comment_start < 0 else line.substr(comment_start)
	# Walk from left to right looking for the word `base` not preceded by a
	# letter/digit/underscore/dot (i.e. not part of an identifier or already
	# qualified). Replace with `super.<method>`.
	var i := 0
	var rewritten := ""
	while i < head.length():
		if i + 4 <= head.length() and head.substr(i, 4) == "base":
			# Check preceding character (word-boundary).
			var prev_ok := true
			if i > 0:
				var pc := head[i - 1]
				if pc >= "a" and pc <= "z":
					prev_ok = false
				elif pc >= "A" and pc <= "Z":
					prev_ok = false
				elif pc >= "0" and pc <= "9":
					prev_ok = false
				elif pc == "_" or pc == ".":
					prev_ok = false
			# Check trailing char is `(` or whitespace-then-`(`.
			var j := i + 4
			while j < head.length() and (head[j] == " " or head[j] == "\t"):
				j += 1
			if prev_ok and j < head.length() and head[j] == "(":
				# Chained-call detection: find matching `)` for base(),
				# peek past it for `.<ident>(`. If present, rewrite the
				# entire `base().<ident>` region to `super.<ident>`.
				# Only empty-parens base() gets the chain absorb -- with
				# args, the arg is meaningful (call parent's enclosing
				# method with it) and must be preserved. `base(arg).foo(x)`
				# falls through to the plain `super.<enclosing>(arg)` path,
				# which yields `super.<enclosing>(arg).foo(x)` -- still
				# semantically correct (Godot 4's super() returns the
				# parent method's value so chaining works).
				var close_idx := _rtv_find_matching_paren(head, j)
				if close_idx == j + 1 and close_idx > 0:
					var k := close_idx + 1
					if k < head.length() and head[k] == ".":
						var name_start := k + 1
						var name_end := name_start
						while name_end < head.length() \
								and _rtv_is_ident_char(head[name_end]):
							name_end += 1
						if name_end > name_start \
								and name_end < head.length() \
								and head[name_end] == "(":
							var chained_name: String = head.substr(name_start, name_end - name_start)
							rewritten += "super." + chained_name
							i = name_end  # advance to chained "("
							continue
				# Plain base(args) -> super.<enclosing>(args).
				rewritten += "super." + method_name
				i += 4
				continue
		rewritten += head[i]
		i += 1
	return rewritten + tail

# Scans from an open paren at open_idx and returns the index of the matching
# close paren, or -1 if not found. Tracks double-quoted strings so parens
# inside "..." don't affect depth. Used by _rtv_rewrite_bare_base to span
# `base(...)` before checking for a chained `.<method>(...)` call.
func _rtv_find_matching_paren(s: String, open_idx: int) -> int:
	if open_idx >= s.length() or s[open_idx] != "(":
		return -1
	var depth := 0
	var in_dq := false   # inside "..."
	var in_sq := false   # inside '...'
	var i := open_idx
	while i < s.length():
		var c := s[i]
		if in_dq:
			if c == "\\" and i + 1 < s.length():
				i += 2
				continue
			if c == "\"":
				in_dq = false
		elif in_sq:
			if c == "\\" and i + 1 < s.length():
				i += 2
				continue
			if c == "'":
				in_sq = false
		else:
			if c == "\"":
				in_dq = true
			elif c == "'":
				in_sq = true
			elif c == "(":
				depth += 1
			elif c == ")":
				depth -= 1
				if depth == 0:
					return i
		i += 1
	return -1

# True for identifier-continuation chars (ASCII [A-Za-z0-9_]). Non-ASCII
# identifiers aren't legal in GDScript so ASCII coverage is sufficient.
func _rtv_is_ident_char(c: String) -> bool:
	if c == "_":
		return true
	if c >= "a" and c <= "z":
		return true
	if c >= "A" and c <= "Z":
		return true
	if c >= "0" and c <= "9":
		return true
	return false

# Comment out `<var>.reload()` lines inside mod helper functions that also
# call `take_over_path`. Rationale: mod override helpers (RTVCoop's _override,
# CustomItemTest's override_script, etc.) often do:
#   var script = load(modPath); script.reload(); script.take_over_path(gamePath)
# The reload() call is a no-op unless source changed between load and call.
# Our hook pack owns the mod subclass source, so reload is always redundant.
# Worse: if the mod had already set_script(script) on a live node earlier
# (RTVCoop does this for /root/Loader), reload fails at gdscript.cpp:756 with
# "Cannot reload script while instances exist." take_over_path succeeds right
# after, so the override still works, but the error spams stderr each launch.
# Stripping the reload eliminates the error with no behavior change.
#
# Scope: only strips lines where the stripped-edges content ends with
# ".reload()" AND the enclosing function body contains ".take_over_path(".
# Comments out with a "# modloader stripped" note so the change is visible
# if a mod author inspects the rewritten source.
#
# Source must be LF-normalized by the caller.
func _rtv_strip_helper_reload(source: String) -> Dictionary:
	var lines: PackedStringArray = source.split("\n")
	var out: PackedStringArray = PackedStringArray()
	var stripped: int = 0
	var i: int = 0
	while i < lines.size():
		var line: String = lines[i]
		if not line.begins_with("func "):
			out.append(line)
			i += 1
			continue
		# Collect function body: header + subsequent indented lines.
		var start: int = i
		var end: int = i + 1
		while end < lines.size():
			var bl: String = lines[end]
			if bl.length() > 0 and not (bl[0] == "\t" or bl[0] == " "):
				break
			end += 1
		# Does this function body call take_over_path anywhere?
		var has_tov: bool = false
		for k in range(start, end):
			if ".take_over_path(" in lines[k]:
				has_tov = true
				break
		if has_tov:
			for k in range(start, end):
				var bl: String = lines[k]
				var trimmed: String = bl.strip_edges()
				# Match bare `<ident>.reload()` statement lines (nothing else
				# on the line). Preserves the original indent and leaves a
				# comment trail.
				if trimmed.ends_with(".reload()") and not trimmed.begins_with("#"):
					var before_paren: int = trimmed.find(".reload()")
					var ident_part: String = trimmed.substr(0, before_paren)
					var is_bare_call: bool = true
					for c in ident_part:
						if not (c == "_" or c == "." or (c >= "a" and c <= "z") \
								or (c >= "A" and c <= "Z") or (c >= "0" and c <= "9")):
							is_bare_call = false
							break
					if is_bare_call:
						var indent_len: int = 0
						while indent_len < bl.length() and (bl[indent_len] == "\t" or bl[indent_len] == " "):
							indent_len += 1
						var indent: String = bl.substr(0, indent_len)
						out.append(indent + "# " + bl.substr(indent_len) + "  # modloader: stripped (redundant + fires Cannot-reload error if instance exists)")
						stripped += 1
						continue
				out.append(bl)
		else:
			for k in range(start, end):
				out.append(lines[k])
		i = end
	return {"source": "\n".join(out), "stripped": stripped}

# Produces ONE inline dispatch wrapper that calls _rtv_vanilla_<name>(...).
# The wrapper is appended to the rewritten vanilla source, co-existing with
# the renamed body in the same class -- no inheritance chain, so no
# _rtv_ready_done flag is needed. `await` is prepended when the vanilla
# method is a coroutine so the wrapper doesn't return before the body
# resolves.

func _rtv_dispatch_inline_src(fe: Dictionary, prefix: String, indent: String = "\t") -> String:
	var method_name: String = fe["name"]
	var params: String = fe["params"]
	var param_names_str: String = ", ".join(fe["param_names"])
	var hook_base: String = "%s-%s" % [prefix, method_name.to_lower()]
	var vanilla_call: String = "_rtv_vanilla_%s(%s)" % [method_name, param_names_str]
	var args_array: String = "[]" if param_names_str.is_empty() else "[%s]" % param_names_str
	var is_coro: bool = bool(fe["is_coroutine"])
	var is_engine_void: bool = method_name in RTV_ENGINE_VOID_METHODS
	var is_void: bool = is_engine_void or not bool(fe["has_return_value"])
	var aw: String = "await " if is_coro else ""

	# Preserve the return type annotation so callers can still type-infer
	# from wrapper returns (e.g. `var chargeLen = self.ChargeShot()` when
	# ChargeShot is `-> int`). Without the annotation, GDScript's strict
	# parser infers Variant and rejects untyped var decls in chained mod
	# subclasses, cascading parse failures through the extends chain.
	var return_annot: String = ""
	var rt = fe.get("return_type")
	if rt != null and not (rt as String).is_empty():
		return_annot = " -> " + (rt as String)
	var sig: String = "func %s()%s:" % [method_name, return_annot] if params.is_empty() \
			else "func %s(%s)%s:" % [method_name, params, return_annot]

	# Indent levels. GDScript requires consistent tabs-or-spaces per file;
	# IXP uses 4-space, vanilla RTV uses tabs. Caller passes the source's
	# detected indent so the emitted wrapper matches.
	var I1: String = indent
	var I2: String = indent + indent
	var I3: String = indent + indent + indent

	var out := ""
	# Step C re-entry guard: when a mod's rewritten wrapper fires and its body
	# calls super() into vanilla's rewritten wrapper, the nested wrapper would
	# dispatch again. Guard checks _lib._wrapper_active for this hook_base and
	# if already set, skips ALL dispatch + replace lookup and just runs the
	# vanilla body. One dispatch per logical call regardless of chain depth.
	if not is_void:
		out += "%s\n" % sig
		# Engine.get_meta with a Nil default still prints an error when the key
		# is absent (Godot 4.6 Object::get_meta at object.cpp:1155). Guard with
		# has_meta so early-boot wrappers that fire before _register_rtv_modlib_meta
		# (e.g. the 16 preempted class_name scripts) don't flood the log.
		out += "%sif not Engine.has_meta(\"RTVModLib\"):\n" % I1
		out += "%sreturn %s%s\n" % [I2, aw, vanilla_call]
		out += "%svar _lib = Engine.get_meta(\"RTVModLib\")\n" % I1
		# Global short-circuit: if no mod has called hook() this session, the
		# whole dispatch pipeline is dead weight. Single bool check skips
		# ~10 dict ops + meta/prop/fn calls. Matches godot-mod-loader.
		out += "%sif not _lib._any_mod_hooked:\n" % I1
		out += "%sreturn %s%s\n" % [I2, aw, vanilla_call]
		# Dev-mode-only per-method dispatch counter. Gated by a property
		# read so non-dev users pay ~1 branch per call; dev users see a
		# top-15 summary at 30s that pinpoints runaway method calls (e.g.
		# a mod's _ready firing 3000x -- typical cause of connect-already-
		# connected error spam). Counts only hook dispatches (after the
		# _any_mod_hooked short-circuit), not every wrapped call, so the
		# total stays meaningful even with hundreds of wrapped methods.
		out += "%sif _lib._developer_mode:\n" % I1
		out += "%s_lib._dispatch_counts[\"%s\"] = int(_lib._dispatch_counts.get(\"%s\", 0)) + 1\n" % [I2, hook_base, hook_base]
		out += "%sif _lib._wrapper_active.has(\"%s\"):\n" % [I1, hook_base]
		out += "%sreturn %s%s\n" % [I2, aw, vanilla_call]
		out += "%s_lib._wrapper_active[\"%s\"] = true\n" % [I1, hook_base]
		# Save prior _caller so nested-wrapper clobbering inside the
		# vanilla body (or replace hook) doesn't leak stale values to
		# whoever called us. Re-set _caller before the post-dispatch so
		# our post hooks see the correct caller even after nested
		# wrappers fired during the body.
		out += "%svar _rtv_prev_caller = _lib._caller\n" % I1
		out += "%s_lib._caller = self\n" % I1
		out += "%s_lib._dispatch(\"%s-pre\", %s)\n" % [I1, hook_base, args_array]
		out += "%svar _result\n" % I1
		out += "%svar _repl = _lib._get_hooks(\"%s\")\n" % [I1, hook_base]
		out += "%sif _repl.size() > 0:\n" % I1
		out += "%svar _prev_skip = _lib._skip_super\n" % I2
		out += "%s_lib._skip_super = false\n" % I2
		out += "%svar _replret = _repl[0].callv(%s)\n" % [I2, args_array]
		out += "%svar _did_skip = _lib._skip_super\n" % I2
		out += "%s_lib._skip_super = _prev_skip\n" % I2
		out += "%sif _did_skip:\n" % I2
		out += "%s_result = _replret\n" % I3
		out += "%selse:\n" % I2
		out += "%s_result = %s%s\n" % [I3, aw, vanilla_call]
		out += "%selse:\n" % I1
		out += "%s_result = %s%s\n" % [I2, aw, vanilla_call]
		out += "%s_lib._caller = self\n" % I1
		out += "%s_lib._dispatch(\"%s-post\", %s)\n" % [I1, hook_base, args_array]
		out += "%s_lib._dispatch_deferred(\"%s-callback\", %s)\n" % [I1, hook_base, args_array]
		out += "%s_lib._wrapper_active.erase(\"%s\")\n" % [I1, hook_base]
		out += "%s_lib._caller = _rtv_prev_caller\n" % I1
		out += "%sreturn _result\n" % I1
	else:
		out += "%s\n" % sig
		# Same has_meta guard as non-void branch above.
		out += "%sif not Engine.has_meta(\"RTVModLib\"):\n" % I1
		out += "%s%s%s\n" % [I2, aw, vanilla_call]
		out += "%sreturn\n" % I2
		out += "%svar _lib = Engine.get_meta(\"RTVModLib\")\n" % I1
		# Global short-circuit: see non-void branch above.
		out += "%sif not _lib._any_mod_hooked:\n" % I1
		out += "%s%s%s\n" % [I2, aw, vanilla_call]
		out += "%sreturn\n" % I2
		# Dev-mode-only per-method dispatch counter (see non-void branch).
		out += "%sif _lib._developer_mode:\n" % I1
		out += "%s_lib._dispatch_counts[\"%s\"] = int(_lib._dispatch_counts.get(\"%s\", 0)) + 1\n" % [I2, hook_base, hook_base]
		out += "%sif _lib._wrapper_active.has(\"%s\"):\n" % [I1, hook_base]
		out += "%s%s%s\n" % [I2, aw, vanilla_call]
		out += "%sreturn\n" % I2
		out += "%s_lib._wrapper_active[\"%s\"] = true\n" % [I1, hook_base]
		# See non-void branch above for rationale on save/re-set/restore
		# of _caller. Same pattern, applied to the void-return template.
		out += "%svar _rtv_prev_caller = _lib._caller\n" % I1
		out += "%s_lib._caller = self\n" % I1
		out += "%s_lib._dispatch(\"%s-pre\", %s)\n" % [I1, hook_base, args_array]
		out += "%svar _repl = _lib._get_hooks(\"%s\")\n" % [I1, hook_base]
		out += "%sif _repl.size() > 0:\n" % I1
		out += "%svar _prev_skip = _lib._skip_super\n" % I2
		out += "%s_lib._skip_super = false\n" % I2
		out += "%s_repl[0].callv(%s)\n" % [I2, args_array]
		out += "%svar _did_skip = _lib._skip_super\n" % I2
		out += "%s_lib._skip_super = _prev_skip\n" % I2
		out += "%sif !_did_skip:\n" % I2
		out += "%s%s%s\n" % [I3, aw, vanilla_call]
		out += "%selse:\n" % I1
		out += "%s%s%s\n" % [I2, aw, vanilla_call]
		out += "%s_lib._caller = self\n" % I1
		out += "%s_lib._dispatch(\"%s-post\", %s)\n" % [I1, hook_base, args_array]
		out += "%s_lib._dispatch_deferred(\"%s-callback\", %s)\n" % [I1, hook_base, args_array]
		out += "%s_lib._wrapper_active.erase(\"%s\")\n" % [I1, hook_base]
		out += "%s_lib._caller = _rtv_prev_caller\n" % I1
	return out

# --- Script enumeration -----------------------------------------------------
# DirAccess.get_files_at() returns at most 1 entry on res://Scripts/ in
# Godot 4.6 -- it doesn't enumerate PCK contents. Parse the PCK file table
# directly instead.

# Returns res://Scripts/*.gd paths found in the game's PCK, or [] on failure
# (encrypted pack, embedded pack, new format, missing file). Callers fall
# back to _class_name_to_path when empty.

## ----- hook_pack.gd -----
## Orchestrates the opt-in rewrite pipeline. Vanilla scripts declared via
## [hooks] in mod.txt or via runtime .hook(...) calls get their declared
## methods renamed to _rtv_vanilla_<name> with dispatch wrappers appended.
## Packed into modloader_hooks.zip with the three-entry recipe per script
## (.gd + .gd.remap + empty .gdc), mounted at res://, and force-activated
## via source_code+reload / CACHE_MODE_IGNORE+take_over_path fallback so
## game code compiles against the wrapped source.
##
## v3.0.1 cutover: zero declarations -> zero generation. No more inference
## from extends/take_over_path; no more mod-source rewriting (old Step C).
## A modlist that declares nothing behaves byte-identical to v2.1.0.

# Scripts that carry rewriter-injected registry helpers. These MUST be
# force-activated (bypass the scene-preload deferral) so the injected fields
# are live on autoload instances when mods call lib.register(). Keep in
# sync with the match statement in _rtv_registry_injection(). Enrollment
# into the wrap surface now REQUIRES at least one mod to declare [registry]
# in its mod.txt -- see _generate_hook_pack's wrap-surface build.
const REGISTRY_TARGETS: Array[String] = [
	"Database.gd",
	"Loader.gd",
	"AISpawner.gd",
	"FishPool.gd",
]

func _is_registry_target(filename: String) -> bool:
	return filename in REGISTRY_TARGETS

# Convert a list of wrapped filenames ("Controller.gd") into the full
# res://Scripts/ paths persisted in pass_state. boot.gd's next-session
# _mount_previous_session uses these to preempt ONLY scripts this
# session wrapped, instead of a hardcoded pinned list.
func _wrapped_paths_packed(filenames: Array[String]) -> PackedStringArray:
	var out := PackedStringArray()
	for fn in filenames:
		out.append("res://Scripts/" + fn)
	return out

# Build the framework pack: enumerate res://Scripts/*.gd, detokenize each via
# _read_vanilla_source, parse + generate wrappers, zip them, mount the zip.
#
# The zip mounts at res://modloader_hooks/ and wrappers load from there. NOT
# from user:// -- Godot 4.6's extends-chain resolution for class_name parents
# breaks for scripts loaded from user://, which shows up as broken super()
# dispatch on class_name-wrapped scripts.
func _generate_hook_pack(defer_activation: bool = false) -> String:
	# Wipe prior-run artifacts even when deferring. Cheap + keeps mode-switches
	# clean.
	var hook_dir := ProjectSettings.globalize_path(HOOK_PACK_DIR)
	DirAccess.make_dir_recursive_absolute(hook_dir)
	# Per-call unique filename. Each _generate_hook_pack invocation writes a
	# new file at a new path so load_resource_pack mounts fresh (no path-dedup
	# stale offsets). Old files get cleaned up at next static-init.
	var pack_zip_rel := HOOK_PACK_DIR.path_join("%s_%d.zip" % [HOOK_PACK_PREFIX, Time.get_ticks_msec()])
	# Do NOT delete the old hook pack zip here. If a previous session mounted
	# it via ProjectSettings.load_resource_pack (_mount_previous_session), the
	# VFS still holds a file handle to the zip. Deleting the file on disk
	# invalidates that handle, causing every VFS read that routes through the
	# hook pack overlay to fail at core/io/file_access_zip.cpp:137 with "Cannot
	# open file". In practice that breaks any load() of a path present in the
	# overlay -- including rewritten vanilla scripts and sibling-rewritten mod
	# autoload scripts. ZIPPacker.open below opens for write and atomically
	# replaces the file on save, so leaving the old file in place is safe.
	var dir := DirAccess.open(hook_dir)
	if dir != null:
		dir.list_dir_begin()
		while true:
			var fname := dir.get_next()
			if fname == "":
				break
			if fname.begins_with("Framework") and fname.ends_with(".gd"):
				DirAccess.remove_absolute(hook_dir.path_join(fname))
		dir.list_dir_end()

	# STABILITY canary B: verify the GDSC tokenizer format is one we support
	# before any rewrite work. One loud, actionable message beats a flood of
	# "Empty detokenized source" warnings, one per hookable script.
	var tok_version := _probe_gdsc_version()
	if tok_version != -1 and tok_version != 100 and tok_version != 101:
		_log_critical("[STABILITY] Unsupported GDSC tokenizer v%d on Godot %s. This ModLoader supports v100 (Godot 4.0-4.4) and v101 (Godot 4.5-4.6). Hook pack generation disabled -- script hooks will not fire. See README for supported Godot versions." \
				% [tok_version, Engine.get_version_info().get("string", "unknown")])
		return ""
	if tok_version != -1:
		_log_info("[STABILITY] Detokenizer compatible: GDSC v%d on Godot %s" \
				% [tok_version, Engine.get_version_info().get("string", "unknown")])

	if _loaded_mod_ids.is_empty():
		return ""

	# OPT-IN GATE (v3.0.1): user mods run against unmodified vanilla unless
	# at least one mod declares [hooks] / .hook() / [registry]. Prior
	# versions' inference triggers (extends_paths, take_over_literal_paths,
	# pinned-always-wrap, REGISTRY_TARGETS-unconditional) all flowed
	# through this code path; the opt-in check below is the single guard
	# protecting legacy mods.
	#
	# Capture the user-declared-empty state BEFORE _seed_core_hooks runs,
	# since the seed adds a core-owned entry (Menu.gd _ready for the
	# main-menu Mods button) that would otherwise mask the empty check.
	# Legacy-mode users get a minimal pack containing only the core wrap;
	# the wrap only affects main-menu UI injection, not anything user mods
	# would hook against, so it's effectively v2.1.0-equivalent from a
	# mod-author perspective.
	var user_wrap_empty: bool = _hooked_methods.is_empty() and not _any_mod_declared_registry

	# Seed core-owned hook declarations (e.g. Menu.gd _ready for the main-menu
	# Mods button). Done after the no-mods short-circuit above so pure-vanilla
	# sessions generate no pack, but before the legacy-mode log below so the
	# pack includes the core wrap when at least one mod is loaded.
	_seed_core_hooks()

	if user_wrap_empty:
		_log_info("[RTVCodegen] No user opt-in declarations ([hooks] / .hook() / [registry]) -- user mods' vanilla targets run unmodified (v2.1.0-equivalent). Pack contains core hooks only.")

	var script_paths: Array[String] = _enumerate_game_scripts()
	if script_paths.is_empty():
		_log_warning("[RTVCodegen] script enumeration failed -- falling back to class_name list (%d)" % _class_name_to_path.size())
		for path: String in _class_name_to_path.values():
			script_paths.append(path)
	# Opt-in wrap surface. A vanilla script enters needed_paths ONLY if:
	#   1. at least one mod declared it in [hooks] in its mod.txt, OR
	#   2. at least one mod called .hook("<stem>-<method>", ...) in source, OR
	#   3. it's a REGISTRY_TARGET (Database.gd) and at least one mod has
	#      a [registry] section in its mod.txt.
	#
	# No extends-based inference, no take_over_path-based inference, no
	# pinned-always-wrap. Mods that extend a vanilla script without declaring
	# a hook get Godot's native compile (no dispatch overhead, no rewrite
	# of their own source). When ANOTHER mod declares a hook on the same
	# path, Godot's extends resolution still threads their mod through the
	# wrapped vanilla naturally -- no mod-source rewrite required.
	# Build a set of enumerated vanilla paths for declared-path validation.
	# A mod declaring a path that doesn't correspond to any vanilla script
	# (typo, bare-filename normalization mismatch in add_hook, path from a
	# game version that renamed the file) would otherwise silently no-op
	# at rewrite time. Warn once at enrollment so mod authors can fix.
	var vanilla_path_set: Dictionary = {}
	for sp: String in script_paths:
		vanilla_path_set[sp] = true
	var needed_paths: Dictionary = {}
	# Per-path per-method mask. Keyed by res_path -> Dictionary[method_name, true].
	# Populated from _hooked_methods (static [hooks] section + scanned .hook()).
	# Empty inner dict = all methods (fallback used only for REGISTRY_TARGETS
	# where the wrap contract is whole-script, not per-method).
	var hook_mask: Dictionary = {}
	for path: String in _hooked_methods:
		# Normalize + validate the declared path. Reject anything outside
		# res://Scripts/ -- hooks are only meaningful on vanilla game scripts.
		# A bad path here is a silent failure mode for mod authors; surface it.
		if not path.begins_with("res://Scripts/"):
			_log_warning("[RTVCodegen] [hooks] declared for non-vanilla path '%s' -- only res://Scripts/*.gd is hookable; entry ignored" % path)
			continue
		if not vanilla_path_set.has(path):
			_log_warning("[RTVCodegen] [hooks] declared path '%s' doesn't match any vanilla script -- check for typos or stale paths; entry will no-op" % path)
			# Still enroll it so the path_mask iteration below logs per-method
			# "declared method not found" diagnostics against the empty set --
			# no wrapper will be generated but the logging chain stays consistent.
		needed_paths[path] = true
		hook_mask[path] = (_hooked_methods[path] as Dictionary).duplicate()
	# Gate Database.gd on explicit [registry] opt-in. REGISTRY_TARGETS are
	# wrapped whole-script (no method mask) so the rewriter can inject the
	# _get()/_rtv_mod_scenes/_rtv_override_scenes helpers; per-method mask
	# would defeat that injection.
	if _any_mod_declared_registry:
		for rt_filename in REGISTRY_TARGETS:
			var rt_path := "res://Scripts/" + rt_filename
			needed_paths[rt_path] = true
			hook_mask.erase(rt_path)  # whole-script wrap, no mask
	_log_info("[RTVCodegen] Wrap surface: %d vanilla script(s) declared (%d via [hooks]/.hook(), %d via [registry])" % [
		needed_paths.size(),
		_hooked_methods.size(),
		REGISTRY_TARGETS.size() if _any_mod_declared_registry else 0,
	])
	# Skip-list breakdown -- gives the README an evidence trail for "we wrap N
	# scripts, skip M". The actual rewritten count is logged below by the
	# "Generated N rewritten" line; this just records the static skip-list sizes.
	_log_info("[RTVCodegen] Skip lists: %d runtime-sensitive, %d data, %d serialized (total %d skipped from rewrite)" % [
		RTV_SKIP_LIST.size(),
		RTV_RESOURCE_DATA_SKIP.size(),
		RTV_RESOURCE_SERIALIZED_SKIP.size(),
		RTV_SKIP_LIST.size() + RTV_RESOURCE_DATA_SKIP.size() + RTV_RESOURCE_SERIALIZED_SKIP.size(),
	])

	# Pre-read mod sibling scripts BEFORE opening ZIPPacker on the hook pack.
	# When the hook pack from a previous session is mounted via
	# ProjectSettings.load_resource_pack, Godot holds a FileAccessZIP handle
	# to the file. ZIPPacker.open below opens the same file for writing,
	# which on Windows invalidates that read handle once the in-progress
	# zip is modified. Any VFS read that routes through the hook pack
	# overlay AFTER zp.open then fails with "Cannot open file" at
	# file_access_zip.cpp:137, breaking mod autoload compilation.
	# Reading here, while the old mount is still valid, keeps the sibling
	# source snapshot safe. Writes happen later via zp.start_file.
	#
	# Emit EVERY iterated sibling into the new hook pack, not just ones
	# autofix changed. Rationale: if a previous session's hook pack already
	# owns a sibling path and we skip emitting it because autofix is
	# idempotent, the new hook pack on disk won't contain that path. Godot's
	# load_resource_pack(replace_files=true) gives the newest mount
	# precedence, and VFS resolves paths against whichever mount claims
	# them. If the new mount doesn't claim a path the old mount did, VFS
	# can end up routing through the old (now stale-indexed) mount and
	# fail at file_access_zip.cpp:141 (the unzGoToFilePos failure, distinct
	# from :137's "Cannot open file"). Emitting unconditionally keeps the
	# new pack a superset of the old for every sibling path we read, so
	# there are no holes for the stale mount to answer.
	# Read directly from each mod archive via ZIPReader rather than via
	# VFS. Going through FileAccess/ResourceLoader would walk every
	# mounted overlay, and a previous-session hook pack is still mounted
	# at this point, its stale copy of these same paths would win and
	# we'd re-emit that stale snapshot into the new hook pack, preventing
	# mod updates from ever taking effect between sessions. The archive
	# path is the original on-disk .zip/.vmz (or cached .zip for .vmz);
	# it always reflects the current mod version.
	var sibling_fixes: Dictionary = {}  # p -> {fixed_src, af, reload_stripped, changed}
	for archive_file: String in _archive_file_sets:
		var paths_set: Dictionary = _archive_file_sets[archive_file]
		var zr: ZIPReader = null
		# Resolve the archive to a readable zip path. Loose folder mods
		# and already-zipped .zip/.pck archives use archive_file as-is;
		# .vmz mods use a cached .zip sibling the loader materialized
		# during discovery.
		var zip_path := archive_file
		var ext := archive_file.get_extension().to_lower()
		if ext == "vmz":
			var cache_dir := ProjectSettings.globalize_path(TMP_DIR)
			zip_path = cache_dir.path_join(archive_file.get_file().get_basename() + ".zip")
		elif ext == "folder":
			var folder_zip := ProjectSettings.globalize_path(TMP_DIR).path_join(archive_file.get_file() + "_dev.zip")
			zip_path = folder_zip
		if FileAccess.file_exists(zip_path):
			zr = ZIPReader.new()
			if zr.open(zip_path) != OK:
				zr = null
		for p: String in paths_set:
			if not p.ends_with(".gd"):
				continue
			if p.begins_with("res://Scripts/"):
				continue  # vanilla, handled in the main rewrite loop
			if zr == null:
				# Last-resort fallback: VFS read. Accepts the stale-overlay
				# risk but keeps mods without a resolvable zip working.
				if not ResourceLoader.exists(p):
					continue
				var raw_vfs := FileAccess.get_file_as_string(p)
				if raw_vfs.is_empty():
					continue
				var norm_vfs := raw_vfs.replace("\r\n", "\n").replace("\r", "\n")
				var af_vfs := _rtv_autofix_legacy_syntax(norm_vfs)
				var fixed_vfs: String = af_vfs["source"]
				var rl_vfs := _rtv_strip_helper_reload(fixed_vfs)
				fixed_vfs = rl_vfs["source"]
				sibling_fixes[p] = {
					"fixed_src": fixed_vfs,
					"af": af_vfs,
					"reload_stripped": int(rl_vfs["stripped"]),
					"changed": fixed_vfs != norm_vfs,
				}
				continue
			# Strip the res:// prefix to get the zip-internal entry name.
			var entry := p.trim_prefix("res://")
			if not (entry in zr.get_files()):
				continue
			var bytes := zr.read_file(entry)
			if bytes.is_empty():
				continue
			var raw := bytes.get_string_from_utf8()
			if raw.is_empty():
				continue
			var norm := raw.replace("\r\n", "\n").replace("\r", "\n")
			var af := _rtv_autofix_legacy_syntax(norm)
			var fixed_src: String = af["source"]
			# Strip redundant `.reload()` calls in helpers that also do
			# take_over_path. Eliminates RTVCoop's Cannot-reload spam.
			var rl := _rtv_strip_helper_reload(fixed_src)
			fixed_src = rl["source"]
			sibling_fixes[p] = {
				"fixed_src": fixed_src,
				"af": af,
				"reload_stripped": int(rl["stripped"]),
				"changed": fixed_src != norm,
			}
		if zr != null:
			zr.close()

	var zip_abs := ProjectSettings.globalize_path(pack_zip_rel)
	var zp := ZIPPacker.new()
	if zp.open(zip_abs) != OK:
		_log_critical("[RTVCodegen] Failed to create framework pack zip at %s" % zip_abs)
		return ""

	var script_count := 0
	var hook_count := 0
	var packed_filenames: Array[String] = []
	# Empty allowlist = process ALL hookable vanilla scripts. Used for
	# measuring the pre-compiled-vs-source-compiled split across the
	# full game. After _activate_rewritten_scripts runs, we can count
	# from COMPILE-PROOF log lines how many scripts fell into the
	# GDScriptCache-pinned bucket vs the live-inline bucket.
	var _step_b_allowlist: Array[String] = []
	var zero_byte_skipped: int = 0
	var surface_skipped: int = 0
	for script_path: String in script_paths:
		var filename := script_path.get_file()

		if filename in RTV_SKIP_LIST:
			_log_debug("[RTVCodegen] Skipped %s (runtime-sensitive)" % filename)
			continue
		if filename in RTV_RESOURCE_SERIALIZED_SKIP or filename in RTV_RESOURCE_DATA_SKIP:
			continue
		# Skip zero-byte PCK entries (base game ships empty .gd files for
		# some scripts; CasettePlayer.gd in RTV 4.6.1). Detokenize cannot
		# read content that doesn't exist. Not a modloader failure.
		if _pck_zero_byte_paths.has(script_path):
			zero_byte_skipped += 1
			continue
		if not _step_b_allowlist.is_empty() and filename not in _step_b_allowlist:
			continue
		# Wrap-surface filter: no mod extends, take_over_paths, or hooks
		# this script, and it's not a pinned-at-boot class_name. Skipping
		# means the script stays pure vanilla at runtime -- no dispatch
		# overhead, same behavior as v2.1.0 for this path.
		if not needed_paths.has(script_path):
			surface_skipped += 1
			_log_debug("[RTVCodegen] Surface-skip %s (no mod extends/hooks/overrides)" % filename)
			continue

		# Warn if a [script_overrides] replacement is also in play. For rewritten
		# scripts this is benign (no extends chain into the override) but the
		# override still displaces our rewrite at its own path, so dispatch
		# won't fire for nodes using the override.
		if _override_registry.has(script_path) or _applied_script_overrides.has(script_path):
			var sources: PackedStringArray = []
			if _override_registry.has(script_path):
				for claim in _override_registry[script_path]:
					sources.append(claim["mod_name"])
			for entry in _pending_script_overrides:
				if entry["vanilla_path"] == script_path:
					sources.append(entry["mod_name"] + " [script_overrides]")
			if sources.size() > 0:
				_log_warning("[RTVCodegen] %s is rewritten and also overridden by %s -- override displaces the rewrite, hooks won't fire for that path" \
						% [script_path, ", ".join(sources)])

		var source := _read_vanilla_source(script_path)
		if source.is_empty():
			_log_warning("[RTVCodegen] Empty detokenized source for %s -- skipped" % script_path)
			continue

		var parsed := _rtv_parse_script(filename, source)
		# Per-method wrap mask (v3.0.1). A path with no mask entry wraps every
		# hookable method (used for REGISTRY_TARGETS where injection needs to
		# see the whole script). A path WITH a mask wraps ONLY declared methods.
		var path_mask: Dictionary = hook_mask.get(script_path, {}) as Dictionary
		var apply_mask: bool = not path_mask.is_empty()
		var hookable_count := 0
		for fe in parsed["functions"]:
			if fe["is_static"]:
				continue
			# Mask keys come from .hook() calls which lowercase the method
			# name at dispatch time (rewriter.gd:211). Vanilla fn["name"]
			# preserves source casing (e.g. UpdateToolTip). Compare case-
			# insensitively so mods writing "updatetooltip" match.
			if apply_mask and not path_mask.has(fe["name"].to_lower()):
				continue
			hookable_count += 1
		if hookable_count == 0:
			if apply_mask:
				_log_warning("[RTVCodegen] %s: declared [hooks] methods %s not found in vanilla -- skipping" \
						% [filename, str(path_mask.keys())])
			continue

		# Record scripts whose module-scope preload() pulls in a PackedScene.
		# _activate_rewritten_scripts skips eager load+reload for these paths
		# so the preload fires later, AFTER mod autoloads run overrideScript().
		# VFS mount precedence (.gd + .remap + empty .gdc) still serves our
		# rewrite when game code lazy-compiles the script at first reference.
		#
		# EXCEPTION: scripts with registry injections MUST be force-activated
		# so the injected _rtv_mod_scenes / _rtv_override_scenes / _get()
		# are live on the autoload instance when mods call lib.register().
		# Lazy-compile would leave the autoload running vanilla bytecode.
		# Registry-target scripts don't have the ext_resource staleness
		# problem because mods don't take_over_path them -- they use the
		# registry API instead.
		var scene_preloads := _collect_module_scope_scene_preloads(source)
		if scene_preloads.size() > 0 and not _is_registry_target(filename):
			_scripts_with_scene_preloads[filename] = scene_preloads

		var rewritten := _rtv_rewrite_vanilla_source(source, parsed, path_mask)
		# Ship at the ORIGINAL vanilla path so class_name registration in the
		# PCK's global_script_class_cache.cfg matches our file. Declaring
		# class_name at a non-registered path triggers "Class X hides a
		# global script class" errors for scripts Godot pre-compiled at
		# startup (Camera, WeaponRig). Same-path keeps the registry
		# consistent with what's at the path.
		var gd_entry := "Scripts/" + filename
		if zp.start_file(gd_entry) != OK:
			_log_warning("[RTVCodegen] Failed to start zip entry %s" % gd_entry)
			continue
		zp.write_file(rewritten.to_utf8_buffer())
		zp.close_file()
		# Self-referencing .gd.remap overrides the PCK's .gd.remap -> .gdc
		# redirect. Godot's _path_remap reads this BEFORE GDScript loader.
		var remap_entry := "Scripts/" + filename + ".remap"
		if zp.start_file(remap_entry) != OK:
			_log_warning("[RTVCodegen] Failed to start zip entry %s" % remap_entry)
			continue
		var remap_body := "[remap]\npath=\"res://Scripts/%s\"\n" % filename
		zp.write_file(remap_body.to_utf8_buffer())
		zp.close_file()
		# Empty .gdc to shadow the PCK's bytecode. Godot's GDScript loader
		# prefers a sibling .gdc when present at the same base path -- even
		# after our self-referencing remap redirects to .gd. A zero-byte
		# .gdc at the same path defeats that preference: Godot can't parse
		# empty bytecode, silently falls back to compiling our .gd. Verified
		# 2026-04-17 -- no engine errors, all 5 rewrites load live.
		var gdc_filename: String = filename.replace(".gd", ".gdc")
		var gdc_entry := "Scripts/" + gdc_filename
		if zp.start_file(gdc_entry) != OK:
			_log_warning("[RTVCodegen] Failed to start zip entry %s" % gdc_entry)
			continue
		zp.write_file(PackedByteArray())
		zp.close_file()

		script_count += 1
		hook_count += hookable_count * 4  # pre/post/callback/replace per method
		packed_filenames.append(filename)
		_log_debug("[RTVCodegen] Rewrote Scripts/%s (%d hooks)" % [filename, hookable_count * 4])

	# Step C (mod-subclass rewrite) removed in v3.0.1. Mods that extend
	# wrapped vanilla now compose via Godot's native extends resolution:
	# their script sees the wrapped vanilla as its parent, super.method()
	# calls land on the dispatch wrapper, hooks fire normally. Mods whose
	# overrides skip super() lose hook composition on those methods --
	# that's the opt-in contract (declare your hooks, or call super()).
	#
	# Step E (sibling autofix) remains. Mod siblings may still be
	# preloaded/extended by subclass scripts and rely on our legacy-syntax
	# autofix (bodyless blocks, Godot-3 annotations) to parse cleanly.
	# Unlike Step C, autofix preserves the mod author's intent semantically
	# -- no rename, no super() rewrite, no dispatch injection.
	var sibling_fixed := 0
	var sibling_carried := 0
	var sibling_total_bodyless := 0
	var sibling_total_reload_stripped := 0
	for p: String in sibling_fixes:
		var fix: Dictionary = sibling_fixes[p]
		var fixed_src: String = fix["fixed_src"]
		var af: Dictionary = fix["af"]
		var reload_stripped: int = int(fix["reload_stripped"])
		var changed: bool = bool(fix["changed"])
		var zip_rel: String = p.trim_prefix("res://")
		if zp.start_file(zip_rel) != OK:
			_log_warning("[Autofix] Failed to pack sibling zip entry %s" % zip_rel)
			continue
		zp.write_file(fixed_src.to_utf8_buffer())
		zp.close_file()
		if changed:
			sibling_fixed += 1
			sibling_total_bodyless += int(af["bodyless"])
			sibling_total_reload_stripped += reload_stripped
			if reload_stripped > 0:
				_log_info("[Autofix] Stripped %d redundant .reload() call(s) from %s -- prevents Cannot-reload-while-instances-exist spam" % [reload_stripped, p])
			_log_info("[Autofix] Patched sibling %s: bodyless=%d tool=%d onready=%d export=%d" \
					% [p, af["bodyless"], af["tool"], af["onready"], af["export"]])
		else:
			sibling_carried += 1
	if sibling_fixed > 0:
		_log_info("[Autofix] %d mod sibling script(s) repaired (%d bodyless blocks, %d reload() stripped) -- packed into hook pack overlay" \
				% [sibling_fixed, sibling_total_bodyless, sibling_total_reload_stripped])
	if sibling_carried > 0:
		_log_debug("[Autofix] Carried %d unchanged mod sibling script(s) forward into new hook pack -- preserves VFS coverage across regen" \
				% sibling_carried)

	# STABILITY canary C: add a tiny known-content file to the hook pack so we
	# can verify VFS mount precedence independently of the script-rewriting
	# path. After mount, a FileAccess.get_file_as_string on this path should
	# return the canary content -- if not, the pack mounted but isn't serving
	# files and no rewrite will take effect this session.
	var canary_content := "MODLOADER-VFS-CANARY-" + MODLOADER_VERSION
	if zp.start_file("__modloader_canary__.txt") == OK:
		zp.write_file(canary_content.to_utf8_buffer())
		zp.close_file()

	zp.close()

	# Mount must happen BEFORE mod autoloads run so [rtvmodlib] needs= resolves
	# and before any scene compiles against the rewritten class_name scripts.
	# replace_files=true is the default in 4.6 but pass explicitly -- the whole
	# design depends on our Scripts/*.gd + .gd.remap entries winning over the
	# PCK's same-path entries in Godot's VFS layering.
	if zero_byte_skipped > 0:
		_log_info("[RTVCodegen] Skipped %d zero-byte PCK entry(ies) (base game ships empty .gd files -- not hookable, not a modloader failure): %s" \
				% [zero_byte_skipped, ", ".join(_pck_zero_byte_paths.keys())])
	if surface_skipped > 0:
		_log_info("[RTVCodegen] Surface-skipped %d vanilla script(s) with no mod interaction -- they run native (no dispatch overhead)" \
				% surface_skipped)
	if script_count > 0:
		if defer_activation:
			# Pass 1 pre-restart: write the zip + persist pass_state so Pass 2's
			# static-init mount picks it up on a fresh engine where GDScriptCache
			# isn't pinned to PCK bytecode. Skipping mount+activate here avoids
			# the misleading STABILITY alarm fired by _activate_rewritten_scripts
			# against the pre-compiled Camera/WeaponRig/Door/etc. that would
			# otherwise scream "hooks WILL NOT fire this session" seconds before
			# we restart and Pass 2 gets 126/126 inline-live.
			_log_info("[RTVCodegen] Generated %d rewritten vanilla script(s), %d hook points -- activation deferred to Pass 2 fresh engine" \
					% [script_count, hook_count])
			_persist_hook_pack_state(pack_zip_rel, _wrapped_paths_packed(packed_filenames))
		elif ProjectSettings.load_resource_pack(pack_zip_rel, true):
			# STABILITY canary C readback: confirm VFS mount precedence works
			# end-to-end. If the canary file isn't readable with expected
			# content, the hook pack mounted but isn't serving files -- every
			# rewrite will silently fall back to vanilla.
			var canary_got := FileAccess.get_file_as_string("res://__modloader_canary__.txt")
			if canary_got.begins_with("MODLOADER-VFS-CANARY-"):
				_log_info("[STABILITY] VFS canary OK: hook pack mount precedence verified (%s)" % canary_got.strip_edges())
			else:
				_log_critical("[STABILITY] VFS canary FAILED (got '%s', expected MODLOADER-VFS-CANARY-*) -- hook pack mounted but files aren't served. Rewrites will not take effect this session." % canary_got.substr(0, 40))
			_log_info("[RTVCodegen] Generated %d rewritten vanilla script(s), %d hook points -- pack mounted at res:// (%s)" \
					% [script_count, hook_count, pack_zip_rel.get_file()])
			_activate_rewritten_scripts(packed_filenames, pack_zip_rel)
		else:
			_log_critical("[RTVCodegen] Failed to mount hook pack at %s -- rewrites won't load" % zip_abs)
	else:
		_log_info("[RTVCodegen] No scripts rewritten -- no pack mounted")
	return pack_zip_rel

# Force the game's ResourceCache entry for each rewritten vanilla path to use
# our source. Necessary because:
#   - pre-mount load()s (engine class_name pre-compile for scripts in the main
#     scene graph, or anything else) cache the PCK's .gdc-compiled script at
#     res://Scripts/<Name>.gd
#   - CACHE_MODE_REPLACE doesn't fully rewrite those entries -- it re-reads
#     through the GDScript loader which keeps the bytecode association
#   - scene ext_resource and ClassName.new() both resolve through the cache,
#     so if the cache is stale our dispatch wrappers never fire
#
# Direct mutation of source_code + reload() recompiles the existing cached
# script in place. Scene nodes, ScriptServer class_cache, and any other
# live references keep working -- they now dispatch through our wrappers.
# Verified 2026-04-17: 158 wrapper calls in 4s across 5 scripts (physics
# tick rate on active Camera/Controller nodes).

func _activate_rewritten_scripts(filenames: Array[String], pack_path: String) -> void:
	# Scripts whose module-scope preload() pulls in a PackedScene are deferred
	# from eager load+reload. Loading them here would fire their preload()
	# chain BEFORE mod autoloads call overrideScript(), baking Script
	# ext_resources in those scenes to the pre-override vanilla. When mods
	# later take_over_path, the baked refs go empty-path (see Godot
	# core/io/resource.cpp Resource::set_path with p_take_over=true) and
	# scene instantiate() produces orphan-scripted nodes that never run mod
	# bodies. VFS mount precedence (.gd + .remap + empty .gdc) still serves
	# our rewrite when game code lazy-loads these paths after mod overrides.
	var deferred: PackedStringArray = []
	for fname: String in filenames:
		if _scripts_with_scene_preloads.has(fname):
			deferred.append(fname)
	if deferred.size() > 0:
		_log_info("[RTVCodegen] DEFER %d script(s) with module-scope scene preload -- will lazy-compile via VFS after mod overrides: %s" \
				% [deferred.size(), ", ".join(Array(deferred))])

	# PRE-ACTIVATE pass: classify each cached script as
	#  (a) already has _rtv_vanilla_* from static-init preload (pinned OK)
	#  (b) source_code matches our rewrite but methods don't (GDScriptCache-pinned)
	#  (c) source_code is empty (tokenized bytecode from PCK, no Static init preload)
	#  (d) something else
	# Summary counts printed at the end so we don't have to count by hand.
	var pre_a := 0
	var pre_b := 0
	var pre_c := 0
	var pre_d := 0
	var pre_b_names: PackedStringArray = []
	var pre_c_names: PackedStringArray = []
	for fname: String in filenames:
		if _scripts_with_scene_preloads.has(fname):
			continue
		var vp := "res://Scripts/" + fname
		var c := load(vp) as GDScript
		if c == null:
			pre_d += 1
			continue
		var pre_rename := false
		for m in c.get_script_method_list():
			if str(m["name"]).begins_with("_rtv_vanilla_"):
				pre_rename = true
				break
		var srclen: int = c.source_code.length()
		if pre_rename:
			pre_a += 1
		elif srclen > 0:
			pre_b += 1
			pre_b_names.append(fname)
		else:
			pre_c += 1
			pre_c_names.append(fname)
	_log_info("[RTVCodegen] PRE-ACTIVATE summary: inline-live=%d, pinned-with-source=%d, pinned-tokenized=%d, other=%d / total=%d" \
			% [pre_a, pre_b, pre_c, pre_d, filenames.size()])
	if pre_b > 0:
		_log_info("[RTVCodegen]   pinned-with-source (GDScriptCache has our text but compiled methods are vanilla): %s" \
				% ", ".join(Array(pre_b_names).slice(0, 25)))
	if pre_c > 0:
		_log_info("[RTVCodegen]   pinned-tokenized (PCK .gdc, our static-init preload missed): %s" \
				% ", ".join(Array(pre_c_names).slice(0, 25)))

	var activated := 0
	var preactivated := 0
	for fname: String in filenames:
		if _scripts_with_scene_preloads.has(fname):
			continue
		var vp := "res://Scripts/" + fname
		var cached := load(vp) as GDScript
		if cached == null:
			_log_warning("[RTVCodegen] activate %s: load returned null -- skip" % vp)
			continue

		# If static-init preload already put our rewrite into this cached
		# script, skip the reload entirely. reload() would fail with
		# "Cannot reload script while instances exist" for autoload-backed
		# scripts (Database, GameData, Inputs, Loader, Menu, etc.) -- and
		# the reload isn't needed anyway since the compiled methods
		# already include our _rtv_vanilla_* renames.
		#
		# Staleness caveat: across modloader releases, the rewriter may add
		# new injected fields (registry dicts, new prelude code) that aren't
		# in the static-init-preloaded script from the previous session. We
		# detect this by comparing the cached script's source_code to the
		# freshly-generated source we'd emit. If they diverge, we skip the
		# "already live" shortcut and fall through to the reload path. This
		# covers the common case where someone updates the modloader and
		# launches: first run picks up the new rewriter output instead of
		# silently running last session's stale cache.
		var already_live := false
		for m in cached.get_script_method_list():
			if str(m["name"]).begins_with("_rtv_vanilla_"):
				already_live = true
				break
		if already_live:
			var fresh_source := FileAccess.get_file_as_string(vp)
			if not fresh_source.is_empty() and fresh_source != cached.source_code:
				_log_info("[RTVCodegen] activate %s: cached rewrite is stale (static-init had an older pack), forcing fresh+take_over_path" % vp)
				var fresh := ResourceLoader.load(vp, "", ResourceLoader.CACHE_MODE_IGNORE) as GDScript
				if fresh == null:
					_log_critical("[RTVCodegen] activate %s: fresh load returned null -- skip" % vp)
					continue
				fresh.take_over_path(vp)
				activated += 1
				continue
			preactivated += 1
			activated += 1
			continue

		# Otherwise: mutate source_code + reload. This covers scripts whose
		# cache entry was compiled-from-source but without our rewrite yet
		# (rare -- normal case is static-init preload already covered it).
		var our_source := FileAccess.get_file_as_string(vp)
		if our_source.is_empty():
			_log_warning("[RTVCodegen] activate %s: FileAccess returned empty -- skip" % vp)
			continue
		cached.source_code = our_source
		var err := cached.reload()
		if err != OK:
			_log_warning("[RTVCodegen] activate %s: reload failed (%s)" % [vp, error_string(err)])
		# Step 2: verify the reload actually took by checking the compiled
		# method list. For scripts originally compiled from .gdc bytecode
		# (Camera, WeaponRig -- pre-compiled by the engine during startup
		# because they're referenced by the initial scene graph), reload()
		# does NOT re-parse from the mutated source_code -- it apparently
		# re-reads bytecode. Fall back to loading a fresh script via
		# CACHE_MODE_IGNORE (which goes through _path_remap -> our .gd
		# with source compile) and take_over_path to displace the stale
		# cache entry.
		var has_rename := false
		for m in cached.get_script_method_list():
			if str(m["name"]).begins_with("_rtv_vanilla_"):
				has_rename = true
				break
		if not has_rename:
			_log_info("[RTVCodegen] activate %s: reload didn't apply (pre-compiled); falling back to fresh+take_over_path" % vp)
			var fresh := ResourceLoader.load(vp, "", ResourceLoader.CACHE_MODE_IGNORE) as GDScript
			if fresh == null:
				_log_critical("[RTVCodegen] activate %s: fresh load returned null -- skip" % vp)
				continue
			var fresh_has_rename := false
			for m in fresh.get_script_method_list():
				if str(m["name"]).begins_with("_rtv_vanilla_"):
					fresh_has_rename = true
					break
			if not fresh_has_rename:
				_log_critical("[RTVCodegen] activate %s: fresh load also lacks renames -- rewrite isn't compiling" % vp)
				continue
			fresh.take_over_path(vp)
			_log_info("[RTVCodegen] activate %s: fresh script took over vanilla path" % vp)
		activated += 1
	var eager_total := filenames.size() - _scripts_with_scene_preloads.size()
	_log_info("[RTVCodegen] Activated %d/%d rewritten script(s) (%d already live from static-init preload; %d deferred to lazy-compile)" \
			% [activated, eager_total, preactivated, _scripts_with_scene_preloads.size()])

	# Step D: persist hook pack path + wrapped-paths list to pass_state so
	# the next session's _mount_previous_session() picks it up at static
	# init -- BEFORE game autoloads compile class_name scripts from the
	# PCK's .gdc. Only then can we rewire pre-compiled scripts like Camera
	# and WeaponRig (ScriptServer.class_cache pins their bytecode once
	# compiled). wrapped_paths drives the narrow preempt: only the scripts
	# we wrapped this session get CACHE_MODE_IGNORE treatment at static
	# init next session.
	_persist_hook_pack_state(pack_path, _wrapped_paths_packed(filenames))

	# End-to-end proof: register REAL hooks via the public RTVModLib API
	# on well-known Controller/Camera/Door methods. If these fire at
	# runtime, the full chain is working:
	#   1. our rewrite is what game code compiles against
	#   2. our dispatch wrapper runs on method entry
	#   3. _dispatch("<hook_name>-pre", args) reaches RTVModLib's _hooks dict
	#   4. our registered callback fires with the right args
	# Each hook bumps its own counter via Engine meta; deferred log
	# reports which hooks fired. If any of the three is zero, that
	# layer of the chain is broken.
	# Hooks spread across three phases: pre-gameplay menu (loader/simulation/
	# profiler fire every physics tick from the start), menu UI (settings/
	# menu fire on user click), gameplay (controller/character fire once in
	# world). If the first set fires and the last doesn't, it's just timing.
	# If NONE fire but dispatch counter is high, _hooks lookup is broken.
	#
	# Developer-mode gate: the probe hooks fire every physics tick on
	# live nodes and the 30s timer prints ~30 log lines of breakdowns.
	# Valuable for validating the hook pipeline during development;
	# redundant once the system is stable.
	if not _developer_mode:
		return
	var probe_counts := {
		"loader_pp": 0, "simulation_proc": 0, "profiler_proc": 0,
		"menu_ready": 0, "settings_load": 0,
		"controller_pp": 0, "character_pp": 0, "camera_pp": 0,
	}
	Engine.set_meta("_rtv_probe_counts", probe_counts)
	Engine.set_meta("_rtv_probe_first_args", {})
	var _bump := func(key: String, arg):
		var pc: Dictionary = Engine.get_meta("_rtv_probe_counts", {})
		pc[key] = int(pc.get(key, 0)) + 1
		Engine.set_meta("_rtv_probe_counts", pc)
		var fa: Dictionary = Engine.get_meta("_rtv_probe_first_args", {})
		if not fa.has(key):
			fa[key] = str(arg)
			Engine.set_meta("_rtv_probe_first_args", fa)
	hook("loader-_physics_process-pre", func(d): _bump.call("loader_pp", d), 100)
	hook("simulation-_process-pre", func(d): _bump.call("simulation_proc", d), 100)
	hook("profiler-_process-pre", func(d): _bump.call("profiler_proc", d), 100)
	hook("menu-_ready-pre", func(): _bump.call("menu_ready", "(no args)"), 100)
	hook("settings-loadpreferences-pre", func(): _bump.call("settings_load", "(no args)"), 100)
	hook("controller-_physics_process-pre", func(d): _bump.call("controller_pp", d), 100)
	hook("character-_physics_process-pre", func(d): _bump.call("character_pp", d), 100)
	hook("camera-_physics_process-pre", func(d): _bump.call("camera_pp", d), 100)

	# Compile proof: inspect the methods on each activated script. If our
	# rewrite compiled into the cached GDScript, the method list contains
	# both the renamed vanilla (e.g. _rtv_vanilla_Movement) AND the
	# dispatch wrapper at the original name (e.g. Movement).
	var compile_proof_ok := 0
	var compile_proof_fail: PackedStringArray = []
	for fname: String in filenames:
		if _scripts_with_scene_preloads.has(fname):
			continue  # deferred to lazy-compile; compile-proof runs post-override elsewhere
		var vp := "res://Scripts/" + fname
		var s := load(vp) as GDScript
		if s == null:
			compile_proof_fail.append(fname)
			continue
		var methods := s.get_script_method_list()
		var has_vanilla_rename := false
		var sample_rename := ""
		for m in methods:
			var n: String = str(m["name"])
			if n.begins_with("_rtv_vanilla_"):
				has_vanilla_rename = true
				if sample_rename == "":
					sample_rename = n
				if sample_rename != "" and has_vanilla_rename:
					break
		if _developer_mode:
			_log_info("[RTVCodegen] COMPILE-PROOF %s: %d methods compiled, _rtv_vanilla_* present=%s (e.g. %s)" \
					% [vp, methods.size(), has_vanilla_rename, sample_rename])
		if has_vanilla_rename:
			compile_proof_ok += 1
		else:
			compile_proof_fail.append(fname)

	# STABILITY canary A: summarize COMPILE-PROOF results and alarm on
	# catastrophic or critical-script failure. Silent breakage is the worst
	# mode -- users should see a clear message if Godot changed something
	# under us (VFS precedence, reload-parse behavior, cache eviction rules).
	var critical_set: Dictionary = {"Controller.gd": true, "Camera.gd": true,
			"WeaponRig.gd": true, "Door.gd": true, "Trader.gd": true,
			"Hitbox.gd": true, "LootContainer.gd": true, "Pickup.gd": true}
	var critical_failures: PackedStringArray = []
	for f in compile_proof_fail:
		if critical_set.has(f):
			critical_failures.append(f)
	# Deferred scripts aren't counted against the total here -- they skipped
	# compile-proof intentionally and will be verified via
	# _verify_rewrite_active_after_override once lazy-compile fires.
	var attempted := filenames.size() - _scripts_with_scene_preloads.size()
	if compile_proof_ok == 0 and attempted > 0:
		_log_critical("[STABILITY] ALL %d rewrites failed to take effect -- VFS mount, hook pack, or cache eviction is broken. Mods will NOT work this session. Click 'Reset to Vanilla' in the UI or create modloader_disabled in the game folder." % attempted)
	elif critical_failures.size() > 0:
		_log_critical("[STABILITY] Hook rewrites missing on critical scripts: %s. Hooks on these scripts will NOT fire this session (likely cache-pinning fallback failure)." % ", ".join(critical_failures))
	else:
		var deferred_tag := ""
		if _scripts_with_scene_preloads.size() > 0:
			deferred_tag = ", %d deferred to lazy-compile" % _scripts_with_scene_preloads.size()
		_log_info("[STABILITY] COMPILE-PROOF summary: %d/%d rewrites active%s%s" \
				% [compile_proof_ok, attempted,
					(" (%d pinned-fallback)" % compile_proof_fail.size()) if compile_proof_fail.size() > 0 else "",
					deferred_tag])

	# Autoload instance inspection: for the "Already in use" set, the
	# script's get_script_method_list() shows our renames, BUT the live
	# autoload node might still be holding a pointer to the original
	# bytecode via its get_script() property. If script_match=false for
	# any of these, our rewrite isn't reaching the actual game instance.
	# Developer-mode only -- pure diagnostic, 9 log lines per session.
	if _developer_mode:
		var autoload_names: Array[String] = ["Database", "GameData", "Settings",
				"Menu", "Loader", "Inputs", "Mode", "Profiler", "Simulation"]
		var root := get_tree().root
		for aname: String in autoload_names:
			var node: Node = root.get_node_or_null(aname)
			if node == null:
				_log_info("[RTVCodegen] AUTOLOAD-CHECK %s: node NOT in tree" % aname)
				continue
			var scr := node.get_script() as GDScript
			if scr == null:
				_log_info("[RTVCodegen] AUTOLOAD-CHECK %s: no script attached" % aname)
				continue
			var has_rename := false
			for m in scr.get_script_method_list():
				if str(m["name"]).begins_with("_rtv_vanilla_"):
					has_rename = true
					break
			# Also check if the method list is available via the INSTANCE (has_method
			# on the node). If the node's bytecode is our rewrite, it should report
			# _rtv_vanilla_<something> as a method.
			var instance_methods_has_rename := false
			for m in node.get_method_list():
				if str(m["name"]).begins_with("_rtv_vanilla_"):
					instance_methods_has_rename = true
					break
			_log_info("[RTVCodegen] AUTOLOAD-CHECK %s: script=%s script_has_rename=%s instance_has_rename=%s" \
					% [aname, scr.resource_path, has_rename, instance_methods_has_rename])

	# Registry smoke probe (runs unconditionally). Verifies tetra's
	# const->dict rewrite + _get() injection on Database actually
	# executed and serves scenes at runtime. Without this, a silent
	# regression in the Database transform would only surface when a
	# mod's lib.register() call returned stale data -- this probe
	# catches it at boot instead.
	var db_node: Node = get_tree().root.get_node_or_null("Database")
	if db_node == null:
		_log_warning("[RegistryProbe] Database autoload not in tree -- cannot verify const->dict transform")
	elif not ("_rtv_vanilla_scenes" in db_node):
		_log_warning("[RegistryProbe] Database._rtv_vanilla_scenes missing -- const->dict rewrite did not execute; lib.register/override will not see vanilla ids")
	else:
		var vs: Dictionary = db_node._rtv_vanilla_scenes
		var scene_count: int = vs.size()
		if scene_count == 0:
			_log_warning("[RegistryProbe] Database._rtv_vanilla_scenes empty -- regex extracted no entries from Database.gd; check vanilla const syntax")
		else:
			var probe_key: String = vs.keys()[0]
			var probe_result = db_node.get(probe_key)
			if probe_result is PackedScene:
				_log_info("[RegistryProbe] Database: _rtv_vanilla_scenes=%d entries; get('%s') returns PackedScene -- const->dict transform + _get() injection OK" \
						% [scene_count, probe_key])
			else:
				_log_warning("[RegistryProbe] Database: _rtv_vanilla_scenes=%d entries but get('%s') returned %s (not PackedScene) -- _get() injection broken" \
						% [scene_count, probe_key, type_string(typeof(probe_result))])

	# 30s gives the player time to get into gameplay so controller-level
	# hooks can fire at least once. HOOK-API summary only by default;
	# dev-mode adds the per-method dispatch counter printout (fed by
	# _dispatch_counts incremented inside each wrapper after the
	# _any_mod_hooked short-circuit -- see _rtv_dispatch_inline_src).
	_dispatch_counts.clear()
	get_tree().create_timer(30.0).timeout.connect(func():
		var pc: Dictionary = Engine.get_meta("_rtv_probe_counts", {})
		var fa: Dictionary = Engine.get_meta("_rtv_probe_first_args", {})
		# Dispatch counts (dev mode only). Show top 20 hot methods. No generic
		# threshold -- hud/interface/character _physics_process legitimately
		# hit 90K+ calls/sec × 30s instance counts, a count-based RUNAWAY
		# flag would drown real anomalies in expected noise.
		#
		# Instead, call out _ready / _enter_tree / _init specifically: those
		# fire once per node lifetime, so any count > 10 is a red flag that
		# a mod is re-invoking them in a loop (typical cause of connect-
		# already-connected error spam).
		if _developer_mode and _dispatch_counts.size() > 0:
			var pairs: Array = []
			for k: String in _dispatch_counts:
				pairs.append([k, int(_dispatch_counts[k])])
			pairs.sort_custom(func(a, b): return a[1] > b[1])
			_log_info("[RTVCodegen] DISPATCH-COUNT top %d / %d tracked methods (dev mode, 30s window):" \
					% [min(20, pairs.size()), pairs.size()])
			for i in range(min(20, pairs.size())):
				_log_info("[RTVCodegen]   %-48s %d" % [pairs[i][0], pairs[i][1]])
			# Flag lifecycle methods that fire way more than they should.
			var lifecycle_runaway: Array = []
			for p in pairs:
				var name: String = p[0]
				if (name.ends_with("-_ready") or name.ends_with("-_enter_tree") \
						or name.ends_with("-_init")) and int(p[1]) > 10:
					lifecycle_runaway.append("%s=%d" % [name, p[1]])
			if lifecycle_runaway.size() > 0:
				_log_critical("[RTVCodegen] LIFECYCLE-RUNAWAY: %s -- these should fire once per node; elevated counts usually mean a mod is explicitly calling them from a loop or frequent callback, which cascades into connect-already-connected error spam" \
						% ", ".join(lifecycle_runaway))
		# HOOK-API per-probe breakdown across phases:
		var total := 0
		for k: String in ["loader_pp", "simulation_proc", "profiler_proc",
				"menu_ready", "settings_load",
				"controller_pp", "character_pp", "camera_pp"]:
			var v := int(pc.get(k, 0))
			total += v
			_log_info("[RTVCodegen] HOOK-API %s: count=%d first_arg=%s" \
					% [k, v, fa.get(k, "n/a")])
		if total > 0:
			_log_info("[RTVCodegen] HOOK-API-LIVE: %d callback fires total across probes -- full chain verified" % total)
		else:
			_log_critical("[RTVCodegen] HOOK-API-DEAD: 0 callback fires -- dispatch runs but _hooks lookup/callback is broken")
		# IXP takeover verification: inspect live Controller/Camera/WeaponRig
		# instances. IXP's take_over_path moves IXP's script onto the vanilla
		# path. If IXP's override is ACTIVE, node.get_script() will be IXP's
		# script (source contains "IXP" or "ImmersiveXP" markers), and the
		# base-script chain should walk IXP -> our rewrite -> engine class.
		# If IXP failed, node.get_script() is our rewrite directly (no IXP
		# ancestor). This is the definitive proof IXP's takeover works.
		var check_classes: Array[String] = ["Controller", "Camera", "WeaponRig"]
		for cls_name: String in check_classes:
			var found: Array = []
			_rtv_collect_nodes_by_class(get_tree().root, cls_name, found)
			if found.is_empty():
				_log_info("[IXP-VERIFY] No %s node in tree yet" % cls_name)
				continue
			var node: Node = found[0]
			var scr := node.get_script() as GDScript
			if scr == null:
				_log_info("[IXP-VERIFY] %s: no script attached" % cls_name)
				continue
			var src: String = scr.source_code
			var has_ixp := "ImmersiveXP" in src or "IXP " in src or "overrideScript" in src
			var has_rewrite := "_rtv_vanilla_" in src
			_log_info("[IXP-VERIFY] %s instance script: path=%s src_len=%d ixp_content=%s rewrite_content=%s" \
					% [cls_name, scr.resource_path, src.length(), has_ixp, has_rewrite])
			# Walk base chain
			var base := scr.get_base_script() as GDScript
			var depth := 1
			while base != null and depth < 6:
				var b_src: String = base.source_code
				var b_has_ixp := "ImmersiveXP" in b_src or "IXP " in b_src
				var b_has_rewrite := "_rtv_vanilla_" in b_src
				_log_info("[IXP-VERIFY]   base[%d]: path=%s src_len=%d ixp=%s rewrite=%s" \
						% [depth, base.resource_path, b_src.length(), b_has_ixp, b_has_rewrite])
				base = base.get_base_script() as GDScript
				depth += 1
	)

## ----- lifecycle.gd -----
## Top-level orchestration: _ready is the entrypoint, dispatches to
## _run_pass_1 (first launch, show UI) or _run_pass_2 (post-restart).
## _finish_* helpers wrap up either path by instantiating queued autoloads
## and emitting frameworks_ready.

func _ready() -> void:
	if _has_loaded:
		return
	_has_loaded = true
	# Honor disabled sentinel. Static init already cleaned persistent state,
	# so we just sit idle for this session. User removes the file to re-enable.
	if _is_modloader_disabled():
		print("[ModLoader] disabled via sentinel file -- sitting idle")
		return
	await get_tree().process_frame
	_compile_regex()
	# Hook _load_all_mods completion to mount our test pack AFTER mod re-mounts.
	# (Test pack mounted before _run_pass_2 gets overwritten when load_all_mods
	# re-mounts ImmersiveXP.vmz with replace_files=true.)
	var is_pass_2 := "--modloader-restart" in OS.get_cmdline_user_args()
	if _load_test_pack_flag() and not is_pass_2:
		_test_pack_precedence()
		_log_info("[TEST-REMAP] test complete (Pass 1, before restart)")
	if is_pass_2:
		await _run_pass_2()
	else:
		await _run_pass_1()
	# Deferred verify: by now all autoloads have run. Check what IXP took over.
	if _load_test_pack_flag():
		await get_tree().create_timer(1.0).timeout
		_test_post_autoload_verify()

# Shared restart helper. Used by Pass 1's two-pass bootstrap, Reset to Vanilla,
# and the post-boot main-menu reopen flow. `clean_pass1` strips
# --modloader-restart so the next run is a clean Pass 1 rather than a Pass 2
# expecting stale state.
func _modloader_restart(clean_pass1: bool) -> void:
	var args: Array = []
	if clean_pass1:
		for a in OS.get_cmdline_args():
			if a != "--modloader-restart":
				args.append(a)
	else:
		args = Array(OS.get_cmdline_args())
	# Godot's arg parser consumes --rendering-driver / --rendering-method
	# (main.cpp:1272,1280) and does NOT push them back to main_args, so
	# OS.get_cmdline_args() returns the stripped list. Without re-injecting,
	# the relaunch loses the Steam launch option and Godot falls back to the
	# default driver (D3D12 on Windows). Visible on fresh-install first
	# launch; subsequent launches short-circuit on the mod-state hash and
	# never restart.
	_preserve_engine_driver_args(args)
	if not clean_pass1:
		args.append_array(["--", "--modloader-restart"])
	OS.set_restart_on_exit(true, args)
	get_tree().quit()

func _preserve_engine_driver_args(args: Array) -> void:
	# Scoped to the two flags RTV's Steam launch-option presets actually set
	# ([DirectX] / [Vulkan] / [Compatibility] pick --rendering-driver and/or
	# --rendering-method). If the user didn't pass a flag, querying returns
	# Godot's default (no-op); if they did, we preserve their choice.
	if not args.has("--rendering-driver"):
		var driver := RenderingServer.get_current_rendering_driver_name()
		if not driver.is_empty():
			args.append("--rendering-driver")
			args.append(driver)
	if not args.has("--rendering-method"):
		var method := RenderingServer.get_current_rendering_method()
		if not method.is_empty():
			args.append("--rendering-method")
			args.append(method)

# Public entry point for the main-menu "Mods" button. Re-shows the launcher UI
# post-boot; if any mutation sets _dirty_since_boot, quits + restarts into a
# clean Pass 1. Noop when the UI is already open.
func reopen_mod_ui() -> void:
	if _ui_window != null:
		return
	_dirty_since_boot = false
	await show_mod_ui()
	if _dirty_since_boot:
		_log_info("[ModLoader] Post-boot mod changes detected -- restarting")
		_modloader_restart(true)

func _run_pass_1() -> void:
	_log_info("Metro Mod Loader v" + MODLOADER_VERSION)
	_check_crash_recovery()
	_check_safe_mode()
	_compile_regex()
	_build_class_name_lookup()
	# Populate _all_game_script_paths NOW so the .hook() prefix resolver in
	# _merge_hook_calls_into_wrap_mask (run from load_all_mods below) can
	# fall back to filename-stem matches for vanilla scripts without
	# class_name (Flashlight, NVG, Interface, ...). Previously this only
	# ran inside _generate_hook_pack, so source-scanned hooks on
	# class_name-less scripts were silently dropped.
	_enumerate_game_scripts()
	_load_developer_mode_setting()
	_ui_mod_entries = collect_mod_metadata()
	_clean_stale_cache()
	_load_ui_config()
	await show_mod_ui()
	_save_ui_config()

	load_all_mods()
	_apply_script_overrides()  # apply [script_overrides] before hook generation

	var sections := _build_autoload_sections()
	var archive_paths := _collect_enabled_archive_paths()

	var new_hash := _compute_state_hash(archive_paths, sections.prepend)
	var old_hash := ""
	var state_cfg := ConfigFile.new()
	if state_cfg.load(PASS_STATE_PATH) == OK:
		old_hash = state_cfg.get_value("state", "mods_hash", "")

	if new_hash == old_hash and not new_hash.is_empty():
		_log_info("Mod state unchanged -- skipping restart")
		await _finish_with_existing_mounts()
		return

	# Note: do NOT generate framework wrappers here. If we restart, the work is
	# wasted. Pass 2 will generate after archives are mounted + class lookup
	# rebuilt. Single-pass paths (_finish_single_pass, _finish_with_existing_mounts)
	# generate before activating hooks.

	if archive_paths.size() > 0:
		_log_info("Preparing two-pass restart -- %d archive(s)" % archive_paths.size())
		if sections.prepend.size() > 0:
			_log_info("  %d early autoload(s) in [autoload_prepend]" % sections.prepend.size())
		# Generate hook pack NOW, before restart, so next session's static-init
		# mount has a fresh pack for the current mod set. Without this, when
		# the pack is missing (first-ever session, after Reset, after mod-set
		# change) Godot pins class_name scripts (Camera, WeaponRig, Door, etc.)
		# as PCK-bytecode during [autoload_prepend] boot and Pass 2's fallback
		# (CACHE_MODE_IGNORE + take_over_path) can't recover them. Verified
		# 2026-04-17: 40/126 rewrites silently die after Reset otherwise.
		# defer_activation=true: write the zip + persist pass_state, but skip
		# mount + reload()/take_over_path. Pass 1's GDScriptCache is already
		# polluted by the PCK's pre-compiled class_name scripts, so activation
		# here would fire a misleading "hooks WILL NOT fire" STABILITY alarm
		# seconds before Pass 2's fresh engine gets 126/126 inline-live.
		_register_rtv_modlib_meta()
		_generate_hook_pack(true)
		_write_heartbeat()
		var err := _write_override_cfg(sections.prepend)
		if err != OK:
			_log_critical("Failed to write override.cfg (error %d) -- single-pass fallback" % err)
			await _finish_single_pass()
			return
		if _write_pass_state(archive_paths, new_hash) != OK:
			await _finish_single_pass()
			return
		_modloader_restart(false)
		return

	# No archives enabled. Clean up stale two-pass state if present.
	if FileAccess.file_exists(PASS_STATE_PATH):
		DirAccess.remove_absolute(ProjectSettings.globalize_path(PASS_STATE_PATH))
		_restore_clean_override_cfg()
	if DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(HOOK_PACK_DIR)):
		_static_wipe_hook_cache()
		_log_info("[Hooks] Cleaned up unused hook artifacts")
	await _finish_single_pass()

func _finish_with_existing_mounts() -> void:
	# Register meta + generate the framework pack BEFORE mod autoloads run so
	# Engine.get_meta("RTVModLib") is live by the time they call .hook().
	# Script overrides were already applied in _run_pass_1() before the hash
	# check; no need to re-apply from pass state.
	_boot_complete = true
	_register_rtv_modlib_meta()
	_generate_hook_pack()
	for entry in _pending_autoloads:
		if get_tree().root.has_node(entry["name"]):
			_log_info("  Autoload '%s' already in tree -- skipped" % entry["name"])
			continue
		_instantiate_autoload(entry["mod_name"], entry["name"], entry["path"])
	if _developer_mode:
		_log_override_timing_warnings()
		_print_conflict_summary()
		_write_conflict_report()
	_emit_frameworks_ready()
	_delete_heartbeat()
	if not _filescope_mounted.is_empty() or not _archive_file_sets.is_empty() or _pending_autoloads.size() > 0:
		var err := get_tree().reload_current_scene()
		if err != OK:
			_log_critical("reload_current_scene() failed with error " + str(err))
			return

func _finish_single_pass() -> void:
	_boot_complete = true
	_register_rtv_modlib_meta()
	_generate_hook_pack()
	for entry in _pending_autoloads:
		_instantiate_autoload(entry["mod_name"], entry["name"], entry["path"])
	if _developer_mode:
		_log_override_timing_warnings()
		_print_conflict_summary()
		_write_conflict_report()
	_emit_frameworks_ready()
	_delete_heartbeat()
	if not _archive_file_sets.is_empty() or _pending_autoloads.size() > 0:
		var err := get_tree().reload_current_scene()
		if err != OK:
			_log_critical("reload_current_scene() failed with error " + str(err))
			return

# Pass 2: Post-restart -- archives already mounted at file-scope


func _run_pass_2() -> void:
	_boot_complete = true
	_log_info("Pass 2 -- %d archive(s) mounted at file-scope" % _filescope_mounted.size())
	# Write dirty marker first thing. If Pass 2 crashes before cleanup below,
	# next launch's static init detects the marker and force-wipes state.
	var _dirty_f := FileAccess.open(PASS2_DIRTY_PATH, FileAccess.WRITE)
	if _dirty_f:
		_dirty_f.store_string(str(Time.get_unix_time_from_system()))
		_dirty_f.close()
	# Restore script overrides from pass state and apply before hooks.
	var _pass_cfg := ConfigFile.new()
	if _pass_cfg.load(PASS_STATE_PATH) == OK:
		var saved_overrides: Array = _pass_cfg.get_value("state", "script_overrides", [])
		for entry in saved_overrides:
			if entry is Dictionary and entry.has("vanilla_path") and entry.has("mod_script_path"):
				_pending_script_overrides.append(entry)
			else:
				_log_warning("[Overrides] Malformed entry in pass state -- skipped")
	_apply_script_overrides()
	_clear_restart_counter()
	_compile_regex()
	_build_class_name_lookup()
	# See _run_pass_1: enumerate before load_all_mods so filename-stem
	# fallback in _merge_hook_calls_into_wrap_mask is populated.
	_enumerate_game_scripts()
	_load_developer_mode_setting()
	_ui_mod_entries = collect_mod_metadata()
	_load_ui_config()

	load_all_mods("Pass 2")
	_register_rtv_modlib_meta()
	_generate_hook_pack()
	# After load_all_mods re-mounts mod archives (wiping our IXP/Controller
	# override), remount the already-generated test pack to re-apply.
	# NOTE: Godot dedupes load_resource_pack by path, so mounting the same
	# filename twice is a no-op. Copy to a different filename each time.
	if _load_test_pack_flag():
		var src_abs := ProjectSettings.globalize_path("user://test_pack_precedence.zip")
		var reapply_abs := ProjectSettings.globalize_path("user://test_pack_reapply_" \
				+ str(Time.get_ticks_msec()) + ".zip")
		if FileAccess.file_exists(src_abs):
			var src := FileAccess.open(src_abs, FileAccess.READ)
			var dst := FileAccess.open(reapply_abs, FileAccess.WRITE)
			if src and dst:
				dst.store_buffer(src.get_buffer(src.get_length()))
				src.close()
				dst.close()
				if ProjectSettings.load_resource_pack(reapply_abs, true):
					_log_info("[TEST-REMAP] Pass 2: re-applied test pack via copy " + reapply_abs.get_file())
					# Verify VFS state post-reapply
					if FileAccess.file_exists("res://ImmersiveXP/Controller.gd"):
						var chk := FileAccess.get_file_as_bytes("res://ImmersiveXP/Controller.gd")
						var has_marker := "TEST-HOOK-IXP" in chk.get_string_from_utf8()
						_log_info("[TEST-REMAP] Pass 2 post-reapply: IXP/Controller.gd = " \
								+ str(chk.size()) + " bytes, has marker: " + str(has_marker))
				else:
					_log_warning("[TEST-REMAP] Pass 2: load_resource_pack on copy failed")
			else:
				_log_warning("[TEST-REMAP] Pass 2: failed to copy test pack")
	for entry in _pending_autoloads:
		if get_tree().root.has_node(entry["name"]):
			_log_info("  Autoload '%s' already in tree -- skipped" % entry["name"])
			continue
		_instantiate_autoload(entry["mod_name"], entry["name"], entry["path"])

	if _developer_mode:
		_log_override_timing_warnings()
		_print_conflict_summary()
		_write_conflict_report()
	_emit_frameworks_ready()
	_delete_heartbeat()
	# Pass 2 reached cleanup -- clear the dirty marker so next launch knows we
	# finished without crashing. If reload_current_scene below fails we still
	# want the marker gone; the state we wrote IS consistent at this point.
	if FileAccess.file_exists(PASS2_DIRTY_PATH):
		DirAccess.remove_absolute(ProjectSettings.globalize_path(PASS2_DIRTY_PATH))
	if not _filescope_mounted.is_empty() or not _archive_file_sets.is_empty() or _pending_autoloads.size() > 0:
		var err := get_tree().reload_current_scene()
		if err != OK:
			_log_critical("reload_current_scene() failed with error " + str(err))
			return


## ----- main_menu_hook.gd -----
## Injects a "Mods" button into RTV's main menu (res://Scripts/Menu.gd) that
## re-opens the launcher UI post-boot. Any mutation to mod_config.cfg while
## the UI is open flips _dirty_since_boot; on close we restart into a clean
## Pass 1 so the new mod set takes effect.
##
## Implementation uses the same hook machinery mods use:
##   1. _seed_core_hooks pre-populates _hooked_methods so the rewriter wraps
##      Menu.gd's _ready even when no user mod asked for it. Called from each
##      finish path + Pass 1's pre-restart generation so every code path that
##      produces a hook pack includes our wrap.
##   2. _register_core_hooks subscribes our injector to menu-_ready-post via
##      the public hook() API. Fired from _emit_frameworks_ready.

const _MENU_SCRIPT_PATH := "res://Scripts/Menu.gd"
const _MENU_HOOK_NAME := "menu-_ready-post"
const _MODS_BUTTON_NAME := "MetroMods"

func _seed_core_hooks() -> void:
	if not _hooked_methods.has(_MENU_SCRIPT_PATH):
		_hooked_methods[_MENU_SCRIPT_PATH] = {}
	(_hooked_methods[_MENU_SCRIPT_PATH] as Dictionary)["_ready"] = true

func _register_core_hooks() -> void:
	hook(_MENU_HOOK_NAME, _on_menu_ready, 100)

func _on_menu_ready() -> void:
	# Resolve the menu root via current_scene. The dispatcher fires -post from
	# the vanilla _ready body, so at this point the Menu node is in the tree
	# and @onready vars are populated.
	var menu_root := get_tree().current_scene
	if menu_root == null or menu_root.get_script() == null:
		return
	if menu_root.get_script().resource_path != _MENU_SCRIPT_PATH:
		return
	_inject_mods_button(menu_root)

func _inject_mods_button(menu_root: Node) -> void:
	var buttons := menu_root.get_node_or_null("Main/Buttons")
	if buttons == null:
		_log_warning("[ModLoader] Main menu has no Main/Buttons container skipping Mods button injection")
		return
	if buttons.get_node_or_null(_MODS_BUTTON_NAME) != null:
		return
	var btn := Button.new()
	btn.name = _MODS_BUTTON_NAME
	btn.text = "Mods"
	btn.custom_minimum_size = Vector2(0, 40)
	btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	var quit_btn := buttons.get_node_or_null("Quit")
	buttons.add_child(btn)
	if quit_btn != null:
		buttons.move_child(btn, quit_btn.get_index())
	btn.pressed.connect(_on_mods_button_pressed)
	_log_info("[ModLoader] Injected Mods button into main menu")

func _on_mods_button_pressed() -> void:
	reopen_mod_ui()

## ----- debug.gd -----
## Test scaffolding gated behind the test_pack_precedence flag in
## mod_config.cfg. Exercises the pack-over-bytecode precedence trick and
## verifies what took over which vanilla paths after autoloads run.
## Removable once the rewrite system is proven stable in production.

func _test_post_autoload_verify() -> void:
	const TAG := "[TEST-REMAP-POST]"
	_log_info(TAG + " === DEFERRED VERIFY: 1s after all autoloads ===")
	var s := load("res://Scripts/Controller.gd") as GDScript
	if s == null:
		_log_critical(TAG + " load() returned null")
		return
	var sc: String = s.source_code
	var methods := s.get_script_method_list()
	var names: Array = []
	for m in methods:
		names.append(m["name"])
	_log_info(TAG + "   Scripts/Controller.gd:")
	_log_info(TAG + "     source_code length: " + str(sc.length()))
	_log_info(TAG + "     has vanilla-side marker: " + str("_rtv_test_remap_marker" in sc))
	_log_info(TAG + "     has IXP-side marker: " + str("TEST-HOOK-IXP" in sc))
	_log_info(TAG + "     _rtv_vanilla_Movement in methods: " + str("_rtv_vanilla_Movement" in names))
	_log_info(TAG + "     Movement in methods: " + str("Movement" in names))
	_log_info(TAG + "     method count: " + str(names.size()))
	_log_info(TAG + "     global_name: '" + str(s.get_global_name()) + "'")
	_log_info(TAG + "     script instance_id: " + str(s.get_instance_id()))

	# Verified 2026-04-17: explicit load(IXP_PATH, REUSE/IGNORE) triggers
	# #83542 after IXP's take_over_path (cache cold -> fresh compile ->
	# find_class("Controller") fails against the IXP overlay at vanilla path).
	# FileAccess-only ground truth avoids the forced recompile.
	const IXP_PATH := "res://ImmersiveXP/Controller.gd"
	if FileAccess.file_exists(IXP_PATH):
		var bytes := FileAccess.get_file_as_bytes(IXP_PATH)
		var txt := bytes.get_string_from_utf8()
		_log_info(TAG + "   FileAccess IXP/Controller.gd: " + str(bytes.size()) + " bytes, has marker: " + str("TEST-HOOK-IXP" in txt))

# =============================================================================
# TEMPORARY: Pack-over-bytecode precedence test. Gated behind a setting flag
# in mod_config.cfg. Remove after verifying whether mounting .gd + .gd.remap
# beats the PCK's .gdc + .gd.remap for a specific resource path.
# =============================================================================

func _load_test_pack_flag() -> bool:
	var cfg := ConfigFile.new()
	if cfg.load(UI_CONFIG_PATH) != OK:
		return false
	return bool(cfg.get_value("settings", "test_pack_precedence", false))

func _test_pack_precedence() -> void:
	const TAG := "[TEST-REMAP]"
	const TARGET_PATH := "res://Scripts/Controller.gd"
	const REMAP_PATH := "res://Scripts/Controller.gd.remap"
	const GDC_PATH := "res://Scripts/Controller.gdc"
	const MARKER_SYMBOL := "_rtv_test_remap_marker"
	const TEST_ZIP := "user://test_pack_precedence.zip"
	_log_info(TAG + " starting pack-over-bytecode test for " + TARGET_PATH)

	# --- PRE-MOUNT DIAGNOSTICS ---
	_log_info(TAG + " === PRE-MOUNT VFS state ===")
	_log_info(TAG + "   FileAccess.file_exists(.gd):       " + str(FileAccess.file_exists(TARGET_PATH)))
	_log_info(TAG + "   FileAccess.file_exists(.gdc):      " + str(FileAccess.file_exists(GDC_PATH)))
	_log_info(TAG + "   FileAccess.file_exists(.gd.remap): " + str(FileAccess.file_exists(REMAP_PATH)))
	_log_info(TAG + "   ResourceLoader.exists(.gd):   " + str(ResourceLoader.exists(TARGET_PATH)))
	_log_info(TAG + "   ResourceLoader.exists(.gdc):  " + str(ResourceLoader.exists(GDC_PATH)))
	if FileAccess.file_exists(REMAP_PATH):
		var pre_remap := FileAccess.get_file_as_string(REMAP_PATH)
		_log_info(TAG + "   PCK's .remap content: " + pre_remap.replace("\n", "|"))

	# --- BUILD TEST PACK ---
	# IMPORTANT: do NOT call load(TARGET_PATH) here -- that would cache the
	# bytecode version and prevent our mounted .gd from winning on subsequent
	# loads. Go straight to detokenize, and explicitly target the .gdc path so
	# a stale test pack mounted at static init can't pollute the input with
	# its own rewritten .gd (duplicate-function parse error -> game breaks).
	var vanilla_source := _detokenize_script(GDC_PATH)
	if vanilla_source.is_empty():
		_log_critical(TAG + " FAIL: could not detokenize vanilla source")
		return

	# Capture PRISTINE IXP/Controller.gd source via ZIPReader directly against
	# the .vmz -- bypasses the VFS entirely, so we're immune to either
	#   (a) destructive ops on a previously-mounted test pack leaving stale
	#       mount entries pointing at a deleted file (old crash path), or
	#   (b) reading an already-rewritten version from a prior session's pack
	#       that got mounted at static init (double-rewrite duplicate funcs).
	var mods_dir := OS.get_executable_path().get_base_dir().path_join(MOD_DIR)
	var ixp_vmz_path := mods_dir.path_join("ImmersiveXP.vmz")
	var captured_ixp_source := ""
	if FileAccess.file_exists(ixp_vmz_path):
		var ixp_zr := ZIPReader.new()
		if ixp_zr.open(ixp_vmz_path) == OK:
			var ixp_bytes := ixp_zr.read_file("ImmersiveXP/Controller.gd")
			ixp_zr.close()
			captured_ixp_source = ixp_bytes.get_string_from_utf8()
			if captured_ixp_source.is_empty():
				_log_warning(TAG + " IXP source read from vmz returned empty")
			else:
				_log_info(TAG + " Captured pristine IXP source from vmz: " \
						+ str(captured_ixp_source.length()) + " bytes")
		else:
			_log_warning(TAG + " ZIPReader.open failed on " + ixp_vmz_path)
	else:
		_log_info(TAG + " ImmersiveXP.vmz not present, TEST 4B will skip")

	# Step A (2026-04-17): feed the pristine vanilla through the production
	# generator _rtv_rewrite_vanilla_source(). It renames EVERY non-static
	# method to _rtv_vanilla_<name> and appends full dispatch wrappers at the
	# original names. This proves the generator produces runtime-correct
	# output for all methods (not just Movement like the prior inline
	# rewrite did).
	var parsed := _rtv_parse_script(TARGET_PATH.get_file(), vanilla_source)
	var hookable_count := 0
	for fe in parsed["functions"]:
		if not fe["is_static"]:
			hookable_count += 1
	_log_info(TAG + " Step A: parsed %d function(s), %d hookable (non-static)" \
			% [(parsed["functions"] as Array).size(), hookable_count])

	var rewritten := _rtv_rewrite_vanilla_source(vanilla_source, parsed)

	# Append the test-remap marker (non-hookable callable) so we can verify the
	# compiled class is usable via script.new() + call(marker) below.
	var marker_block: String = "\n# rtv test-remap marker\nfunc " + MARKER_SYMBOL \
			+ "() -> String:\n\treturn \"test-remap-ok\"\n"
	rewritten += marker_block

	_log_info(TAG + " rewritten source length: " + str(rewritten.length()) + " chars (+" \
			+ str(rewritten.length() - vanilla_source.length()) + ")")

	# Which variant to test. Toggle by editing the next few lines:
	var variant := "A"  # "A"=.gd + self-ref remap, "B"=.gd only, "C"=empty remap, "D"=.gd + .gdc-remap
	_log_info(TAG + " VARIANT: " + variant)

	var test_zip_abs := ProjectSettings.globalize_path(TEST_ZIP)
	if FileAccess.file_exists(test_zip_abs):
		DirAccess.remove_absolute(test_zip_abs)
	var zp := ZIPPacker.new()
	if zp.open(test_zip_abs) != OK:
		_log_critical(TAG + " FAIL: cannot open test zip")
		return
	zp.start_file("Scripts/Controller.gd")
	zp.write_file(rewritten.to_utf8_buffer())
	zp.close_file()
	match variant:
		"A":  # .gd + self-referencing .remap (redirect-the-redirect)
			zp.start_file("Scripts/Controller.gd.remap")
			zp.write_file("[remap]\npath=\"res://Scripts/Controller.gd\"\n".to_utf8_buffer())
			zp.close_file()
		"B":  # .gd only, no .remap override
			pass
		"C":  # .gd + empty .remap (no path key)
			zp.start_file("Scripts/Controller.gd.remap")
			zp.write_file("[remap]\n".to_utf8_buffer())
			zp.close_file()
		"D":  # .gd + .remap pointing at same .gdc as before (no-op override)
			zp.start_file("Scripts/Controller.gd.remap")
			zp.write_file("[remap]\npath=\"res://Scripts/Controller.gdc\"\n".to_utf8_buffer())
			zp.close_file()

	# === TEST 4B: also pre-wrap ImmersiveXP's Controller.gd ===
	# When ImmersiveXP's autoload does load("res://ImmersiveXP/Controller.gd")
	# .take_over_path(vanilla_path), it'll load our pre-wrapped version and
	# move THAT to the vanilla path. Hooks fire through the mod's chain.
	# Uses captured_ixp_source captured at top of function -- reading IXP_PATH
	# HERE fails after we delete the old test pack zip above (VFS has stale
	# mount entries pointing at the deleted file).
	const IXP_PATH := "res://ImmersiveXP/Controller.gd"
	if not captured_ixp_source.is_empty():
		var ixp_source := captured_ixp_source
		if not ixp_source.is_empty():
			_log_info(TAG + " IXP Controller source length: " + str(ixp_source.length()))
			var ixp_lines: PackedStringArray = ixp_source.split("\n")
			var ixp_renamed := false
			for i in ixp_lines.size():
				var line_str := str(ixp_lines[i])
				if line_str.strip_edges().begins_with("func Movement("):
					ixp_lines[i] = line_str.replace("func Movement(", "func _rtv_vanilla_Movement(")
					ixp_renamed = true
					break
			var ixp_new_lines: Array = []
			for line in ixp_lines:
				ixp_new_lines.append(line)
			if ixp_renamed:
				# Detect indentation style: if source uses spaces, our appended
				# wrapper must too (GDScript errors on mixed tabs/spaces).
				var uses_spaces := false
				for line in ixp_lines:
					var line_str2 := str(line)
					if line_str2.begins_with("    ") and not line_str2.begins_with("\t"):
						uses_spaces = true
						break
					if line_str2.begins_with("\t"):
						break
				var ind := "    " if uses_spaces else "\t"
				_log_info(TAG + " IXP indent style: " + ("spaces" if uses_spaces else "tabs"))
				ixp_new_lines.append("")
				ixp_new_lines.append("func Movement(delta):")
				ixp_new_lines.append(ind + "if Engine.get_frames_drawn() % 60 == 0:")
				ixp_new_lines.append(ind + ind + 'print("[TEST-HOOK-IXP] Movement called (frame %d)" % Engine.get_frames_drawn())')
				ixp_new_lines.append(ind + "_rtv_vanilla_Movement(delta)")
				ixp_new_lines.append("")
				var ixp_rewritten := "\n".join(ixp_new_lines)
				zp.start_file("ImmersiveXP/Controller.gd")
				zp.write_file(ixp_rewritten.to_utf8_buffer())
				zp.close_file()
				# Also ship .gd.remap pointing back at .gd in case ImmersiveXP
				# shipped a remap (mod archives from Godot export often do).
				zp.start_file("ImmersiveXP/Controller.gd.remap")
				zp.write_file("[remap]\npath=\"res://ImmersiveXP/Controller.gd\"\n".to_utf8_buffer())
				zp.close_file()
				_log_info(TAG + " TEST 4B: wrote pre-wrapped ImmersiveXP/Controller.gd (" \
						+ str(ixp_rewritten.length()) + " chars)")
			else:
				_log_warning(TAG + " TEST 4B: Movement not found in IXP source, skipped")
		else:
			_log_info(TAG + " TEST 4B: IXP Controller source empty, skipped")
	else:
		_log_info(TAG + " TEST 4B: IXP Controller not in VFS (ImmersiveXP disabled?)")

	zp.close()
	_log_info(TAG + " wrote test pack")

	# --- MOUNT ---
	if not ProjectSettings.load_resource_pack(TEST_ZIP, true):
		_log_critical(TAG + " FAIL: load_resource_pack returned false")
		return
	_log_info(TAG + " mounted test pack OK (replace_files=true)")

	# --- POST-MOUNT DIAGNOSTICS ---
	_log_info(TAG + " === POST-MOUNT VFS state ===")
	_log_info(TAG + "   FileAccess.file_exists(.gd):       " + str(FileAccess.file_exists(TARGET_PATH)))
	_log_info(TAG + "   FileAccess.file_exists(.gdc):      " + str(FileAccess.file_exists(GDC_PATH)))
	_log_info(TAG + "   FileAccess.file_exists(.gd.remap): " + str(FileAccess.file_exists(REMAP_PATH)))
	if FileAccess.file_exists(REMAP_PATH):
		var post_remap := FileAccess.get_file_as_string(REMAP_PATH)
		_log_info(TAG + "   .remap content post-mount: " + post_remap.replace("\n", "|"))
	if FileAccess.file_exists(TARGET_PATH):
		var gd_bytes := FileAccess.get_file_as_bytes(TARGET_PATH)
		_log_info(TAG + "   .gd bytes readable: " + str(gd_bytes.size()) + " bytes")
		if gd_bytes.size() > 0:
			var first_80 := gd_bytes.slice(0, 80).get_string_from_utf8()
			_log_info(TAG + "   .gd first 80 bytes: " + first_80.replace("\n", "|"))
	# Also verify IXP path post-mount
	const IXP_PATH_CHECK := "res://ImmersiveXP/Controller.gd"
	if FileAccess.file_exists(IXP_PATH_CHECK):
		var ixp_bytes := FileAccess.get_file_as_bytes(IXP_PATH_CHECK)
		var ixp_content := ixp_bytes.get_string_from_utf8()
		var has_our_hook := "TEST-HOOK-IXP" in ixp_content
		_log_info(TAG + "   IXP/Controller.gd bytes: " + str(ixp_bytes.size()) + " has our marker: " + str(has_our_hook))
		if not has_our_hook and ixp_content.length() > 0:
			_log_info(TAG + "   IXP/Controller.gd first 80: " + ixp_content.substr(0, 80).replace("\n", "|"))

	# --- LOAD TESTS ---
	_log_info(TAG + " === LOAD ATTEMPTS (cache should be cold -- we never pre-loaded) ===")

	# Attempt 1: default load(), same as any game code. If this has our marker,
	# production scripts will get our version too.
	var post := load(TARGET_PATH) as GDScript
	if post:
		var post_source: String = post.source_code
		_log_info(TAG + "   load(.gd) [default=REUSE]: source_code length=" + str(post_source.length()) \
				+ " has_marker=" + str(MARKER_SYMBOL in post_source))
	else:
		_log_critical(TAG + "   load(.gd) [default=REUSE]: returned null")

	# Attempt 2: load again -- confirm cache is stable (returns same instance)
	var post2 := load(TARGET_PATH) as GDScript
	if post and post2:
		_log_info(TAG + "   load(.gd) [2nd]: same_instance=" + str(post == post2) \
				+ " same_source_length=" + str(post.source_code.length() == post2.source_code.length()))

	# Attempt 3: ResourceLoader.CACHE_MODE_IGNORE (fresh VFS fetch, bypass cache)
	var post_ignore := ResourceLoader.load(TARGET_PATH, "", ResourceLoader.CACHE_MODE_IGNORE) as GDScript
	if post_ignore:
		var ignore_source: String = post_ignore.source_code
		_log_info(TAG + "   load(.gd, IGNORE): source_code length=" + str(ignore_source.length()) \
				+ " has_marker=" + str(MARKER_SYMBOL in ignore_source))

	# Attempt 4: deeper diagnostics on the compiled script
	var marker_in_method_list := false
	if post:
		_log_info(TAG + "   global_name: " + str(post.get_global_name()))
		_log_info(TAG + "   instance_base_type: " + post.get_instance_base_type())
		var methods := post.get_script_method_list()
		var method_names: Array = []
		for m in methods:
			method_names.append(m["name"])
		marker_in_method_list = MARKER_SYMBOL in method_names
		_log_info(TAG + "   method list count: " + str(method_names.size()) \
				+ "  marker_in_list: " + str(marker_in_method_list))

	# Attempt 5: DEFINITIVE test -- instantiate the script and call the marker method
	var instantiate_ok := false
	var call_returned: Variant = null
	var call_err := ""
	if post and marker_in_method_list:
		# Script.new() for Node-derived scripts creates a new instance; must free() it.
		# Wrap in a safety check -- CharacterBody3D init may fail without scene context.
		var inst = null
		var new_ok := false
		inst = post.new() if post.can_instantiate() else null
		new_ok = inst != null
		_log_info(TAG + "   script.new() returned non-null: " + str(new_ok))
		if new_ok:
			instantiate_ok = true
			if inst.has_method(MARKER_SYMBOL):
				call_returned = inst.call(MARKER_SYMBOL)
				_log_info(TAG + "   instance.call(marker): returned " + str(call_returned))
			else:
				call_err = "instance lacks marker method despite class having it"
				_log_info(TAG + "   " + call_err)
			# Clean up -- don't leak Node instances
			if inst is Node:
				(inst as Node).queue_free()
			else:
				inst = null

	# Check Movement method rewrite landed in compiled class
	var vanilla_movement_in_list := false
	var wrapper_movement_in_list := false
	if post:
		var methods2 := post.get_script_method_list()
		for m in methods2:
			if m["name"] == "_rtv_vanilla_Movement":
				vanilla_movement_in_list = true
			elif m["name"] == "Movement":
				wrapper_movement_in_list = true
		_log_info(TAG + "   _rtv_vanilla_Movement in method list: " + str(vanilla_movement_in_list))
		_log_info(TAG + "   Movement (wrapper) in method list: " + str(wrapper_movement_in_list))

	# Final verdict
	var final_sc := (post.source_code if post else "") as String
	if marker_in_method_list and call_returned == "test-remap-ok":
		_log_info(TAG + " ====== CONFIRMED SUCCESS: rewrite compiled AND callable ======")
		if vanilla_movement_in_list and wrapper_movement_in_list:
			_log_info(TAG + " Step A: Movement rename + dispatch wrapper both compiled OK")
			_log_info(TAG + " (wrapper prints nothing by itself; [TEST-HOOK-IXP] from IXP-side wrapper is the in-game signal)")
		else:
			_log_warning(TAG + " Step A: Movement intercept NOT compiled (vanilla_in=%s, wrapper_in=%s)" \
					% [vanilla_movement_in_list, wrapper_movement_in_list])
	elif marker_in_method_list:
		_log_info(TAG + " ====== LIKELY SUCCESS: compiled script has marker method ======")
		_log_info(TAG + " (couldn't instantiate to call -- instantiate_ok=" + str(instantiate_ok) \
				+ " err='" + call_err + "')")
	elif MARKER_SYMBOL in final_sc:
		_log_critical(TAG + " ====== SOURCE-ONLY: text has marker but compiled class does not ======")
	elif final_sc.is_empty():
		_log_critical(TAG + " ====== FAIL: got bytecode, source empty ======")
	else:
		_log_critical(TAG + " ====== UNKNOWN: got source but no marker ======")

