cthangover - source code
Plain-text dump for AI agents: 1145 C# files, tests excluded.

===== SOURCE CODE (C#) =====

===== FILE Core/Actions/Atomic/CharacterAddSkillAction.cs =====
namespace Cthangover.Core.Actions.Atomic{/// <summary> Grants a skill to the player by skill ID. Delegates to <see cref="ICharacterService.AddSkill"/>, which routes through the skills subsystem (SkillsBridge into the skills_base engine mod): the skill is added to the owned set and any action card named by its GrantsAction field lands in the action pool, ready to be assigned to a character. Already owned skills are ignored, making the action safe to replay after loading a save. </summary>
public class CharacterAddSkillAction:IScenarioAction{/// <summary> Registered as "character.add_skill" — grants the skill named by the "skill_id" dialog variable. The variable matches the skill's Id in its JSON definition (e.g. "slave_master"). Unknown IDs are rejected downstream with a log warning. </summary>
public string Name=>"character.add_skill";/// <summary> Reads "skill_id" and grants the skill. Returns early with a log message when the variable is missing or empty, so a malformed scenario line cannot half-apply the acquisition. </summary>
public void Run(IActionContext ctx){var skillId=ctx.GetParam("skill_id");if(string.IsNullOrEmpty(skillId)){ctx.Log("EVENT","CharacterAddSkillAction: missing 'skill_id' variable");return;}ctx.Character.AddSkill(skillId);ctx.Log("EVENT",$"CharacterAddSkillAction: granted skill '{skillId}'");}}}

===== FILE Core/Actions/Atomic/CharacterAddToPartyAction.cs =====
namespace Cthangover.Core.Actions.Atomic{/// <summary> Recruits a character to the player's party by character ID string. The "type" variable should match a character ID (e.g. "Marao", "Murakami"). Delegates to CharacterData.AddCharacterToParty which handles the actual roster mutation and refuses unknown IDs with an error log — a typo here never reaches the persistent roster. </summary>
public class CharacterAddToPartyAction:IScenarioAction{/// <summary> Registered as "character.add_to_party" — adds a character to the player's party roster. The "type" dialog variable is a character ID string. Delegates to CharacterData.AddCharacterToParty which handles persistence; unknown IDs are refused with an error log instead of creating a placeholder party member. </summary>
public string Name=>"character.add_to_party";/// <summary> Reads the "type" variable and recruits the character to the party. Returns early with a log warning if "type" is missing or empty. The character is immediately available in the party roster after this call returns. </summary>
public void Run(IActionContext ctx){var typeRaw=ctx.GetParam("type");if(string.IsNullOrEmpty(typeRaw)){ctx.Log("EVENT","CharacterAddToPartyAction: missing 'type' variable");return;}ctx.Character.AddToParty(typeRaw);ctx.Log("EVENT",$"CharacterAddToPartyAction: added '{typeRaw}' to party");}}}

===== FILE Core/Actions/Atomic/CharacterSendNotificationAction.cs =====
namespace Cthangover.Core.Actions.Atomic{/// <summary> Shows a "character joined" UI notification without actually adding them to the party. Used when the recruitment happens via a different mechanism but the player still needs to see the visual confirmation. </summary>
public class CharacterSendNotificationAction:IScenarioAction{/// <summary> Registered as "character.send_notification" — shows a "character joined" UI popup without modifying the party roster. Used when recruitment happens through external means but the player needs visual confirmation, or when re-triggering a notification at a specific story beat. </summary>
public string Name=>"character.send_notification";/// <summary> Reads the "type" variable and triggers the join notification via CharacterData.SendAddNotification. The notification system tracks which characters have already been notified to avoid duplicate popups for the same character. </summary>
public void Run(IActionContext ctx){var typeRaw=ctx.GetParam("type");if(string.IsNullOrEmpty(typeRaw)){ctx.Log("EVENT","CharacterSendNotificationAction: missing 'type' variable");return;}ctx.Character.SendNotification(typeRaw);ctx.Log("EVENT",$"CharacterSendNotificationAction: sent notification for '{typeRaw}'");}}}

===== FILE Core/Actions/Atomic/SceneInstantiateAction.cs =====
namespace Cthangover.Core.Actions.Atomic;/// <summary> Instantiates a PackedScene into the current scene tree at runtime. The "path" is a Godot resource path (e.g. "res://scenes/SomeObject.tscn"), "name" sets the node's Name property. The instantiated node becomes a child of SceneContextNode.Instance (the current scene root), making it visible immediately. </summary>
public class SceneInstantiateAction:IScenarioAction{/// <summary> Registered as "scene.instantiate" — loads a PackedScene from a Godot resource path and inserts it into the current scene tree at runtime. The "path" variable is a Godot resource path (e.g. "res://scenes/SomeObject.tscn"), "name" sets the node's Name property. The node becomes a child of SceneContextNode.Instance and is visible immediately. Both variables are required. </summary>
public string Name=>"scene.instantiate";/// <summary> Reads "path" and "name" from dialog variables and delegates to ctx.Scene.Instantiate. Uses GD.Load&lt;PackedScene&gt; internally — if the resource fails to load (wrong path), an error is logged but the dialog continues. Returns early with a warning if either variable is missing. </summary>
public void Run(IActionContext ctx){var path=ctx.GetParam("path");var name=ctx.GetParam("name");if(string.IsNullOrEmpty(path)||string.IsNullOrEmpty(name)){ctx.Log("EVENT","SceneInstantiateAction: missing 'path' or 'name' variable");return;}ctx.Scene.Instantiate(path,name);ctx.Log("SCENE",$"SceneInstantiateAction: instantiated '{path}' as '{name}'");}}

===== FILE Core/Actions/Atomic/SceneRemoveObjectAction.cs =====
namespace Cthangover.Core.Actions.Atomic;/// <summary> Removes a named child from the scene tree. Before RemoveChild+QueueFree, it calls SceneContextNode.RemoveEventObject to notify the event system that the object is being destroyed — this prevents stale references in the event chain. The "name" must match the node's Name property exactly. </summary>
public class SceneRemoveObjectAction:IScenarioAction{/// <summary> Registered as "scene.remove_object" — removes a named child node from the scene tree. Before freeing the node, it notifies the event system via SceneContextNode.RemoveEventObject to clean up subscriptions. The "name" variable must match the node's Name property exactly — partial matches won't work. </summary>
public string Name=>"scene.remove_object";/// <summary> Reads the "name" variable and delegates to ctx.Scene.Remove. Safe to call on non-existent nodes — the implementation silently returns if the named child is not found. Returns early with a warning if "name" is missing. </summary>
public void Run(IActionContext ctx){var name=ctx.GetParam("name");if(string.IsNullOrEmpty(name)){ctx.Log("EVENT","SceneRemoveObjectAction: missing 'name' variable");return;}ctx.Scene.Remove(name);ctx.Log("SCENE",$"SceneRemoveObjectAction: removed '{name}'");}}

===== FILE Core/Actions/Atomic/TogglePanelAction.cs =====
using Cthangover.Core.Bootstrap;using Cthangover.Core.UI;using Godot;namespace Cthangover.Core.Actions.Atomic;/// <summary> Toggles the visibility of any Control node in the scene by name. Uses ctx.Scene.Find for recursive tree search — panels can be nested arbitrarily deep. When the name is not found in the scene, the action falls back to <see cref="UiRegistry.Toggle"/> so host-built panels (e.g. the world map, live under UiHost's CanvasLayer rather than the scene tree) can be opened from scenarios. Unlike Show/Hide methods that use Widget lifecycle, scene controls are toggled through Godot's native Visible property directly, so this works on non-Widget controls as well. </summary>
public class TogglePanelAction:IScenarioAction{/// <summary> Registered as "ui.toggle_panel" — toggles the visibility of any Control-derived node in the scene by name; if no scene node matches, the name is treated as a mod UI entry id and toggled through the UI registry. The toggle is unconditional — if neither exists, it logs a warning rather than creating anything. </summary>
public string Name=>"ui.toggle_panel";/// <summary> Reads the "name" variable, locates the Control node via recursive scene tree search, and flips its Visible property. Falls back to UiRegistry.Toggle(id) for hosted panels. Logs both the toggle result (new visibility state) and the not-found case. Returns early with a warning if "name" is missing. </summary>
public void Run(IActionContext ctx){var name=ctx.GetParam("name");if(string.IsNullOrEmpty(name)){ctx.Log("EVENT","TogglePanelAction: missing 'name' variable");return;}var panel=ctx.Scene.Find<Control>(name);if(panel!=null){panel.Visible= !panel.Visible;ctx.Log("WIDGET",$"TogglePanelAction: toggled '{name}' visibility to {panel.Visible}");return;}if(AppServices.Current.Ui.GetById(name)!=null){AppServices.Current.Ui.ToggleEntry(name);ctx.Log("WIDGET",$"TogglePanelAction: toggled hosted panel '{name}'");return;}ctx.Log("EVENT",$"TogglePanelAction: panel '{name}' not found");}}

===== FILE Core/Actions/CharacterServiceImpl.cs =====
using Cthangover.Core.Settings;namespace Cthangover.Core.Actions{/// <summary> Delegates character operations to GameData.Instance.Runtime.CharacterData. The type string is a character ID — it is passed directly to CharacterData without parsing. Malformed or missing IDs are handled inside CharacterData (null-character fallback for unknown IDs). </summary>
public class CharacterServiceImpl:ICharacterService{/// <summary> Passes the type string directly to CharacterData.AddCharacterToParty. The character ID is resolved through CharacterFactory; an unknown ID is refused (error log, party unchanged) so a typo in scenario content cannot add a broken party member. A valid character is added to the runtime party and persists through the save system. </summary>
public void AddToParty(string type){var characterData=GameData.Instance.Runtime.CharacterData;characterData.AddCharacterToParty(type);}/// <summary> Passes the type string directly to CharacterData.SendAddNotification. Unlike AddToParty, this only triggers the UI popup without modifying the party roster. Useful when a character was recruited earlier (or externally) but the notification needs to appear at a specific story beat. </summary>
public void SendNotification(string type){var characterData=GameData.Instance.Runtime.CharacterData;characterData.SendAddNotification(type);}/// <summary> Passes the skill ID to the skills subsystem through <see cref="SkillsBridge"/>. The grant implementation owns both halves of an acquisition — the skill entry and the action card it may grant — and rejects unknown skill IDs with a warning, so a typo in a scenario cannot corrupt the runtime state. </summary>
public void AddSkill(string id){SkillsBridge.Grant(id);}}}

===== FILE Core/Actions/GDActionContext.cs =====
using Cthangover.Core.Events;using Godot;namespace Cthangover.Core.Actions;/// <summary> GDScript-facing action context passed to <c>run(ctx)</c> of GDScript scenario actions (see <see cref="GDScriptScenarioAction"/>). Mirrors the <see cref="IActionContext"/> surface with snake-callable wrappers: subsystem services (<see cref="Quests"/>, <see cref="Character"/>, <see cref="Lighting"/>, <see cref="Scene"/>, <see cref="Inventory"/>, <see cref="ModRegistry"/>), parameter access (<see cref="GetParam"/>), logging, and event publishing. The backing C# context is attached just before the call via <see cref="GDActionContextCore.Inner"/>, so a GDScript action sees exactly the state a C# action would. </summary>
[GlobalClass]public partial class GDActionContext:RefCounted{internal GDActionContextCore Core=new();/// <summary>Quest subsystem: status, tags, and UI notifications.</summary>
public GDQuestService Quests=>Core.Quests;/// <summary>Character subsystem: party recruitment and join notifications.</summary>
public GDCharacterService Character=>Core.Character;/// <summary>Lighting subsystem: depth/albedo maps and time-of-day lighting.</summary>
public GDLightingService Lighting=>Core.Lighting;/// <summary>Scene node subsystem: instantiate, remove, and find nodes.</summary>
public GDSceneNodeService Scene=>Core.Scene;/// <summary>Player inventory subsystem.</summary>
public Items.GDInventoryService Inventory=>Core.Inventory;/// <summary>Mod registry: query loaded mods and their metadata.</summary>
public Mods.GDModRegistryService ModRegistry=>Core.ModRegistry;/// <summary>Reads a named dialog variable (GDScript: <c>ctx.GetParam("quest_id")</c>). Returns null when unset.</summary>
public string GetParam(string name)=>Core.GetParam(name);/// <summary>Emits a log entry to the central game log under the given category.</summary>
public void Log(string category,string message)=>Core.Log(category,message);/// <summary>Emits a warning-level log entry (prefixed with "[WARN]").</summary>
public void LogWarning(string category,string message)=>Core.LogWarning(category,message);/// <summary> GDScript: ctx.PublishEvent("cooking_meal_ready", {"dish": "wolf_stew"}) Publishes an event to the global bus for inter-mod communication. </summary>
public void PublishEvent(string eventKey,Godot.Collections.Dictionary data){ModEventBus.Instance?.Publish(eventKey,data);}}

===== FILE Core/Actions/GDActionContextCore.cs =====
namespace Cthangover.Core.Actions;/// <summary> Shared backing state for the GDScript-facing context wrappers. Holds the six GD service singletons and the C# <see cref="IActionContext"/> they delegate to. Assigning <see cref="Inner"/> fans the context out to every service at once, so <see cref="GDScriptScenarioAction"/> can bind a freshly created <see cref="GDActionContext"/> to the running dialog context with a single property set. All members are null-safe: before binding, calls silently no-op instead of throwing inside GDScript. </summary>
public class GDActionContextCore{/// <summary> The live C# context of the running scenario action. Setting it propagates the underlying services into every GD wrapper field. Null while the context is not bound to a running action. </summary>
public IActionContext Inner{get=>_inner;set{_inner=value;Quests.Inner=value?.Quests;Character.Inner=value?.Character;Lighting.Inner=value?.Lighting;Scene.Inner=value?.Scene;Inventory.Inner=value?.Inventory;ModRegistry.Inner=value?.ModRegistry;}}/// <summary>Delegates to the bound context; returns null before binding.</summary>
public string GetParam(string name)=>_inner?.GetParam(name);/// <summary>Delegates to the bound context; no-op before binding.</summary>
public void Log(string category,string message)=>_inner?.Log(category,message);/// <summary>Delegates to the bound context; no-op before binding.</summary>
public void LogWarning(string category,string message)=>_inner?.LogWarning(category,message);/// <summary>Quest subsystem wrapper bound by <see cref="Inner"/>.</summary>
public readonly GDQuestService Quests=new();/// <summary>Character subsystem wrapper bound by <see cref="Inner"/>.</summary>
public readonly GDCharacterService Character=new();/// <summary>Lighting subsystem wrapper bound by <see cref="Inner"/>.</summary>
public readonly GDLightingService Lighting=new();/// <summary>Scene node subsystem wrapper bound by <see cref="Inner"/>.</summary>
public readonly GDSceneNodeService Scene=new();/// <summary>Inventory subsystem wrapper bound by <see cref="Inner"/>.</summary>
public readonly Items.GDInventoryService Inventory=new();/// <summary>Mod registry wrapper bound by <see cref="Inner"/>.</summary>
public readonly Mods.GDModRegistryService ModRegistry=new();private IActionContext _inner;}

===== FILE Core/Actions/GDCharacterService.cs =====
using Godot;namespace Cthangover.Core.Actions{/// <summary> GDScript-facing character subsystem exposed on the action context (<c>ctx.Character</c>). Thin delegate over <see cref="ICharacterService"/>; every call forwards to the C# context bound by <see cref="GDActionContextCore.Inner"/>, so GDScript actions get the same behavior (and the same safe handling of unknown IDs) as C# actions. </summary>
[GlobalClass]public partial class GDCharacterService:RefCounted{internal ICharacterService Inner;/// <summary>GDScript: ctx.Character.AddToParty("Murakami") — adds a character to the player's party by ID.</summary>
public void AddToParty(string type)=>Inner.AddToParty(type);/// <summary>GDScript: ctx.Character.SendNotification("Murakami") — shows the "character joined" popup without touching the roster.</summary>
public void SendNotification(string type)=>Inner.SendNotification(type);/// <summary>GDScript: ctx.Character.AddSkill("slave_master") — grants a skill and its action card to the player.</summary>
public void AddSkill(string id)=>Inner.AddSkill(id);}}

===== FILE Core/Actions/GDLightingService.cs =====
using Godot;namespace Cthangover.Core.Actions{/// <summary> GDScript-facing lighting subsystem exposed on the action context (<c>ctx.Lighting</c>). Thin delegate over <see cref="ILightingService"/>; calls forward to the C# context bound by <see cref="GDActionContextCore.Inner"/>. </summary>
[GlobalClass]public partial class GDLightingService:RefCounted{internal ILightingService Inner;/// <summary>GDScript: ctx.Lighting.ClearDepthMap() — resets the depth and albedo lighting maps (back to flat lighting).</summary>
public void ClearDepthMap()=>Inner.ClearDepthMap();/// <summary>GDScript: ctx.Lighting.SetUseTime(true) — toggles time-of-day-driven light colors and angles.</summary>
public void SetUseTime(bool useTime)=>Inner.SetUseTime(useTime);}}

===== FILE Core/Actions/GDQuestService.cs =====
using Godot;namespace Cthangover.Core.Actions{/// <summary> GDScript-facing quest subsystem exposed on the action context (<c>ctx.Quests</c>). Thin delegate over <see cref="IQuestService"/>; calls forward to the C# context bound by <see cref="GDActionContextCore.Inner"/>, so missing quests are skipped safely exactly as in C# actions. </summary>
[GlobalClass]public partial class GDQuestService:RefCounted{internal IQuestService Inner;/// <summary>GDScript: ctx.Quests.Exists("q") — true when the quest is registered.</summary>
public bool Exists(string id)=>Inner.Exists(id);/// <summary>GDScript: ctx.Quests.SetStatus("q", "Progress") — sets the lifecycle state from a case-insensitive QuestStatus enum name (NotStarted / Progress / End).</summary>
public void SetStatus(string id,string status)=>Inner.SetStatus(id,status);/// <summary>GDScript: ctx.Quests.SetDataStatus("q", 3) — sets the quest's numeric progress counter.</summary>
public void SetDataStatus(string id,int level)=>Inner.SetDataStatus(id,level);/// <summary>GDScript: ctx.Quests.AddTag("q", "met_marao") — attaches a condition tag.</summary>
public void AddTag(string id,string tag)=>Inner.AddTag(id,tag);/// <summary>GDScript: ctx.Quests.RemoveTag("q", "met_marao") — removes a condition tag.</summary>
public void RemoveTag(string id,string tag)=>Inner.RemoveTag(id,tag);/// <summary>GDScript: ctx.Quests.SendNotification("q") — shows the "Quest Updated" popup without changing state.</summary>
public void SendNotification(string id)=>Inner.SendNotification(id);}}

===== FILE Core/Actions/GDSceneNodeService.cs =====
using Godot;namespace Cthangover.Core.Actions{/// <summary> GDScript-facing scene node subsystem exposed on the action context (<c>ctx.Scene</c>). Thin delegate over <see cref="ISceneNodeService"/>; calls forward to the C# context bound by <see cref="GDActionContextCore.Inner"/>. </summary>
[GlobalClass]public partial class GDSceneNodeService:RefCounted{internal ISceneNodeService Inner;/// <summary>GDScript: ctx.Scene.Instantiate("res://scenes/ui/x.tscn", "X") — instantiates a PackedScene into the current scene tree.</summary>
public void Instantiate(string scenePath,string nodeName)=>Inner.Instantiate(scenePath,nodeName);/// <summary>GDScript: ctx.Scene.Remove("X") — removes a named node from the scene tree (no-op when absent).</summary>
public void Remove(string nodeName)=>Inner.Remove(nodeName);}}

===== FILE Core/Actions/GDScriptScenarioAction.cs =====
using Godot;namespace Cthangover.Core.Actions;/// <summary> Adapter that exposes a GDScript object with <c>get_name()</c> + <c>run(ctx)</c> as a regular <see cref="IScenarioAction"/>. Created by <c>ModGDScriptLoader</c> for every matching <c>.gd</c> file and registered in <see cref="Cthangover.Core.Factories.Impls.ScenarioActionFactory"/> like any C# action, so the DSL <c>action</c> command cannot tell the two apart. </summary>
public class GDScriptScenarioAction:IScenarioAction{private readonly GodotObject _instance;/// <summary> Wraps a compiled GDScript instance. </summary> <param name="name">Action name returned by the script's <c>get_name()</c>; becomes the DSL registry key.</param> <param name="instance">Live GodotObject backing the GDScript; <c>run(ctx)</c> is invoked on it.</param>
public GDScriptScenarioAction(string name,GodotObject instance){Name=name;_instance=instance;}/// <summary>DSL action name from the script's <c>get_name()</c> (e.g. "cooking.meal_cooked").</summary>
public string Name{get;}/// <summary> Invokes the script's <c>run(ctx)</c> with a <see cref="GDActionContext"/> bound to the C# context, giving the script access to dialog variables and all subsystem services. </summary>
public void Run(IActionContext ctx){var gdCtx=new GDActionContext();gdCtx.Core.Inner=ctx;_instance.Call("run",gdCtx);}}

===== FILE Core/Actions/IActionContext.cs =====
using Cthangover.Core.Items;using Cthangover.Core.Mods;namespace Cthangover.Core.Actions;/// <summary> Context passed to IScenarioAction.Run — provides dialog variable access (GetParam) and references to all subsystem service interfaces. Acts as a facade: a single object that grants the action access to quests, characters, lighting, scene nodes, mod registry, and inventory, plus logging. GetParam reads from the DialogRuntime's variable store, so actions can consume values set by the scenario DSL's "set" command or by earlier actions. </summary>
public interface IActionContext{/// <summary> Quest subsystem: full CRUD over quest state, status, tags, and UI notifications. Backed by QuestServiceImpl which uses QuestFactory for lookup and TryGet for safe fallback on missing quests. </summary>
IQuestService Quests{get;}/// <summary> Character subsystem: add characters to the player's party and dispatch "character joined" UI notifications. Accepts character ID strings from scenario parameters. </summary>
ICharacterService Character{get;}/// <summary> Lighting subsystem: controls depth/albedo texture maps on the UiLightController singleton and toggles time-of-day lighting. Used for scene transitions with different lighting setups. </summary>
ILightingService Lighting{get;}/// <summary> Scene node subsystem: runtime instantiation, removal, and typed lookup of nodes within the current scene tree. All operations go through SceneContextNode.Instance as the scene root. </summary>
ISceneNodeService Scene{get;}/// <summary> Mod registry reference: allows scenario actions to query loaded mods, their metadata, and custom action registrations at runtime. Singleton instance shared across all contexts. </summary>
IModRegistry ModRegistry{get;}/// <summary> Player inventory reference, obtained from GameData at context construction time. May be null if game data hasn't fully loaded yet — consumers must tolerate a null inventory. </summary>
IInventory Inventory{get;}/// <summary> World map subsystem: open/close zones for travel and show/hide them on the map. Backed by <c>MapZoneServiceImpl</c> over the process-wide <c>MapZoneService</c> singleton; state changes are persisted by the core save slot and invalidate scenario conditions through <c>ConditionStateMonitor</c>. </summary>
IMapZoneService Map{get;}/// <summary> Reads a named variable from the dialog runtime's variable store. Variables are populated by the scenario DSL's "set" command or by earlier actions during dialog execution. Returns null when the variable doesn't exist — callers must null-check. This is the sole data channel between scenario actions and the dialog system. </summary>
string GetParam(string name);/// <summary> Emits a debug-level entry to the central game log. The category string groups related entries (convention: "EVENT", "QUEST", "BATTLE", "SCENE", "WIDGET"). Consistent categories enable log filtering during debugging of scenario scripts. </summary>
void Log(string category,string message);/// <summary> Emits a warning-level entry to the central game log. The message is prefixed with "[WARN]" and logged at LogLevel.Warning. Use for non-fatal issues that scenario authors should investigate (missing variables, unregistered quests, etc.) without halting dialog execution. </summary>
void LogWarning(string category,string message);}

===== FILE Core/Actions/ICharacterService.cs =====
namespace Cthangover.Core.Actions;/// <summary> Character manipulation contract for scenario actions. AddToParty recruits a character by its string ID; SendNotification displays a UI notification that a character has joined. </summary>
public interface ICharacterService{/// <summary> Adds a character to the player's party roster. The type string is a character ID — it is passed directly to CharacterData.AddCharacterToParty which handles roster mutation, persistence, and fallback for unknown IDs (a minimal CharacterInfoData is created with default attributes when no template is found). </summary>
void AddToParty(string type);/// <summary> Displays a "character joined" UI notification without modifying the party roster. Used when recruitment happens through an external mechanism but the player still needs visual feedback, or when replaying a notification for an already-recruited character. The type string is a character ID passed directly to CharacterData.SendAddNotification. </summary>
void SendNotification(string type);/// <summary> Grants a skill to the player by skill ID. Routes through the skills subsystem (SkillsBridge into the skills_base engine mod), which also places the action card named by the skill's GrantsAction field into the action pool. Re-granting an owned skill is a no-op, so scenario events replayed after a load cannot duplicate cards. </summary>
void AddSkill(string id);}

===== FILE Core/Actions/ILightingService.cs =====
namespace Cthangover.Core.Actions;/// <summary> Lighting control contract for scenario actions. ClearDepthMap resets the depth/albedo textures on the UiLightController singleton, effectively removing scene-specific lighting masks. SetUseTime toggles time-of-day lighting on/off. </summary>
public interface ILightingService{/// <summary> Resets both the depth texture and albedo texture on the UiLightController singleton to null. This disables scene-specific lighting masks, reverting the shader to default flat lighting. Both maps are cleared together because partial clearing would leave the lighting shader in an inconsistent state. Used during scene transitions — call before entering a scene that doesn't use depth-based lighting, or when switching between scenes with different lighting setups. </summary>
void ClearDepthMap();/// <summary> Enables or disables time-of-day lighting on the UiLightController. When enabled, the controller adjusts scene lighting based on the in-game time; when disabled, lighting remains static. Use this when entering interior scenes (disable) or exterior scenes (enable) to match the environmental context. </summary>
void SetUseTime(bool useTime);}

===== FILE Core/Actions/IMapZoneService.cs =====
namespace Cthangover.Core.Actions;/// <summary> Scenario-facing facade over the map subsystem. Exposes only the four state transitions authors need from scripts — open/close (travel access) and show/hide (map visibility) — instead of the whole <see cref="Cthangover.Core.UI.Tool.MapZoneService"/> surface. Kept as an interface so <see cref="IActionContext"/> implementations used by tests can substitute a lightweight mock, exactly like the quest and character services. </summary>
public interface IMapZoneService{/// <summary>Effective open state of the zone; unknown IDs are closed.</summary>
bool IsOpen(string id);/// <summary>Effective visibility of the zone; unknown IDs are invisible.</summary>
bool IsVisible(string id);/// <summary>Opens the zone for travel. Unknown IDs are ignored with a warning.</summary>
void Open(string id);/// <summary>Closes the zone for travel. Unknown IDs are ignored with a warning.</summary>
void Close(string id);/// <summary>Reveals a hidden zone on the map. Unknown IDs are ignored with a warning.</summary>
void Show(string id);/// <summary>Removes a zone from the map. Unknown IDs are ignored with a warning.</summary>
void Hide(string id);}

===== FILE Core/Actions/IQuestService.cs =====
using Cthangover.Core.Quests;namespace Cthangover.Core.Actions;/// <summary> Quest manipulation contract for scenario actions. Provides full CRUD over quest state: Get by ID, status change (parsed via Enums&lt;QuestStatus&gt;), data-level status tracking, tag management, and UI notification dispatch. Separates quest.Status (global state like Active/Completed) from quest.Data.Status (incremental progress within the quest). </summary>
public interface IQuestService{/// <summary> Retrieves a quest by its string ID from QuestFactory. Throws KeyNotFoundException if the quest doesn't exist — prefer Exists() or the safe TryGet() pattern in the implementation when the quest may legitimately be absent. </summary>
IQuest Get(string id);/// <summary> Checks whether a quest with the given ID is registered in QuestFactory. Returns false on any exception (missing data, corrupted save), treating "can't read" as "doesn't exist" to prevent scenario scripts from crashing. </summary>
bool Exists(string id);/// <summary> Sets the global lifecycle state of a quest (Active, Completed, Failed, etc.). The status string is parsed via Enums&lt;QuestStatus&gt;.Parse — case-sensitive. Invalid status strings are caught and logged as errors without throwing. </summary>
void SetStatus(string id,string status);/// <summary> Sets the numeric progress level within a quest's Data.Status field. This is incremental progress (e.g. "2/5 wolves killed"), separate from the global quest state managed by SetStatus(). The quest's data object tracks the level for display and completion checks. </summary>
void SetDataStatus(string id,int level);/// <summary> Attaches a string tag to a quest for use in conditional logic. Tags are stored in the quest's save data and queried by scenario scripts via the "has_tag" condition to branch dialog or gate quest progression. Adding a tag that already exists is a no-op. </summary>
void AddTag(string id,string tag);/// <summary> Removes a string tag from a quest. Used to clear temporary conditions — for example, removing a "witnessed_event" tag after the event has been addressed. Removing a non-existent tag is a no-op. </summary>
void RemoveTag(string id,string tag);/// <summary> Triggers a UI notification for the quest (e.g. "Quest Updated" or "New Quest" popup). This is the visual feedback companion to state changes — call after SetStatus/SetDataStatus for the player to see the update. The notification itself does not change quest state. </summary>
void SendNotification(string id);}

===== FILE Core/Actions/IScenarioAction.cs =====
namespace Cthangover.Core.Actions;/// <summary> Contract for atomic scenario actions — named commands that the scenario DSL dispatches at runtime via ScenarioActionFactory. Each action has a unique Name (e.g. "quest.set_status", "battle.init") used as the registry key. Actions receive an IActionContext giving access to dialog variables and core subsystem services (quests, characters, lighting, scene, inventory); gameplay subsystems implemented in mods can receive extra data through dialog variables instead of new context services. Implementations are discovered via reflection and require no manual registration — the factory scans all assemblies for IScenarioAction types. </summary>
public interface IScenarioAction{/// <summary> Unique action identifier used as the registry key in ScenarioActionFactory. Follows the "subsystem.verb" convention (e.g. "quest.set_status", "battle.init", "scene.instantiate"). This is the name that scenario DSL scripts use after the "action" keyword. Must be unique across all registered actions — if a mod registers an action with a duplicate name, it is silently ignored (earliest registration wins). </summary>
string Name{get;}/// <summary> Executes the action using the provided context. The context provides access to dialog variables (via GetParam) and all subsystem services. Run() is called synchronously by the dialog engine when it encounters the corresponding action command in a scenario script — the dialog pauses until Run() returns, so actions must not block indefinitely. </summary>
void Run(IActionContext context);}

===== FILE Core/Actions/ISceneNodeService.cs =====
using Godot;namespace Cthangover.Core.Actions;/// <summary> Scene node manipulation contract for scenario actions. Instantiate loads a PackedScene and adds it as a child of SceneContextNode.Instance (the current scene root). Remove finds a child by name and frees it. Find provides typed lookup — used by TogglePanelAction to toggle Control visibility in the scene. </summary>
public interface ISceneNodeService{/// <summary> Loads a PackedScene from the given resource path via GD.Load&lt;PackedScene&gt;, instantiates it, assigns the given node name, and attaches it as a child of SceneContextNode.Instance (the current scene's autoload root). The node becomes visible immediately. Logs an error if the resource fails to load (wrong path, missing file) rather than throwing — this keeps the dialog running even with broken scene references. </summary>
void Instantiate(string scenePath,string nodeName);/// <summary> Removes a named child node from the scene tree and frees it. First calls SceneContextNode.RemoveEventObject to notify the event system of the pending destruction (prevents stale event references), then performs RemoveChild + QueueFree. Safe to call on non-existent nodes — silently returns. </summary>
void Remove(string nodeName);/// <summary> Performs a typed recursive search for a node by name across the entire scene tree, starting from SceneContextNode.Instance. Returns null if no matching node of type T with the given name exists. Used by actions like TogglePanelAction to locate UI controls regardless of nesting depth. </summary>
T Find<T>(string nodeName)where T:Node;}

===== FILE Core/Actions/LightingBridge.cs =====
using System;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Actions{/// <summary> Core-side bridge for the scene lighting stack. The implementation (locating the scene's lighting controller and pushing maps to it) lives in the time_base engine mod; the kernel — SceneManager's scene-switch reset and background updates — only knows these delegate slots. The mod installs its hooks through <see cref="Register"/> when the mod pipeline reaches <c>IModInitializer.OnModResourcesReady</c>. Registration is first-wins: reloading the mod re-runs its static initializers, and a second hook pair must never stack on top of the first. While no hooks are bound (kernel built without the time mod, or calls before mods load) invocations are skipped with a warning instead of throwing, so a scene switch cannot crash the runtime. </summary>
public static class LightingBridge{private static readonly object Gate=new();private static Action _clearSceneLights;private static Action<Texture2D,Texture2D>_setupBackgroundMaps;/// <summary>Installs the lighting hooks; first registration wins, re-registrations are logged and ignored.</summary>
public static void Register(Action clearSceneLights,Action<Texture2D,Texture2D>setupBackgroundMaps){if(clearSceneLights==null||setupBackgroundMaps==null)return;lock(Gate){if(_clearSceneLights!=null){GameLogger.Log("LIGHT","LightingBridge: lighting hooks already registered; ignoring re-registration",LogLevel.Warning);return;}_clearSceneLights=clearSceneLights;_setupBackgroundMaps=setupBackgroundMaps;}}/// <summary> Resets static lights and both lighting maps for the outgoing scene. Skipped with a warning when no time_base implementation is bound. </summary>
public static void ClearSceneLights(){Action hook;lock(Gate)hook=_clearSceneLights;if(hook==null){GameLogger.Log("LIGHT","LightingBridge: no lighting hooks bound (time_base not loaded); scene light reset skipped",LogLevel.Warning);return;}hook();}/// <summary> Pushes background depth/albedo textures onto the lighting controller. Skipped with a warning when no time_base implementation is bound. </summary>
public static void SetupBackgroundMaps(Texture2D depth,Texture2D albedo){Action<Texture2D,Texture2D>hook;lock(Gate)hook=_setupBackgroundMaps;if(hook==null){GameLogger.Log("LIGHT","LightingBridge: no lighting hooks bound (time_base not loaded); background maps update skipped",LogLevel.Warning);return;}hook(depth,albedo);}}}

===== FILE Core/Actions/MapStateBridge.cs =====
using System;using Cthangover.Core.UI.Tool;using Cthangover.Core.Utils;namespace Cthangover.Core.Actions{/// <summary> Core-side bridge for the world map zone state. The state owner (the zone list plus runtime open/visible overrides) lives in the map_base engine mod; the kernel only touches it through these delegate slots — <c>RuntimeSerializer</c> persists the overrides into the <c>__core__</c> save slot and <c>AppServices</c> resets them when a new game starts. The mod installs its hooks through <see cref="Register"/> when the mod pipeline reaches <c>IModInitializer.OnModResourcesReady</c>. Registration is first-wins: reloading the mod re-runs its static initializers, and a second hook set must never stack on top of the first. While no hooks are bound (kernel built without the map mod, or calls before mods load) exports yield an empty delta — a save without a map mod stays loadable — and imports/resets are skipped with a warning instead of throwing, mirroring <see cref="SkillsBridge"/>. </summary>
public static class MapStateBridge{private static readonly object Gate=new();private static Func<MapZoneStateData>_exportState;private static Action<MapZoneStateData>_importState;private static Action _resetOverrides;/// <summary>Installs the zone-state hooks; first registration wins, re-registrations are logged and ignored.</summary>
public static void Register(Func<MapZoneStateData>exportState,Action<MapZoneStateData>importState,Action resetOverrides){if(exportState==null||importState==null||resetOverrides==null)return;lock(Gate){if(_exportState!=null){GameLogger.Log("MAP","MapStateBridge: zone-state hooks already registered; ignoring re-registration",LogLevel.Warning);return;}_exportState=exportState;_importState=importState;_resetOverrides=resetOverrides;}}/// <summary> Copies the sparse zone overrides for the save system. Returns an empty delta (with a warning) when no map mod is bound, so saving keeps working and the slot layout stays stable. </summary>
public static MapZoneStateData ExportState(){Func<MapZoneStateData>hook;lock(Gate)hook=_exportState;if(hook==null){GameLogger.Log("MAP","MapStateBridge: no zone-state hooks bound (map_base not loaded); exporting empty map state",LogLevel.Warning);return new MapZoneStateData();}return hook();}/// <summary>Replaces the overrides with the values from a loaded save; skipped with a warning when no map mod is bound.</summary>
public static void ImportState(MapZoneStateData data){Action<MapZoneStateData>hook;lock(Gate)hook=_importState;if(hook==null){GameLogger.Log("MAP","MapStateBridge: no zone-state hooks bound (map_base not loaded); map state restore skipped",LogLevel.Warning);return;}hook(data);}/// <summary>Forgets every override when a new game starts; skipped with a warning when no map mod is bound.</summary>
public static void ResetOverrides(){Action hook;lock(Gate)hook=_resetOverrides;if(hook==null){GameLogger.Log("MAP","MapStateBridge: no zone-state hooks bound (map_base not loaded); map reset skipped",LogLevel.Warning);return;}hook();}}}

===== FILE Core/Actions/SceneNodeServiceImpl.cs =====
using Cthangover.Core.Scenes;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Actions{/// <summary> Scene node operations implementation. Instantiate loads a PackedScene by path and attaches it to SceneContextNode.Instance — the current scene's autoload root. Remove delegates to SceneContextNode.RemoveEventObject before the raw RemoveChild+QueueFree, suggesting the event system tracks instantiated objects and needs cleanup notification. Find uses SceneContextNode.FindNode for typed recursive lookup across the scene tree. </summary>
public class SceneNodeServiceImpl:ISceneNodeService{/// <summary> Loads a PackedScene by resource path via GD.Load, instantiates it, assigns the node name, and adds it as a child of SceneContextNode.Instance (the current scene's autoload root). If the resource fails to load (wrong path, missing file), logs an error and returns without throwing — the dialog continues. The instantiated node becomes visible immediately. </summary>
public void Instantiate(string scenePath,string nodeName){var packedScene=GD.Load<PackedScene>(scenePath);if(packedScene==null){GameLogger.Log("SCENE",$"SceneNodeService: failed to load '{scenePath}'",LogLevel.Error);return;}var instance=packedScene.Instantiate();instance.Name=nodeName;SceneContextNode.Instance?.AddChild(instance);}/// <summary> Removes a named child node from SceneContextNode.Instance. First notifies the event system via RemoveEventObject to clean up any event subscriptions referencing the node, then performs RemoveChild + QueueFree. Safe to call on non-existent nodes (silently returns if the context or child is null). </summary>
public void Remove(string nodeName){var ctx=SceneContextNode.Instance;if(ctx==null)return;ctx.RemoveEventObject(nodeName);var child=ctx.FindChild(nodeName,false,false);if(child!=null){ctx.RemoveChild(child);child.QueueFree();}}/// <summary> Typed recursive lookup by node name across the entire scene tree via SceneContextNode.FindNode. Returns null if no matching node of type T with the given name exists. Used by TogglePanelAction to locate UI controls at any nesting depth. </summary>
public T Find<T>(string nodeName)where T:Node{return SceneContextNode.FindNode<T>(nodeName);}}}

===== FILE Core/Actions/SkillsBridge.cs =====
using System;using Cthangover.Core.Utils;namespace Cthangover.Core.Actions{/// <summary> Core-side bridge for skill grants. The grant logic itself (resolving the definition and pairing the skill with its granted action card) lives in the skills_base engine mod; the kernel only knows this delegate slot. The mod installs its implementation through <see cref="Register"/> when the mod pipeline reaches <c>IModInitializer.OnModResourcesReady</c>. Registration is first-wins: reloading the mod re-runs its static initializers, and a second delegate must never stack on top of the first. While no sink is bound (kernel built without the skills mod, or the call happens before mods load) grants are rejected with a warning instead of throwing, so a scenario cannot crash the runtime. </summary>
public static class SkillsBridge{private static readonly object Gate=new();private static Func<string,bool>_grantSkill;/// <summary>Installs the grant implementation; first registration wins, re-registrations are logged and ignored.</summary>
public static void Register(Func<string,bool>grantSkill){if(grantSkill==null)return;lock(Gate){if(_grantSkill!=null){GameLogger.Log("SKILL","SkillsBridge: grant sink already registered; ignoring re-registration",LogLevel.Warning);return;}_grantSkill=grantSkill;}}/// <summary> Grants a skill through the bound sink. Returns <c>false</c> when no skills mod is loaded (with a warning) or when the sink rejects the ID — mirroring the old direct <c>SkillService.GrantSkill</c> result. </summary>
public static bool Grant(string id){Func<string,bool>sink;lock(Gate)sink=_grantSkill;if(sink==null){GameLogger.Log("SKILL",$"SkillsBridge: no grant sink bound (skills_base not loaded); skill '{id}' rejected",LogLevel.Warning);return false;}return sink(id);}}}

===== FILE Core/Audio/IAudioService.cs =====
namespace Cthangover.Core.Audio{/// <summary> Contract for the central audio service. Separates per-bus playback (music, ambient, SFX) and volume/enabled control. The PlaySound overload with <c>variations</c> picks a random suffix from 1..N, enabling sound variation without explicit variant IDs in callers. </summary>
public interface IAudioService{/// <summary> Loads a music track by asset <paramref name="id"/> through the audio engine mod's music factory and assigns it to the dedicated Music <c>AudioStreamPlayer</c>. The <paramref name="musicType"/> tag is informational — it does not change playback routing; the caller is responsible for using the correct type so that auto-advance logic in the audio engine mod's music player picks the right playlist bucket on track end. </summary>
void PlayMusic(string id,MusicType musicType);/// <summary> Immediately stops the dedicated music player. Any saved playback position in <c>PlaylistContext</c> is preserved, allowing a later <c>PlayMusic</c> or auto-advance to pick a fresh track or resume the saved one. </summary>
void StopMusic();/// <summary> Pauses or resumes the music stream in-place via <c>StreamPaused</c>. The current track is not evicted, so a subsequent <c>PauseMusic(false)</c> continues from the exact sample where audio was suspended. </summary>
void PauseMusic(bool pause);/// <summary> Plays a single sound effect identified by <paramref name="id"/> through the per-<see cref="SoundType"/> player pool. If a sound of the same <paramref name="soundType"/> is already playing it is cut off, while sounds of different types stack independently. </summary>
void PlaySound(string id,SoundType soundType);/// <summary> Plays a sound effect with random variation. When <paramref name="variations"/> is greater than 1, a suffix <c>"_N"</c> (1..variations) is appended to <paramref name="id"/> so the caller can provide a base name like <c>"footstep"</c> and get <c>"footstep_3"</c> at runtime without enumerating variants manually. </summary>
void PlaySound(string id,int variations,SoundType soundType);/// <summary> Stops the player associated with the given <paramref name="type"/> pool. Other <see cref="SoundType"/> players are unaffected. </summary>
void StopSound(SoundType type);/// <summary> Pauses the per-type sound player. Only the specified <paramref name="type"/> is affected; other pools keep playing. Use <c>PlaySound</c> to restart — the stream resumes from where it was paused because <c>Play()</c> on an already-loaded <c>AudioStreamPlayer</c> continues from the paused position. </summary>
void PauseSound(SoundType type);/// <summary> Starts or restarts an ambient loop on the dedicated Ambient bus. If the same ambient stream is already playing the call is silently ignored, preventing an audible reset glitch. On track end the player automatically re-plays the stream. </summary>
void PlayAmbient(string id);/// <summary> Stops the ambient loop. Unlike the music bus there is no auto-advance or saved state — calling <c>PlayAmbient</c> again starts the stream from the beginning. </summary>
void StopAmbient();/// <summary> Sets the linear volume (0–1) for the audio bus identified by <paramref name="type"/>. The value is converted to dB via <c>LinearToDb</c> before being pushed to <c>AudioServer.SetBusVolumeDb</c>. Changes are immediate and independent across the three buses. </summary>
void SetVolume(MixerType type,float volume);/// <summary> Mutes the bus by forcing its volume to -80 dB when <paramref name="enabled"/> is <c>false</c>. When <paramref name="enabled"/> is <c>true</c> the method is a no-op — normal volume is restored by a subsequent <c>SetVolume</c> or settings poll. </summary>
void SetEnabled(MixerType type,bool enabled);}}

===== FILE Core/Audio/MixerType.cs =====
namespace Cthangover.Core.Audio{/// <summary> Identifies one of the three hardware audio buses. Each bus has independent volume and mute state, allowing e.g. music to be muted while SFX and ambient continue playing. </summary>
public enum MixerType{/// <summary>Bus for short sound effects (SFX).</summary>
Sounds,/// <summary>Bus for background music.</summary>
Musics,/// <summary>Bus for ambient loops (dynamically crossfaded).</summary>
Ambient}}

===== FILE Core/Audio/MusicType.cs =====
namespace Cthangover.Core.Audio{/// <summary> Tags music tracks by gameplay context. <c>Force</c> is a transient signal — Normalized to <c>Ambient</c> during playlist init. The <c>Combat</c> ↔ <c>Ambient</c> transition is stateful: switching to Combat saves the ambient track and playback position so it can resume seamlessly when combat ends. </summary>
public enum MusicType{/// <summary> Transient signal used to force a specific track. Normalised to <c>Ambient</c> during playlist initialisation so it never persists across scene transitions. </summary>
Force,/// <summary> Combat music. Transitioning to Combat saves the current Ambient track and position for later restoration. </summary>
Combat,/// <summary> Background exploration music. The default type; restored with the saved track when returning from Combat. </summary>
Ambient}}

===== FILE Core/Audio/SoundType.cs =====
namespace Cthangover.Core.Audio{/// <summary> Sound category used as a pooling key. Each type gets its own AudioStreamPlayer, so a <c>CardEffect</c> sound cuts the previous card effect while a <c>UI</c> sound plays independently. <c>Timed</c> is intended for short-lived, one-shot events. </summary>
public enum SoundType{/// <summary>Short-lived one-shot events; cut off by the next Timed sound.</summary>
Timed,/// <summary>Long-running background effects; independent pool.</summary>
Background,/// <summary>Foreground sounds that should not overlap with Background.</summary>
Foreground,/// <summary>Interface clicks/hovers; isolated from game-world sounds.</summary>
UI,/// <summary>Notification chimes; separate pool to avoid interrupting UI.</summary>
Notification,/// <summary>Card ability VFX sounds; cuts previous card effects.</summary>
CardEffect,/// <summary>Card action (play/discard) sounds; independent of CardEffect.</summary>
CardAction,}}

===== FILE Core/Boot/AppServices.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Factories;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Scenes;using Cthangover.Core.Scenes.TscnBuild;using Cthangover.Core.Scenes.TscnParsing;using Cthangover.Core.Settings;using Cthangover.Core.UI;using Cthangover.Core.UI.Menu;using Cthangover.Core.UI.Tool;using Cthangover.Core.Utils;namespace Cthangover.Core.Bootstrap{/// <summary> Composition root of the game. Owns every service, registry and factory the core creates, in one place, with explicit construction order. Lifecycle: Boot creates a fresh instance at the start of every boot and publishes it through SetCurrent. Outside the running game (xUnit tests, design-time tooling) Current materializes a default instance on first touch, so facades keep working without a boot; construction of individual services stays lazy (each slot below is a Lazy of T) and is therefore timing-preserving. The default instance is throwaway: boot replaces it, so early read-only access is safe, but anything registered on it (resettables) would be discarded — runtime code registers only after the boot pipeline has begun (mod initializers run inside it). Two data-driven loops live here: - Registries: IGameRegistry.Rebuild/Clear replace hand-maintained lists in ModManager.Reload; - Resettables: ResetForNewGame replaces scattered per-owner reset calls and NewGameStartedEvent subscriptions; mods register extra owners via RegisterResettable from an IModInitializer. Migration note: factory static facades (BackgroundFactory.Instance and friends) delegate into the matching slot here. The facades stay as the public API until all consumers are migrated to explicit injection. A few process-wide singletons are still owned outside this class — ModRegistry/ModManager (mod pipeline) and MapZoneService (reset through the lambda resettables below) — migrating them is a documented follow-up. Quest state moved to the quests_base engine mod, whose initializer registers its own resettable with the same "quests" id through <see cref="RegisterResettable"/>. </summary>
public sealed class AppServices{private static AppServices current;private static readonly object CurrentLock=new();/// <summary> The active service set. In a running game this is the instance the boot pipeline published; in tests and tools it is a lazily created default. Never null after the first touch. </summary>
public static AppServices Current{get{var snapshot=current;if(snapshot!=null)return snapshot;return CreateDefault();}}/// <summary> Publishes the boot-created service set. Called once per process by Boot before any phase runs; replaces a default instance if tests happened to materialize one earlier. </summary>
public static void SetCurrent(AppServices instance){if(instance==null)throw new ArgumentNullException(nameof(instance));lock(CurrentLock)current=instance;}private static AppServices CreateDefault(){lock(CurrentLock){if(current==null)current=new AppServices();return current;}}/// <summary>Mod-derived registries, cleared and rebuilt as a set.</summary>
public IReadOnlyList<IGameRegistry>Registries{get;}/// <summary>Typed accessors for the registries the static facades delegate to.</summary>
public ModConflictRegistry Conflicts{get;}/// <summary>Mod-declared GUI entries (panels and overlays); <see cref="UiBuilder"/> reads it to build the toolbar.</summary>
public UiRegistry Ui{get;}/// <summary>Menu items (buttons with their bindings) and reusable panels, from mod manifests and runtime registration.</summary>
public MenuRegistry Menus{get;}/// <summary>Executable scene events discovered via attributes or registered by mods at runtime.</summary>
public SceneEventRegistry SceneEvents{get;}/// <summary>Logical scene names mapped to .tscn paths, registered by mod initializers.</summary>
public SceneRouteRegistry SceneRoutes{get;}/// <summary>Scene definitions collected from mod manifests, with an instance-level cache.</summary>
public SceneDefinitions SceneDefs{get;}/// <summary>.tscn external-resource builders, indexed by resource type.</summary>
public ExtResourceBuilderRegistry ExtResourceBuilders{get;}/// <summary>Ordered node property resolvers for .tscn building; first match wins.</summary>
public PropertyResolverRegistry PropertyResolvers{get;}/// <summary>.tscn sub-resource builders, indexed by resource type.</summary>
public SubResourceBuilderRegistry SubResourceBuilders{get;}/// <summary>.tscn text section parsers, indexed by section type.</summary>
public SectionParserRegistry SectionParsers{get;}private readonly List<IResettable>resettables=new();private readonly object resettablesLock=new();/// <summary>Owners of playthrough state, reset together on a new game.</summary>
public IReadOnlyList<IResettable>Resettables=>resettables;/// <summary> Constructs the service set. Registries are created immediately because they are cheap and side-effect free by contract; core resettables (map zones) are registered here with lambda-deferred construction so the first reset keeps the pre-migration touch order; mod-owned subsystems (quests) register theirs from initializers. Factory slots stay lazy (see the slot list below). </summary>
public AppServices(){// Registry instances are cheap and side-effect free by contract; Rebuild fills them from the loaded mod set.
Conflicts=new ModConflictRegistry();Ui=new UiRegistry();Menus=new MenuRegistry();SceneEvents=new SceneEventRegistry();SceneRoutes=new SceneRouteRegistry();SceneDefs=new SceneDefinitions();ExtResourceBuilders=new ExtResourceBuilderRegistry();PropertyResolvers=new PropertyResolverRegistry();SubResourceBuilders=new SubResourceBuilderRegistry();SectionParsers=new SectionParserRegistry();Registries=new IGameRegistry[]{Conflicts,Ui,Menus,SceneEvents,SceneRoutes,SceneDefs,ExtResourceBuilders,PropertyResolvers,SubResourceBuilders,SectionParsers,};// Core state owners. Lambdas keep construction deferred until the first real reset, exactly like the pre-migration touch order. Quest state is owned by the quests_base mod and registers its own "quests" resettable from QuestsModInitializer.OnModResourcesReady.
RegisterResettable(new LambdaResettable("map_zones",()=>Cthangover.Core.Actions.MapStateBridge.ResetOverrides()));}/// <summary> Registers an additional new-game state owner (mods register theirs from an IModInitializer, i.e. after the boot pipeline has begun). Registering on Current before SetCurrent would target the throwaway default instance and be lost. Duplicates by IResettable.Id are ignored. </summary>
public void RegisterResettable(IResettable resettable){if(resettable==null)return;lock(resettablesLock){foreach(var existing in resettables){if(string.Equals(existing.Id,resettable.Id,StringComparison.OrdinalIgnoreCase))return;}resettables.Add(resettable);}}/// <summary>Resets every registered state owner, in registration order.</summary>
public void ResetForNewGame(){IResettable[]snapshot;lock(resettablesLock)snapshot=resettables.ToArray();foreach(var resettable in snapshot){try{resettable.ResetForNewGame();GameLogger.Log("BOOT","reset: "+resettable.Id);}catch(Exception ex){GameLogger.Log("BOOT","reset "+resettable.Id+" FAILED: "+ex.Message,LogLevel.Error);}}}/// <summary>Clears every registry (mod reload entry point).</summary>
public void ClearRegistries(){foreach(var registry in Registries){try{registry.Clear();GameLogger.Log("BOOT","cleared: "+registry.Id,LogLevel.Debug);}catch(Exception ex){GameLogger.Log("BOOT","clear "+registry.Id+" FAILED: "+ex.Message,LogLevel.Error);}}}/// <summary>Rebuilds every registry from the currently loaded mods.</summary>
public void RebuildRegistries(){foreach(var registry in Registries){try{registry.Rebuild();GameLogger.Log("BOOT","rebuilt: "+registry.Id,LogLevel.Debug);}catch(Exception ex){GameLogger.Log("BOOT","rebuild "+registry.Id+" FAILED: "+ex.Message,LogLevel.Error);}}}// ---- Service slots ------------------------------------------------ Each slot is a Lazy of T so construction timing is identical to the pre-migration Lazy singletons; facades on the service classes delegate here (AppServices.Current.Slot.Value). <summary>Background images shown behind dialog scenes, resolved through the mod resource pipeline.</summary>
public Lazy<BackgroundFactory>Backgrounds{get;}=new(()=>new BackgroundFactory());/// <summary>Full-size UI panel textures (frames, overlays, menu backgrounds).</summary>
public Lazy<UITextureFactory>UITextures{get;}=new(()=>new UITextureFactory());/// <summary>Small UI icons (toolbar buttons, badges) from the <c>ui/icons</c> group.</summary>
public Lazy<UIIconFactory>UIIcons{get;}=new(()=>new UIIconFactory());/// <summary>Inventory item icons, with a dedicated LRU cache budget.</summary>
public Lazy<ItemSpriteFactory>ItemSprites{get;}=new(()=>new ItemSpriteFactory());/// <summary>Skill definitions for the card-based skill system (migrated to the skills_base engine mod).</summary> SkillFactory lives in mods/skills_base now; the AppServices slot is gone by design. <summary>Interactive object definitions for scene hotspots (migrated to the interactive_base engine mod).</summary> InteractiveFactory lives in mods/interactive_base now; the AppServices slot is gone by design. <summary>DSL scenario actions indexed by their registered <c>Name</c> (e.g. <c>"map.open_zone"</c>).</summary>
public Lazy<ScenarioActionFactory>ScenarioActions{get;}=new(()=>new ScenarioActionFactory());/// <summary>Dev tool windows (<see cref="IToolProvider"/> implementations), discovered by reflection.</summary>
public Lazy<ToolFactory>Tools{get;}=new(()=>new ToolFactory());/// <summary>Toolbar button definitions for dev tools (<see cref="IToolBoxButton"/> implementations).</summary>
public Lazy<ToolBoxButtonFactory>ToolBoxButtons{get;}=new(()=>new ToolBoxButtonFactory());/// <summary>Mod pipeline configuration (asset groups, assembly caching, file mappings) from <c>mod_config.json</c>.</summary>
public Lazy<ModConfig>ModConfigService{get;}=new(()=>ModConfig.CreateInstance());/// <summary>Game configuration (audio, display, language, logging) from <c>game_config.json</c>.</summary>
public Lazy<GameConfig>GameConfigService{get;}=new(()=>GameConfig.CreateInstance());/// <summary>Trivial IResettable adapter over a delegate.</summary>
private sealed class LambdaResettable:IResettable{private readonly Action reset;/// <summary>Binds an id and the reset delegate it forwards to.</summary>
public LambdaResettable(string id,Action reset){Id=id;this.reset=reset;}/// <summary>Stable resettable id used for logging and duplicate detection.</summary>
public string Id{get;}/// <summary>Runs the wrapped reset delegate.</summary>
public void ResetForNewGame()=>reset();}}}

===== FILE Core/Boot/Boot.cs =====
using System;using System.Linq;using System.Threading;using System.Threading.Tasks;using Cthangover.Core.Localization;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Bootstrap{/// <summary> Phases of the unified boot pipeline, in execution order. Comparison by value is meaningful: a later phase implies that all earlier ones finished. </summary>
public enum BootPhase{NotStarted=0,/// <summary>Godot-bound paths resolved on the main thread (mod roots, caches, config).</summary>
PreparingPaths,/// <summary>Discovery, manifest reading and Roslyn compilation of mod assemblies.</summary>
Discovering,/// <summary>Compiled assemblies registered, GDScript loaded, mod load order fixed.</summary>
FinalizingMods,/// <summary>Mod set is final: factories and resolvers may read mod data.</summary>
ModsReady,/// <summary>Locale merged from all loaded mods and activated.</summary>
Locale,/// <summary>Scene/UI registries collected, mod initializers notified, mod settings loaded.</summary>
Scenes,/// <summary>Everything is usable.</summary>
Ready,}/// <summary>How the boot pipeline is being executed.</summary>
public enum BootMode{None,/// <summary>Launcher-driven: heavy phases on a background thread, boot overlay visible.</summary>
LauncherAsync,/// <summary>Blocking synchronous run before the main scene (tests, direct scene launches).</summary>
HeadlessSync,}/// <summary> Single orchestrator of game initialization. Owns the exact order of boot phases. Both entry points drive the SAME sequence through this class: <list type="bullet"> <item><see cref="RunLauncherAsync"/> — the pre-game launcher renders a boot overlay while discovery and Roslyn compilation run on a background thread;</item> <item><see cref="RunSync"/> — direct scene launches and integration tests, executed from the SceneManager autoload _Ready so the whole pipeline completes before any main-scene node runs.</item> </list> Thread contract (mirrors the old launcher-driven flow): path preparation, finalize, runtime, locale and scene registries run on the Godot main thread; only discovery/compilation is pure .NET and moves to a background thread in the launcher mode. Until <see cref="ModsReady"/>, no subsystem may pull data through <see cref="ModRegistry"/> or the factories: lazy self-initialization was removed deliberately, and <see cref="ModRegistry"/> logs a loud error instead of silently booting on first touch. From <see cref="ModsReady"/> on the mod set is final and the pipeline itself consumes it (runtime, starter inventory). If a new caller triggers that refusal error, the fix is to consume the data after readiness (or behind a readiness guard like the audio mod's music player does) — never to re-add a lazy Initialize. </summary>
public static class Boot{private static readonly object startLock=new();/// <summary>Current pipeline phase; <c>NotStarted</c> before the first run.</summary>
public static BootPhase Phase{get;private set;}=BootPhase.NotStarted;/// <summary>Mode of the current (or last) run; <c>None</c> before the first run.</summary>
public static BootMode Mode{get;private set;}=BootMode.None;/// <summary>True once the full pipeline finished; registries and services are usable.</summary>
public static bool IsReady=>Phase==BootPhase.Ready;/// <summary>True once the mod set is final: factories may read mod data.</summary>
public static bool ModsReady=>Phase>=BootPhase.ModsReady;/// <summary>True while the pipeline is executing in any mode.</summary>
public static bool IsBooting=>Mode!=BootMode.None&& !IsReady;/// <summary> Synchronous boot for everything that is not the pre-game launcher: Integration test scenes, direct scene runs and editor "play current scene". Called from the SceneManager autoload _Ready, which is guaranteed to run after GameData exists and before the main scene's nodes. </summary>
public static void RunSync(SceneManager sceneManager){if(!Begin(BootMode.HeadlessSync))return;try{Phase=BootPhase.PreparingPaths;// The whole pipeline runs under ModRegistry's pipeline lock (pipelineRunning is set for its duration), so a stray concurrent Initialize from a background thread or tooling is serialized instead of racing these phases.
ModManager.Instance.Initialize();Phase=BootPhase.ModsReady;EnsureRuntime();Phase=BootPhase.Locale;LocaleLoader.LoadCurrentLanguage();Phase=BootPhase.Scenes;sceneManager?.Initialize();Phase=BootPhase.Ready;Mods.HotReload.ModHotReloadService.InstallIfEnabled(sceneManager?.GetTree());GameLogger.Log("BOOT","Boot (sync) complete");}catch(OperationCanceledException){GameLogger.Log("BOOT","Boot (sync) cancelled",LogLevel.Warning);}catch(Exception ex){// Phase intentionally left where it failed: readiness guards stay armed and every too-early consumer keeps logging loudly instead of silently working against a half-initialized registry.
GameLogger.Log("BOOT",$"Boot (sync) FAILED at phase {Phase}: {ex.Message}",LogLevel.Error);}}/// <summary> Launcher-driven boot. Yields one frame first so the boot overlay is on screen, runs path preparation on the main thread, discovery and Roslyn compilation on a background thread (cooperatively cancellable when the launcher scene dies), then the Godot-bound phases back on the main thread. Throws <see cref="OperationCanceledException"/> if cancelled. </summary>
public static async Task RunLauncherAsync(Node context,CancellationToken cancellationToken){if(!Begin(BootMode.LauncherAsync))return;// Let the boot overlay render one frame before the heavy phases, mirroring the previous launcher-driven timing.
await context.ToSignal(context.GetTree(),SceneTree.SignalName.ProcessFrame);try{Phase=BootPhase.PreparingPaths;BootProgress.EnterPhase(BootPhase.PreparingPaths);ModManager.Instance.PreparePaths();Phase=BootPhase.Discovering;BootProgress.EnterPhase(BootPhase.Discovering);await Task.Run(()=>ModManager.Instance.DiscoverAndCompile(cancellationToken),cancellationToken);Phase=BootPhase.FinalizingMods;BootProgress.EnterPhase(BootPhase.FinalizingMods);ModRegistry.Instance.FinalizeGodotInit();Phase=BootPhase.ModsReady;BootProgress.EnterPhase(BootPhase.ModsReady);EnsureRuntime();Phase=BootPhase.Locale;BootProgress.EnterPhase(BootPhase.Locale);LocaleLoader.LoadCurrentLanguage();Phase=BootPhase.Scenes;BootProgress.EnterPhase(BootPhase.Scenes);context.GetNodeOrNull<SceneManager>("/root/SceneManager")?.Initialize();Phase=BootPhase.Ready;BootProgress.EnterPhase(BootPhase.Ready);Mods.HotReload.ModHotReloadService.InstallIfEnabled(context.GetTree());GameLogger.Log("BOOT","Boot (launcher) complete");}finally{// Phase intentionally left where the run stopped: on cancellation readiness guards stay armed, so post-cancel consumers are loud.
}}/// <summary> Constructs the session RuntimeData at a defined point: after the mod set is final, so the starter inventory is seeded by fully loaded factories. Replaces the old lazy getter on GameData.Runtime whose result silently depended on who touched it first. </summary>
private static void EnsureRuntime(){var data=GameData.Instance;if(data==null){GameLogger.Log("BOOT","GameData autoload missing — RuntimeData not created",LogLevel.Error);return;}// A RuntimeData that slipped through before the mod set was final (e.g. a timer tick racing the launcher boot) seeded its starter inventory against an empty registry, and those skips are permanent. Recreate it now that factories can resolve mod items.
if(data.ConsumePrematureRuntimeCreation()){GameLogger.Log("BOOT","RuntimeData was created before mods were ready — recreating it so the starter inventory seeds from loaded mods",LogLevel.Warning);data.ResetRuntime("created before boot finished; starter inventory was empty");}_=data.Runtime;}/// <summary>Idempotence guard: exactly one boot per process.</summary>
private static bool Begin(BootMode mode){lock(startLock){if(Mode!=BootMode.None){GameLogger.Log("BOOT",$"Run({mode}) ignored: boot already ran (mode={Mode}, phase={Phase})",LogLevel.Warning);return false;}Mode=mode;WaitForDebuggerIfRequested();// Fresh service set for this boot: registries empty, the reset list restored to the core defaults, factory slots unused.
AppServices.SetCurrent(new AppServices());return true;}}/// <summary> Debug entry helper (plan-mod-testing.md §9.2): with the <c>--wait-for-debugger</c> command-line flag, offers the OS debugger selection (Windows JIT dialog) before any boot phase runs, so the developer can attach before mod compilation — the phase where runtime-compiled mod code usually needs inspection. A no-op when a debugger is already attached or the flag is absent; the game continues normally when the user cancels the dialog. </summary>
private static void WaitForDebuggerIfRequested(){if(!GameEnvironment.IsRuntime)return;var requested=OS.GetCmdlineArgs().Any(a=>a=="--wait-for-debugger")||OS.GetCmdlineUserArgs().Any(a=>a=="--wait-for-debugger");if(!requested)return;if(System.Diagnostics.Debugger.IsAttached){GameLogger.Log("BOOT","--wait-for-debugger: debugger already attached, continuing");return;}GameLogger.Log("BOOT","--wait-for-debugger: launching debugger selection (cancel = continue without debugger)");var launched=System.Diagnostics.Debugger.Launch();GameLogger.Log("BOOT",$"--wait-for-debugger: Debugger.Launch -> {launched}");}}}

===== FILE Core/Boot/BootProgress.cs =====
using System;namespace Cthangover.Core.Bootstrap{/// <summary> Progress source for the launcher boot overlay. The boot pipeline and the mod compiler report coarse phase entries and fine-grained sub-steps here; the launcher UI reads a thread-safe snapshot once per frame and maps it onto a progress bar. Weights live here rather than in the UI so the progress model has a single owner: Discovering dominates real boot time (manifest scanning, hashing, Roslyn compilation), while the Godot-bound phases are comparatively cheap. Values were sampled from GameLogger timestamps on a cold-cache run and are hardcoded on purpose — a config entry would be read before paths exist. </summary>
public static class BootProgress{private static readonly object Sync=new();private static BootPhase _phase=BootPhase.NotStarted;private static float _sub;/// <summary> Marks the start of a pipeline phase. Called from the boot thread(s); sub-progress resets because sub-steps never span phases. </summary>
public static void EnterPhase(BootPhase phase){lock(Sync){_phase=phase;_sub=0f;}}/// <summary>Reports sub-progress (0..1) inside the current phase.</summary>
public static void ReportSub(float sub){sub=Math.Clamp(sub,0f,1f);lock(Sync){if(sub>_sub)_sub=sub;}}/// <summary> Snapshot of overall boot progress (0..1). Weighted by the phase weights below; phases after the current one contribute nothing. </summary>
public static float GetOverall(){lock(Sync){return OverallFor(_phase,_sub);}}private static readonly int WeightPreparingPaths=5;private static readonly int WeightDiscovering=60;private static readonly int WeightFinalizingMods=15;private static readonly int WeightPostMods=20;private const int TotalWeight=100;private static float OverallFor(BootPhase phase,float sub){switch(phase){case BootPhase.Ready:return 1f;case BootPhase.PreparingPaths:return WeightPreparingPaths*sub/TotalWeight;case BootPhase.Discovering:return(WeightPreparingPaths+WeightDiscovering*sub)/TotalWeight;case BootPhase.FinalizingMods:return(WeightPreparingPaths+WeightDiscovering+WeightFinalizingMods*sub)/TotalWeight;case BootPhase.ModsReady:return(WeightPreparingPaths+WeightDiscovering+WeightFinalizingMods)/TotalWeight;case BootPhase.Locale:return(WeightPreparingPaths+WeightDiscovering+WeightFinalizingMods+WeightPostMods/3f)/TotalWeight;case BootPhase.Scenes:return(WeightPreparingPaths+WeightDiscovering+WeightFinalizingMods+WeightPostMods*2f/3f)/TotalWeight;default:return 0f;}}}}

===== FILE Core/Boot/IGameRegistry.cs =====
namespace Cthangover.Core.Bootstrap{/// <summary> A mod-derived registry that can be rebuilt from the currently loaded mod set and cleared back to its empty state. Registries register themselves in <see cref="AppServices"/> (explicit list in its constructor), which turns mod reload and boot from hand-maintained clear-lists into a data-driven loop: adding a registry is one line in <see cref="AppServices"/>, with no edits in <c>ModManager.Reload</c>. <para/> Implementation contract: the instance constructor must stay cheap and side-effect free (no Godot API, no mod reads); <see cref="Rebuild"/> fills the registry from the loaded mod set and must be safe to call again without a preceding <see cref="Clear"/>; <see cref="Clear"/> returns the registry to its just-constructed state. </summary>
public interface IGameRegistry{/// <summary>Stable registry name for logs.</summary>
string Id{get;}/// <summary>(Re)fills the registry from the currently loaded mods.</summary>
void Rebuild();/// <summary>Drops all collected entries; the next <see cref="Rebuild"/> re-reads the mods.</summary>
void Clear();}}

===== FILE Core/Boot/IResettable.cs =====
namespace Cthangover.Core.Bootstrap{/// <summary> A state owner that can return to its fresh "new game" state. Registered in <see cref="AppServices"/> (core owners via the explicit list there, mod owners through <see cref="AppServices.RegisterResettable"/> from an <c>IModInitializer</c>), so <c>GameData.StartNewGame</c> resets everything through one loop instead of hand-maintained calls and scattered <c>NewGameStartedEvent</c> subscriptions. </summary>
public interface IResettable{/// <summary>Stable owner name for logs.</summary>
string Id{get;}/// <summary> Discards playthrough state so the next session starts fresh. Must not publish <see cref="Cthangover.Core.Events.NewGameStartedEvent"/> itself — the publisher is <c>GameData.StartNewGame</c>. </summary>
void ResetForNewGame();}}

===== FILE Core/Characters/ActionCharacter.cs =====
using Cthangover.Core.Factories;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Characters{/// <summary> A battle action available to a character (e.g. "Attack", "Heal"). Carries a PropertyData bag keyed by free-form string keys (e.g. "Attack", "Defence", "Turn", "RequiredPoint") rather than typed properties — this allows mods to add custom action parameters without schema changes. Copy() deep-clones Properties to prevent shared state between character instances that may modify action data at runtime (e.g. temporary buffs). </summary>
public class ActionCharacter:IIdentifiable{/// <summary> Unique action identifier, used as the factory lookup key. </summary>
public string ID{get;set;}/// <summary> Display name shown in the battle action menu. </summary>
public string Name{get;set;}/// <summary> Tooltip/description text shown in the battle action menu. </summary>
public string Description{get;set;}/// <summary> Targeting category: <see cref="ActionCharacterType.ToSelf"/> (caster), <see cref="ActionCharacterType.ToAlias"/> (allies), or <see cref="ActionCharacterType.ToEnemy"/> (opponents). </summary>
public ActionCharacterType Type{get;set;}/// <summary> Icon texture for the battle action menu. </summary>
public Texture2D Image{get;set;}/// <summary> String-indexed property bag containing the action's numeric parameters (damage, heal, cost, etc.) keyed by free-form strings. Mods define their own keys without schema changes. </summary>
public PropertyData Properties{get;set;}/// <summary> Reads a string property from <see cref="Properties"/> by key, with an optional default for missing entries. </summary>
public string GetStr(string name,string defaultValue=null)=>Properties.GetStr(name,defaultValue);/// <summary> Reads an integer property from <see cref="Properties"/> by key, with an optional default for missing entries. </summary>
public int GetInt(string name,int defaultValue=0)=>Properties.GetInt(name,defaultValue);/// <summary> Reads a float property from <see cref="Properties"/> by key, with an optional default for missing entries. </summary>
public float GetFloat(string name,float defaultValue=0f)=>Properties.GetFloat(name,defaultValue);/// <summary> Reads a boolean property from <see cref="Properties"/> by key. </summary>
public bool GetBool(string name)=>Properties.GetBool(name);/// <summary> Creates an independent copy of this action. All scalar/string fields are value-copied; <see cref="Properties"/> is deep- cloned via <see cref="PropertyData.Clone"/> to prevent shared state mutations (e.g. temporary effect modifiers) from leaking across character instances. </summary>
public ActionCharacter Copy(){return new ActionCharacter(){ID=ID,Name=Name,Description=Description,Type=Type,Image=Image,Properties=Properties?.Clone(),};}}}

===== FILE Core/Characters/ActionCharacterType.cs =====
namespace Cthangover.Core.Characters{/// <summary> Target category for a battle action. ToSelf affects the caster, ToAlias targets allies, ToEnemy targets opponents. This simple tri-state covers the targeting logic without complex targeting rules — the battle system uses this to filter valid targets when the player selects an action. </summary>
public enum ActionCharacterType{ToSelf,ToAlias,ToEnemy,Passive,Active,};/// <summary> Classification helper for <see cref="ActionCharacterType"/>: distinguishes the three in-battle targeting categories from the panel-level <see cref="ActionCharacterType.Passive"/> and <see cref="ActionCharacterType.Active"/> kinds, which never appear in a battle action menu. </summary>
public static class ActionCharacterTypeExtension{/// <summary>Returns <c>true</c> for ToSelf/ToAlias/ToEnemy — the types battle targeting consumes.</summary>
public static bool UseInBattle(this ActionCharacterType type){return type==ActionCharacterType.ToSelf||type==ActionCharacterType.ToAlias||type==ActionCharacterType.ToEnemy;}}}

===== FILE Core/Characters/ActionTemplateRegistry.cs =====
namespace Cthangover.Core.Characters{/// <summary> Kernel access point for battle action template resolution, decoupling the core from the engine mod that owns the factory (<c>mods/characters_base</c>). The mod's initializer assigns <see cref="Provider"/> during <c>IModInitializer.OnModLoaded</c>; the skills panel resolves granted action cards through <see cref="Get"/>. Returns <c>null</c> when the mod has not registered yet. </summary>
public static class ActionTemplateRegistry{/// <summary>Provider published by the characters engine mod; <c>null</c> until the mod loads.</summary>
public static IActionTemplateProvider Provider{get;set;}/// <summary>Returns an independent copy of the action card template, or <c>null</c> when no provider is registered or the ID is unknown.</summary>
public static ActionCharacter Get(string id)=>Provider?.Get(id);}}

===== FILE Core/Characters/ActiveCharacterRegistry.cs =====
namespace Cthangover.Core.Characters{/// <summary> Static slot for the gameplay system that currently owns the active character (see <see cref="IActiveCharacterProvider"/>). The provider is set when the owning system enters its scene root and cleared when it leaves; core UI reads <see cref="ActiveCharacter"/> to decide whether action-point rules apply. The core holds no reference to the concrete system, so battle (or any other turn-based mode) stays entirely in mods. </summary>
public static class ActiveCharacterRegistry{/// <summary>Current provider; <c>null</c> outside an active turn context.</summary>
public static IActiveCharacterProvider Provider{get;set;}/// <summary>The acting character of the current provider, or <c>null</c>.</summary>
public static Character ActiveCharacter=>Provider?.ActiveCharacter;}}

===== FILE Core/Characters/Attribute.cs =====
using System;namespace Cthangover.Core.Characters{/// <summary> Mutable stat with event-driven change tracking. Value is the current (potentially modified) value; BaseValue is the unmodified maximum. Percent gives the 0–1 ratio for health bars and stat displays. The OnChange delegate fires on every Value set — even if the value hasn't changed — making it a notification mechanism rather than a diff detector. This is important for UI that must react to "was modified" events regardless of whether the value actually changed (e.g. a heal that fizzles at full health should still trigger visual feedback). </summary>
[Serializable]public class Attribute{/// <summary> Fires on every <see cref="Value"/> setter call, regardless of whether the value actually changed. Subscribers receive (currentValue, baseValue) — this is a notification mechanism, not a diff detector. Important for UI that must react to "was modified" events even when the value is unchanged (e.g. a heal that fizzles at full health should still trigger visual feedback). </summary>
public event ChangeAttribute OnChange;private int val;/// <summary> The current (potentially modified) value of this attribute. Setting triggers <see cref="OnChange"/> with both the new value and <see cref="BaseValue"/> for percentage computation. </summary>
public int Value{get{return val;}set{this.val=value;OnChange?.Invoke(this.val,BaseValue);}}/// <summary> The unmodified maximum/base value. Together with <see cref="Value"/>, this drives the <see cref="Percent"/> ratio used by health bars and stat displays. </summary>
public int BaseValue{get;set;}/// <summary> Sets both <see cref="Value"/> and <see cref="BaseValue"/> to the same starting value. Used during character initialization to establish the baseline before any modifications. </summary> <param name="val">The initial value for both current and base.</param>
public void Init(int val){Value=val;BaseValue=val;}/// <summary> Current value as a fraction of base value (0.0 to 1.0+ if value exceeds base). Returns 0 when <see cref="BaseValue"/> is 0 to avoid division by zero. Used by health bar fill ratios and stat comparison displays. </summary>
public float Percent=>BaseValue==0?0f:(float)Value/BaseValue;/// <summary> Creates an independent copy with the same <see cref="Value"/> and <see cref="BaseValue"/>. The <see cref="OnChange"/> event subscribers are NOT copied — the clone starts with an empty delegate list, which is correct for battle instances that need their own UI bindings. </summary>
public Attribute Copy(){return new Attribute(){Value=Value,BaseValue=BaseValue,};}}}

===== FILE Core/Characters/ChangeAttribute.cs =====
namespace Cthangover.Core.Characters{/// <summary> Delegate for attribute change notifications. Receives both the new value and the base (max) value so subscribers can compute percentage without accessing the Attribute object directly. </summary>
public delegate void ChangeAttribute(float value,float baseValue);}

===== FILE Core/Characters/Character.cs =====
using System.Collections.Generic;using Cthangover.Core.Factories;using Godot;namespace Cthangover.Core.Characters{/// <summary> Core character data model used in both the character factory and battle runtime. The Behaviour string is an extensibility hook — it names a behaviour class not defined here, resolved externally (likely by the AI or battle system). RecruitmentChance is the percentage for the recruit system. Copy() deep-clones Attributes so battle stat changes do not leak back into the template data. Battle-only state that is not part of the character identity (for example status effects) is owned by the battle mod in a side table keyed by the character instance, so this model stays free of battle subsystems. </summary>
public class Character:IIdentifiable{/// <summary> Unique identifier string used as the factory lookup key and save-data reference. Must match the ID in the character's JSON data file (CharacterInfo.ID). </summary>
public string ID{get;set;}/// <summary> Name of the behaviour class that controls this character's AI during battle. Resolved externally by the battle engine or AI system — not defined within this assembly. Null for player- controlled characters or simple enemies. </summary>
public string Behaviour{get;set;}/// <summary> Current character level. Loaded from the template and carried forward into CharacterInfoData on recruitment. </summary>
public int Level{get;set;}/// <summary> Current experience points. Accumulated through combat and used by the leveling system to determine when a level-up occurs. </summary>
public int Exp{get;set;}/// <summary> Percentage chance (0–100) that a defeated enemy becomes recruitable via the recruit system. Read by battle resolution to determine if the "recruit" option appears post-combat. </summary>
public int RecruitmentChance{get;set;}/// <summary> Display name shown in the UI. Localized via TranslationServer when presented to the player (e.g. in party roster). </summary>
public string Name{get;set;}/// <summary> Full-body character portrait texture for the character detail screen. Loaded from disk at factory construction time. </summary>
public Texture2D Image{get;set;}/// <summary> Small portrait frame texture for list views (party roster, battle HUD). Typically a cropped or smaller version of Image. </summary>
public Texture2D Frame{get;set;}/// <summary> List of battle actions available to this character. Drawn from the character's JSON data file and populated by the factory. </summary>
public List<ActionCharacter>Actions{get;set;}/// <summary> Complete stat block: Health, Defence, Attack, Strength, Magic, Point (all <see cref="Attribute"/>-wrapped) plus raw Discipline, Fullness. Deep-copied by <see cref="Copy"/>. </summary>
public CharacterAttributes Attributes{get;set;}/// <summary> Loot drop table for when this character is defeated as an enemy. Each <see cref="LootEntry"/> defines an item, count range, and drop probability. </summary>
public List<LootEntry>Loot{get;set;}/// <summary> Default constructor. Initializes Actions and Attributes with empty/default collections to prevent null-reference errors in downstream code that iterates them. </summary>
public Character(){Actions=new List<ActionCharacter>();Attributes=new CharacterAttributes();}/// <summary> Creates an independent deep copy of this character. All scalar fields are value-copied. <see cref="Attributes"/> is deep-cloned — this is critical for battle, where each combatant operates on an isolated copy so temporary stat changes don't leak back into the character template. </summary>
public Character Copy(){var character=new Character(){ID=ID,Behaviour=Behaviour,Level=Level,Exp=Exp,Name=Name,Image=Image,Frame=Frame,Actions=Actions,RecruitmentChance=RecruitmentChance,Loot=Loot,Attributes=Attributes?.Copy(),};return character;}}}

===== FILE Core/Characters/CharacterAttributes.cs =====
using System;using Cthangover.Core.Utils;namespace Cthangover.Core.Characters{/// <summary> The stat block for a character. Health, Defence, Attack, and Point use the <see cref="Attribute"/> wrapper (current + base + change events) because they are the core battle stats accessed by C# battle systems. Any mod-defined extras — live in the string-keyed <see cref="Properties"/> bag so that mods can add arbitrary stats without schema changes. </summary>
[Serializable]public class CharacterAttributes{/// <summary> Health points wrapped in <see cref="Attribute"/> for event- driven change tracking (.Value = current, .BaseValue = max). </summary>
public Attribute Health{get;set;}=new();/// <summary> Defence stat wrapped in <see cref="Attribute"/>. Reduces incoming damage. </summary>
public Attribute Defence{get;set;}=new();/// <summary> Attack power wrapped in <see cref="Attribute"/>. Base damage dealt by this character. </summary>
public Attribute Attack{get;set;}=new();/// <summary> Action points wrapped in <see cref="Attribute"/>. Resource pool consumed by abilities during battle. </summary>
public Attribute Point{get;set;}=new();/// <summary> String-keyed property bag for all non-core stats: any mod-defined extras. Keys are free-form strings — mods define their own without registry. </summary>
public PropertyData Properties{get;set;}=new();/// <summary> Creates an independent deep copy. All four <see cref="Attribute"/> fields are cloned via <see cref="Attribute.Copy"/> (preserving Value/BaseValue but dropping OnChange subscribers). <see cref="Properties"/> is cloned via <see cref="PropertyData.Clone"/> which duplicates the key-value dictionary. Interceptor chains are NOT copied — they are runtime behaviour, not data. </summary>
public CharacterAttributes Copy(){return new CharacterAttributes(){Health=Health.Copy(),Defence=Defence.Copy(),Attack=Attack.Copy(),Point=Point.Copy(),Properties=Properties.Clone(),};}}}

===== FILE Core/Characters/CharacterData.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Settings;using Cthangover.Core.UI;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Characters{/// <summary> Runtime character roster — owns the party composition and per-character progression data. BattleSet tracks which characters are in the active party for combat. Characters dictionary maps character ID strings to CharacterInfoData where CharacterInfoData carries the runtime-copied version of the template Character (via CharacterTemplateRegistry). The initial party roster is populated by mods through IModInitializer implementations — each mod adds its default starter characters during mod loading. AddCharacterToParty both registers the character and adds to BattleSet, while SendAddNotification produces the UI message via MessagesHelper, using TranslationServer for localized character names — names come from character templates, not hardcoded strings. </summary>
public class CharacterData{/// <summary> Set of character IDs currently in the active battle party. Populated when characters join the party via <see cref="AddCharacterToParty"/> and consulted by the battle system to determine available combatants. </summary>
public ISet<string>BattleSet=new HashSet<string>();/// <summary> Full roster of all recruited characters, keyed by character ID string (e.g. "Marao", "Murakami"). Each entry is a <see cref="CharacterInfoData"/> carrying the runtime copy of the character template plus progression state. Populated by mod initializers during startup and by scenario actions during play. </summary>
public IDictionary<string,CharacterInfoData>Characters{get;set;}=new Dictionary<string,CharacterInfoData>();/// <summary> Creates a runtime <see cref="CharacterInfoData"/> from a character template. If the template is <c>null</c> — which happens before the characters mod publishes its provider (tests, tools, very early boot) — returns a minimal fallback with default attributes so pure-data code paths never see a null card. Gameplay entry points must not rely on this branch: <see cref="AddCharacterToParty"/> refuses unknown IDs before reaching here, so a typo in scenario content can no longer put a Level-0 placeholder card into the persistent roster. </summary> <param name="characterType">Character ID string.</param> <param name="card">Character template from factory, may be null.</param> <returns>A new <see cref="CharacterInfoData"/> instance.</returns>
public CharacterInfoData Create(string characterType,Character card){if(card==null){return new CharacterInfoData{Attributes=new CharacterAttributes(),Level=0,Exp=0,ID=characterType,CharacterType=characterType,};}return new CharacterInfoData{Attributes=card.Attributes,Level=card.Level,Exp=card.Exp,ID=characterType,CharacterType=characterType,ActionSlots=card.Actions?.Select(a=>a.ID).ToList()??new List<string>(),};}/// <summary> Adds a character to the party roster and active battle set. Resolves the template through <see cref="CharacterTemplateRegistry"/>, builds the runtime card via <see cref="Create(string, Character)"/>, inserts it into <see cref="Characters"/>, and adds the ID to <see cref="BattleSet"/>. <para/> Unknown character IDs are refused instead of silently creating a zero-stat placeholder: such a card would persist through saves and surface much later as a broken party member that is hard to trace back to the scenario command or mod data that named it. The refusal is loud (error log naming the ID) and conservative (neither roster nor battle set changes), which keeps the BattleSet ⊆ Characters invariant intact — the battle scene reads <c>Characters[characterType]</c> directly for every BattleSet entry. Content callers (scenario actions, mod initializers) treat a false return as "the ID or its declaring mod is wrong"; use <see cref="CharacterTemplateRegistry.GetRequired"/> where a missing template is an invariant breach instead of a content typo. </summary> <param name="characterType">Character ID string to recruit.</param> <returns>True when the character was added; false when the ID has no declared template (party unchanged).</returns>
public bool AddCharacterToParty(string characterType){var card=CharacterTemplateRegistry.Get(characterType);if(card==null){GameLogger.Log("GAME",$"AddCharacterToParty: unknown character id '{characterType}' — party unchanged. "+"Declare the character (Id field in characters JSON) in a loaded mod or fix the reference.",LogLevel.Error);return false;}Characters[characterType]=Create(characterType,card);BattleSet.Add(characterType);return true;}/// <summary> Displays a localized "character joined" UI notification without modifying the party roster. Looks up the character name from <see cref="CharacterTemplateRegistry"/> (falls back to the raw ID if the template is missing), prepends the localized "ui/add_to_party" prefix, and dispatches through the kernel NotificationBridge (the floating-message implementation lives in the interface engine mod). </summary> <param name="characterType">Character ID string for the notification.</param>
public void SendAddNotification(string characterType){var card=CharacterTemplateRegistry.Get(characterType);var name=card?.Name??characterType;NotificationBridge.Show(TranslationServer.Translate("ui/add_to_party")+" - "+TranslationServer.Translate(name));}}}

===== FILE Core/Characters/CharacterTemplateRegistry.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Core.Characters{/// <summary> Kernel access point for character template resolution, decoupling the core from the engine mod that owns the factory (<c>mods/characters_base</c>). The mod's initializer assigns <see cref="Provider"/> during <c>IModInitializer.OnModLoaded</c>; all core-side consumers (<see cref="CharacterData"/>, inventory and skills UI) resolve templates through <see cref="Get"/>. Before the mod registers — tests, tools, very early boot — <see cref="Get"/> returns <c>null</c>, which <see cref="CharacterData.Create"/> already treats as the unknown-ID fallback path. <para/> The registry deliberately exposes two flavors, mirroring the project's lookup policy: <see cref="Get"/> is the lenient content lookup (a typo in scenario content degrades UI gracefully), while <see cref="GetRequired"/> is the fail-fast twin for engine-critical paths — boot wiring, tools, tests — where continuing without the template would hide the defect. The same split exists in the scenario action registry (<c>ScenarioActionFactory.Get/GetRequired</c>) and in <c>BattleCoreRegistry</c>, which throws on unknown core IDs. </summary>
public static class CharacterTemplateRegistry{/// <summary>Provider published by the characters engine mod; <c>null</c> until the mod loads.</summary>
public static ICharacterTemplateProvider Provider{get;set;}/// <summary>Returns an independent copy of the character template, or <c>null</c> when no provider is registered or the ID is unknown.</summary>
public static Character Get(string id)=>Provider?.Get(id);/// <summary> Resolves a character template and fails fast when it cannot: either no provider is registered yet (the characters engine mod has not loaded — a kernel wiring bug, not a content problem) or the ID is not declared in any loaded mod. Never returns null. </summary> <exception cref="InvalidOperationException">No provider registered — <see cref="Provider"/> is still null.</exception> <exception cref="KeyNotFoundException">The ID has no template declaration.</exception>
public static Character GetRequired(string id){if(Provider==null)throw new InvalidOperationException("CharacterTemplateRegistry has no provider: the characters engine mod has not published CharacterFactory yet.");return Provider.Get(id)??throw new KeyNotFoundException($"Character template '{id}' is not declared in any loaded mod. "+"Check the Id field in the character JSON or the reference spelling.");}}}

===== FILE Core/Characters/IActionTemplateProvider.cs =====
namespace Cthangover.Core.Characters{/// <summary> Kernel contract for resolving battle action card templates by ID. The concrete factory (<c>ActionCharacterFactory</c>) lives in the engine mod <c>mods/characters_base</c> and publishes itself here from its <c>IModInitializer.OnModLoaded</c>. Every lookup returns an independent copy because action cards carry mutable battle state (cooldowns, modifiers) and must never be shared across character instances. </summary>
public interface IActionTemplateProvider{/// <summary>Returns an independent copy of the action card template, or <c>null</c> when the ID is unknown.</summary>
ActionCharacter Get(string id);}}

===== FILE Core/Characters/IActiveCharacterProvider.cs =====
namespace Cthangover.Core.Characters{/// <summary> Neutral provider for the character that currently acts on behalf of the player. Turn-based battle is the original consumer, but the core only needs to know that some gameplay system can hand out an active actor — for example the inventory panel reads the actor's action points and charges item usage to them. A mod that owns such a system implements this interface and publishes itself through <see cref="ActiveCharacterRegistry"/>. </summary>
public interface IActiveCharacterProvider{/// <summary>The acting character, or <c>null</c> when no action context is active.</summary>
Character ActiveCharacter{get;}}}

===== FILE Core/Characters/ICharacterTemplateProvider.cs =====
namespace Cthangover.Core.Characters{/// <summary> Kernel contract for resolving character templates by ID. The concrete factory (<c>CharacterFactory</c>) lives in the engine mod <c>mods/characters_base</c> and publishes itself here from its <c>IModInitializer.OnModLoaded</c>, so the core assembly never references a runtime-compiled mod assembly. Copy semantics are part of the contract: every successful lookup returns an independent template copy, because <see cref="Character.Attributes"/> mutate during gameplay. </summary>
public interface ICharacterTemplateProvider{/// <summary>Returns an independent copy of the character template, or <c>null</c> when the ID is unknown.</summary>
Character Get(string id);}}

===== FILE Core/Characters/IPropertyInterceptor.cs =====
namespace Cthangover.Core.Characters{/// <summary> Contract for intercepting property writes in <see cref="Cthangover.Core.Utils.PropertyData"/>. Each interceptor is bound to a single property key via <see cref="PropertyName"/> and is called in registration order when that property is written through any typed setter (<c>SetStr</c>, <c>SetInt</c>, <c>SetFloat</c>, <c>SetBool</c>, <c>SetLong</c>). Multiple interceptors for the same property form a pipeline: each receives the value returned by the previous one, so interceptors can compose (e.g. one doubles healing, another caps max HP). </summary> <remarks> Registration is per-instance via <see cref="Cthangover.Core.Utils.PropertyData.AddInterceptor"/>. Interceptors are NOT copied by <see cref="Cthangover.Core.Utils.PropertyData.Clone"/> — they are runtime behaviour scoped to a specific character's property bag. </remarks>
public interface IPropertyInterceptor{/// <summary> Called before the value is stored in the backing dictionary. Receives the previous string value (<c>null</c> if the property didn't exist) and the new value (possibly already modified by earlier interceptors). Returns the value to store after this interceptor's transformation. </summary> <param name="prevValue"> Raw string value before this write, or <c>null</c> if the property was absent. Use <see cref="PropertyInterceptor{T}"/> for type-safe access — it handles parsing and formatting. </param> <param name="newValue"> Value being written — may have been modified by earlier interceptors in the chain. Guaranteed non-null: all <c>SetXxx</c> methods produce a non-null string. </param> <returns>The (possibly modified) string value to store.</returns>
string Intercept(string prevValue,string newValue);/// <summary> The property key this interceptor watches. <see cref="PropertyData.Set{T}"/> only calls interceptors whose <see cref="PropertyName"/> matches the key being written. </summary>
string PropertyName{get;}}}

===== FILE Core/Characters/LootEntry.cs =====
using System.Text.Json.Serialization;using Cthangover.Core.Mods.Patches;namespace Cthangover.Core.Characters{/// <summary> Loot drop definition for enemies: ItemId, min/max count range, and probability (0–100 percentage). Implements <see cref="IMergeable"/> with <c>ItemId</c> as the identity key so that <c>Loot$add</c> and <c>Loot$remove</c> patches can match entries by item rather than by exact JSON text. </summary>
public class LootEntry:IMergeable{string IMergeable.MergeKeyProperty=>nameof(ItemId);/// <summary> Item identifier string for the loot drop. Resolved against the item registry at drop time. </summary>
[JsonPropertyName("ItemId")]public string ItemId{get;set;}/// <summary> Minimum quantity of this item to drop when the roll succeeds. </summary>
[JsonPropertyName("MinCount")]public int MinCount{get;set;}/// <summary> Maximum quantity of this item to drop when the roll succeeds. Together with <see cref="MinCount"/> defines an inclusive range for randomized drop amounts. </summary>
[JsonPropertyName("MaxCount")]public int MaxCount{get;set;}/// <summary> Drop probability as a percentage (0–100). Evaluated by the loot system after battle to determine whether this entry produces a drop. </summary>
[JsonPropertyName("Probability")]public int Probability{get;set;}}}

===== FILE Core/Characters/PropertyInterceptor.cs =====
using System;using System.Globalization;using Cthangover.Core.Utils.Types;namespace Cthangover.Core.Characters{/// <summary> Typed base for <see cref="IPropertyInterceptor"/> implementations. Eliminates manual string parsing/formatting — the interceptor author overrides <see cref="Intercept(T, T)"/> working with typed values, and the base class handles conversion through <see cref="IParse{T}"/> (resolved via <see cref="TParser"/>). </summary> <typeparam name="T"> The value type this interceptor operates on. Must match a parser registered in <see cref="TParser"/> (e.g. <c>int</c>, <c>int?</c>, <c>float</c>, <c>bool</c>, <c>string</c>). </typeparam> <remarks> <para> <c>prevValue</c> parses to <c>default(T)</c> when the property didn't exist (null for nullable types, 0/0f/false for primitives). The converted result uses <see cref="Convert.ToString(object, IFormatProvider)"/> with <see cref="CultureInfo.InvariantCulture"/> to ensure round-trip compatibility with <see cref="FloatParse"/>. </para> <para> Example — double healing: <code> class DoubleHeal : PropertyInterceptor&lt;int&gt; { public override string PropertyName => "Health"; protected override int Intercept(int prev, int next) => next > prev ? prev + (next - prev) * 2 : next; } </code> </para> </remarks>
public abstract class PropertyInterceptor<T>:IPropertyInterceptor{private static readonly IParse<T>_parse=TParser.Resolve<T>();/// <inheritdoc cref="IPropertyInterceptor.Intercept"/>
public string Intercept(string prevValue,string newValue){var prev=_parse.Parse(prevValue);var next=_parse.Parse(newValue);var result=Intercept(prev,next);return Convert.ToString(result,CultureInfo.InvariantCulture);}/// <summary> Type-safe interception point. Called when a property matching <see cref="PropertyName"/> is written. Receives the parsed previous and new values, returns the (possibly modified) new value. </summary> <param name="prevValue"> Parsed previous value. For nullable types, <c>null</c> means the property didn't exist. For primitive types, 0/false means either absent or genuinely zero. </param> <param name="newValue"> Parsed new value being written, possibly modified by earlier interceptors in the chain for the same property. </param>
protected abstract T Intercept(T prevValue,T newValue);/// <inheritdoc/>
public abstract string PropertyName{get;}}}

===== FILE Core/Events/EventKeyAttribute.cs =====
using System;namespace Cthangover.Core.Events{/// <summary> Строковый ключ для авто-моста тип → строка. При Publish&lt;T&gt; шина дополнительно бродкастит на этот ключ для GDScript-подписчиков. Без атрибута — fallback на typeof(T).Name в snake_case. </summary>
[AttributeUsage(AttributeTargets.Class)]public class EventKeyAttribute:Attribute{/// <summary>The string event bus key the annotated class bridges to (snake_case, e.g. "scene_entered").</summary>
public string Key{get;}/// <summary>Binds the annotated event class to <paramref name="key"/> on the string (GDScript) bus layer.</summary>
public EventKeyAttribute(string key)=>Key=key;}}

===== FILE Core/Events/Events/CharactersDefeatedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Published when a group of characters is defeated as one encounter outcome — a won battle, a resolved trap, a scripted execution. The core stays neutral about who defeated them: <see cref="Source"/> names the reporting system (<c>"battle"</c>, <c>"trap"</c>, …) and interested mods subscribe without depending on the publisher. <para/> The payload carries ids only rather than live <c>Character</c> objects so the event bridges to GDScript safely (the string bridge cannot marshal arbitrary managed collections). Subscribers that need full character data resolve it through <c>CharacterFactory</c>. </summary>
[EventKey("characters_defeated")]public class CharactersDefeatedEvent:ModEvent{/// <summary>Free-form id of the system that reported the defeat.</summary>
public string Source{get;init;}/// <summary>Template ids of the defeated characters.</summary>
public string[]CharacterIds{get;init;}}}

===== FILE Core/Events/Events/DialogEndedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary>Published on the mod event bus when a dialog finishes; mods subscribe by the <c>dialog_ended</c> key.</summary>
[EventKey("dialog_ended")]public class DialogEndedEvent:ModEvent{/// <summary>Identifier of the finished dialog. The engine currently publishes this event without a payload.</summary>
public string DialogId{get;init;}/// <summary> Identifier of the ExecutableEvent that locked the dialog box while the queue ran (its <c>ID</c>), or null when the dialog ran without a locker. Event chains compare it against their last-run event to break self-trigger loops. </summary>
public string LockerId{get;init;}}}

===== FILE Core/Events/Events/DialogStartedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary>Published on the mod event bus when a dialog starts; mods subscribe by the <c>dialog_started</c> key.</summary>
[EventKey("dialog_started")]public class DialogStartedEvent:ModEvent{/// <summary>Identifier of the dialog that started (declared; not yet published by the engine).</summary>
public string DialogId{get;init;}/// <summary>Speaker identifier of the first line (declared; not yet published by the engine).</summary>
public string Speaker{get;init;}}}

===== FILE Core/Events/Events/FlagChangedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Fired when a named scenario flag changes value. Published by <see cref="Cthangover.Core.Scenes.ScenarioCondition.SetFlag"/> at the point of mutation, and only when the value actually changed. Flag values feed condition evaluation, so this event invalidates cached condition results. </summary>
[EventKey("flag_changed")]public class FlagChangedEvent:ModEvent{/// <summary>The flag name (condition identifier).</summary>
public string Key{get;init;}/// <summary>The new flag value.</summary>
public string Value{get;init;}}}

===== FILE Core/Events/Events/InteractiveStateChangedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Marker fired when an interactive scene object is removed, published by the interactive_base engine mod (InteractiveManager.Remove / ClearAll). Deliberately NOT published on <c>Add</c> — adding is idempotent, and publishing it would make "ensure"-scenarios with <c>interactive_add</c> retrigger themselves. Consumers use it to invalidate cached condition results that depend on interactive object presence. </summary>
[EventKey("interactive_state_changed")]public class InteractiveStateChangedEvent:ModEvent{/// <summary>The definition ID of the removed interactive object.</summary>
public string Id{get;init;}}}

===== FILE Core/Events/Events/InventoryChangedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary>Published on the mod event bus after every successful inventory add/remove; mirrors the inventory Change callback so bus subscribers get the same signal.</summary>
[EventKey("inventory_changed")]public class InventoryChangedEvent:ModEvent{/// <summary>Template id of the changed item.</summary>
public string ItemId{get;init;}/// <summary>Signed quantity change (positive = added, negative = removed).</summary>
public int Delta{get;init;}/// <summary>Total count of the item after the change.</summary>
public int NewCount{get;init;}}}

===== FILE Core/Events/Events/MapZoneChangeKind.cs =====
namespace Cthangover.Core.Events{/// <summary> String constants for <see cref="MapZoneChangedEvent.Change"/>. Plain strings instead of an enum on purpose: the same values cross the ModEventBus GDScript bridge as dictionary fields, and GDScript compares strings far more conveniently than enum ordinals. </summary>
public static class MapZoneChangeKind{/// <summary>A zone was opened for travel.</summary>
public const string Open="open";/// <summary>A zone was closed for travel.</summary>
public const string Close="close";/// <summary>A zone was revealed on the map.</summary>
public const string Show="show";/// <summary>A zone was removed from the map.</summary>
public const string Hide="hide";/// <summary>A zone's overrides were dropped, returning it to authoring defaults.</summary>
public const string Reset="reset";/// <summary>Overrides were replaced wholesale, e.g. by a save load.</summary>
public const string Restore="restore";/// <summary>The raw zone list was re-read; every visual layer must rebuild.</summary>
public const string Reload="reload";}}

===== FILE Core/Events/Events/MapZoneChangedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Published by <see cref="Cthangover.Core.UI.Tool.MapZoneService"/> whenever a zone's effective open or visible state changes (or the whole override set is replaced). <see cref="Cthangover.Core.Scenes.ConditionStateMonitor"/> subscribes to it so scenario header conditions that depend on map progression re-evaluate without a scene reload; the map widget subscribes to rebuild its layers. <para/> <see cref="ZoneId"/> may be empty for bulk changes (<see cref="MapZoneChangeKind.Reset"/>, <see cref="MapZoneChangeKind.Restore"/>, <see cref="MapZoneChangeKind.Reload"/>), meaning "rebuild everything" rather than a single zone. </summary>
[EventKey("map_zone_changed")]public class MapZoneChangedEvent:ModEvent{/// <summary>ID of the affected zone, or empty for bulk changes.</summary>
public string ZoneId{get;init;}/// <summary>One of the <see cref="MapZoneChangeKind"/> constants.</summary>
public string Change{get;init;}}}

===== FILE Core/Events/Events/ModLoadedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary>Published on the mod event bus when a mod finishes loading or hot-reloading; lets other mods react to new content appearing.</summary>
[EventKey("mod_loaded")]public class ModLoadedEvent:ModEvent{/// <summary>Id of the mod that finished loading (or re-loading).</summary>
public string ModId{get;init;}}}

===== FILE Core/Events/Events/ModResourcesReadyEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Published on the mod event bus once every mod has been loaded and all resources are ready — the "boot finished" signal for mods that must wait for the full content set (e.g. collections or cross-mod wiring) before acting. Carries no payload. </summary>
[EventKey("mod_resources_ready")]public class ModResourcesReadyEvent:ModEvent{}}

===== FILE Core/Events/Events/ModSetReloadedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Published after an in-process mod-set reload finished (game switch or hot reload), once registries, locale and scenes were rebuilt and the released contexts were verified. <see cref="Applied"/> is false when the reload was rejected (compile errors) and the previous set still runs; <see cref="Leaked"/> names mods whose old code stayed in memory. </summary>
[EventKey("mod_set_reloaded")]public class ModSetReloadedEvent:ModEvent{/// <summary>False when the reload was rejected and the previous set still runs.</summary>
public bool Applied{get;init;}/// <summary>Mods that loaded into a new context.</summary>
public string[]Loaded{get;init;}/// <summary>Mods whose context was released.</summary>
public string[]Unloaded{get;init;}/// <summary>Released mods whose context survived collection.</summary>
public string[]Leaked{get;init;}/// <summary>Compile errors of a rejected reload.</summary>
public string[]Errors{get;init;}}}

===== FILE Core/Events/Events/NewGameStartedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Published by <see cref="Settings.GameData.StartNewGame"/> right after the runtime state is wiped for a "New Game". Mods use it to re-seed per-session data — the core mod's starter party, for example — because <c>IModInitializer.OnModResourcesReady</c> runs only once at boot and cannot restore state that <c>ResetRuntime</c> has just discarded. </summary>
[EventKey("new_game_started")]public class NewGameStartedEvent:ModEvent{}}

===== FILE Core/Events/Events/NotificationRequestedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Published before a notification banner is created. Allows intercepting/modifying notifications. </summary>
[EventKey("notification_requested")]public class NotificationRequestedEvent:ModEvent{/// <summary>Notification text; the panel buffers it until a visible slot is free.</summary>
public string Text{get;init;}/// <summary>Optional grouping label used by UI consumers.</summary>
public string Category{get;init;}}}

===== FILE Core/Events/Events/QuestDataStatusChangedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Fired when a quest's numeric progression level (<c>QuestData.Status</c>) changes. Published by the quests engine mod's QuestData.SetStatus at the exact point of mutation, and only when the value actually changed. Carries no quest ID because <c>QuestData</c> does not know its owner — the marker is enough to invalidate cached condition results, which re-read live quest state on the next evaluation. </summary>
[EventKey("quest_data_status_changed")]public class QuestDataStatusChangedEvent:ModEvent{/// <summary>Previous numeric status value.</summary>
public int Old{get;init;}/// <summary>New numeric status value.</summary>
public int New{get;init;}}}

===== FILE Core/Events/Events/QuestStateRestoredEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Marker fired once after a save file's quest state is fully restored by the quests engine mod's QuestFactory.SetAll. Published a single time after the whole restore loop (not per-quest) to invalidate cached condition results without generating a per-quest event storm. </summary>
[EventKey("quest_state_restored")]public class QuestStateRestoredEvent:ModEvent{}}

===== FILE Core/Events/Events/QuestStatusChangedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary>Published on the mod event bus when a quest transitions between lifecycle states; the payload records both the old and the new status.</summary>
[EventKey("quest_status_changed")]public class QuestStatusChangedEvent:ModEvent{/// <summary>Id of the quest whose lifecycle state changed.</summary>
public string QuestId{get;init;}/// <summary>Previous QuestStatus enum name.</summary>
public string OldStatus{get;init;}/// <summary>New QuestStatus enum name as written by the caller (case preserved).</summary>
public string NewStatus{get;init;}}}

===== FILE Core/Events/Events/QuestTagChangedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary>Published on the mod event bus when a tag is added to or removed from a quest (see QuestBase.AddTag/RemoveTag).</summary>
[EventKey("quest_tag_changed")]public class QuestTagChangedEvent:ModEvent{/// <summary>Id of the quest whose tag set changed.</summary>
public string QuestId{get;init;}/// <summary>The tag that was added or removed.</summary>
public string Tag{get;init;}/// <summary>True when the tag was added, false when removed.</summary>
public bool Added{get;init;}}}

===== FILE Core/Events/Events/SceneComposedEvent.cs =====
using System.Collections.Generic;using Cthangover.Core.Scenes;namespace Cthangover.Core.Events{/// <summary> Published by <see cref="SceneManager.SwitchScene"/> after it composed the event chain of a scene: every candidate in start order with its outcome (queued, condition false, already completed, shadowed by a same-named event, failed). Debugging tools use it to answer "why did my scenario not start"; nothing in the game flow depends on it. Not published for the editor's test play, which bypasses composition. </summary>
[EventKey("scene_composed")]public class SceneComposedEvent:ModEvent{/// <summary>Logical name of the composed scene.</summary>
public string SceneName{get;init;}/// <summary>Candidates in composition order, shadowed ones included.</summary>
public IReadOnlyList<SceneCandidateReport>Candidates{get;init;}}}

===== FILE Core/Events/Events/SceneEnteredEvent.cs =====
using Godot;namespace Cthangover.Core.Events{/// <summary> Published after a new scene loads (via CallDeferred, so Root is in the tree). Bus subscriber priorities: 0=ApplySceneDefaults, 5=PlaySceneAmbient, 10=UpdateMusicType, 30=UiHost. The scenario event chain itself is composed synchronously inside SceneManager.SwitchScene before this event is published, and enqueued right after. </summary>
[LifecycleEvent][EventKey("scene_entered")]public class SceneEnteredEvent:ModEvent{/// <summary>Logical name of the entered scene.</summary>
public string SceneName{get;init;}/// <summary>Root node of the freshly loaded scene, already in the tree.</summary>
public Node Root{get;init;}}}

===== FILE Core/Events/Events/SceneExitedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Published on the mod event bus when the player leaves a scene; one of the lifecycle events composed into the scene event chain. </summary>
[LifecycleEvent][EventKey("scene_exited")]public class SceneExitedEvent:ModEvent{/// <summary>Logical name of the scene being left.</summary>
public string SceneName{get;init;}}}

===== FILE Core/Events/Events/SceneRouteActivatingEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Published by <c>GodotSceneService</c> right before a scene switch resolves its route, carrying the route's music configuration. Lets subscribers (e.g. the audio engine mod) prepare playlists before the new scene enters. Not a lifecycle event: the scenario event chain does not gate on it. </summary>
[EventKey("scene_route_activating")]public class SceneRouteActivatingEvent:ModEvent{/// <summary>Logical name of the scene being activated.</summary>
public string LogicalName{get;init;}/// <summary>Resource path of the scene file being loaded.</summary>
public string ScenePath{get;init;}/// <summary>Playlist key from the route definition (e.g. "town", "Battle").</summary>
public string MusicScene{get;init;}}}

===== FILE Core/Events/Events/ShellReadyEvent.cs =====
using Godot;namespace Cthangover.Core.Events{/// <summary> Published by the base shell (<c>ExecutableMainEventChain._Ready</c>) once the in-game UI shell is instantiated and its children are ready. <c>UiHost</c> uses it as the single "build the mod GUI now" trigger: it attaches cached panel instances to the new shell and <c>UiBuilder</c> creates the toolbar buttons. There is deliberately no teardown counterpart — panels live in the UiHost autoload and survive shell changes such as entering battle. </summary>
[LifecycleEvent][EventKey("shell_ready")]public class ShellReadyEvent:ModEvent{/// <summary>Root node of the freshly created shell (the base scene root).</summary>
public Node Root{get;init;}}}

===== FILE Core/Events/Events/TimeTickEvent.cs =====
namespace Cthangover.Core.Events{/// <summary>Published on the mod event bus on every in-game clock tick; drives anything time-based in mods (day/night logic, timed quests).</summary>
[EventKey("time_tick")]public class TimeTickEvent:ModEvent{/// <summary>Absolute in-game tick counter.</summary>
public long TimeTick{get;init;}/// <summary>Time of day normalized to 0.0–1.0 across the 24-hour cycle.</summary>
public float NormalizedTime{get;init;}}}

===== FILE Core/Events/LifecycleEventAttribute.cs =====
using System;namespace Cthangover.Core.Events{/// <summary> Маркер: событие поддерживает приоритеты подписчиков. Меньшее значение priority = вызывается раньше. Применять только к lifecycle-событиям (Scene/Battle). </summary>
[AttributeUsage(AttributeTargets.Class)]public class LifecycleEventAttribute:Attribute{}}

===== FILE Core/Events/ModEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Base class for all event bus events. C# mods inherit from it, GDScript uses the string layer (Dictionary). </summary>
public abstract class ModEvent{/// <summary>Sending mod (null = engine).</summary>
public string SenderModId{get;init;}/// <summary> Tick from GameData.Instance.Runtime.Time at publish time. 0 if GameData is not yet initialized. </summary>
public long Tick{get;init;}}}

===== FILE Core/Events/ModEventBus.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Reflection;using Cthangover.Core.Utils;using Godot;using GdDictionary=Godot.Collections.Dictionary;using GdArray=Godot.Collections.Array;namespace Cthangover.Core.Events{/// <summary> Global event bus. Autoload Node, survives scene changes. Two subscription layers: 1. Typed (C#): Subscribe&lt;T&gt;/Publish&lt;T&gt; — dispatch by type. 2. String (GDScript): subscribe/publish — key + Callable/Dictionary. Auto-bridge: Publish&lt;T&gt; additionally broadcasts on the [EventKey] string so GDScript subscribers receive the same events as C#. Cleanup: ClearForMod(modId) on mod unload removes its subscriptions. </summary>
public partial class ModEventBus:Node{/// <summary>Live autoload instance; null outside the Godot runtime (the typed layer still works there, the GDScript bridge does not).</summary>
public static ModEventBus Instance{get;private set;}/// <summary>Registers the autoload singleton on tree entry.</summary>
public override void _EnterTree()=>Instance=this;/// <summary>Clears the autoload singleton reference on tree exit.</summary>
public override void _ExitTree(){if(Instance==this)Instance=null;}// ── typed layer (static, for C#) ──
private static readonly Dictionary<Type,List<TypedSubscription>>_typedSubs=new();private static readonly object _lock=new();/// <summary> Subscribe to a typed event. <paramref name="priority"/> only meaningful for [LifecycleEvent] — lower values fire first. Otherwise ignored. <paramref name="modId"/> — mod ID for ClearForMod. </summary>
public static void Subscribe<T>(Action<T>handler,int priority=0,string modId=null)where T:ModEvent{var type=typeof(T);lock(_lock){if(!_typedSubs.TryGetValue(type,out var list)){list=new List<TypedSubscription>();_typedSubs[type]=list;}list.Add(new TypedSubscription{Handler=e=>handler((T)e),Original=handler,ModId=modId,Priority=priority,});var isLifecycle=type.GetCustomAttribute<LifecycleEventAttribute>()!=null;if(isLifecycle)list.Sort((a,b)=>a.Priority.CompareTo(b.Priority));}}public static void Unsubscribe<T>(Action<T>handler)where T:ModEvent{var type=typeof(T);lock(_lock){if(!_typedSubs.TryGetValue(type,out var list))return;list.RemoveAll(s=>s.Original is Action<T>typed&&typed==handler);}}/// <summary> Publishes event to typed subscribers, then auto-bridges to the string layer for GDScript. Typed (C#) subscribers are always invoked — even without a live autoload <see cref="Instance"/> — so unit tests and headless code paths can observe events. The GDScript bridge is a no-op when <see cref="Instance"/> is null (bridge requires the Godot node). </summary>
public static void Publish<T>(T evt)where T:ModEvent{List<TypedSubscription>snapshot;lock(_lock){if(!_typedSubs.TryGetValue(typeof(T),out var list)||list.Count==0)snapshot=null;else snapshot=new List<TypedSubscription>(list);}if(snapshot!=null){foreach(var sub in snapshot){try{sub.Handler(evt);}catch(Exception ex){GameLogger.Log("EVENT_BUS",$"Typed handler for {typeof(T).Name} failed (mod='{sub.ModId}'): {ex.Message}",LogLevel.Error);}}}// The GDScript bridge creates Godot Dictionary/Callable objects, which is only legal on the main thread. During the background mod-load phase no GDScript subscribers are registered yet, so skipping the bridge off the main thread is a no-op in practice and keeps the load thread safe.
if(GameEnvironment.IsMainThread)Instance?.BridgeToGdScript(evt);}// ── string layer (instance, for GDScript) ──
private readonly Dictionary<string,List<CallableSubscription>>_stringSubs=new();/// <summary> GDScript: ModEventBus.subscribe("scene_entered", _on_scene, 0, "my_mod") </summary>
public void Subscribe(string eventKey,Callable handler,int priority=0,string modId=null){if(string.IsNullOrEmpty(eventKey)||handler.Target==null)return;lock(_stringSubs){if(!_stringSubs.TryGetValue(eventKey,out var list)){list=new List<CallableSubscription>();_stringSubs[eventKey]=list;}list.Add(new CallableSubscription{Handler=handler,ModId=modId,Priority=priority,});}}/// <summary> GDScript: ModEventBus.unsubscribe("scene_entered", _on_scene) </summary>
public void Unsubscribe(string eventKey,Callable handler){lock(_stringSubs){if(!_stringSubs.TryGetValue(eventKey,out var list))return;list.RemoveAll(s=>s.Handler.Equals(handler));}}/// <summary> GDScript: ModEventBus.publish("cooking_meal_ready", {"dish": "wolf_stew"}) </summary>
public void Publish(string eventKey,Godot.Collections.Dictionary data){if(string.IsNullOrEmpty(eventKey))return;List<CallableSubscription>snapshot;lock(_stringSubs){if(!_stringSubs.TryGetValue(eventKey,out var list)||list.Count==0)return;snapshot=new List<CallableSubscription>(list);}foreach(var sub in snapshot){try{if(sub.Handler.Target==null)continue;sub.Handler.Call(data);}catch(Exception ex){GameLogger.Log("EVENT_BUS",$"String handler for '{eventKey}' failed (mod='{sub.ModId}'): {ex.Message}",LogLevel.Error);}}}// ── cleanup ── <summary> Removes all subscriptions for a mod — both typed and string. Called on mod unload/reload. </summary>
public void ClearForMod(string modId){if(string.IsNullOrEmpty(modId))return;lock(_lock){foreach(var kvp in _typedSubs)kvp.Value.RemoveAll(s=>s.ModId==modId);}lock(_stringSubs){foreach(var kvp in _stringSubs)kvp.Value.RemoveAll(s=>s.ModId==modId);}GameLogger.Log("EVENT_BUS",$"Cleared all subscriptions for mod '{modId}'");}/// <summary> Unload pass by code ownership rather than by declared mod id: removes typed subscriptions whose handler lives in a dying mod assembly and subscriptions to event types declared there. Most mods subscribe without passing their id, so <see cref="ClearForMod"/> alone leaves their handlers behind — and every such handler pins the mod's load context. Returns the number of removed subscriptions. </summary>
public static int RemoveOwned(Mods.Unload.AssemblyOwnership ownership){if(ownership==null||ownership.IsEmpty)return 0;var removed=0;lock(_lock){var ownedTypes=new List<Type>();foreach(var type in _typedSubs.Keys){if(ownership.Owns(type))ownedTypes.Add(type);}foreach(var type in ownedTypes){removed+=_typedSubs[type].Count;_typedSubs.Remove(type);}foreach(var list in _typedSubs.Values){for(var i=list.Count-1;i>=0;i--){if(!ownership.Owns(list[i].Original))continue;list.RemoveAt(i);removed++;}}}return removed;}/// <summary>Wipes every subscription on both the typed and string layers. Test/setup helper — not part of the normal unload path.</summary>
public void ClearAll(){lock(_lock)_typedSubs.Clear();lock(_stringSubs)_stringSubs.Clear();}// ── internals ──
private void BridgeToGdScript(ModEvent evt){var type=evt.GetType();var keyAttr=type.GetCustomAttribute<EventKeyAttribute>();if(keyAttr==null)return;var eventKey=keyAttr.Key;List<CallableSubscription>snapshot;lock(_stringSubs){if(!_stringSubs.TryGetValue(eventKey,out var list)||list.Count==0)return;snapshot=new List<CallableSubscription>(list);}var data=new Godot.Collections.Dictionary();foreach(var prop in type.GetProperties(BindingFlags.Public|BindingFlags.Instance|BindingFlags.DeclaredOnly)){var key=SnakeCase(prop.Name);var value=prop.GetValue(evt);if(value is Node node)data[key]=node;else if(value is string[]arr)data[key]=new Godot.Collections.Array<string>(arr);else if(value!=null)data[key]=Variant.From(value);}foreach(var sub in snapshot){try{if(sub.Handler.Target==null)continue;sub.Handler.Call(data);}catch(Exception ex){GameLogger.Log("EVENT_BUS",$"GDScript bridge handler for '{eventKey}' failed (mod='{sub.ModId}'): {ex.Message}",LogLevel.Error);}}}private static string SnakeCase(string pascal){if(string.IsNullOrEmpty(pascal))return pascal;var chars=new List<char>{char.ToLowerInvariant(pascal[0])};for(int i=1;i<pascal.Length;i++){if(char.IsUpper(pascal[i])){chars.Add('_');chars.Add(char.ToLowerInvariant(pascal[i]));}else{chars.Add(pascal[i]);}}return new string(chars.ToArray());}private sealed class TypedSubscription{/// <summary>Type-erased handler; the generic wrapper casts back to the event type.</summary>
public Action<ModEvent>Handler;/// <summary>The subscriber's own delegate: identity for <see cref="Unsubscribe{T}"/>, ownership for <see cref="RemoveOwned"/>.</summary>
public Delegate Original;/// <summary>Owning mod id for <see cref="ClearForMod"/>; null for engine subscriptions.</summary>
public string ModId;/// <summary>Delivery order for [LifecycleEvent] event types (lower = earlier).</summary>
public int Priority;}private sealed class CallableSubscription{/// <summary>GDScript callable invoked with the bridged Dictionary payload.</summary>
public Callable Handler;/// <summary>Owning mod id for <see cref="ClearForMod"/>; null for engine subscriptions.</summary>
public string ModId;/// <summary>Reserved for parity with the typed layer (the string layer delivers in subscription order).</summary>
public int Priority;}}}

===== FILE Core/Factories/AvatarProviderRegistry.cs =====
namespace Cthangover.Core.Factories{/// <summary> Kernel access point for avatar texture resolution, decoupling the core from the engine mod that owns the avatar factory (<c>mods/characters_base</c>). The mod's initializer assigns <see cref="Provider"/> during <c>IModInitializer.OnModLoaded</c>; dialog actions resolve portraits through <see cref="Get"/>. Returns <c>null</c> when the mod has not registered yet. </summary>
public static class AvatarProviderRegistry{/// <summary>Provider published by the characters engine mod; <c>null</c> until the mod loads.</summary>
public static IAvatarProvider Provider{get;set;}/// <summary>Returns the avatar texture, or <c>null</c> when no provider is registered or the ID is unknown.</summary>
public static Godot.Texture2D Get(string id)=>Provider?.Get(id);}}

===== FILE Core/Factories/EntityKindAttribute.cs =====
using System;namespace Cthangover.Core.Factories{/// <summary> Declares which entity kind a JSON-deserializable DTO describes. The kind is the sole type discriminator of the mod data pipeline: every data JSON carries a top-level <c>"Kind"</c> string in its <c>FileData&lt;T&gt;</c> envelope, and <see cref="EntityKindRegistry"/> resolves the kind to exactly one DTO type. This replaces the old convention where the directory a file lived in implied its type — a convention that broke down when several index files (characters, enemies, actions, skills) shared the <c>characters/</c> directory and every factory ended up parsing its neighbours' files. <para/> One kind maps to one DTO, and one JSON file declares one kind. Files that are not mod data (manifests, collections, live2d models, scene definitions) simply do not carry a kind and are never parsed by the entity resolver. </summary>
[AttributeUsage(AttributeTargets.Class,Inherited=false,AllowMultiple=false)]public sealed class EntityKindAttribute:Attribute{/// <summary> The canonical lowercase kind string (e.g. <c>"skill"</c>) that matches the <c>"Kind"</c> field written in the DTO's JSON files. </summary>
public string Kind{get;}/// <summary> Creates the attribute. The kind is normalised to lowercase so that authoring case differences never split one entity type into two caches. </summary> <param name="kind">Canonical kind string, e.g. <c>"skill"</c>.</param>
public EntityKindAttribute(string kind){Kind=string.IsNullOrWhiteSpace(kind)?null:kind.Trim().ToLowerInvariant();}}}

===== FILE Core/Factories/EntityKindRegistry.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Reflection;using Cthangover.Core.Utils;namespace Cthangover.Core.Factories{/// <summary> Maps entity kind strings to DTO types declared with <see cref="EntityKindAttribute"/>. The registry is the authoritative vocabulary of the data pipeline: the entity scanner asks it whether a file's <c>"Kind"</c> is known before routing it, and factories resolve their own kind from their DTO type. <para/> The reverse map (kind → <see cref="Type"/>) is built lazily by scanning every loaded assembly, because entity DTOs also live in Roslyn-compiled mod assemblies (<c>StatusEffectInfo</c> being the first example) that only exist after mod loading. <see cref="Refresh"/> is called when the resolver cache generation turns over, so a mod reload re-discovers the vocabulary. </summary>
public static class EntityKindRegistry{private static readonly object _lock=new();private static Dictionary<string,Type>_byKind;/// <summary> Returns the kind declared by <paramref name="type"/>, or <c>null</c> when the type carries no <see cref="EntityKindAttribute"/>. Reads the attribute directly so it works for types in dynamic mod assemblies that the AppDomain scan may not have reached yet. </summary>
public static string KindOf(Type type){return type?.GetCustomAttribute<EntityKindAttribute>(false)?.Kind;}/// <summary> Returns the kind declared by <typeparamref name="T"/>, or <c>null</c> when the DTO is not annotated. </summary>
public static string KindOf<T>(){return KindOf(typeof(T));}/// <summary> Resolves a kind string from a JSON file to its DTO type. Returns <c>false</c> for unknown kinds so the scanner can report the file instead of silently dropping it. </summary>
public static bool TryResolve(string kind,out Type type){EnsureBuilt();return _byKind.TryGetValue(kind??string.Empty,out type);}/// <summary>True when the kind is declared by some loaded DTO.</summary>
public static bool IsKnown(string kind){EnsureBuilt();return kind!=null&&_byKind.ContainsKey(kind);}/// <summary>All known kinds, for diagnostics and validation.</summary>
public static IReadOnlyCollection<string>KnownKinds{get{EnsureBuilt();return _byKind.Keys.ToList();}}/// <summary> Discards the reverse map so the next lookup re-scans the AppDomain. Called by the entity resolver when mods are (re)loaded. </summary>
public static void Refresh(){lock(_lock){_byKind=null;}}private static void EnsureBuilt(){if(_byKind!=null)return;lock(_lock){if(_byKind!=null)return;var map=new Dictionary<string,Type>(StringComparer.OrdinalIgnoreCase);foreach(var assembly in Reflections.LoadedAssemblies()){Type[]types;try{types=assembly.GetTypes();}catch(ReflectionTypeLoadException ex){types=ex.Types.Where(t=>t!=null).ToArray();}catch(Exception){continue;}foreach(var type in types){var kind=KindOf(type);if(string.IsNullOrEmpty(kind))continue;if(map.TryGetValue(kind,out var existing)){if(existing!=type)GameLogger.Log("ENTITIES",$"kind '{kind}' is declared by both '{existing.FullName}' and '{type.FullName}'; keeping the first",LogLevel.Error);continue;}map[kind]=type;}}_byKind=map;}}}}

===== FILE Core/Factories/FileFactory.cs =====
using System.Collections.Generic;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;namespace Cthangover.Core.Factories{/// <summary> Lightweight JSON-driven factory. All data discovery is delegated to <see cref="EntityResolver"/>, which scans every mod file once and routes entities by their declared kind (read from <typeparamref name="T"/>'s <see cref="EntityKindAttribute"/>). The cache dictionary is built lazily on the first <c>Get</c> call — factories that are created early (e.g. during boot) but rarely accessed don't pay for parsing data they may never use. <para/> The collected dictionary is published only after the mod registry is finalized. A dirty cache is impossible: the registry refuses mod-data access until <c>Boot.IsReady</c>, so a lookup can never collect from a half-built mod set and cache an empty result. </summary>
public abstract class FileFactory<T>:IFileFactory<T>where T:class,IIdentifiable{private Dictionary<string,T>cache;/// <summary> Returns the entity registered under <paramref name="id"/>, collecting the kind-routed dictionary from the entity resolver on first use. Unknown or empty ids return <c>null</c>. </summary>
public T Get(string id){if(string.IsNullOrEmpty(id))return null;var infos=cache??Entities.Collect<T>();if(ModManager.Instance.IsInitialized)cache??=infos;infos.TryGetValue(id,out var item);return item;}}}

===== FILE Core/Factories/IAvatarProvider.cs =====
using Godot;namespace Cthangover.Core.Factories{/// <summary> Kernel contract for resolving character avatar textures by ID. The concrete factory (<c>AvatarFactory</c>) lives in the engine mod <c>mods/characters_base</c> and publishes itself through <see cref="AvatarProviderRegistry"/> from its <c>IModInitializer.OnModLoaded</c>, so dialog and UI code in the core stays independent of the mod assembly. </summary>
public interface IAvatarProvider{/// <summary>Returns the avatar texture for the given ID, or <c>null</c> when the ID is unknown.</summary>
Texture2D Get(string id);}}

===== FILE Core/Factories/IFileFactory.cs =====
namespace Cthangover.Core.Factories{/// <summary> Contract for factories that load JSON-serialized objects from mod data. The <c>out T</c> covariance enables returning a <c>IFileFactory&lt;Derived&gt;</c> where an <c>IFileFactory&lt;Base&gt;</c> is expected, since the factory only produces, never consumes <c>T</c>. The <c>IIdentifiable</c> constraint ensures every object can be keyed by its <c>ID</c> — <c>FileFactory&lt;T&gt;</c> uses this to build the initial lookup dictionary from the entity resolver. </summary>
public interface IFileFactory<out T>where T:class,IIdentifiable{/// <summary>Returns the entity registered under <paramref name="id"/>, or <c>null</c> when unknown.</summary>
T Get(string id);}}

===== FILE Core/Factories/IFileSerializer.cs =====
namespace Cthangover.Core.Factories{/// <summary> Deserialization adapter injected into the <c>ModManager</c> pipeline. Decouples "how to parse a JSON string into <c>T</c>" from the mod file crawling and caching logic. The optional <c>resourcePath</c> parameter allows the serializer to resolve relative paths within the mod's directory tree (e.g. a character JSON that references a sprite at <c>"./images/portrait.png"</c>). </summary>
public interface IFileSerializer<T>where T:class{/// <summary>Parses <paramref name="content"/> into <typeparamref name="T"/>; <paramref name="resourcePath"/> anchors relative asset references.</summary>
T Read(string content,string resourcePath=null);}}

===== FILE Core/Factories/IIdentifiable.cs =====
namespace Cthangover.Core.Factories{/// <summary> Fundamental identity contract for any object that participates in the factory lookup system. Every factory (file-based or prefab-based) ultimately maps a string <c>ID</c> to a game entity. Classes implementing this interface can be stored in <c>Dictionary&lt;string, T&gt;</c> caches and referenced from other data objects by ID rather than by direct reference, which is essential for JSON-driven content where inter-object links must survive serialization boundaries. </summary>
public interface IIdentifiable{/// <summary>Unique string key used for factory lookups and cross-entity references.</summary>
string ID{get;}}}

===== FILE Core/Factories/IPrefabFactory.cs =====
namespace Cthangover.Core.Factories{/// <summary> Contract for factories that produce binary assets (Textures, AudioStreams, PackedScenes) from raw files stored in mod archives. Distinct from <c>IFileFactory</c> because the input is a byte array — not JSON text — and the conversion logic depends on the file extension. </summary>
public interface IPrefabFactory<out T>{/// <summary>Mod asset group this factory scans; ids are relative to it.</summary>
string GroupName{get;}/// <summary>Returns the cached asset for <paramref name="id"/>, or <c>null</c> when missing.</summary>
T Get(string id);}}

===== FILE Core/Factories/Impls/Avatars.cs =====
namespace Cthangover.Core.Factories.Impls{/// <summary> Compile-time catalog of avatar IDs used by scenario scripts. Each nested class maps a character name to a set of emotional states, forming a type-safe alternative to raw string lookups — a typo in a constant produces a build error, whereas a typo in a string literal silently returns a null texture at runtime. </summary>
public static class Avatars{/// <summary>Story-book avatar variants (<c>book_1</c>..<c>book_4</c>).</summary>
public static class Book{public const string Variant1="book_1";public const string Variant2="book_2";public const string Variant3="book_3";public const string Variant4="book_4";}/// <summary>Marao's expression ids referenced by dialogue <c>first=</c> parameters. Note: <c>sad</c> exists only as a file and is not catalogued here.</summary>
public static class Marao{public const string Angry="marao/angry";public const string Think="marao/think";public const string What="marao/what";public const string Relax="marao/relax";public const string Smile="marao/smile";}/// <summary>Murakami's expression ids referenced by dialogue <c>second=</c> parameters; <c>normal_3</c> is catalogued but has no file on disk.</summary>
public static class Murakami{public const string Fear1="murakami/fear_1";public const string Fear2="murakami/fear_2";public const string Normal1="murakami/normal_1";public const string Normal2="murakami/normal_2";public const string Normal3="murakami/normal_3";}}}

===== FILE Core/Factories/Impls/BackgroundFactory.cs =====
using Cthangover.Core.Bootstrap;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Factories.Impls{/// <summary> Thin <c>Texture2DFactory</c> for scene backgrounds. Isolated from the general UI texture factory because backgrounds are typically full-resolution images that consume significantly more GPU memory — a separate cache budget prevents background evictions from cascading into UI sprite thrashing. <para/> The single instance is owned by the <see cref="AppServices"/> composition root; the static facade stays as the public API during migration, with laziness preserved by the owning <c>Lazy</c> slot. </summary>
public class BackgroundFactory:Texture2DFactory{internal BackgroundFactory():base("backgrounds",64){}/// <summary>Facade over the AppServices-owned instance; kept as the public API during migration.</summary>
public static BackgroundFactory Instance=>AppServices.Current.Backgrounds.Value;/// <summary>Mod asset group scanned by this factory — files live under <c>mods/*/</c><b>backgrounds</b>.</summary>
public override string GroupName=>"backgrounds";}}

===== FILE Core/Factories/Impls/ItemSpriteFactory.cs =====
using Cthangover.Core.Bootstrap;namespace Cthangover.Core.Factories.Impls{/// <summary> Thin <c>Texture2DFactory</c> for inventory item icons. Separated from the general UI texture factory so that the item sprite cache uses a dedicated LRU budget — the inventory UI may render dozens of sprites simultaneously and should evict its own stale entries rather than competing with dialog backgrounds or avatars. <para/> The single instance is owned by the <see cref="AppServices"/> composition root; the static facade stays as the public API during migration, with laziness preserved by the owning <c>Lazy</c> slot. </summary>
public class ItemSpriteFactory:Texture2DFactory{internal ItemSpriteFactory():base("items",64){}/// <summary>Facade over the AppServices-owned instance; kept as the public API during migration.</summary>
public static ItemSpriteFactory Instance=>AppServices.Current.ItemSprites.Value;/// <summary>Mod asset group scanned by this factory — files live under <c>mods/*/</c><b>items</b>.</summary>
public override string GroupName=>"items";}}

===== FILE Core/Factories/Impls/ScenarioActionFactory.cs =====
using System.Collections.Generic;using System.Linq;using System.Reflection;using Cthangover.Core.Actions;using Cthangover.Core.Bootstrap;using Cthangover.Core.Utils;namespace Cthangover.Core.Factories.Impls{/// <summary> Singleton registry that discovers and indexes all IScenarioAction implementations via reflection. Actions are keyed by their Name property (e.g. "quest.set_status"). The single instance is owned by the AppServices composition root in a Lazy&lt;T&gt; slot for thread-safe initialization via Reflections.FindAndCreate which scans all loaded assemblies. RegisterAssembly allows mods to inject custom actions at runtime — deduplication by Name ensures mods can override core actions by registering first. The factory is consumed by ActionScenario (dialog action) which bridges the dialog system to these atomic scenario commands. </summary>
public class ScenarioActionFactory{/// <summary> Facade over the AppServices-owned instance; kept as the public API during migration. Laziness and thread safety come from the owning Lazy&lt;T&gt; slot. Construction triggers a full assembly scan for all IScenarioAction implementations via Reflections.FindAndCreate and registers each by its Name. </summary>
public static ScenarioActionFactory Instance=>AppServices.Current.ScenarioActions.Value;private readonly Dictionary<string,IScenarioAction>actions=new();/// <summary> Guards <see cref="actions"/>. The factory is a shared singleton reached from parallel test collections and from GDScript registration paths; without the lock a GetAll snapshot racing a Register resize throws "Destination array is not long enough" — observed as a rare flake in ScenarioActionFactoryTests. </summary>
private readonly object actionsLock=new();internal ScenarioActionFactory(){foreach(var action in Reflections.FindAndCreate<IScenarioAction>()){Register(action);}lock(actionsLock){GameLogger.Log("FACTORY",$"loaded '{actions.Count}' scenario actions");}}/// <summary> Looks up a registered action by its Name (e.g. "quest.set_status"). Returns null and logs an error if no action with the given name is registered. This is the primary entry point for the dialog engine — ActionScenario calls Get() with the command name from the scenario script and invokes Run() on the returned action. </summary>
public IScenarioAction Get(string name){lock(actionsLock){if(actions.TryGetValue(name,out var action))return action;}GameLogger.Log("FACTORY",$"ScenarioAction '{name}' not found",LogLevel.Error);return null;}/// <summary> Looks up a registered action by its Name and fails fast when it is missing. This is the fail-fast twin of <see cref="Get"/>: scenario content playback must stay resilient (a typo in a mod's scenario degrades to an error log and a skipped command), but engine-critical code — tools, boot wiring, validators, tests — resolves actions by names that are contractually known or already validated, and there a null return would only postpone the failure to a harder-to-trace spot. The same Get/GetRequired split exists in <c>CharacterTemplateRegistry</c>; <c>BattleCoreRegistry</c> throws unconditionally for the same reason. </summary> <exception cref="KeyNotFoundException">No action with the given Name is registered.</exception>
public IScenarioAction GetRequired(string name){lock(actionsLock){if(actions.TryGetValue(name,out var action))return action;throw new KeyNotFoundException($"Scenario action '{name}' is not registered ({actions.Count} actions loaded). "+"Check the action Name spelling or make sure the mod that defines it is loaded.");}}/// <summary> Registers an action instance. If an action with the same Name is already registered, the new one is silently ignored — this allows core actions to take precedence over mod overrides, and prevents accidental duplicates from reflection scans. Logs the registration for debugging. </summary>
public void Register(IScenarioAction action){lock(actionsLock){if(actions.ContainsKey(action.Name))return;actions.Add(action.Name,action);}GameLogger.Log("FACTORY",$"registered scenario action '{action.Name}'");}/// <summary> Scans all types in the given assembly for non-abstract IScenarioAction implementations, instantiates each via Activator.CreateInstance, and registers them. This is the extension point for mods — mod assemblies call this at startup to inject custom scenario actions into the dialog engine. Actions with names already registered are skipped. </summary>
public void RegisterAssembly(Assembly assembly){foreach(var action in Reflections.FindAndCreate<IScenarioAction>(assembly)){Register(action);}}/// <summary> Removes actions whose concrete type comes from the given assembly — the unload pass of the assembly consumer pipeline. Core actions survive; a mod reinstall re-registers through the normal fan-out. </summary>
public void UnregisterAssembly(Assembly assembly){lock(actionsLock){foreach(var name in actions.Where(kvp=>kvp.Value.GetType().Assembly==assembly).Select(kvp=>kvp.Key).ToList())actions.Remove(name);}}/// <summary> Returns a snapshot list of all registered actions. Used for debugging and introspection (listing available commands). Not intended for hot-path lookups — use Get() by name for that. </summary>
public List<IScenarioAction>GetAll(){lock(actionsLock)return actions.Values.ToList();}}}

===== FILE Core/Factories/Impls/UIIconFactory.cs =====
using Cthangover.Core.Bootstrap;namespace Cthangover.Core.Factories.Impls{/// <summary> Thin <c>Texture2DFactory</c> for small UI icons (buttons, indicators, status badges). Isolated from <c>UITextureFactory</c> under the <c>"ui/icons"</c> group so that icon atlases can be organised separately from full UI panel textures, and each gets its own cache budget proportional to its expected memory footprint. <para/> The single instance is owned by the <see cref="AppServices"/> composition root; the static facade stays as the public API during migration, with laziness preserved by the owning <c>Lazy</c> slot. </summary>
public class UIIconFactory:Texture2DFactory{internal UIIconFactory():base("ui/icons",64){}/// <summary>Facade over the AppServices-owned instance; kept as the public API during migration.</summary>
public static UIIconFactory Instance=>AppServices.Current.UIIcons.Value;/// <summary>Mod asset group scanned by this factory — files live under <c>mods/*/</c><b>ui/icons</b>.</summary>
public override string GroupName=>"ui/icons";}}

===== FILE Core/Factories/Impls/UITextureFactory.cs =====
using Cthangover.Core.Bootstrap;namespace Cthangover.Core.Factories.Impls{/// <summary> Thin <c>Texture2DFactory</c> for full-size UI panel textures (frames, overlays, menu backgrounds). Uses the top-level <c>"ui"</c> group so that UI textures sit alongside their companion files (theme JSON, font atlases) without needing a separate subdirectory per asset type. <para/> The single instance is owned by the <see cref="AppServices"/> composition root; the static facade stays as the public API during migration, with laziness preserved by the owning <c>Lazy</c> slot. </summary>
public class UITextureFactory:Texture2DFactory{internal UITextureFactory():base("ui",64){}/// <summary>Facade over the AppServices-owned instance; kept as the public API during migration.</summary>
public static UITextureFactory Instance=>AppServices.Current.UITextures.Value;/// <summary>Mod asset group scanned by this factory — files live under <c>mods/*/</c><b>ui</b>.</summary>
public override string GroupName=>"ui";}}

===== FILE Core/Factories/PrefabFactory.cs =====
using System;using System.Collections.Generic;using System.IO;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Factories{/// <summary> Core binary-asset factory with LRU-bounded caching and multi-extension mod file scanning. Implements <c>ICacheLoader</c> so that cache misses trigger a fresh scan of all mods — each registered file extension is tried in order and the first matching asset wins. The <c>factoryKey</c> passed to the constructor is used to look up a cache size override from <c>ModConfig</c>, allowing per-asset-type memory budgeting without hard-coding sizes per subclass. Subclasses implement <c>ConvertFromBytes</c> to transform raw file data into Godot resources. Falls back silently for <c>_albedo</c> / <c>_depth</c> suffix lookups (normal-map companion textures that may legitimately not exist for every sprite), avoiding log noise for optional secondary textures. Cached payloads are Godot resources (<c>Texture2D</c>, <c>AudioStream</c>, <c>PackedScene</c>) whose native/GPU memory outlives a plain .NET reference, so the LRU cache is wired to a disposal hook (<see cref="ReleaseResource"/>): when an entry leaves the cache and no engine object (sprite, audio player, ...) holds a native reference any more, the resource is disposed immediately instead of waiting for the .NET GC finalizer. Without the hook, VRAM and audio memory of evicted assets stay allocated until the next collection — which manifests as a leak during long sessions with frequent location changes. </summary>
public abstract class PrefabFactory<T>:IPrefabFactory<T>,ICacheLoader<string,T>where T:class{private readonly BoundedCache<string,T>_cache;protected PrefabFactory(string factoryKey,int fallbackCacheSize){var size=ModConfig.Instance.Cache.GetCacheSize(factoryKey,fallbackCacheSize);_cache=new BoundedCache<string,T>(size,this,ReleaseResource);}/// <summary>Mod asset group this factory scans (e.g. <c>"music"</c>, <c>"backgrounds"</c>); ids are relative to it.</summary>
public abstract string GroupName{get;}/// <summary>Converts raw mod file bytes into the Godot resource this factory produces.</summary>
protected abstract T ConvertFromBytes(string id,byte[]data,string extension);/// <summary>File extensions tried in order during the mod scan; the first matching asset wins.</summary>
protected abstract List<string>Extensions{get;}T ICacheLoader<string,T>.Load(string id){foreach(var extension in Extensions){var result=LoadFromMods(id,extension);if(result!=null)return result;}if(id==null||(!id.EndsWith("_albedo",StringComparison.OrdinalIgnoreCase)&& !id.EndsWith("_depth",StringComparison.OrdinalIgnoreCase)))GameLogger.Log("FACTORY",$"resource from factory '{GetType().Name}' with id '{id}' - not found in group '{GroupName}'!",LogLevel.Error);return null;}/// <summary> Returns the cached asset for <paramref name="id"/>, loading it through the LRU-bounded cache on first access. Missing assets are logged — except the optional <c>_albedo</c>/<c>_depth</c> companion lookups, which may legitimately not exist. </summary>
public virtual T Get(string id){if(string.IsNullOrEmpty(id))return null;var result=_cache.Get(id);if(result==null&& !id.EndsWith("_albedo",StringComparison.OrdinalIgnoreCase)&& !id.EndsWith("_depth",StringComparison.OrdinalIgnoreCase))GameLogger.Log("FACTORY",$"resource from factory '{GetType().Name}' with id '{id}' - can't get in group '{GroupName}'!",LogLevel.Error);return result;}/// <summary> BoundedCache eviction hook: releases the native/GPU memory of an evicted payload. Godot resources are ref-counted natively and the cache's managed wrapper owns exactly one of those references, so the resource is disposed only when that reference is the last one (<c>GetReferenceCount() == 1</c>) — a texture still assigned to a sprite or a stream still played by an audio player keeps its native owner and is left intact, its wrapper is finalised by the GC later. Direct external disposal of a cached value (mod code calling Dispose itself) is tolerated via <see cref="GodotObject.IsInstanceValid"/>. Consequence for callers: values must be handed to engine objects (node properties, players) rather than stored as bare C# fields for the whole session, otherwise an LRU eviction may dispose a wrapper that is still referenced from managed code only. </summary>
private void ReleaseResource(T resource){if(resource is RefCounted refCounted&&GodotObject.IsInstanceValid(refCounted)&&refCounted.GetReferenceCount()==1){refCounted.Dispose();}}/// <summary>Scans the group's collected files for <paramref name="id"/> plus the given extension and reads the matching mod file's bytes.</summary>
protected virtual T LoadFromMods(string id,string extension){var files=Files.Collect(GroupName);string matchedKey=null;string ext=null;if(extension!=null){matchedKey=id+extension;}else{foreach(var key in files.Keys){if(key==id||key.StartsWith(id+".",StringComparison.OrdinalIgnoreCase)){matchedKey=key;break;}}}if(matchedKey==null|| !files.TryGetValue(matchedKey,out var entry)){return default(T);}ext=Path.GetExtension(matchedKey);var bytes=ModManager.Instance.ReadFileBinary(entry.ModId,entry.FullPath);if(bytes==null){GameLogger.Log("FACTORY",$"resource '{GetType().Name}' with id '{id}' - failed to read binary!",LogLevel.Error);return default(T);}return ConvertFromBytes(id,bytes,ext);}}}

===== FILE Core/Factories/Texture2DFactory.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Factories{/// <summary> Specialised <c>PrefabFactory</c> for <c>.png</c> and <c>.jpg</c> textures. Creates a temporary <c>Image</c> to decode pixel data via Godot's built-in loaders, then immediately wraps it in an <c>ImageTexture</c> and disposes the intermediate <c>Image</c>. This two-step pipeline is necessary because Godot does not provide a direct "byte array to Texture2D" method — the <c>Image</c> holds CPU-side pixel data while <c>ImageTexture</c> uploads it to the GPU, and keeping the <c>Image</c> alive would double memory usage for every loaded sprite. </summary>
public abstract class Texture2DFactory:PrefabFactory<Texture2D>{protected Texture2DFactory(string factoryKey,int fallbackCacheSize):base(factoryKey,fallbackCacheSize){}/// <summary>Texture extensions from mod config (typically .png and .jpg), tried in order.</summary>
protected override List<string>Extensions=>ModConfig.Instance.TextureExtensions;/// <summary>Decodes PNG/JPEG bytes into an <c>ImageTexture</c>; unsupported formats are logged and return null.</summary>
protected override Texture2D ConvertFromBytes(string id,byte[]data,string extension){var image=new Image();if(extension?.Equals(".png",StringComparison.OrdinalIgnoreCase)==true)image.LoadPngFromBuffer(data);else if(extension?.Equals(".jpg",StringComparison.OrdinalIgnoreCase)==true||extension?.Equals(".jpeg",StringComparison.OrdinalIgnoreCase)==true){image.LoadJpgFromBuffer(data);}else{GameLogger.Log("FACTORY",$"unsupported image format '{extension}' for '{id}'",LogLevel.Error);return null;}var texture=ImageTexture.CreateFromImage(image);image.Dispose();return texture;}}}

===== FILE Core/Games/ActiveGame.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Mods;namespace Cthangover.Core.Games{/// <summary> The game this process runs, chosen at boot from <c>--game=&lt;id&gt;</c>. Empty when the process loads every mod (<c>--all-mods</c>, development launches and tests) — callers then keep the pre-collection behaviour (<see cref="EntryScene"/> falls back to <c>start_scene</c>, saves go to the shared folder). The id is the save identity of the game: it stays stable while the player edits the collection's mechanics. </summary>
public static class ActiveGame{/// <summary>Entry scene used without an active game or when the collection declares none.</summary>
public const string DefaultEntryScene="start_scene";private static GameModSet _set;public static bool IsActive=>_set?.Game!=null;/// <summary>True when the process booted only the front-end (no game chosen yet).</summary>
public static bool IsFrontendOnly{get;private set;}public static string Id=>_set?.Game?.Id;public static ModCollection Collection=>_set?.Game;/// <summary>Mods loaded for the game; empty when no game is active.</summary>
public static IReadOnlyCollection<string>ModIds=>(IReadOnlyCollection<string>)_set?.ModIds??Array.Empty<string>();public static string EntryScene=>string.IsNullOrWhiteSpace(_set?.Game?.Entry?.Scene)?DefaultEntryScene:_set.Game.Entry.Scene;/// <summary>Game parameter from the collection's <c>settings</c>, or null.</summary>
public static string GetSetting(string key)=>key!=null&&_set?.Game?.Settings!=null&&_set.Game.Settings.TryGetValue(key,out var value)?value:null;/// <summary>Called by the boot pipeline once the game's mod set is applied.</summary>
public static void Activate(GameModSet set){_set=set;IsFrontendOnly=false;}/// <summary>Called by the boot pipeline when it kept only the front-end.</summary>
public static void ActivateFrontend(GameModSet set){_set=set;IsFrontendOnly=true;}public static void Clear(){_set=null;IsFrontendOnly=false;}}}

===== FILE Core/Games/ActiveGameChoice.cs =====
namespace Cthangover.Core.Games{/// <summary> The outcome of filtering a mod set for a launch selection — which game (or the front-end) it is — kept apart from <see cref="ActiveGame"/> so a mod-set reload can compute the new set, compile it and only then switch the process over. A failed reload (compile error) never applies its choice and the running game stays active. </summary>
public sealed class ActiveGameChoice{/// <summary>No game: every mod stays (<c>--all-mods</c>, development launches) or the selection is unknown.</summary>
public static readonly ActiveGameChoice None=new(null,frontend:false);/// <summary>A game's set (<paramref name="frontend"/> = false) or the front-end set; a null set means "no game".</summary>
public ActiveGameChoice(GameModSet set,bool frontend){Set=set;Frontend=frontend;}/// <summary>The resolved mod set of the chosen game or front-end; null when every mod stays.</summary>
public GameModSet Set{get;}/// <summary>True when <see cref="Set"/> is the front-end only (no game chosen yet).</summary>
public bool Frontend{get;}/// <summary>Makes this choice the process-wide <see cref="ActiveGame"/>.</summary>
public void Apply(){if(Set==null)ActiveGame.Clear();else if(Frontend)ActiveGame.ActivateFrontend(Set);else ActiveGame.Activate(Set);}}}

===== FILE Core/Games/GameCatalog.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Settings;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Games{/// <summary> What the menus know about games, computed against every installed mod (not only the loaded ones — the front-end loads almost nothing): the game collections with their readiness, the composition a game would run with now (for comparing with a save) and the cover image. </summary>
public static class GameCatalog{/// <summary>Merged collections (user over built-in over catalog), keyed by id.</summary>
public static Dictionary<string,ModCollection>LoadCollections(){var result=new Dictionary<string,ModCollection>(StringComparer.OrdinalIgnoreCase);foreach(var collection in ModCollectionStore.LoadAll())result[collection.Id]=collection;return result;}/// <summary>One summary per game collection, ordered by name; '@key' text resolved.</summary>
public static List<ModCollectionGameSummary>Games(IReadOnlyCollection<string>sessionInstalled=null)=>ModCollectionGallery.Build(LoadCollections(),ModRegistry.Instance.AllDiscoveredMods,DisabledModsStore.Load(),CatalogService.CachedEntries??new List<CatalogExportEntry>(),sessionInstalled??Array.Empty<string>(),CollectionText.Resolve);/// <summary> Mods (with versions) <paramref name="gameId"/> runs with now: the loaded set for the active game, otherwise the set its collection resolves to among installed mods. </summary>
public static List<SaveGameMod>Composition(string gameId){var discovered=ModRegistry.Instance.AllDiscoveredMods;IEnumerable<string>ids;if(ActiveGame.IsActive&&string.Equals(ActiveGame.Id,gameId,StringComparison.OrdinalIgnoreCase)){ids=ModRegistry.Instance.GetOrderedModIds();}else{var set=GameModSet.Compute(gameId,LoadCollections(),discovered,DisabledModsStore.Load());if(set==null)return null;ids=set.ModIds.OrderBy(id=>id,StringComparer.Ordinal);}return ids.Select(id=>new SaveGameMod{Id=id,Version=discovered.TryGetValue(id,out var info)?info.Version:null,}).ToList();}/// <summary>The collection's cover from its package, or null.</summary>
public static Texture2D LoadCover(ModCollection collection){if(string.IsNullOrEmpty(collection?.Cover)||collection.Package==null)return null;try{var bytes=collection.Package.ReadFileBinary(collection.Cover);if(bytes==null||bytes.Length==0)return null;var image=new Image();var extension=Path.GetExtension(collection.Cover).ToLowerInvariant();var error=extension switch{".jpg"or".jpeg"=>image.LoadJpgFromBuffer(bytes),".webp"=>image.LoadWebpFromBuffer(bytes),_=>image.LoadPngFromBuffer(bytes),};return error==Error.Ok?ImageTexture.CreateFromImage(image):null;}catch(Exception ex){GameLogger.Log("GAME",$"Cover '{collection.Cover}' of '{collection.Id}' failed to load: {ex.Message}",LogLevel.Warning);return null;}}}}

===== FILE Core/Games/GameLaunchArgs.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Games{/// <summary> User args (after <c>--</c>) that pick what the boot loads and where the launcher goes next: <list type="bullet"> <item><c>--game=&lt;collection id&gt;</c> — load only that game's mods (plus the front-end) and enter it;</item> <item><c>--new</c> — start a new game (default when no save is given);</item> <item><c>--load=&lt;save&gt;</c> — load a save of that game instead;</item> <item><c>--with-mods=a,b</c> — extra mods on top of the game (tests, debugging);</item> <item><c>--all-mods</c> — ignore <c>--game</c> and load every installed mod.</item> </list> Without <c>--game</c> the boot loads only the front-end (main menu, mod manager) and the player picks a game; development launches (<c>--scene=</c>, <c>--run-tests</c>, <c>--itest=</c>, test drivers) keep loading every mod, see <see cref="Mode"/>. The parse of the command line happens once per process and is cached on the main thread by <see cref="WarmUp"/>; switching games in-process replaces the cached value with <see cref="WithSelection"/> and <see cref="SetCurrent"/> (<see cref="GameSession.SwitchToAsync"/>), so every later reader (the mod-set filter, the launcher) sees the game the process runs now, not the one it was started with. </summary>
public sealed class GameLaunchArgs{public const string GamePrefix="--game=";public const string LoadPrefix="--load=";public const string WithModsPrefix="--with-mods=";public const string NewFlag="--new";public const string AllModsFlag="--all-mods";public const string FrontendFlag="--frontend";/// <summary>Development/test switches that need the whole mod set when no game is named.</summary>
private static readonly string[]DevPrefixes={"--scene=","--run-tests","--itest=","--choices=","--timeout=","--resource-atlas","--locale-atlas","--wait-for-debugger",};private static GameLaunchArgs _current;public string GameId{get;private init;}public string LoadSave{get;private init;}public bool NewGame{get;private init;}public bool AllMods{get;private init;}/// <summary>Forces the front-end even with development switches (menu tests).</summary>
public bool ForceFrontend{get;private init;}public IReadOnlyList<string>WithMods{get;private init;}=Array.Empty<string>();/// <summary>A development or test switch is present.</summary>
public bool HasDevFlag{get;private init;}/// <summary>The raw user args (after <c>--</c>), for gates parsed off the main thread (test suites).</summary>
public IReadOnlyList<string>UserArgs{get;private init;}=Array.Empty<string>();/// <summary> What the boot loads: <c>--all-mods</c> wins, then <c>--game</c>, then a development switch (all mods), otherwise the front-end alone. </summary>
public GameLaunchMode Mode=>AllMods?GameLaunchMode.AllMods:!string.IsNullOrEmpty(GameId)?GameLaunchMode.Game:ForceFrontend?GameLaunchMode.Frontend:HasDevFlag?GameLaunchMode.AllMods:GameLaunchMode.Frontend;/// <summary>True when the boot should load a single game.</summary>
public bool SelectsGame=>Mode==GameLaunchMode.Game;/// <summary>Process-wide args; parsed lazily, or eagerly on the main thread by <see cref="WarmUp"/>.</summary>
public static GameLaunchArgs Current=>_current??=GameEnvironment.IsRuntime?Parse(OS.GetCmdlineUserArgs()):new GameLaunchArgs();/// <summary>Parses on the main thread before the background boot phases read <see cref="Current"/>.</summary>
public static void WarmUp()=>_=Current;public static GameLaunchArgs Parse(IEnumerable<string>userArgs){var args=(userArgs??Array.Empty<string>()).ToList();string Value(string prefix)=>args.Where(a=>a.StartsWith(prefix,StringComparison.Ordinal)).Select(a=>a.Substring(prefix.Length).Trim()).FirstOrDefault(v=>v.Length>0);return new GameLaunchArgs{GameId=Value(GamePrefix),LoadSave=Value(LoadPrefix),NewGame=args.Contains(NewFlag),AllMods=args.Contains(AllModsFlag),ForceFrontend=args.Contains(FrontendFlag),HasDevFlag=args.Any(a=>DevPrefixes.Any(p=>a.StartsWith(p,StringComparison.Ordinal))),UserArgs=args,WithMods=(Value(WithModsPrefix)??string.Empty).Split(',',StringSplitOptions.RemoveEmptyEntries|StringSplitOptions.TrimEntries),};}/// <summary>Process-wide override for tests that need a specific mode without a command line.</summary>
public static void OverrideForTests(GameLaunchArgs args)=>_current=args;/// <summary>Makes <paramref name="args"/> the selection of this process (an in-process game switch).</summary>
public static void SetCurrent(GameLaunchArgs args)=>_current=args;/// <summary> The same launch with another selection: <paramref name="gameId"/> started fresh or from <paramref name="loadSave"/>, or the front-end when <paramref name="gameId"/> is null. Development and test switches (<c>--itest=</c>, <c>--with-mods=</c>, …) are kept, the previous selection (<c>--game</c>, <c>--new</c>, <c>--load</c>, <c>--all-mods</c>, <c>--frontend</c>) is replaced. </summary>
public GameLaunchArgs WithSelection(string gameId,string loadSave=null){var args=new List<string>();foreach(var arg in UserArgs){if(!IsSelectionArg(arg))args.Add(arg);}if(string.IsNullOrEmpty(gameId))args.Add(FrontendFlag);else args.AddRange(Build(gameId,loadSave));return Parse(args);}private static bool IsSelectionArg(string arg)=>arg.StartsWith(GamePrefix,StringComparison.Ordinal)||arg.StartsWith(LoadPrefix,StringComparison.Ordinal)||arg==NewFlag||arg==AllModsFlag||arg==FrontendFlag;/// <summary>User args that start <paramref name="gameId"/> fresh or from <paramref name="loadSave"/>.</summary>
public static List<string>Build(string gameId,string loadSave){var args=new List<string>{GamePrefix+gameId};args.Add(string.IsNullOrEmpty(loadSave)?NewFlag:LoadPrefix+loadSave);return args;}}}

===== FILE Core/Games/GameLaunchMode.cs =====
namespace Cthangover.Core.Games{/// <summary>Which mods a process boots with; see <see cref="GameLaunchArgs.Mode"/>.</summary>
public enum GameLaunchMode{/// <summary>Only the front-end collections: main menu, settings, mod manager, game picker.</summary>
Frontend,/// <summary>One game collection plus the front-end.</summary>
Game,/// <summary>Every installed mod (development launches and tests).</summary>
AllMods,}}

===== FILE Core/Games/GameModSet.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Mods;namespace Cthangover.Core.Games{/// <summary> The mods one game boots with, computed from the game's current collection definition — never stored — so editing the collection changes the next launch. Members are the game's resolved graph (selected mechanics, included blocks and transitive <c>depends</c>) plus every <see cref="ModCollectionKind.Frontend"/> collection and the explicit <c>--with-mods</c> extras with their dependencies. </summary>
public sealed class GameModSet{public ModCollection Game{get;private init;}/// <summary>Mod ids that stay loaded.</summary>
public HashSet<string>ModIds{get;}=new(StringComparer.OrdinalIgnoreCase);/// <summary>Mods the game needs but which are not installed.</summary>
public List<string>MissingMods{get;}=new();/// <summary>Resolver problems of the game collection (missing blocks, cycles, ...).</summary>
public List<ModCollectionProblem>Problems{get;}=new();/// <summary> Pure computation over discovered mods; returns null when <paramref name="gameId"/> is not a known game collection. </summary> <summary>The front-end alone: every <see cref="ModCollectionKind.Frontend"/> collection's graph.</summary>
public static GameModSet ComputeFrontend(IReadOnlyDictionary<string,ModCollection>collections,IReadOnlyDictionary<string,IModInfo>discovered,IReadOnlyCollection<string>disabled,IReadOnlyCollection<string>withMods=null){var set=new GameModSet();var any=false;foreach(var frontend in(collections?.Values??Enumerable.Empty<ModCollection>()).Where(c=>c?.Kind==ModCollectionKind.Frontend)){any=true;set.AddCollection(frontend,collections,discovered,disabled??Array.Empty<string>(),keepProblems:true);}if(!any)return null;foreach(var extra in withMods??Array.Empty<string>())AddWithDependencies(extra,set,discovered);return set;}public static GameModSet Compute(string gameId,IReadOnlyDictionary<string,ModCollection>collections,IReadOnlyDictionary<string,IModInfo>discovered,IReadOnlyCollection<string>disabled,IReadOnlyCollection<string>withMods=null){if(string.IsNullOrEmpty(gameId)||collections==null|| !collections.TryGetValue(gameId,out var game)||game==null||game.Kind!=ModCollectionKind.Game)return null;var set=new GameModSet{Game=game};disabled??=Array.Empty<string>();set.AddCollection(game,collections,discovered,disabled,keepProblems:true);foreach(var frontend in collections.Values.Where(c=>c?.Kind==ModCollectionKind.Frontend))set.AddCollection(frontend,collections,discovered,disabled,keepProblems:false);foreach(var extra in withMods??Array.Empty<string>())AddWithDependencies(extra,set,discovered);return set;}private void AddCollection(ModCollection collection,IReadOnlyDictionary<string,ModCollection>collections,IReadOnlyDictionary<string,IModInfo>discovered,IReadOnlyCollection<string>disabled,bool keepProblems){var graph=ModCollectionResolver.Resolve(collection,collections,discovered,disabled,new List<CatalogExportEntry>());foreach(var node in graph.Nodes){if(node.State is ModCollectionNodeState.Missing or ModCollectionNodeState.InCatalog){if(!MissingMods.Contains(node.ModId,StringComparer.OrdinalIgnoreCase))MissingMods.Add(node.ModId);continue;}ModIds.Add(node.ModId);}if(keepProblems)Problems.AddRange(graph.Problems);}private static void AddWithDependencies(string modId,GameModSet set,IReadOnlyDictionary<string,IModInfo>discovered){var pending=new Stack<string>();pending.Push(modId);while(pending.Count>0){var id=pending.Pop();if(!discovered.TryGetValue(id,out var info)){if(!set.MissingMods.Contains(id,StringComparer.OrdinalIgnoreCase))set.MissingMods.Add(id);continue;}if(!set.ModIds.Add(id))continue;foreach(var dependency in(info as ModInfo)?.Manifest?.DependencyIds??new List<string>())pending.Push(dependency);}}}}

===== FILE Core/Games/GameSession.cs =====
using System;using System.Threading.Tasks;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;using Cthangover.Core.UI.Menu;using Cthangover.Core.Settings;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Games{/// <summary> Starting, loading and switching games, shared by the launcher (args after boot) and the menu mods. Starting enters the active game's entry scene; loading falls back to it when the saved scene no longer exists (its mechanic was removed from the game). Switching to another game replaces the mod set in-process (<see cref="SwitchToAsync"/>). </summary>
public static class GameSession{/// <summary>Resets the runtime and enters <see cref="ActiveGame.EntryScene"/> through the base shell.</summary>
public static void StartNew(Node host){GameData.Instance?.StartNewGame();EnterScene(host,ActiveGame.EntryScene);}/// <summary> Loads <paramref name="fileName"/> from the active game's saves and enters the saved scene (or the entry scene when the saved one is unknown). Returns false when the save could not be loaded. </summary>
public static bool LoadAndEnter(Node host,string fileName){if(!SaveService.Load(fileName))return false;var sceneManager=host.GetNodeOrNull<SceneManager>("/root/SceneManager");sceneManager?.Initialize();var scene=SaveService.LastLoadedSceneName;if(string.IsNullOrEmpty(scene)||scene=="unknown"||sceneManager?.HasScene(scene)!=true){if(!string.IsNullOrEmpty(scene)&&scene!="unknown")GameLogger.Log("SAVE",$"Saved scene '{scene}' is not part of the game any more, entering '{ActiveGame.EntryScene}'",LogLevel.Warning);scene=ActiveGame.EntryScene;}EnterScene(host,scene);return true;}/// <summary>True while <see cref="SwitchToAsync"/> runs; menus ignore further switch requests meanwhile.</summary>
public static bool IsSwitching{get;private set;}/// <summary> Switches the running process to <paramref name="gameId"/> — a new game, or <paramref name="loadSave"/> of it — without a restart: the window shows <see cref="ModSetTransitionScreen"/>, the game's mod set is prepared (compiled) in the background, mods that leave the set are unloaded and their contexts released, the new ones load, and the game is entered exactly as a cold <c>--game</c> launch would. Mods shared by both sets (the front-end, common engine mods) stay loaded. When the new set does not compile, the running set is untouched and the screen lists the errors with a way back to the main menu. Returns whether the game was entered. </summary>
public static async Task<bool>SwitchToAsync(Node host,string gameId,string loadSave=null){var tree=host.GetTree();var manager=ModManager.Instance;if(IsSwitching||manager.IsReloading){GameLogger.Log("GAME",$"Switch into '{gameId}' ignored: a mod-set switch is already running",LogLevel.Warning);return false;}IsSwitching=true;try{GameLogger.Log("GAME",$"Switching into game '{gameId}' ({(loadSave == null ? "new game" : "load " + loadSave)}) in-process");var selection=GameLaunchArgs.Current.WithSelection(gameId,loadSave);var screen=ModSetTransitionScreen.Show(tree);var prep=await manager.PrepareReloadAsync(selection);var result=manager.ApplyReload(prep,tree);if(!result.Applied){screen.ShowErrors(result.Errors,OpenMainMenuDeferred);return false;}await manager.VerifyReloadAsync(result,tree);var anchor=tree.Root.GetNodeOrNull<SceneManager>("SceneManager");if(string.IsNullOrEmpty(loadSave)){StartNew(anchor);return true;}if(LoadAndEnter(anchor,loadSave))return true;GameLogger.Log("GAME",$"Save '{loadSave}' of game '{gameId}' could not be loaded",LogLevel.Error);OpenMainMenu(tree);return false;}catch(Exception ex){GameLogger.Log("GAME",$"Switch into '{gameId}' FAILED: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}",LogLevel.Error);OpenMainMenu(tree);return false;}finally{IsSwitching=false;}}/// <summary> Enters the title screen. The menu scene is provided by the <c>menu_base</c> mod and resolved through the runtime tscn pipeline (which also applies scene patches); if even that cannot be built, the engine loader runs anyway so a broken mod set degrades to a loud log instead of a crash. </summary>
public static void OpenMainMenu(SceneTree tree){var path=GodotSceneService.GetScenePath(GodotSceneType.MainMenu);var node=TscnScenes.LoadAndBuild(path);if(node!=null){tree.ChangeSceneToNode(node);return;}GameLogger.Log("LAUNCHER",$"main menu scene '{path}' could not be built by any mod — falling back to the native loader",LogLevel.Warning);tree.ChangeSceneToFile("res://"+path);}private static void OpenMainMenuDeferred(){if(Engine.GetMainLoop()is SceneTree tree)OpenMainMenu(tree);}private static void EnterScene(Node host,string scene){var sceneManager=host.GetNodeOrNull<SceneManager>("/root/SceneManager");if(sceneManager==null){GameLogger.Log("GAME","SceneManager autoload not found, cannot enter the game",LogLevel.Error);return;}sceneManager.PendingSceneName=scene;host.GetNodeOrNull<GodotSceneService>("/root/GodotSceneService")?.LoadShell();}}}

===== FILE Core/Interactive/IInteractiveDialogRunner.cs =====
namespace Cthangover.Core.Interactive{/// <summary> Dialog-side executor for interactive click/hover DSL commands. Implemented by the dialog_base engine mod (which owns DialogBox, DialogRuntime and the scenario parser); InteractiveManager resolves the implementation from the well-known /root/DialogRunner node without referencing mod types. Takes the interactive definition id as a string so this contract stays free of the definition DTO, which lives in the interactive_base engine mod. </summary>
public interface IInteractiveDialogRunner{/// <summary> Builds and runs the click dialog for the interactive definition with the given id. Returns false when the definition is unknown, carries no runnable commands, or the dialog box is missing/busy. </summary>
bool TryExecuteClickDialog(string definitionId);/// <summary>Runs inline DSL commands (hover effects and similar) through a standalone dialog runtime.</summary>
void ExecuteInlineCommands(string dsl);}}

===== FILE Core/Items/GDInventoryService.cs =====
using Godot;namespace Cthangover.Core.Items{/// <summary> GDScript-facing wrapper around the player inventory (<see cref="IInventory"/>). Godot scripts cannot see the managed interface, so the engine hands them this <c>[GlobalClass]</c> <c>RefCounted</c> instead. Plain-id methods mirror the C# API; the <c>*ByItem</c> variants accept a <see cref="GDItem"/> snapshot (as passed to a script's <c>use(item)</c> callback) and unwrap it back to the managed item, so script code never touches ids it does not know. </summary>
[GlobalClass]public partial class GDInventoryService:RefCounted{/// <summary>The wrapped player inventory; assigned by the engine when the service is created.</summary>
internal IInventory Inner;/// <summary>True when any slot holds the item with the given id.</summary>
public bool HasItem(string id)=>Inner.HasItem(id);/// <summary>True when any slot holds the wrapped item.</summary>
public bool HasItemByItem(GDItem item)=>item?.Inner!=null&&Inner.HasItem(item.Inner);/// <summary>Total stack size across slots for the given item id (0 if absent).</summary>
public int CheckCount(string id)=>Inner.CheckCount(id);/// <summary>Total stack size for the wrapped item (0 for a null wrapper).</summary>
public int CheckCountByItem(GDItem item)=>item?.Inner!=null?Inner.CheckCount(item.Inner):0;/// <summary>Adds items by id; unknown ids are silently ignored by the inner inventory.</summary>
public void Add(string id,int count=1)=>Inner.Add(id,count);/// <summary>Adds the wrapped item; a null wrapper is a no-op.</summary>
public void AddByItem(GDItem item,int count=1){if(item?.Inner!=null)Inner.Add(item.Inner,count);}/// <summary>Removes items by id; returns false when the slot holds fewer than <paramref name="count"/>.</summary>
public bool Remove(string id,int count=1)=>Inner.Remove(id,count);/// <summary>Removes the wrapped item; returns false for a null wrapper.</summary>
public bool RemoveByItem(GDItem item,int count=1)=>item?.Inner!=null&&Inner.Remove(item.Inner,count);}}

===== FILE Core/Items/GDItem.cs =====
using Godot;namespace Cthangover.Core.Items{/// <summary> GDScript-facing snapshot of an <see cref="IItem"/>. Godot scripts cannot implement or inspect the managed <c>IItem</c> interface, so the engine wraps it in this <c>[GlobalClass]</c> <c>RefCounted</c> with plain fields before handing it to a GDScript's <c>use(item)</c> callback (see <see cref="GDItemAction"/>). <see cref="Inner"/> keeps the real item so the bridge can resolve it back for inventory calls; <c>Sprite</c> and <c>Properties</c> are deliberately not exposed — scripts work with ids and plain data only. </summary>
[GlobalClass]public partial class GDItem:RefCounted{/// <summary>Item identifier (path-style id, e.g. <c>"food/ration"</c>).</summary>
public string Id;/// <summary>Display name.</summary>
public string Name;/// <summary>Tooltip/description text.</summary>
public string Description;/// <summary>Purchase price.</summary>
public int Cost;/// <summary><see cref="ItemType"/> flags as a plain int (flags survive the bridge better than enums).</summary>
public int ItemType;/// <summary>Back-reference to the wrapped managed item; internal by design.</summary>
internal IItem Inner;/// <summary> Wraps a managed item into the GDScript snapshot. Returns an empty wrapper (null fields) for a <c>null</c> input rather than throwing, so a missing item definition degrades to a harmless no-op in script code instead of crashing the bridge. </summary>
public static GDItem From(IItem item){var gd=new GDItem();if(item==null)return gd;gd.Id=item.ID;gd.Name=item.Name;gd.Description=item.Description;gd.Cost=item.Cost;gd.ItemType=(int)item.ItemType;gd.Inner=item;return gd;}}}

===== FILE Core/Items/GDItemAction.cs =====
using Godot;namespace Cthangover.Core.Items{/// <summary> Adapter that exposes a GDScript object as an <see cref="IItemAction"/>. A mod script that provides <c>get_item_action_id()</c> is registered under that id (the bridge constructs this class); <see cref="UseAction"/> then marshals the managed item into a <see cref="GDItem"/> snapshot and calls the script's <c>use(item)</c>, whose return value becomes the success flag (returning <c>false</c> keeps the item unconsumed). </summary>
public class GDItemAction:IItemAction{private readonly GodotObject _instance;/// <summary> Binds the adapter to a live GDScript object. The caller (item action registration from the GDScript bridge) supplies the id the script reported via <c>get_item_action_id()</c>. </summary>
public GDItemAction(string id,GodotObject instance){ID=id;_instance=instance;}/// <summary>Registry key matching the item JSON's <c>"ItemAction"</c> field.</summary>
public string ID{get;}/// <summary>Calls the script's <c>use(item)</c> with a GDScript-safe item snapshot; the bool result is the consumption flag.</summary>
public bool UseAction(IItem item){var gdItem=GDItem.From(item);return _instance.Call("use",gdItem).AsBool();}}}

===== FILE Core/Items/IIngredient.cs =====
namespace Cthangover.Core.Items{/// <summary> A quantity of an item used as a recipe input or output. Structurally identical to <c>IItemContainer</c> but intentionally kept as a separate interface — ingredients represent a recipe <b>formula</b> (what is required or produced), while containers represent an <b>inventory slot</b> (what is currently held). This distinction allows the type system to prevent accidentally treating a recipe ingredient as a mutable inventory entry. </summary>
public interface IIngredient{/// <summary>The resolved item this formula entry references.</summary>
IItem Item{get;set;}/// <summary>How many copies the recipe consumes or produces.</summary>
int Count{get;set;}}}

===== FILE Core/Items/IInventory.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Core.Items{/// <summary> Reactive inventory contract. Items are stored as an ordered list of <c>IItemContainer</c> slots rather than a <c>Dictionary&lt;IItem, int&gt;</c> because slot order determines UI layout and each slot needs to be independently observable by the inventory renderer. The <c>Change</c> event fires on every add or remove so that UI panels can re-render without polling. All mutating methods accept both <c>IItem</c> and <c>string id</c> overloads — the <c>string</c> variants delegate to <c>ItemFactory.Instance.Get</c> internally, so callers passing raw IDs (e.g. from quest scripts or save data) don't need to resolve items themselves. </summary>
public interface IInventory{/// <summary>Ordered slot list — order drives UI layout; empty slots are deleted on zero stacks.</summary>
List<IItemContainer>Items{get;set;}/// <summary>Fires on every successful mutation with (itemId, signed delta).</summary>
event Action<string,int>Change;/// <summary>True when any slot holds the item.</summary>
public bool HasItem(IItem item);/// <summary>True when any slot holds the item with the given id.</summary>
public bool HasItem(string id);/// <summary>Returns the slot holding the item, or <c>null</c>.</summary>
public IItemContainer TryGet(IItem item);/// <summary>Returns the slot holding the given id, or <c>null</c>.</summary>
public IItemContainer TryGet(string id);/// <summary>Total count across slots (0 if absent).</summary>
public int CheckCount(IItem item);/// <summary>Total count across slots for the given id (0 if absent).</summary>
public int CheckCount(string id);/// <summary>Stacks onto an existing slot or creates one by resolving the item reference.</summary>
public void Add(IItem item,int count=1);/// <summary>Stacks onto an existing slot or creates one via <c>ItemFactory</c>; unknown ids are silently ignored.</summary>
public void Add(string id,int count=1);/// <summary>Removes items; returns <c>false</c> without changes when the slot holds fewer than <paramref name="count"/>.</summary>
public bool Remove(IItem item,int count=1);/// <summary>Removes items by id; returns <c>false</c> without changes when the slot holds fewer than <paramref name="count"/>.</summary>
public bool Remove(string id,int count=1);}}

===== FILE Core/Items/IItem.cs =====
using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Items{/// <summary> Read-only contract for an inventory item resolved from JSON data (concrete implementation: <see cref="Item"/>). Exposes the display fields (name, description, cost, icon) plus the gameplay hooks: the <see cref="ItemType"/> flags that drive UI categorisation, and the <see cref="ItemAction"/> behaviour invoked on use. <see cref="Properties"/> is the mod-defined extension bag imported from extra JSON keys — core code never reads it, but custom item actions and scenario logic may. </summary>
public interface IItem{/// <summary>Unique item ID in path style (e.g. <c>"food/ration"</c>); the factory lookup key.</summary>
string ID{get;}/// <summary>Display name (resolved locale string).</summary>
string Name{get;}/// <summary>Tooltip/info text (resolved locale string).</summary>
string Description{get;}/// <summary>Base purchase price in the game economy.</summary>
int Cost{get;}/// <summary>Loaded icon texture from the mod's <c>items</c> texture group.</summary>
Texture2D Sprite{get;}/// <summary><see cref="ItemType"/> flags describing the item's categories.</summary>
ItemType ItemType{get;}/// <summary>On-use behaviour resolved via <see cref="IItemAction.ID"/>, or <c>null</c> when the item has no use effect.</summary>
IItemAction ItemAction{get;}/// <summary>String-keyed extension bag imported from extra JSON keys (<c>[JsonExtensionData]</c>).</summary>
PropertyData Properties{get;}}}

===== FILE Core/Items/IItemAction.cs =====
namespace Cthangover.Core.Items{/// <summary> Contract for an item's on-use behaviour. <c>UseAction</c> returns <c>false</c> when the action cannot be performed (e.g. using a healing item at full health), letting the UI play a failure animation or show a tooltip rather than silently consuming the item. The <c>IItem</c> parameter passes the item definition to the action so the behaviour can read item properties without owning a reference — this allows a single action class to serve multiple items with different magnitudes (e.g. a generic "HealAction" where the amount comes from the item's data). </summary>
public interface IItemAction{string ID{get;}bool UseAction(IItem item);}}

===== FILE Core/Items/IItemContainer.cs =====
namespace Cthangover.Core.Items{/// <summary> A mutable inventory slot: an item reference paired with a stack count. The settable <c>Count</c> supports stacking and splitting without recreating the container object. Distinct from <c>IIngredient</c> because the game treats "what a recipe needs" and "what the player holds" as separate concerns — recipe validation should not accidentally modify inventory state. </summary>
public interface IItemContainer{/// <summary>The shared item definition held by this slot.</summary>
IItem Item{get;set;}/// <summary>Stack size of this slot; the slot is removed when it reaches zero.</summary>
int Count{get;set;}}}

===== FILE Core/Items/IRecipe.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Items{/// <summary> Recipe contract for the crafting system. All properties have setters despite recipes being logically immutable — this is a pragmatic concession for JSON deserialization rather than enforcing a pure immutable model. <c>WorkbenchType</c> gates which crafting station can produce this recipe; <c>Input</c> and <c>Output</c> are lists of <c>IIngredient</c> rather than raw <c>IItem</c> collections because a recipe may consume or produce multiple copies of the same item type. </summary>
public interface IRecipe{/// <summary>Unique recipe ID (path-style, e.g. <c>"recipe/wolf_meat_to_ration"</c>).</summary>
string ID{get;set;}/// <summary>Locale key for the recipe display name.</summary>
string Name{get;set;}/// <summary>Locale key for the recipe description text.</summary>
string Description{get;set;}/// <summary>Crafting time in in-game minutes.</summary>
int Time{get;set;}/// <summary>Required crafting station; the crafting UI filters recipes by the opened bench.</summary>
WorkbenchType WorkbenchType{get;set;}/// <summary>Ingredients consumed on a successful craft.</summary>
List<IIngredient>Input{get;set;}/// <summary>Items produced on a successful craft.</summary>
List<IIngredient>Output{get;set;}}}

===== FILE Core/Items/ItemActionBridge.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Core.Items{/// <summary> Core-side delivery bridge for GDScript-declared item actions (registered by <c>ModGDScriptLoader</c>, which runs before the items mod is ready). They are buffered until the items subsystem binds its sink — typically from <c>IModInitializer.OnModResourcesReady</c>; after <see cref="Bind"/> registrations are delivered straight through, so a bind is safe at any point in the boot pipeline. C# actions do not pass here: the items mod collects them from mod assemblies with its own assembly consumer. </summary>
public static class ItemActionBridge{private static readonly object Gate=new();private static readonly List<IItemAction>PendingActions=new();private static Action<IItemAction>_actionSink;/// <summary>Delivers one action to the bound sink, or buffers it until <see cref="Bind"/>.</summary>
public static void Register(IItemAction action){if(action==null)return;lock(Gate){if(_actionSink!=null)_actionSink(action);else PendingActions.Add(action);}}/// <summary> Installs the sink (typically the items mod's action factory) and flushes everything buffered so far. Subsequent registrations go straight to the sink. </summary>
public static void Bind(Action<IItemAction>actionSink){lock(Gate){_actionSink=actionSink;foreach(var action in PendingActions)actionSink?.Invoke(action);PendingActions.Clear();}}}}

===== FILE Core/Items/ItemType.cs =====
using System;namespace Cthangover.Core.Items{/// <summary> <c>[Flags]</c> item category enum. Multiple flags can combine so that a single item belongs to several categories simultaneously (e.g. an edible quest objective). <c>CantDrop</c> occupies the high bit (<c>0x80000000</c>) to isolate "meta" flags from gameplay categories — the lower bits describe what the item <i>is</i>, the high bit describes a restriction on how it can be <i>used</i>. <c>Used</c> vs <c>TargetUsed</c> differentiate self-targeting items from ally/enemy-targeting items, affecting which targeting UI the game presents when the item is selected. </summary>
[Flags]public enum ItemType:uint{None=0x00000000,Quest=0x00000001,Used=0x00000002,TargetUsed=0x00000004,Food=0x00000008,Resource=0x00000010,Recipe=0x00000020,CantDrop=0x80000000}}

===== FILE Core/Items/WorkbenchType.cs =====
namespace Cthangover.Core.Items{/// <summary> Crafting station type. Currently only <c>Cooking</c> exists, suggesting the crafting system was designed with multiple workbench types in mind (workshop, alchemy, forge) but the implementation started with cooking as the first — and so far only — station. Recipes specify their required bench via <c>RecipeInfo.Type</c>, and the crafting UI filters available recipes by the currently opened station. </summary>
public enum WorkbenchType{Cooking,}}

===== FILE Core/Localization/BootMessages.cs =====
namespace Cthangover.Core.Localization{/// <summary> Built-in strings for the launcher boot overlay — the very first frame the player sees, rendered before mod discovery and compilation. Mods are the only source of locale files, and <see cref="LocaleLoader"/> merges them only after the compile phase, so during boot <c>Tr()</c> has no entries and would leak raw keys. The hint is therefore hardcoded per language (resolved from <c>GameData.Settings.Language</c>) with an English fallback for unknown, missing or null language codes. </summary>
public static class BootMessages{/// <summary> Hint shown while mods compile and cache on a background thread. Unknown, missing or null language codes resolve to English instead of throwing or leaking a raw key. </summary>
public static string FirstLaunchHint(string language){return language switch{"ru-ru"=>"Первый запуск может быть долгим, выполняется компиляция и кеширование...","zh"=>"首次启动可能需要较长时间:正在编译和缓存模组...",_=>"The first launch may take a while: mods are being compiled and cached...",};}/// <summary> Title of the screen shown while the process switches its mod set (another game, hot reload). The new set's locale is not merged yet, so this cannot come from mod locale files either. </summary>
public static string LoadingMods(string language){return language switch{"ru-ru"=>"Загрузка модов...","zh"=>"正在加载模组...",_=>"Loading mods...",};}/// <summary>Title shown when the new mod set did not compile and the switch was cancelled.</summary>
public static string LoadingModsFailed(string language){return language switch{"ru-ru"=>"Не удалось загрузить моды — ошибки компиляции:","zh"=>"无法加载模组 — 编译错误:",_=>"Could not load the mods — compile errors:",};}/// <summary>Button that leaves the failed switch screen.</summary>
public static string Back(string language){return language switch{"ru-ru"=>"Назад","zh"=>"返回",_=>"Back",};}}}

===== FILE Core/Localization/GodotTranslationProvider.cs =====
using Godot;namespace Cthangover.Core.Localization{/// <summary> Singleton adapter that wraps Godot's built-in <c>TranslationServer.Translate</c>. Returns <c>null</c> when the translation key is returned verbatim (Godot's signal for "no translation found"), enabling callers to detect missing entries and substitute a fallback instead of leaking raw keys into the UI. </summary>
public class GodotTranslationProvider:ILocalizationProvider{/// <summary>Shared singleton instance.</summary>
public static readonly GodotTranslationProvider Instance=new();/// <summary> Translates through <c>TranslationServer</c>; returns <c>null</c> when Godot echoes the key back, which is its signal for "not registered". </summary>
public string Get(string key){var result=TranslationServer.Translate(key);return result!=key?result:null;}}}

===== FILE Core/Localization/ILocalizationProvider.cs =====
namespace Cthangover.Core.Localization{/// <summary> Abstraction over Godot's <c>TranslationServer</c> so that UI and game logic code never depends on the engine's translation API directly. The key semantic: <c>Get</c> returns <c>null</c> when a key is untranslated (rather than returning the raw key itself), letting callers fall back to a default string or hide an element instead of showing an untranslated key to the player. </summary>
public interface ILocalizationProvider{/// <summary>Returns the translation for <paramref name="key"/>, or <c>null</c> when the key is not registered.</summary>
string Get(string key);}}

===== FILE Core/Localization/LocaleLoader.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Settings;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Localization{/// <summary> Static loader that assembles Godot <c>Translation</c> resources from <c>.properties</c> files distributed across mods. Chose the <c>.properties</c> format (plain <c>key=value</c>) over Godot's CSV translation format because it requires no editor round-trip and is trivially editable by mod authors in any text editor. Merges entries from <b>all</b> loaded mods into a single <c>Translation</c> — later values overwrite earlier ones via <c>Dictionary</c> assignment — so mods can selectively override base game strings without copying the entire locale file. File matching is by suffix (<c>_{locale}.properties</c>) allowing mods to organise translations however they like as long as the filename ends with the correct locale code. On locale switch, the previous <c>Translation</c> is removed from <c>TranslationServer</c> and disposed to avoid leaking Godot resource handles across language changes. </summary>
public static class LocaleLoader{private static readonly Dictionary<string,string>LangToLocale=new(){{"ru-ru","ru"},{"en","en"},{"zh","zh"},};private static readonly Dictionary<string,Translation>_translations=new();/// <summary> Reads the language saved in <see cref="GameData.Settings"/>, maps it through the fixed <c>LangToLocale</c> table (unsupported values fall back to <c>"ru"</c>), and delegates to <see cref="LoadLocale"/>. </summary>
public static void LoadCurrentLanguage(){var settingsLang=GameData.Instance.Settings.Language;if(!LangToLocale.TryGetValue(settingsLang,out var locale))locale="ru";LoadLocale(locale);}/// <summary> Merges the <c>.properties</c> files of every loaded mod for the given locale into one Godot <see cref="Translation"/>, replaces any previous translation for that locale on <c>TranslationServer</c>, and sets it as the active locale. Safe to call again to switch languages. </summary>
public static void LoadLocale(string locale){var master=new Dictionary<string,string>();MergeCollectionLocales(locale,master);MergeModLocales(locale,master);if(_translations.TryGetValue(locale,out var old)){TranslationServer.RemoveTranslation(old);old.Dispose();_translations.Remove(locale);}var translation=new Translation();translation.Locale=locale;foreach(var kv in master)translation.AddMessage(kv.Key,kv.Value);TranslationServer.AddTranslation(translation);TranslationServer.SetLocale(locale);_translations[locale]=translation;GameLogger.Log("LANG",$"Loaded {master.Count} strings for locale '{locale}' from mods");}/// <summary> Texts of collection packages (<c>collections/&lt;id&gt;/locale/</c>): names and descriptions a game picker shows before the game's mods are loaded. Merged before the mods, so a mod may still override a key. </summary>
private static void MergeCollectionLocales(string locale,Dictionary<string,string>master){foreach(var collection in ModCollectionStore.LoadAll()){if(collection.Package!=null)MergeLocaleFiles(collection.Package,locale,master);}}private static void MergeLocaleFiles(IModFileProvider provider,string locale,Dictionary<string,string>master){var suffix=$"_{locale}.properties";var exactName=$"{locale}.properties";foreach(var filePath in provider.ListFiles("locale").ToList()){if(filePath.EndsWith("/"))continue;var fileName=System.IO.Path.GetFileName(filePath);if(fileName==exactName||fileName.EndsWith(suffix))LoadPropertiesFromMod(provider,filePath,master);}}private static void MergeModLocales(string locale,Dictionary<string,string>master){ModManager.Instance.Initialize();foreach(var modId in ModRegistry.Instance.GetOrderedModIds()){if(!ModManager.Instance.Mods.TryGetValue(modId,out var modInfo))continue;MergeLocaleFiles(modInfo.FileProvider,locale,master);}}private static void LoadPropertiesFromMod(IModFileProvider provider,string path,Dictionary<string,string>target){var text=provider.ReadFileText(path);if(string.IsNullOrEmpty(text))return;var count=0;foreach(var rawLine in text.Replace("\r\n","\n").Split('\n')){var line=rawLine.Trim();if(line.Length==0||line[0]=='#')continue;var eqPos=line.IndexOf('=');if(eqPos<0)continue;var key=line.Substring(0,eqPos).Trim();var value=line.Substring(eqPos+1).Trim();if(key.Length>0){target[key]=value;count++;}}if(count>0){GameLogger.Log("LANG",$"Merged '{provider.Mod}' -> {count} keys from '{path}'",LogLevel.Debug);}}}}

===== FILE Core/Mods/BootController.cs =====
using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods{/// <summary> Determines whether the running main scene is the pre-game launcher. When the game starts directly into <c>scenes/menu/launcher.tscn</c>, the launcher renders its boot overlay first and drives the whole boot pipeline itself through the Boot orchestrator (pure .NET work on a background thread, Godot-bound work on the main thread). Every other entry point — integration tests, direct scene launches for debugging — keeps the synchronous autoload path. This class is only about boot-mode detection: mod readiness now lives in <see cref="ModRegistry.IsInitialized"/> and the Boot phases, and the wait-handle mechanics were removed together with lazy initialization. The flag is read from arbitrary threads and therefore stays volatile. </summary>
public static class BootController{private static readonly object detectLock=new();private static volatile bool _detected;private static volatile bool _isLauncherBoot;/// <summary>True when the current main scene is the pre-game launcher.</summary>
public static bool IsLauncherBoot{get{Detect();return _isLauncherBoot;}}/// <summary> Idempotently resolves the effective main scene path and flags a launcher boot. A positional <c>*.tscn</c> command-line argument (used by direct scene launches and tests) overrides the project <c>application/run/main_scene</c> setting. </summary>
public static void Detect(){if(_detected)return;lock(detectLock){if(_detected)return;_detected=true;if(!GameEnvironment.IsRuntime)return;var args=OS.GetCmdlineArgs();string scene=null;for(var i=0;i<args.Length;i++){var arg=args[i];if(arg.StartsWith("--"))continue;if(arg.EndsWith(".tscn")||arg.EndsWith(".scn")||arg.EndsWith(".escn"))scene=arg;}if(scene==null)scene=ProjectSettings.GetSetting("application/run/main_scene").AsString();_isLauncherBoot=scene!=null&&scene.Replace('\\','/').EndsWith("scenes/menu/launcher.tscn",System.StringComparison.OrdinalIgnoreCase);}}}}

===== FILE Core/Mods/CacheConfig.cs =====
using System.Collections.Generic;using System.Text.Json.Serialization;namespace Cthangover.Core.Mods{/// <summary> JSON DTO for the <c>cache</c> section of <c>mod_config.json</c>. The <c>MaxSizes</c> dictionary maps factory keys (e.g. "backgrounds", "avatars") to explicit LRU capacity overrides — factories query this via <c>GetCacheSize(key, fallback)</c> at construction time so that memory budgets can be tuned per asset category without recompiling. </summary>
public class CacheConfig{/// <summary> Godot path to the on-disk cache root (e.g. "user://mod_cache/"). Shaders extracted from zip mods land here so Godot can load them. </summary>
[JsonPropertyName("root")]public string Root{get;set;}="user://mod_cache/";/// <summary> Per-factory LRU capacity overrides keyed by factory name (e.g. {"backgrounds": 32, "avatars": 128}). Negative values are treated as "unset" and fall through to the default. </summary>
[JsonPropertyName("max_sizes")]public Dictionary<string,int>MaxSizes{get;set;}=new();/// <summary> Resolves the LRU cache size for a factory: returns the override from <c>MaxSizes</c> if present and non-negative, otherwise the caller-supplied fallback. </summary>
public int GetCacheSize(string factoryKey,int fallback){if(MaxSizes.TryGetValue(factoryKey,out var size)&&size>=0)return size;return fallback;}}}

===== FILE Core/Mods/Caches/ModCacheManager.cs =====
using System;using System.Collections.Generic;using System.IO;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods.Caches{/// <summary> On-disk cache manager for shader files extracted from zip mods. Godot's <c>GD.Load&lt;Shader&gt;</c> requires a real filesystem path — it cannot read shader source from a byte array or a zip stream — so shaders shipped inside zip mods must be extracted to <c>user://mod_cache/shaders/</c> before loading. The extraction step also copies <c>.gdshaderinclude</c> files alongside their owning <c>.gdshader</c>, because Godot resolves shader includes relative to the shader file's location on disk. Without this co-location, any shader using an include directive would fail to compile. Extraction is idempotent — <c>IsShaderCached</c> checks before writing — so repeated calls during development don't overwrite unchanged files and trigger unnecessary shader recompilation. </summary>
public static class ModCacheManager{private const string ShaderGroupDir="shaders";/// <summary>Returns the globalised cache root path from <c>ModConfig</c>.</summary>
public static string GetCacheRoot(){if(!GameEnvironment.IsRuntime)return Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),"cthangover","mod_cache");return ProjectSettings.GlobalizePath(ModConfig.Instance.Cache.Root);}/// <summary>Directory path where a specific shader's files are cached.</summary>
public static string GetShaderCacheDir(string shaderName){return Path.Combine(GetCacheRoot(),ShaderGroupDir,shaderName);}/// <summary>Full file path where a specific shader is expected to be cached.</summary>
public static string GetShaderCachePath(string shaderName){return Path.Combine(GetShaderCacheDir(shaderName),shaderName+".gdshader");}/// <summary>True if the shader already exists in the on-disk cache.</summary>
public static bool IsShaderCached(string shaderName){return File.Exists(GetShaderCachePath(shaderName));}/// <summary> Extracts shader files from a mod (typically a zip mod) to the on-disk cache so Godot can load them. Shader includes are copied alongside each shader because Godot resolves include directives relative to the shader's location. </summary>
public static void ExtractShaders(string modId,IModFileProvider provider){var files=provider.ListFiles(ShaderGroupDir);if(files==null)return;var shaderExts=ModConfig.Instance.GetShaderExtensionSet();var includeData=new Dictionary<string,byte[]>();var shaderEntries=new List<string>();foreach(var entry in files){if(entry.EndsWith("/"))continue;var relativeName=entry.Substring(ShaderGroupDir.Length+1);var dotIndex=relativeName.LastIndexOf('.');if(dotIndex<=0)continue;var ext=relativeName.Substring(dotIndex);if(!shaderExts.Contains(ext))continue;if(ext==".gdshaderinclude"){var bytes=provider.ReadFileBinary(entry);if(bytes!=null)includeData[relativeName]=bytes;}else{shaderEntries.Add(entry);}}foreach(var entry in shaderEntries){var relativeName=entry.Substring(ShaderGroupDir.Length+1);var dotIndex=relativeName.LastIndexOf('.');var shaderName=relativeName.Substring(0,dotIndex);var targetDir=GetShaderCacheDir(shaderName);if(IsShaderCached(shaderName))continue;if(!Directory.Exists(targetDir))Directory.CreateDirectory(targetDir);var shaderBytes=provider.ReadFileBinary(entry);if(shaderBytes!=null){var shaderPath=Path.Combine(targetDir,relativeName);File.WriteAllBytes(shaderPath,shaderBytes);}foreach(var(incName,incBytes)in includeData){var incPath=Path.Combine(targetDir,incName);if(!File.Exists(incPath))File.WriteAllBytes(incPath,incBytes);}GameLogger.Log("CACHE",$"Cached shader '{shaderName}' from mod '{modId}'");}}}}

===== FILE Core/Mods/Caches/ModIconCache.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Threading.Tasks;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods.Caches{/// <summary> On-disk and in-memory cache for mod preview icons fetched from remote catalog entries. Each icon is keyed by <c>mod_id</c> and stored as a PNG file under <c>user://mod_cache/icons/</c>. Three-tier lookup for <see cref="GetOrDownloadAsync"/>: <list type="number"> <item>In-memory <see cref="Texture2D"/> dictionary (fast repeat access)</item> <item>On-disk PNG file (survives game restart)</item> <item>HTTP download from the catalog's <c>icon_url</c> field</item> </list> <see cref="Clear"/> drops both tiers and is called by <see cref="CatalogService"/> when the catalog is force-refreshed, so stale icons for removed mods don't linger on disk. </summary>
public static class ModIconCache{private static readonly System.Net.Http.HttpClient _httpClient=new(){Timeout=TimeSpan.FromSeconds(15),DefaultRequestHeaders={{"User-Agent","Cthangover/1.0"}},};private static readonly Dictionary<string,Texture2D>_memoryCache=new();private const string IconsSubDir="icons";/// <summary>Globalised mod-cache root (<see cref="ModConfig.Cache"/>.<c>Root</c>), with an OS-profile fallback outside the runtime.</summary>
public static string GetCacheRoot(){if(!GameEnvironment.IsRuntime)return Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),"cthangover","mod_cache");return ProjectSettings.GlobalizePath(ModConfig.Instance.Cache.Root);}/// <summary>Icons subdirectory under the cache root.</summary>
public static string GetIconsDir(){return Path.Combine(GetCacheRoot(),IconsSubDir);}/// <summary>Disk path of the cached PNG preview for one mod id.</summary>
public static string GetIconPath(string modId){return Path.Combine(GetIconsDir(),$"{modId}.png");}/// <summary> Returns the cached <see cref="Texture2D"/> for the given mod, downloading it from <paramref name="iconUrl"/> if necessary. Returns <c>null</c> when <paramref name="iconUrl"/> is empty or the download fails. </summary>
public static async Task<Texture2D>GetOrDownloadAsync(string modId,string iconUrl){if(string.IsNullOrEmpty(modId)||string.IsNullOrEmpty(iconUrl))return null;if(_memoryCache.TryGetValue(modId,out var cached))return cached;var diskPath=GetIconPath(modId);byte[]imageData=null;if(File.Exists(diskPath)){imageData=File.ReadAllBytes(diskPath);}else{try{imageData=await _httpClient.GetByteArrayAsync(iconUrl);if(imageData!=null&&imageData.Length>0){var dir=GetIconsDir();if(!Directory.Exists(dir))Directory.CreateDirectory(dir);File.WriteAllBytes(diskPath,imageData);GameLogger.Log("ICON_CACHE",$"Downloaded icon for mod '{modId}'");}}catch(Exception ex){GameLogger.Log("ICON_CACHE",$"Failed to download icon for mod '{modId}': {ex.Message}",LogLevel.Warning);return null;}}if(imageData==null||imageData.Length==0)return null;var texture=DecodeImage(imageData,modId);if(texture!=null)_memoryCache[modId]=texture;return texture;}/// <summary> Synchronous version. In non-runtime (test) mode, downloads block the calling thread. In runtime, this is a fire-and-forget wrapper best used from callbacks that don't need the result. </summary>
public static void GetOrDownload(string modId,string iconUrl,Action<Texture2D>onComplete){if(string.IsNullOrEmpty(modId)||string.IsNullOrEmpty(iconUrl)){onComplete?.Invoke(null);return;}if(_memoryCache.TryGetValue(modId,out var cached)){onComplete?.Invoke(cached);return;}_=DownloadAndCompleteAsync(modId,iconUrl,onComplete);}private static async Task DownloadAndCompleteAsync(string modId,string iconUrl,Action<Texture2D>onComplete){var texture=await GetOrDownloadAsync(modId,iconUrl);onComplete?.Invoke(texture);}/// <summary> Drops all in-memory cached textures and deletes the on-disk icons directory. Called by <see cref="CatalogService"/> on force-refresh so that icons for removed or renamed mods are re-fetched from the updated catalog. </summary>
public static void Clear(){_memoryCache.Clear();var iconsDir=GetIconsDir();if(Directory.Exists(iconsDir)){try{Directory.Delete(iconsDir,recursive:true);GameLogger.Log("ICON_CACHE","Cleared icon cache");}catch(Exception ex){GameLogger.Log("ICON_CACHE",$"Failed to clear icon cache: {ex.Message}",LogLevel.Warning);}}}private static Texture2D DecodeImage(byte[]data,string modId){if(!GameEnvironment.IsRuntime)return null;var image=new Image();Error error;if(data[0]==0x89)error=image.LoadPngFromBuffer(data);else if(data[0]==0xFF)error=image.LoadJpgFromBuffer(data);else return null;if(error!=Error.Ok){image.Dispose();return null;}var texture=ImageTexture.CreateFromImage(image);image.Dispose();return texture;}}}

===== FILE Core/Mods/CatalogExportEntry.cs =====
using System.Collections.Generic;using System.Linq;using System.Text.Json.Serialization;namespace Cthangover.Core.Mods{/// <summary> JSON DTO for a single mod entry in the online catalog. Designed to be embedded into the <c>"mods"</c> array of a catalog file hosted on a web server. Every field maps directly to the catalog schema researched from the in-game mod list UI: name, description, dependencies, thumbnail URL, download metadata, and two SHA256 checksums for integrity and modification-detection. <see cref="Depends"/> and <see cref="ConflictsWith"/> use the same string format as the local <c>manifest.json</c> (e.g. <c>"cthangover_game>=1"</c>, <c>"ui_base>=1?"</c>). </summary>
public class CatalogExportEntry{/// <summary>Canonical mod identifier (from manifest.json).</summary>
[JsonPropertyName("id")]public string Id{get;set;}/// <summary>Human-readable mod name.</summary>
[JsonPropertyName("name")]public string Name{get;set;}/// <summary>Mod author credit string.</summary>
[JsonPropertyName("author")]public string Author{get;set;}/// <summary>Semver version string (e.g. "1.0.0").</summary>
[JsonPropertyName("version")]public string Version{get;set;}/// <summary>Short description of what the mod does.</summary>
[JsonPropertyName("description")]public string Description{get;set;}/// <summary> Localized name/description entries. When populated the game picks the best match for the player's current language: requested lang → "en" → first available → flat fields. </summary>
[JsonPropertyName("locale")]public List<CatalogLocaleEntry>Locale{get;set;}/// <summary>Category (or comma-separated combination of categories, e.g. "Library, Gameplay") for filtering and grouping. Serialised as enum name(s).</summary>
[JsonPropertyName("category")][JsonConverter(typeof(JsonStringEnumConverter))]public CategoryType Category{get;set;}/// <summary>Search tags (e.g. ["cooking", "crafting"]).</summary>
[JsonPropertyName("tags")]public List<string>Tags{get;set;}/// <summary>Full URL to the mod's preview icon (PNG or JPEG).</summary>
[JsonPropertyName("icon_url")]public string IconUrl{get;set;}/// <summary> Versioned dependency strings in the same format as local manifest.json (e.g. "cthangover_game>=1", "ui_base>=2?"). </summary>
[JsonPropertyName("depends")]public List<string>Depends{get;set;}/// <summary>Mutually exclusive mod IDs.</summary>
[JsonPropertyName("conflicts_with")]public List<string>ConflictsWith{get;set;}/// <summary> Version-specific download entries with mirror URLs and per-version checksums. Multiple entries allow downloading older versions of the mod. </summary>
[JsonPropertyName("downloads")]public List<CatalogEntryDownload>Downloads{get;set;}/// <summary>Release status: stable, beta, deprecated, archived.</summary>
[JsonPropertyName("status")]public string Status{get;set;}/// <summary>Minimum compatible game version (semver).</summary>
[JsonPropertyName("min_game_version")]public string MinGameVersion{get;set;}/// <summary>Maximum compatible game version, or null if unbounded.</summary>
[JsonPropertyName("max_game_version")]public string MaxGameVersion{get;set;}/// <summary> Catalog URLs this entry was fetched from. Filled by <see cref="CatalogService"/> during refresh (one URL per catalog that published the mod) and persisted in the on-disk cache so the UI can show where a mod comes from without a network round-trip. Null for entries built for export — exporters omit it. </summary>
[JsonPropertyName("source_urls")]public List<string>SourceUrls{get;set;}/// <summary> Set when several catalogs publish the same id with the same version and title but different content hashes. Such entries are not merged: each variant stays a separate entry flagged for the UI to highlight, because one of the catalogs is likely serving a modified build. Null (omitted) for normal entries so catalog export JSON stays clean. </summary>
[JsonPropertyName("variant_conflict")]public bool?VariantConflict{get;set;}/// <summary>Display name for the language: matching locale entry, then "en", then the first entry, then the flat <see cref="Name"/> (or id).</summary>
public string GetLocalizedName(string language){var locale=ResolveLocale(language);if(locale!=null&& !string.IsNullOrEmpty(locale.Name))return locale.Name;return Name??Id;}/// <summary>Description for the language: matching locale entry, then "en", then the first entry, then the flat <see cref="Description"/>.</summary>
public string GetLocalizedDescription(string language){var locale=ResolveLocale(language);if(locale!=null&& !string.IsNullOrEmpty(locale.Description))return locale.Description;return Description??string.Empty;}private CatalogLocaleEntry ResolveLocale(string language){if(Locale==null||Locale.Count==0)return null;return Locale.FirstOrDefault(l=>l.Lang==language)??Locale.FirstOrDefault(l=>l.Lang=="en")??Locale[0];}}/// <summary> Version-specific download entry with mirror URLs and per-version integrity checksums. Multiple mirrors for the same version are supported — the game will try each URL in order. </summary>
public class CatalogEntryDownload{/// <summary>Semver version this download entry provides.</summary>
[JsonPropertyName("version")]public string Version{get;set;}/// <summary>Mirror URLs for the mod's .zip archive. The game tries each URL in order until one succeeds.</summary>
[JsonPropertyName("urls")]public List<string>Urls{get;set;}/// <summary>File size of the downloadable archive in bytes, for progress-bar display before the download starts.</summary>
[JsonPropertyName("size_bytes")]public long SizeBytes{get;set;}/// <summary> SHA256 checksum of the downloadable .zip archive for this specific version. Used to verify download integrity. </summary>
[JsonPropertyName("archive_sha256")]public string ArchiveSha256{get;set;}/// <summary> SHA256 checksum of the mod's extracted content for this specific version. Compared against the locally-installed copy to detect player modifications. </summary>
[JsonPropertyName("install_sha256")]public string InstallSha256{get;set;}/// <summary>Optional changelog describing what changed in this specific version.</summary>
[JsonPropertyName("changelog")]public string Changelog{get;set;}}}

===== FILE Core/Mods/CatalogLocaleEntry.cs =====
using System.Text.Json.Serialization;namespace Cthangover.Core.Mods{/// <summary> One localized name/description entry of a catalog mod or collection (the <c>locale[]</c> array). Consumers pick the best entry for the player's language, falling back to the "en" entry and then to the flat top-level fields. </summary>
public class CatalogLocaleEntry{/// <summary>Language code (ISO 639-1, e.g. "en", "ru").</summary>
[JsonPropertyName("lang")]public string Lang{get;set;}/// <summary>Localized display name.</summary>
[JsonPropertyName("name")]public string Name{get;set;}/// <summary>Localized short description.</summary>
[JsonPropertyName("description")]public string Description{get;set;}}}

===== FILE Core/Mods/CatalogMerger.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods{/// <summary> Pure merging rules for the online mod catalog. Extracted from <see cref="CatalogService"/> so the conflict/provenance logic is unit testable without network or Godot. <para/> Normal case: all catalogs publishing the same mod id are combined into one entry — the newest version wins, downloads of every version are merged and <see cref="CatalogExportEntry.SourceUrls"/> lists every catalog that published it. Exceptional case: the same id, version and title appear in several catalogs with <b>different content hashes</b>; those are kept as separate entries flagged with <see cref="CatalogExportEntry.VariantConflict"/> (each carrying its own source URL) so the UI can warn the player about a likely counterfeit build instead of silently picking one. </summary>
public static class CatalogMerger{/// <summary> Tags a freshly fetched entry with the catalog URL it came from, preserving URLs already present. </summary>
public static void TagSource(CatalogExportEntry entry,string url){if(entry==null||string.IsNullOrWhiteSpace(url)||url==entry.SourceUrls?.FirstOrDefault())return;entry.SourceUrls??=new List<string>();if(!entry.SourceUrls.Contains(url))entry.SourceUrls.Add(url);}/// <summary> Merges raw catalog entries (possibly from several catalogs) into the deduplicated, conflict-aware list consumed by the UI and downloader. </summary>
public static List<CatalogExportEntry>MergeAndDeduplicate(List<CatalogExportEntry>entries){var result=new List<CatalogExportEntry>();if(entries==null)return result;foreach(var group in entries.GroupBy(e=>e.Id)){var list=group.ToList();if(!HasVariantConflict(list)){result.Add(MergeGroup(list));continue;}foreach(var variant in list.GroupBy(GetVariantKey)){var merged=MergeGroup(variant.ToList());merged.VariantConflict=true;result.Add(merged);}}return result;}/// <summary> Selects the entry to install. When conflicting variants share an id, the caller passes the source URL of the chosen one; without a source (or when it matches nothing) the first matching entry wins. </summary>
public static CatalogExportEntry FindEntry(IReadOnlyList<CatalogExportEntry>entries,string modId,string sourceUrl){if(entries==null||string.IsNullOrEmpty(modId))return null;CatalogExportEntry fallback=null;foreach(var entry in entries){if(!string.Equals(entry.Id,modId,StringComparison.OrdinalIgnoreCase))continue;fallback??=entry;if(string.IsNullOrEmpty(sourceUrl)||entry.SourceUrls==null)continue;foreach(var url in entry.SourceUrls){if(string.Equals(url,sourceUrl,StringComparison.OrdinalIgnoreCase))return entry;}}return fallback;}/// <summary> True when two entries describe the same mod (id, version, title) but their content hashes differ — one of the catalogs is serving a modified build or a different mod under the same name. </summary>
private static bool HasVariantConflict(List<CatalogExportEntry>list){for(var i=0;i<list.Count;i++){for(var j=i+1;j<list.Count;j++){var a=list[i];var b=list[j];if(!string.Equals(a.Version,b.Version,StringComparison.OrdinalIgnoreCase))continue;if(!string.Equals(a.Name??a.Id,b.Name??b.Id,StringComparison.OrdinalIgnoreCase))continue;if(!string.Equals(GetVariantKey(a),GetVariantKey(b),StringComparison.OrdinalIgnoreCase))return true;}}return false;}/// <summary> Content identity of an entry: its own version plus the hash of that version's download. Entries with the same key are the same build and may be merged across catalogs. </summary>
private static string GetVariantKey(CatalogExportEntry entry)=>$"{entry?.Version ?? "?"}|{GetVersionHash(entry, entry?.Version)}";private static string GetVersionHash(CatalogExportEntry entry,string version){if(entry?.Downloads==null||entry.Downloads.Count==0)return"nohash";CatalogEntryDownload match=null;foreach(var download in entry.Downloads){if(string.Equals(download.Version,version,StringComparison.OrdinalIgnoreCase)){match=download;break;}}match??=entry.Downloads[0];if(!string.IsNullOrEmpty(match.InstallSha256))return match.InstallSha256;if(!string.IsNullOrEmpty(match.ArchiveSha256))return match.ArchiveSha256;return"nohash";}private static CatalogExportEntry MergeGroup(List<CatalogExportEntry>group){var best=group.OrderByDescending(e=>e.Version??"0.0.0",Comparer<string>.Create((a,b)=>VersionHelper.Compare(a,b))).First();var allDownloads=group.SelectMany(e=>e.Downloads??new List<CatalogEntryDownload>());best.Downloads=MergeDownloadVersions(allDownloads);best.SourceUrls=MergeSources(group);if(group.Any(e=>e.VariantConflict==true))best.VariantConflict=true;return best;}private static List<string>MergeSources(List<CatalogExportEntry>group){var sources=new List<string>();foreach(var entry in group){if(entry.SourceUrls==null)continue;foreach(var url in entry.SourceUrls){if(!string.IsNullOrWhiteSpace(url)&& !sources.Contains(url))sources.Add(url);}}return sources.Count>0?sources:null;}private static List<CatalogEntryDownload>MergeDownloadVersions(IEnumerable<CatalogEntryDownload>downloads){var merged=new Dictionary<string,CatalogEntryDownload>();foreach(var dl in downloads){if(string.IsNullOrEmpty(dl.Version))continue;if(merged.TryGetValue(dl.Version,out var existing)){var urlSet=new HashSet<string>(existing.Urls??new List<string>());if(dl.Urls!=null)foreach(var u in dl.Urls)if(!string.IsNullOrWhiteSpace(u))urlSet.Add(u);existing.Urls=urlSet.ToList();if(dl.SizeBytes>0&&existing.SizeBytes==0)existing.SizeBytes=dl.SizeBytes;if(!string.IsNullOrEmpty(dl.ArchiveSha256)&&string.IsNullOrEmpty(existing.ArchiveSha256))existing.ArchiveSha256=dl.ArchiveSha256;if(!string.IsNullOrEmpty(dl.InstallSha256)&&string.IsNullOrEmpty(existing.InstallSha256))existing.InstallSha256=dl.InstallSha256;}else{merged[dl.Version]=new CatalogEntryDownload{Version=dl.Version,Urls=dl.Urls?.Where(u=> !string.IsNullOrWhiteSpace(u)).ToList()??new List<string>(),SizeBytes=dl.SizeBytes,ArchiveSha256=dl.ArchiveSha256,InstallSha256=dl.InstallSha256,};}}return merged.Values.OrderByDescending(d=>d.Version??"0.0.0",Comparer<string>.Create((a,b)=>VersionHelper.Compare(a,b))).ToList();}}}

===== FILE Core/Mods/CatalogRoot.cs =====
using System.Collections.Generic;using System.Text.Json.Serialization;namespace Cthangover.Core.Mods{/// <summary> JSON envelope for the online mod catalog. Expected structure: <code> { "catalog": { "version": 1, "updated_at": "2026-..." }, "mods": [ ...CatalogExportEntry... ], "collections": [ ...ModCollection... ] } </code> <see cref="CatalogService"/> fetches this from each configured URL, deserializes, and merges the <c>"mods"</c> and <c>"collections"</c> arrays independently. The <c>collections</c> key is optional so catalogs written before collections existed keep working. </summary>
public class CatalogRoot{/// <summary>Catalog-level metadata (version, last-updated timestamp).</summary>
[JsonPropertyName("catalog")]public CatalogMeta Meta{get;set;}/// <summary>List of mod entries available in this catalog.</summary>
[JsonPropertyName("mods")]public List<CatalogExportEntry>Mods{get;set;}/// <summary>Curated mod collections this catalog distributes; see <see cref="ModCollectionStore"/>.</summary>
[JsonPropertyName("collections")]public List<ModCollection>Collections{get;set;}}/// <summary> Metadata about the catalog file itself — version of the format and when it was last refreshed on the server. </summary>
public class CatalogMeta{/// <summary>Schema version of this catalog file.</summary>
[JsonPropertyName("version")]public int Version{get;set;}/// <summary>ISO-8601 timestamp of the last update on the server.</summary>
[JsonPropertyName("updated_at")]public string UpdatedAt{get;set;}}}

===== FILE Core/Mods/CatalogService.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text.Json;using System.Threading;using System.Threading.Tasks;using Cthangover.Core.Mods.Caches;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods{/// <summary> Fetches, caches, and merges online mod catalogs from the URLs configured in <see cref="ModConfig.CatalogUrls"/>. Multiple catalogs are merged via deduplication by mod ID — the entry with the highest <see cref="CatalogExportEntry.Version"/> wins. Two-tier caching: an on-disk JSON cache at <c>user://catalog_cache.json</c> avoids network round-trips on every open of the Network tab; the cache is loaded eagerly on first access and refreshed when the user presses the Refresh button or when <see cref="ModConfig.CatalogUrls"/> changes. <see cref="OnRefreshed"/> / <see cref="OnRefreshFailed"/> fire on the main thread after an async fetch completes so that UI subscribers can safely rebuild their node trees. </summary>
public static class CatalogService{private static readonly System.Net.Http.HttpClient _httpClient=new(){Timeout=TimeSpan.FromSeconds(30),DefaultRequestHeaders={{"User-Agent","Cthangover/1.0"}},};private static List<CatalogExportEntry>_cachedEntries;private static List<ModCollection>_cachedCollections;private static bool _isFetching;private static DateTime _lastFetchTime=DateTime.MinValue;private static readonly TimeSpan FetchCooldown=TimeSpan.FromSeconds(5);/// <summary>The current merged catalog data, or null until first load.</summary>
public static List<CatalogExportEntry>CachedEntries=>_cachedEntries;/// <summary> The current merged collection list from every catalog, or null until first load. Consumed by <see cref="ModCollectionStore.LoadAll"/>. </summary>
public static List<ModCollection>CachedCollections=>_cachedCollections;/// <summary>True while an async refresh is in flight.</summary>
public static bool IsFetching=>_isFetching;/// <summary>Fired on the main thread after a successful refresh.</summary>
public static event Action OnRefreshed;/// <summary>Fired on the main thread with an error message when refresh fails.</summary>
public static event Action<string>OnRefreshFailed;/// <summary> Loads cached catalog data from disk if available. Safe to call multiple times — subsequent calls are no-ops. </summary>
public static void LoadCache(){if(_cachedEntries!=null)return;var path=GetCachePath();if(File.Exists(path)){try{var json=File.ReadAllText(path);if(TryLoadEnvelope(json)){GameLogger.Log("CATALOG",$"Loaded catalog cache: {_cachedEntries?.Count ?? 0} mod(s), {_cachedCollections?.Count ?? 0} collection(s)");return;}_cachedEntries=JsonSerializer.Deserialize<List<CatalogExportEntry>>(json,JsonOptionsProvider.ReadOptions);GameLogger.Log("CATALOG",$"Loaded legacy catalog cache: {_cachedEntries?.Count ?? 0} entr(y/ies)");}catch(Exception ex){GameLogger.Log("CATALOG",$"Failed to load catalog cache: {ex.Message}",LogLevel.Warning);}}}/// <summary> Reads the two-list cache envelope. Returns false for the legacy array-only format (a bare JSON array), which the caller then parses as a plain mod list. </summary>
private static bool TryLoadEnvelope(string json){if(string.IsNullOrWhiteSpace(json))return false;var trimmed=json.TrimStart();if(trimmed[0]!='{')return false;var root=JsonSerializer.Deserialize<CatalogCacheRoot>(json,JsonOptionsProvider.ReadOptions);if(root==null)return false;_cachedEntries=root.Mods??new List<CatalogExportEntry>();_cachedCollections=root.Collections??new List<ModCollection>();return true;}/// <summary> Fetches all configured catalog URLs, merges the results, and fires <see cref="OnRefreshed"/> on the main thread. Clears the on-disk cache if <paramref name="forceRefresh"/> is true before fetching. Cancelling <paramref name="cancellationToken"/> aborts the in-flight HTTP requests and suppresses the completion events, so a caller whose window is closing is never called back. The <see cref="IsFetching"/> flag is always cleared — on success, cancellation, and unexpected exceptions alike — otherwise a single failed merge would permanently block every later refresh. </summary>
public static async Task RefreshAsync(bool forceRefresh=false,CancellationToken cancellationToken=default){if(_isFetching)return;var sinceLast=DateTime.UtcNow-_lastFetchTime;if(!forceRefresh&&sinceLast<FetchCooldown){var remain=(FetchCooldown-sinceLast).TotalSeconds;GameLogger.Log("CATALOG",$"Skipping refresh (cooldown, last attempt was {sinceLast.TotalSeconds:F1}s ago, {remain:F1}s remaining)");return;}_isFetching=true;_lastFetchTime=DateTime.UtcNow;try{cancellationToken.ThrowIfCancellationRequested();if(forceRefresh){ClearCache();ModIconCache.Clear();}var allEntries=new List<CatalogExportEntry>();var allCollections=new List<ModCollection>();var errors=new List<string>();var urls=ModConfig.Instance.CatalogUrls;if(urls==null||urls.Count==0){_cachedEntries=new List<CatalogExportEntry>();_cachedCollections=new List<ModCollection>();OnRefreshed?.Invoke();return;}GameLogger.Log("CATALOG",$"Starting catalog refresh from {urls.Count} URL(s)...");foreach(var url in urls){cancellationToken.ThrowIfCancellationRequested();if(string.IsNullOrWhiteSpace(url))continue;try{GameLogger.Log("CATALOG",$"Fetching catalog from '{url}'...");var json=await _httpClient.GetStringAsync(url,cancellationToken);var catalog=JsonSerializer.Deserialize<CatalogRoot>(json,JsonOptionsProvider.ReadOptions);if(catalog?.Mods!=null){foreach(var mod in catalog.Mods)CatalogMerger.TagSource(mod,url);allEntries.AddRange(catalog.Mods);GameLogger.Log("CATALOG",$"Fetched {catalog.Mods.Count} entr(y/ies) from '{url}'");}if(catalog?.Collections!=null){foreach(var collection in catalog.Collections)CatalogCollectionMerger.TagSource(collection,url);allCollections.AddRange(catalog.Collections);GameLogger.Log("CATALOG",$"Fetched {catalog.Collections.Count} collection(s) from '{url}'");}}catch(OperationCanceledException)when(cancellationToken.IsCancellationRequested){throw;}catch(Exception ex){var msg=$"Failed to fetch catalog from '{url}': {ex.Message}";GameLogger.Log("CATALOG",msg,LogLevel.Error);errors.Add(msg);}}if(allEntries.Count>0||allCollections.Count>0){_cachedEntries=CatalogMerger.MergeAndDeduplicate(allEntries);_cachedCollections=CatalogCollectionMerger.MergeAndDeduplicate(allCollections);SaveCache(_cachedEntries,_cachedCollections);GameLogger.Log("CATALOG",$"Catalog refresh complete: {_cachedEntries.Count} total mod(s), {_cachedCollections.Count} collection(s) merged");OnRefreshed?.Invoke();}else if(_cachedEntries==null&&errors.Count>0){GameLogger.Log("CATALOG","Catalog refresh failed: all URLs returned errors",LogLevel.Error);OnRefreshFailed?.Invoke(string.Join("\n",errors));}else{GameLogger.Log("CATALOG","Catalog refresh complete: no new entries");OnRefreshed?.Invoke();}}finally{_isFetching=false;}}/// <summary> Deletes the on-disk cache and clears in-memory state. The next call to <see cref="RefreshAsync"/> will fetch everything from scratch. </summary>
public static void ClearCache(){_cachedEntries=null;_cachedCollections=null;var path=GetCachePath();if(File.Exists(path))File.Delete(path);}/// <summary> Compares a locally-installed mod against its catalog entry. </summary> <param name="localVersion">Installed mod version string.</param> <param name="localInstallSha256">SHA256 of the installed mod's content, computed by <see cref="ModHasher.ComputeInstallSha256"/>.</param> <param name="remoteEntry">Corresponding catalog entry.</param> <returns>The relationship between the local copy and the remote version.</returns>
public static RemoteModStatus GetRemoteModStatus(string localVersion,string localInstallSha256,CatalogExportEntry remoteEntry){if(localVersion==null)return RemoteModStatus.NotInstalled;var download=FindDownloadForVersion(remoteEntry,localVersion);var remoteVersion=remoteEntry.Version;if(!string.IsNullOrEmpty(remoteVersion)&&VersionHelper.IsGreaterThan(remoteVersion,localVersion))return RemoteModStatus.UpdateAvailable;if(!string.IsNullOrEmpty(localInstallSha256)&&download!=null&& !string.IsNullOrEmpty(download.InstallSha256)&& !string.Equals(localInstallSha256,download.InstallSha256,StringComparison.OrdinalIgnoreCase))return RemoteModStatus.ModifiedLocally;return RemoteModStatus.Installed;}private static CatalogEntryDownload FindDownloadForVersion(CatalogExportEntry entry,string version){if(entry?.Downloads==null)return null;foreach(var d in entry.Downloads){if(string.Equals(d.Version,version,StringComparison.OrdinalIgnoreCase))return d;}return null;}private static void SaveCache(List<CatalogExportEntry>entries,List<ModCollection>collections){try{var path=GetCachePath();var root=new CatalogCacheRoot{Mods=entries,Collections=collections};var json=JsonSerializer.Serialize(root,JsonOptionsProvider.WriteIndentedOptions);File.WriteAllText(path,json);}catch(Exception ex){GameLogger.Log("CATALOG",$"Failed to save catalog cache: {ex.Message}",LogLevel.Warning);}}private static string GetCachePath(){if(GameEnvironment.IsRuntime)return ProjectSettings.GlobalizePath("user://catalog_cache.json");return"catalog_cache.json";}}/// <summary> Relationship between a locally-installed mod and its remote catalog counterpart. </summary>
public enum RemoteModStatus{/// <summary>The mod is installed and up-to-date with the catalog.</summary>
Installed,/// <summary>A newer version is available in the catalog.</summary>
UpdateAvailable,/// <summary>The mod is not installed locally.</summary>
NotInstalled,/// <summary>Same version, but the local content differs from the catalog's <c>install_sha256</c> — the player has edited the mod locally and overwriting would lose their changes.</summary>
ModifiedLocally,}}

===== FILE Core/Mods/CategoryType.cs =====
namespace Cthangover.Core.Mods;/// <summary> Catalog category — or a combination of categories — used to group mods in the online catalog and in the mod manifests. Declared as a bit-flag enum so one mod can belong to several categories at once (e.g. a battle engine is both <c>Library</c> and <c>Gameplay</c>). JSON serialisation via <c>JsonStringEnumConverter</c> keeps single categories as plain names (<c>"gameplay"</c>, fully backward compatible with catalogs written before combinations existed) and writes combinations as a comma-separated list (<c>"library, gameplay"</c>). Parsing is case-insensitive. </summary>
[System.Flags]public enum CategoryType{Story=1<<0,Characters=1<<1,Gameplay=1<<2,Visual=1<<3,Audio=1<<4,UI=1<<5,Locale=1<<6,Library=1<<7,Engine=1<<8,Overhaul=1<<9,Tools=1<<10,Editor=1<<11,Debug=1<<12,Other=1<<13,}

===== FILE Core/Mods/Collections/CatalogCacheRoot.cs =====
using System.Collections.Generic;using System.Text.Json.Serialization;namespace Cthangover.Core.Mods{/// <summary> On-disk envelope for <c>user://catalog_cache.json</c>. Replaced the bare mod-entry array when collections were added to the catalog: the cache now stores both lists together so a refresh persists everything in one write. <see cref="CatalogService.LoadCache"/> still understands the legacy array-only format and falls back to it when the file does not start with an object. </summary>
public class CatalogCacheRoot{/// <summary>Merged mod entries, as produced by <see cref="CatalogMerger"/>.</summary>
[JsonPropertyName("mods")]public List<CatalogExportEntry>Mods{get;set;}/// <summary>Merged collection definitions, as produced by <see cref="CatalogCollectionMerger"/>.</summary>
[JsonPropertyName("collections")]public List<ModCollection>Collections{get;set;}}}

===== FILE Core/Mods/Collections/CatalogCollectionMerger.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods{/// <summary> Pure merge rules for collections published by several online catalogs, the collection counterpart of <see cref="CatalogMerger"/>. Collections carry no content hashes, so the rules are simpler: entries with the same id collapse into the one with the highest version, and every catalog that published the id is recorded in <see cref="ModCollection.SourceUrls"/> so the UI can show provenance (and future per-source variants). </summary>
public static class CatalogCollectionMerger{/// <summary> Tags a freshly fetched collection with the catalog URL it came from, preserving URLs already present. </summary>
public static void TagSource(ModCollection collection,string url){if(collection==null||string.IsNullOrWhiteSpace(url))return;collection.SourceUrls??=new List<string>();if(!collection.SourceUrls.Contains(url))collection.SourceUrls.Add(url);}/// <summary> Merges raw catalog collections into the deduplicated list consumed by <see cref="CatalogService.CachedCollections"/>. Returns a new list; the winning collection objects are mutated in place with the merged sources. </summary>
public static List<ModCollection>MergeAndDeduplicate(List<ModCollection>collections){var result=new List<ModCollection>();if(collections==null)return result;var byId=new Dictionary<string,ModCollection>(StringComparer.OrdinalIgnoreCase);foreach(var collection in collections){if(collection==null||string.IsNullOrEmpty(collection.Id))continue;if(!byId.TryGetValue(collection.Id,out var existing)){byId[collection.Id]=collection;result.Add(collection);continue;}if(VersionHelper.Compare(collection.Version??"0.0.0",existing.Version??"0.0.0")>0){MergeSources(collection,existing);var index=result.IndexOf(existing);if(index>=0)result[index]=collection;byId[collection.Id]=collection;}else{MergeSources(existing,collection);}}return result;}private static void MergeSources(ModCollection target,ModCollection other){if(other.SourceUrls==null)return;target.SourceUrls??=new List<string>();foreach(var url in other.SourceUrls){if(!string.IsNullOrWhiteSpace(url)&& !target.SourceUrls.Contains(url))target.SourceUrls.Add(url);}}}}

===== FILE Core/Mods/Collections/CollectionLayoutStore.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Text.Json;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods{/// <summary> Loads and persists scheme layouts from <c>user://collection_layout.json</c>. The file holds one <see cref="ModCollectionLayout"/> per game id, so the player's arrangement of the "РПГ Новелла" canvas is independent from any other assembly. Layouts are UI-only: if the file is missing or broken the editor simply falls back to its default packed layout, and nothing in the collection documents is affected. <para/> Follows the <see cref="ModCollectionStore"/> warm-up pattern: the user path is resolved once on the main thread because <c>ProjectSettings.GlobalizePath</c> is Godot API and is not available from worker threads. </summary>
public static class CollectionLayoutStore{private const string UserFilePath="user://collection_layout.json";private static Dictionary<string,ModCollectionLayout>_cache;private static string _resolvedPath;/// <summary> Resolves the user storage path once on the main thread before the background mod-load phase, mirroring <see cref="ModCollectionStore"/>. </summary>
public static void WarmUp(){_resolvedPath=GameEnvironment.IsRuntime?ProjectSettings.GlobalizePath(UserFilePath):"collection_layout.json";}private static string ResolvedPath=>_resolvedPath??(GameEnvironment.IsRuntime?ProjectSettings.GlobalizePath(UserFilePath):"collection_layout.json");/// <summary>Returns the saved layout for a game, or a fresh empty one. Never null.</summary>
public static ModCollectionLayout Load(string gameId){var all=LoadAll();if(!string.IsNullOrEmpty(gameId)&&all.TryGetValue(gameId,out var layout)&&layout!=null)return layout;return new ModCollectionLayout();}/// <summary>Stores the layout for a game and flushes the whole file.</summary>
public static void Save(string gameId,ModCollectionLayout layout){if(string.IsNullOrEmpty(gameId)||layout==null)return;var all=LoadAll();all[gameId]=layout;SaveAll(all);}private static Dictionary<string,ModCollectionLayout>LoadAll(){if(_cache!=null)return _cache;_cache=new Dictionary<string,ModCollectionLayout>(StringComparer.OrdinalIgnoreCase);var path=ResolvedPath;if(!File.Exists(path))return _cache;try{var json=File.ReadAllText(path);var parsed=JsonSerializer.Deserialize<Dictionary<string,ModCollectionLayout>>(json,JsonOptionsProvider.ReadOptions);if(parsed!=null)_cache=new Dictionary<string,ModCollectionLayout>(parsed,StringComparer.OrdinalIgnoreCase);}catch(Exception ex){GameLogger.Log("MOD_COLLECTIONS",$"Failed to load collection layouts: {ex.Message}",LogLevel.Warning);}return _cache;}private static void SaveAll(Dictionary<string,ModCollectionLayout>all){try{var path=ResolvedPath;var dir=Path.GetDirectoryName(path);if(!string.IsNullOrEmpty(dir)&& !Directory.Exists(dir))Directory.CreateDirectory(dir);var json=JsonSerializer.Serialize(all,JsonOptionsProvider.WriteIndentedIgnoreNullOptions);File.WriteAllText(path,json);}catch(Exception ex){GameLogger.Log("MOD_COLLECTIONS",$"Failed to save collection layouts: {ex.Message}",LogLevel.Error);}}}}

===== FILE Core/Mods/Collections/CollectionText.cs =====
using Cthangover.Core.Localization;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods{/// <summary> Implements the collection text convention: a <c>name</c>, <c>description</c>, include <c>label</c>/<c>note</c> (or a locale entry) that starts with <c>'@'</c> is a translation key (e.g. <c>"@collections/novel_template/name"</c>), registered by <see cref="LocaleLoader"/> from the <c>locale/*.properties</c> files of every mod. The remainder after <c>'@'</c> is looked up through Godot's <c>TranslationServer</c>; when no translation is registered the raw text after <c>'@'</c> is shown instead, so an untranslated collection never renders as a bare key or an empty string. <para/> The class is the Godot-dependent half of the convention: <see cref="StripKey"/> stays pure for tests and headless tools, while <see cref="Resolve"/> is what the collections screen passes to <see cref="ModCollectionResolver"/> as its <c>translator</c> argument. </summary>
public static class CollectionText{/// <summary>Prefix marking a field value as a translation key.</summary>
private const char KeyPrefix='@';/// <summary> True when the raw field value is a translation-key reference (starts with <c>'@'</c>). </summary>
public static bool IsKey(string raw)=> !string.IsNullOrEmpty(raw)&&raw[0]==KeyPrefix;/// <summary> Translation key without the leading <c>'@'</c>, or null when the value is not a key reference. Pure — safe outside the Godot runtime. </summary>
public static string StripKey(string raw)=>IsKey(raw)?raw.Substring(1):null;/// <summary> Resolves a raw field value for display: plain text is returned unchanged, an <c>'@'</c>-prefixed value is translated through the Godot <c>TranslationServer</c>, falling back to the stripped key text when the key is not registered (or when no Godot runtime is available, e.g. in unit tests). Never returns null for non-null input, so UI code can bind the result directly. </summary>
public static string Resolve(string raw){var key=StripKey(raw);if(key==null)return raw;if(string.IsNullOrEmpty(key)|| !GameEnvironment.IsRuntime)return key;try{// The provider returns null when Godot echoes the key back, which is its signal for "no translation registered".
return GodotTranslationProvider.Instance.Get(key)??key;}catch{// Godot API unavailable on this thread or not initialized — degrade gracefully to the human-readable key text.
return key;}}}}

===== FILE Core/Mods/Collections/ModCollection.cs =====
using System.Collections.Generic;using System.Linq;using System.Text.Json.Serialization;namespace Cthangover.Core.Mods{/// <summary> A curated set of mods that together implement a game mechanic ("card novel", "developer tools") — the game-construction unit the mod catalog is organized by. Unlike <see cref="CategoryType"/>, which labels a single mod, a collection describes a composition: the same mod can be a required backbone in one collection and an optional layer in another. <para/> Collections are packages of their own (<c>collections/&lt;id&gt;/collection.json</c> with a cover and texts), shipped with the game, fetched from online catalogs or created by the player (<c>user://collections/</c>) — see <see cref="ModCollectionStore"/>. The dependency graph shown to the player is not stored here: it is derived from the mods' own <c>depends</c> by <see cref="ModCollectionResolver"/>, with <see cref="Mechanics"/> only telling the graph view how to group the nodes. </summary>
public class ModCollection{/// <summary>Canonical collection id, unique across all sources.</summary>
[JsonPropertyName("id")]public string Id{get;set;}/// <summary>Human-readable collection name.</summary>
[JsonPropertyName("name")]public string Name{get;set;}/// <summary>Author credit string.</summary>
[JsonPropertyName("author")]public string Author{get;set;}/// <summary> Where this collection belongs in the assembly pyramid. Missing values deserialize to <see cref="ModCollectionKind.Game"/> so documents written before kinds existed keep appearing as playable games. </summary>
[JsonPropertyName("kind")][JsonConverter(typeof(JsonStringEnumConverter))]public ModCollectionKind Kind{get;set;}=ModCollectionKind.Game;/// <summary>Short description of the gameplay or toolset the collection assembles.</summary>
[JsonPropertyName("description")]public string Description{get;set;}/// <summary>Free-form search tags ("systems", "cards", "2d", ...).</summary>
[JsonPropertyName("tags")]public List<string>Tags{get;set;}/// <summary> Capability ids this collection implements ("character_system", "inventory", ...). Used by the scheme editor to suggest compatible blocks; resolution does not enforce them. </summary>
[JsonPropertyName("provides")]public List<string>Provides{get;set;}/// <summary> Capability ids this collection needs from its siblings. The resolver reports a <see cref="ModCollectionProblemKind.RequirementMissing"/> problem when no resolved block provides them. </summary>
[JsonPropertyName("requires")]public List<string>Requires{get;set;}/// <summary> Child collections this one is assembled from, each with its slot role and optional flag. Includes are the recursive edge of the pyramid: the same collection may be included by several parents with different roles, and a parent may also declare its own <see cref="Mechanics"/>. Resolution flattens both into scheme groups. </summary>
[JsonPropertyName("includes")]public List<ModCollectionInclude>Includes{get;set;}/// <summary>Semver version, used to pick the newest of identically-named catalog collections.</summary>
[JsonPropertyName("version")]public string Version{get;set;}/// <summary> Ordered mechanics that make up the collection. The order drives the top-to-bottom layout of the graph view; one mod belongs to the first mechanic that lists it. </summary>
[JsonPropertyName("mechanics")]public List<ModCollectionMechanic>Mechanics{get;set;}/// <summary> Where a new game of this collection starts. Only meaningful for <see cref="ModCollectionKind.Game"/>; null falls back to the historical <c>start_scene</c>. </summary>
[JsonPropertyName("entry")]public ModCollectionEntry Entry{get;set;}/// <summary> Game parameters mods read through the kernel's active game (e.g. <c>"battle.core": "card_battle"</c>). Keys are owned by the mods that read them; unknown keys are ignored. </summary>
[JsonPropertyName("settings")]public Dictionary<string,string>Settings{get;set;}/// <summary>Cover image for the game picker, a path inside the collection package (e.g. <c>cover.png</c>).</summary>
[JsonPropertyName("cover")]public string Cover{get;set;}/// <summary> Catalog URLs that published this collection. Filled by <see cref="CatalogService"/> during refresh; null for local files. </summary>
[JsonPropertyName("source_urls")]public List<string>SourceUrls{get;set;}/// <summary> The package the document was read from (a folder or zip under <c>collections/</c> or <c>user://collections/</c>); resolves <see cref="Cover"/> and the package's <c>locale/</c> files. Null for catalog collections. Set by <see cref="ModCollectionStore"/>. </summary>
[JsonIgnore]public IModFileProvider Package{get;set;}/// <summary>Where this instance was loaded from; set by <see cref="ModCollectionStore"/>.</summary>
[JsonIgnore]public ModCollectionOrigin Origin{get;set;}/// <summary>Safe UI label falling back to the id.</summary>
[JsonIgnore]public string DisplayTitle=>string.IsNullOrEmpty(Name)?Id:Name;/// <summary> Localized name/description entries, same shape as catalog mod entries. When populated, consumers pick the best match for the player's language (requested lang -> flat fields -> "en" -> first), so a shipped collection can present itself in several languages without duplicating the document. Values starting with '@' are translation keys resolved by <see cref="CollectionText.Resolve"/>. </summary>
[JsonPropertyName("locale")]public List<CatalogLocaleEntry>Locale{get;set;}/// <summary> Name for the given language: requested locale entry -> flat name -> English locale entry -> first entry -> id. A flat '@key' value is returned verbatim; the caller (UI or <see cref="ModCollectionResolver"/>) resolves it through <see cref="CollectionText.Resolve"/>, keeping this method free of Godot calls. </summary>
public string GetLocalizedName(string language){var localized=Match(language,true,englishFallback:false);if(localized!=null)return localized;if(!string.IsNullOrEmpty(Name))return Name;localized=Match(language,true,englishFallback:true);if(localized!=null)return localized;var first=Locale?.FirstOrDefault(l=> !string.IsNullOrEmpty(l.Name));return first?.Name??Id;}/// <summary> Description for the given language: requested locale entry -> flat description -> English locale entry -> first entry. A flat '@key' value is returned verbatim for the caller's translator to resolve. </summary>
public string GetLocalizedDescription(string language){var localized=Match(language,false,englishFallback:false);if(localized!=null)return localized;if(!string.IsNullOrEmpty(Description))return Description;localized=Match(language,false,englishFallback:true);if(localized!=null)return localized;var first=Locale?.FirstOrDefault(l=> !string.IsNullOrEmpty(l.Description));return first?.Description??string.Empty;}/// <summary> Scans <see cref="Locale"/> for the requested language; with <paramref name="englishFallback"/> the 'en' entry is consulted as a second pass, so callers can slot the flat fields between the two. </summary>
private string Match(string language,bool wantName,bool englishFallback){if(Locale==null||Locale.Count==0||string.IsNullOrEmpty(language))return null;foreach(var entry in Locale){if(string.Equals(entry.Lang,language,System.StringComparison.OrdinalIgnoreCase)&&HasText(entry,wantName))return wantName?entry.Name:entry.Description;}if(!englishFallback)return null;foreach(var entry in Locale){if(string.Equals(entry.Lang,"en",System.StringComparison.OrdinalIgnoreCase)&&HasText(entry,wantName))return wantName?entry.Name:entry.Description;}return null;}private static bool HasText(CatalogLocaleEntry entry,bool wantName)=>wantName? !string.IsNullOrEmpty(entry.Name):!string.IsNullOrEmpty(entry.Description);}}

===== FILE Core/Mods/Collections/ModCollectionEdge.cs =====
namespace Cthangover.Core.Mods{/// <summary> Directed edge of a <see cref="ModCollectionGraph"/>: <see cref="FromModId"/> is the dependency, <see cref="ToModId"/> is the mod that depends on it. The direction matches load order — dependencies are enabled and compiled before their dependents — so the graph view can draw arrows pointing from the foundation towards the mods built on top of it. </summary>
public class ModCollectionEdge{/// <summary>Id of the dependency mod.</summary>
public string FromModId{get;set;}/// <summary>Id of the mod that declares <see cref="FromModId"/> in its <c>depends</c>.</summary>
public string ToModId{get;set;}}}

===== FILE Core/Mods/Collections/ModCollectionEntry.cs =====
using System.Text.Json.Serialization;namespace Cthangover.Core.Mods{/// <summary> Entry point of a <see cref="ModCollectionKind.Game"/> collection: the logical scene a new game opens. Kept as an object so later entry options (intro scenario, starting flags) extend it without breaking existing documents. </summary>
public class ModCollectionEntry{/// <summary>Logical scene name (a scene definition from one of the collection's mods).</summary>
[JsonPropertyName("scene")]public string Scene{get;set;}}}

===== FILE Core/Mods/Collections/ModCollectionGallery.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Core.Mods{/// <summary> Aggregated, UI-ready snapshot of one <see cref="ModCollectionKind.Game"/> collection: how many mods it needs, how many are already playable, what is missing or version-mismatched, and how big the install/enable plan would be. Built by <see cref="ModCollectionGallery"/> for the game gallery screen; every number is recomputed from the live registry, disabled store and catalog cache, so it can never go stale. <para/> <see cref="Collection"/> keeps the raw document — including its <see cref="ModCollection.Origin"/> (BuiltIn/Catalog/User) which the UI shows as the source badge, and its '@'-prefixed fields which <see cref="Name"/>/<see cref="Description"/> already carry resolved when a translator was passed. </summary>
public class ModCollectionGameSummary{/// <summary>The resolved-against Game collection document.</summary>
public ModCollection Collection{get;set;}/// <summary>Collection display name with the translator applied; raw '@key' when no translator was given.</summary>
public string Name{get;set;}/// <summary>Collection display description with the translator applied; raw '@key' when no translator was given.</summary>
public string Description{get;set;}/// <summary>Total mods the collection needs, transitive dependencies included.</summary>
public int TotalMods{get;set;}/// <summary>Mods already installed, enabled and satisfying any version pin.</summary>
public int InstalledCount{get;set;}/// <summary>Ids of the mods counted by <see cref="InstalledCount"/>.</summary>
public List<string>ReadyMods{get;}=new();/// <summary>Ids that are neither installed nor available in any catalog — the plan cannot fix them.</summary>
public List<string>MissingIds{get;}=new();/// <summary>Ids whose installed version (or available download) does not satisfy the collection pin.</summary>
public List<string>MismatchIds{get;}=new();/// <summary>Resolution inconsistencies (missing blocks, cycles, role conflicts, unmet requirements).</summary>
public int ProblemsCount{get;set;}/// <summary>Mods <see cref="ModCollectionPlan"/> would download and install.</summary>
public int PlanInstallCount{get;set;}/// <summary>Installed-but-disabled mods <see cref="ModCollectionPlan"/> would re-enable.</summary>
public int PlanEnableCount{get;set;}/// <summary> True when the collection can be played right now: it needs at least one mod, every needed mod is installed with a satisfying version, and the resolution reported no problems. </summary>
public bool IsReady=>TotalMods>0&&MissingIds.Count==0&&MismatchIds.Count==0&&ProblemsCount==0;}/// <summary> Read-only aggregation layer over the merged collection store for the game gallery UI: resolves every <see cref="ModCollectionKind.Game"/> collection through <see cref="ModCollectionResolver"/> and folds the resulting graphs and plans into one <see cref="ModCollectionGameSummary"/> per game. There are no hardcoded collection ids — everything comes from the store passed by the caller (<see cref="ModCollectionStore.LoadAll"/>), so mods, catalogs and player documents all appear without code changes. <para/> Like the resolver the class is pure: every input is an argument, which keeps the summaries testable without Godot. </summary>
public static class ModCollectionGallery{/// <summary> Builds one summary per Game collection in the store. Component and Toolset collections are building material, not playable games, and are skipped. Entries are ordered by display name, then id, so the gallery renders deterministically regardless of store order; the <paramref name="translator"/> (e.g. <see cref="CollectionText.Resolve"/>) resolves '@key' display text, and null keeps raw keys for tests. </summary>
public static List<ModCollectionGameSummary>Build(IReadOnlyDictionary<string,ModCollection>collections,IReadOnlyDictionary<string,IModInfo>localMods,IReadOnlyCollection<string>disabledIds,IReadOnlyList<CatalogExportEntry>catalogEntries,IReadOnlyCollection<string>sessionInstalled,Func<string,string>translator=null){var result=new List<ModCollectionGameSummary>();if(collections==null)return result;foreach(var pair in collections){var collection=pair.Value;if(collection==null||string.IsNullOrEmpty(collection.Id))continue;if(collection.Kind!=ModCollectionKind.Game)continue;var graph=ModCollectionResolver.Resolve(collection,collections,localMods,disabledIds,catalogEntries,sessionInstalled,null,translator);var plan=ModCollectionPlan.Build(graph,catalogEntries);var summary=new ModCollectionGameSummary{Collection=collection,Name=Translate(collection.GetLocalizedName(null),translator),Description=Translate(collection.GetLocalizedDescription(null),translator),TotalMods=graph.TotalCount,InstalledCount=graph.InstalledCount,ProblemsCount=graph.Problems.Count,PlanInstallCount=plan.Install.Count,PlanEnableCount=plan.Enable.Count,};foreach(var node in graph.Nodes){switch(node.State){case ModCollectionNodeState.Installed:summary.ReadyMods.Add(node.ModId);break;case ModCollectionNodeState.Missing:summary.MissingIds.Add(node.ModId);break;case ModCollectionNodeState.VersionMismatch:summary.MismatchIds.Add(node.ModId);break;}}result.Add(summary);}result.Sort((left,right)=>{var byName=string.Compare(left.Name??left.Collection?.Id??string.Empty,right.Name??right.Collection?.Id??string.Empty,StringComparison.OrdinalIgnoreCase);if(byName!=0)return byName;var leftId=left.Collection?.Id??string.Empty;var rightId=right.Collection?.Id??string.Empty;return string.Compare(leftId,rightId,StringComparison.OrdinalIgnoreCase);});return result;}/// <summary> Answers "which games use this mod?" for the mod-side cross links: returns a map with a single entry keyed by the normalized mod id (version constraints are stripped, comparison is case-insensitive) whose value lists the ids of <see cref="ModCollectionKind.Game"/> collections that reference the mod — directly through a mechanic's mod list or transitively through their includes. Entries are sorted by collection id; a game-less mod yields an empty list, and an empty <paramref name="modId"/> yields an empty map. </summary>
public static Dictionary<string,List<string>>FindGamesUsingMod(IReadOnlyDictionary<string,ModCollection>collections,string modId){var result=new Dictionary<string,List<string>>(StringComparer.OrdinalIgnoreCase);var target=NormalizeId(modId);if(collections==null||string.IsNullOrEmpty(target))return result;var games=new List<string>();foreach(var pair in collections){var collection=pair.Value;if(collection==null||string.IsNullOrEmpty(collection.Id))continue;if(collection.Kind!=ModCollectionKind.Game)continue;if(ReferencesMod(collections,collection,target,new HashSet<string>(StringComparer.OrdinalIgnoreCase)))games.Add(collection.Id);}games.Sort(StringComparer.OrdinalIgnoreCase);result[target]=games;return result;}/// <summary> Walks a collection's own mechanics and, through its includes, the mechanics of every reachable block, looking for the mod id. The visited set turns include cycles into a plain "already seen" stop. </summary>
private static bool ReferencesMod(IReadOnlyDictionary<string,ModCollection>collections,ModCollection collection,string modId,HashSet<string>visited){if(!visited.Add(collection.Id))return false;foreach(var mechanic in collection.Mechanics??new List<ModCollectionMechanic>()){if(mechanic==null)continue;foreach(var raw in mechanic.Mods??new List<string>()){if(string.IsNullOrWhiteSpace(raw))continue;if(string.Equals(NormalizeId(raw),modId,StringComparison.OrdinalIgnoreCase))return true;}}foreach(var include in collection.Includes??new List<ModCollectionInclude>()){if(include==null||string.IsNullOrWhiteSpace(include.Ref))continue;var childId=NormalizeId(include.Ref);if(TryGetCollection(collections,childId,out var child)&&ReferencesMod(collections,child,modId,visited)){return true;}}return false;}/// <summary>Strips a version constraint ("<c>app&gt;=1</c>" -> <c>app</c>), keeping comparison version-free.</summary>
private static string NormalizeId(string raw){if(string.IsNullOrWhiteSpace(raw))return null;var dependency=ModDependency.Parse(raw);return dependency.IsValid?dependency.ModId:raw.Trim();}/// <summary>Case-insensitive lookup mirroring the resolver's tolerance for loosely-keyed stores.</summary>
private static bool TryGetCollection(IReadOnlyDictionary<string,ModCollection>collections,string id,out ModCollection collection){collection=null;if(collections==null||string.IsNullOrEmpty(id))return false;if(collections.TryGetValue(id,out collection)&&collection!=null)return true;foreach(var pair in collections){if(pair.Value!=null&&string.Equals(pair.Key,id,StringComparison.OrdinalIgnoreCase)){collection=pair.Value;return true;}}return false;}/// <summary>Applies the optional translator; null output keeps the raw value.</summary>
private static string Translate(string raw,Func<string,string>translator){if(raw==null||translator==null)return raw;return translator(raw)??raw;}}}

===== FILE Core/Mods/Collections/ModCollectionGraph.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Mods{/// <summary> Resolved, render-ready form of a <see cref="ModCollection"/>: the mods it needs (including transitive dependencies), the dependency edges between them, and each mod's availability state. Built by <see cref="ModCollectionResolver"/>; consumed by the collections screen and its <c>GraphEdit</c> view, and by <see cref="ModCollectionPlan"/> when the player applies the collection. <para/> The graph is intentionally not persisted: it is always derived from the live registry + catalog cache, so it cannot go stale after installs or catalog refreshes. </summary>
public class ModCollectionGraph{/// <summary>Mechanics in declaration order; ids normalized to non-empty values by the resolver.</summary>
public List<ModCollectionMechanic>Mechanics{get;set;}=new();/// <summary> Scheme contours in rendering order: root includes first, then the root's own mechanics. Each group carries its display name, description and the mods flattened from its subtree, so the editor can draw the game as a two-level pyramid without walking include chains itself. </summary>
public List<ModCollectionGroup>Groups{get;set;}=new();/// <summary> Resolution inconsistencies (missing blocks, cycles, role conflicts, unmet requirements). Empty for a consistent assembly; the editor blocks applying while errors are present. </summary>
public List<ModCollectionProblem>Problems{get;set;}=new();/// <summary>All mod vertices: mechanic members first, then external dependencies in discovery order.</summary>
public List<ModCollectionNode>Nodes{get;set;}=new();/// <summary>Dependency edges; <c>From</c> is the dependency of <c>To</c>.</summary>
public List<ModCollectionEdge>Edges{get;set;}=new();/// <summary>Number of nodes already installed and enabled with a satisfying version.</summary>
public int InstalledCount=>CountByState(ModCollectionNodeState.Installed);/// <summary>Total number of mods the collection needs, external dependencies included.</summary>
public int TotalCount=>Nodes.Count;private int CountByState(ModCollectionNodeState state){var count=0;foreach(var node in Nodes){if(node.State==state)count++;}return count;}}}

===== FILE Core/Mods/Collections/ModCollectionGroup.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Mods{/// <summary> One contour of the resolved scheme: either an included child collection (<see cref="IsInclude"/>) or a leaf mechanic declared directly by the root collection. The scheme editor renders one group card per entry with <see cref="Mods"/> placed inside, and nested collections are flattened into the nearest group so the player sees a two-level pyramid (game → groups → mods) even when the documents nest deeper. <para/> <see cref="Key"/> is stable across resolutions (<c>"i:parent:ref"</c> or <c>"m:parent:mechanic"</c>) and is used as the persistency key for the layout store, so moving a group on the canvas survives reloads. </summary>
public class ModCollectionGroup{/// <summary>Stable layout key: <c>"i:&lt;parentId&gt;:&lt;ref&gt;"</c> or <c>"m:&lt;parentId&gt;:&lt;mechanicId&gt;"</c>.</summary>
public string Key{get;set;}/// <summary>True for a group produced by an <see cref="ModCollectionInclude"/>, false for a leaf mechanic.</summary>
public bool IsInclude{get;set;}/// <summary>Collection shown inside the group (child collection or the owner of the mechanic).</summary>
public string CollectionId{get;set;}/// <summary>Collection that declared the group (the include's parent).</summary>
public string ParentCollectionId{get;set;}/// <summary>Owning mechanic id for leaf groups; null for includes.</summary>
public string MechanicId{get;set;}/// <summary>Display name: include label, child collection name or mechanic name.</summary>
public string Name{get;set;}/// <summary>Display description: include note, child collection or mechanic description.</summary>
public string Description{get;set;}/// <summary>Slot role ("systems", "story", "ui", ...).</summary>
public string Role{get;set;}/// <summary>True when the group may be toggled off.</summary>
public bool Optional{get;set;}/// <summary>Whether the group participates in resolution (off optional groups stay visible but faded).</summary>
public bool Selected{get;set;}=true;/// <summary>Mods inside the group, in declaration order, deduplicated by mod id.</summary>
public List<ModCollectionModRef>Mods{get;set;}=new();}}

===== FILE Core/Mods/Collections/ModCollectionInclude.cs =====
using System.Text.Json.Serialization;namespace Cthangover.Core.Mods{/// <summary> Reference from a parent <see cref="ModCollection"/> to a child collection inside <see cref="ModCollection.Includes"/>. This is the recursive edge of the collection pyramid: the parent declares which blocks (mechanics, systems, content) compose it, and the resolver expands them into groups of mods. <see cref="Ref"/> uses the same versioned syntax as <c>manifest.json</c> dependencies (<c>"rpg_2d&gt;=1"</c>), parsed with <see cref="ModDependency.Parse"/>. <para/> <see cref="Label"/> and <see cref="Note"/> are the scheme-editor overlays: the player may rename a block inside one game without touching the shared collection document, so a component can be called "2D RPG" in the catalog and "Основа" in a specific game. Null/empty values fall back to the child collection's own name and description. </summary>
public class ModCollectionInclude{/// <summary>Versioned collection reference, e.g. <c>"rpg_2d&gt;=1"</c>.</summary>
[JsonPropertyName("ref")]public string Ref{get;set;}/// <summary>Slot role inside the parent ("systems", "story", "ui", ...); drives conflict checks.</summary>
[JsonPropertyName("role")]public string Role{get;set;}/// <summary>True when the block may be excluded without breaking the parent.</summary>
[JsonPropertyName("optional")]public bool Optional{get;set;}/// <summary> Player's choice for an optional block. <c>null</c> (omitted) means the default: optional blocks are on until the player turns them off, which keeps old documents and hand-written collections behaving as before. </summary>
[JsonPropertyName("selected")]public bool?Selected{get;set;}/// <summary>Per-game name override; null falls back to the child collection name.</summary>
[JsonPropertyName("label")]public string Label{get;set;}/// <summary>Per-game description override; null falls back to the child collection description.</summary>
[JsonPropertyName("note")]public string Note{get;set;}/// <summary>True when the block participates in resolution (not an off optional).</summary>
[JsonIgnore]public bool IsSelected=> !(Optional&&Selected==false);}}

===== FILE Core/Mods/Collections/ModCollectionInstallItem.cs =====
namespace Cthangover.Core.Mods{/// <summary> One mod that <see cref="ModCollectionPlan"/> wants to download: the id, the version chosen from the catalog entry (the newest download that satisfies the collection's version pin, if any) and the catalog URL the entry came from. The pair <c>(ModId, Version)</c> plus <see cref="SourceUrl"/> is exactly what <see cref="ModDownloader.InstallAsync"/> needs. </summary>
public class ModCollectionInstallItem{/// <summary>Canonical mod id to install.</summary>
public string ModId{get;set;}/// <summary>Catalog version to install, or null to let the downloader pick the newest.</summary>
public string Version{get;set;}/// <summary>Catalog URL that published the entry; disambiguates conflicting variants.</summary>
public string SourceUrl{get;set;}}}

===== FILE Core/Mods/Collections/ModCollectionInstaller.cs =====
using System;using System.Collections.Generic;using System.Threading.Tasks;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods{/// <summary> Shared "apply a collection plan" routine for every surface that can install a collection: the mod hub gallery, the scheme editor and the main menu's game picker. Downloads every <see cref="ModCollectionPlan.Install"/> item through <see cref="ModDownloader"/> in graph order and, when all of them succeeded, re-enables the <see cref="ModCollectionPlan.Enable"/> ids in <c>disabled_mods.json</c> — the same file operations the collections screen has always performed on apply. Ids of mods installed during this session are added to the caller's set so both the registry-blind UI and a restart banner stay honest until the next launch. </summary>
public static class ModCollectionInstaller{/// <summary> Applies the plan; returns true when every download succeeded and the enable list was written. Never throws for download failures — they are logged and reported through the return value. </summary>
public static async Task<bool>ApplyAsync(ModCollectionPlan plan,ISet<string>sessionInstalled){if(plan==null||plan.IsEmpty||plan.Missing.Count>0)return false;foreach(var item in plan.Install){bool success;try{success=await ModDownloader.InstallAsync(item.ModId,item.Version,item.SourceUrl);}catch(Exception ex){GameLogger.Log("MOD_COLLECTIONS",$"Install '{item.ModId}' failed: {ex.Message}",LogLevel.Error);success=false;}if(!success)return false;sessionInstalled?.Add(item.ModId);}var disabled=DisabledModsStore.Load();var changed=false;foreach(var id in plan.Enable)changed|=disabled.Remove(id);if(changed)DisabledModsStore.Save(disabled);return true;}}}

===== FILE Core/Mods/Collections/ModCollectionKind.cs =====
namespace Cthangover.Core.Mods{/// <summary> Role a <see cref="ModCollection"/> plays in the catalog and in the scheme editor. The kind is metadata only — resolution does not depend on it — but it drives the game picker: only <see cref="Game"/> collections are offered as playable assemblies, while <see cref="Component"/> and <see cref="Toolset"/> blocks are building material for the scheme. Missing values deserialize to <see cref="Game"/> so collections written before kinds existed keep working as standalone games. </summary>
public enum ModCollectionKind{/// <summary>Finished composition the player can assemble and play.</summary>
Game,/// <summary>Reusable block: a mechanic, a system layer or content group.</summary>
Component,/// <summary>Developer toolset (editors, validators, the mod manager).</summary>
Toolset,/// <summary> Front-end loaded with every game and on its own when no game is selected: the main menu, the mod manager and what they need. </summary>
Frontend,}}

===== FILE Core/Mods/Collections/ModCollectionLayout.cs =====
using System.Collections.Generic;using System.Text.Json.Serialization;namespace Cthangover.Core.Mods{/// <summary> Free-form arrangement of one scheme: where each group sits and how it is sized, plus where inside it every mod chip was placed. Purely a UI concern — the collection documents never store it — so it lives in <c>user://collection_layout.json</c> through <see cref="CollectionLayoutStore"/> and is keyed by game id. <para/> Group keys match <see cref="ModCollectionGroup.Key"/>; mod keys are <c>"&lt;groupKey&gt;|&lt;modId&gt;"</c>. Entries for groups or mods that no longer exist are harmless and simply ignored, which keeps re-adding a removed mechanic to its old spot. </summary>
public class ModCollectionLayout{/// <summary>Group key -> frame rectangle.</summary>
[JsonPropertyName("groups")]public Dictionary<string,ModCollectionLayoutRect>Groups{get;set;}=new();/// <summary>"groupKey|modId" -> chip position inside the group.</summary>
[JsonPropertyName("mods")]public Dictionary<string,ModCollectionLayoutPoint>Mods{get;set;}=new();}}

===== FILE Core/Mods/Collections/ModCollectionLayoutPoint.cs =====
namespace Cthangover.Core.Mods{/// <summary> Position of one mod chip inside its group, relative to the group's mods layer. Persisted by <see cref="CollectionLayoutStore"/> next to <see cref="ModCollectionLayoutRect"/> so the player's manual arrangement of chips is restored together with the group frames. </summary>
public class ModCollectionLayoutPoint{/// <summary>Horizontal offset inside the group, in canvas pixels.</summary>
public float X{get;set;}/// <summary>Vertical offset inside the group, in canvas pixels.</summary>
public float Y{get;set;}}}

===== FILE Core/Mods/Collections/ModCollectionLayoutRect.cs =====
namespace Cthangover.Core.Mods{/// <summary> Position and size of one scheme group in canvas coordinates. Persisted by <see cref="CollectionLayoutStore"/> so the arrangement the player built survives restarts; canvas coordinates are independent of the viewport, so the same layout stays valid on any window size. <para/> <see cref="X"/> and <see cref="Y"/> stay <c>null</c> until the group is placed: the store serializes them away, so a layout saved by an older session (or a group added later) gets a fresh packed position instead of stacking at the origin. </summary>
public class ModCollectionLayoutRect{/// <summary>Left edge; null until the player first places the group.</summary>
public float?X{get;set;}/// <summary>Top edge; null until the player first places the group.</summary>
public float?Y{get;set;}/// <summary>Frame width in canvas pixels.</summary>
public float W{get;set;}/// <summary>Frame height in canvas pixels.</summary>
public float H{get;set;}}}

===== FILE Core/Mods/Collections/ModCollectionMechanic.cs =====
using System.Collections.Generic;using System.Linq;using System.Text.Json.Serialization;namespace Cthangover.Core.Mods{/// <summary> One mechanic inside a <see cref="ModCollection"/> — a named role that a group of mods plays in the collection ("card battle", "novel interface", "developer tools"). Mechanics are declared by the collection, not by the mods: the same mod may be the backbone of one collection and an optional layer of another, so the role cannot live in the mod manifest. <para/> <see cref="Mods"/> entries use the same versioned string format as <c>manifest.json</c> dependencies (<c>"turn_battle_base&gt;=1"</c>). The optional suffix <c>"?"</c> is accepted but ignored — optionality of a whole mechanic is expressed by <see cref="Optional"/>. </summary>
public class ModCollectionMechanic{/// <summary>Stable id unique within the collection; used by the graph view to group nodes.</summary>
[JsonPropertyName("id")]public string Id{get;set;}/// <summary>Human-readable mechanic name shown in the graph frame header.</summary>
[JsonPropertyName("name")]public string Name{get;set;}/// <summary>Short explanation of what the mechanic adds to the game.</summary>
[JsonPropertyName("description")]public string Description{get;set;}/// <summary> Slot role of the mechanic ("characters", "story", "battle", ...). The scheme editor groups the mechanic's mods under this label and the resolver uses it to detect two required blocks claiming one slot together with <see cref="ModCollectionInclude.Role"/>. </summary>
[JsonPropertyName("role")]public string Role{get;set;}/// <summary> Versioned mod references of this mechanic, same syntax as <c>manifest.json</c> <c>depends</c> (e.g. <c>"interface&gt;=1"</c>). </summary>
[JsonPropertyName("mods")]public List<string>Mods{get;set;}/// <summary> True when the mechanic can be skipped without breaking the rest of the collection — the seed of the future game constructor. The resolver still includes optional mechanics in the graph and the install plan. </summary>
[JsonPropertyName("optional")]public bool Optional{get;set;}/// <summary> Player's choice for an optional mechanic. <c>null</c> (omitted) means the default: the mechanic is on until the player turns it off. </summary>
[JsonPropertyName("selected")]public bool?Selected{get;set;}/// <summary>True when the mechanic participates in resolution (not an off optional).</summary>
[JsonIgnore]public bool IsSelected=> !(Optional&&Selected==false);/// <summary> Localized name/description entries, same shape as catalog mod entries; the scheme editor shows the match for the player's language and falls back to <see cref="Name"/> and <see cref="Description"/>. </summary>
[JsonPropertyName("locale")]public List<CatalogLocaleEntry>Locale{get;set;}/// <summary> Name for the given language: requested locale entry -> flat name -> English locale entry -> first entry -> id. A flat '@key' value is returned verbatim; the caller (UI or <see cref="ModCollectionResolver"/>) resolves it through <see cref="CollectionText.Resolve"/>, keeping this method free of Godot calls. </summary>
public string GetLocalizedName(string language){var localized=Match(language,true,englishFallback:false);if(localized!=null)return localized;if(!string.IsNullOrEmpty(Name))return Name;localized=Match(language,true,englishFallback:true);if(localized!=null)return localized;var first=Locale?.FirstOrDefault(l=> !string.IsNullOrEmpty(l.Name));return first?.Name??Id;}/// <summary> Description for the given language: requested locale entry -> flat description -> English locale entry -> first entry. A flat '@key' value is returned verbatim for the caller's translator to resolve. </summary>
public string GetLocalizedDescription(string language){var localized=Match(language,false,englishFallback:false);if(localized!=null)return localized;if(!string.IsNullOrEmpty(Description))return Description;localized=Match(language,false,englishFallback:true);if(localized!=null)return localized;var first=Locale?.FirstOrDefault(l=> !string.IsNullOrEmpty(l.Description));return first?.Description??string.Empty;}/// <summary> Scans <see cref="Locale"/> for the requested language; with <paramref name="englishFallback"/> the 'en' entry is consulted as a second pass, so callers can slot the flat fields between the two. </summary>
private string Match(string language,bool wantName,bool englishFallback){if(Locale==null||Locale.Count==0||string.IsNullOrEmpty(language))return null;foreach(var entry in Locale){if(string.Equals(entry.Lang,language,System.StringComparison.OrdinalIgnoreCase)&&HasText(entry,wantName))return wantName?entry.Name:entry.Description;}if(!englishFallback)return null;foreach(var entry in Locale){if(string.Equals(entry.Lang,"en",System.StringComparison.OrdinalIgnoreCase)&&HasText(entry,wantName))return wantName?entry.Name:entry.Description;}return null;}private static bool HasText(CatalogLocaleEntry entry,bool wantName)=>wantName? !string.IsNullOrEmpty(entry.Name):!string.IsNullOrEmpty(entry.Description);}}

===== FILE Core/Mods/Collections/ModCollectionModRef.cs =====
namespace Cthangover.Core.Mods{/// <summary> One mod entry inside a resolved <see cref="ModCollectionGroup"/>. The group owns the presentation (frame, name, description) while this points at the actual mod and the leaf mechanic that declared it, so the scheme editor can show the mechanic role on the chip and remove the mod from the right <c>mechanics[].mods</c> array without walking the tree again. </summary>
public class ModCollectionModRef{/// <summary>Canonical mod id (matches <see cref="ModInfo.Id"/>).</summary>
public string ModId{get;set;}/// <summary>Leaf mechanic role the mod was declared with ("characters", "story", ...).</summary>
public string Role{get;set;}/// <summary>Collection that owns the mechanic (may be nested inside the group's collection).</summary>
public string CollectionId{get;set;}/// <summary>Owning mechanic id inside <see cref="CollectionId"/>.</summary>
public string MechanicId{get;set;}}}

===== FILE Core/Mods/Collections/ModCollectionNode.cs =====
namespace Cthangover.Core.Mods{/// <summary> One mod vertex of a <see cref="ModCollectionGraph"/>. Nodes exist both for the mods explicitly listed by the collection's mechanics and for their transitive dependencies pulled in by <see cref="ModCollectionResolver"/>; the latter carry <see cref="IsExternal"/> so the graph view can draw them in a separate "dependencies" cluster instead of mixing them into a mechanic. </summary>
public class ModCollectionNode{/// <summary>Canonical mod id (matches <see cref="ModInfo.Id"/> and catalog ids).</summary>
public string ModId{get;set;}/// <summary>Best available display name (local manifest or catalog), falling back to the id.</summary>
public string Title{get;set;}/// <summary>Owning mechanic id, or null for external dependencies.</summary>
public string MechanicId{get;set;}/// <summary>True when the node was pulled in as a dependency, not declared by a mechanic.</summary>
public bool IsExternal{get;set;}/// <summary> Version constraint the collection (or a dependent mod) puts on this mod, parsed with <see cref="ModDependency.Parse"/>. An invalid default value means "any version". </summary>
public ModDependency Dependency{get;set;}/// <summary>Installed version, or null when the mod is not present locally.</summary>
public string InstalledVersion{get;set;}/// <summary>Derived availability state; see <see cref="ModCollectionNodeState"/>.</summary>
public ModCollectionNodeState State{get;set;}/// <summary>Human-readable version constraint (operator + version), or null when unconstrained.</summary>
public string RequiredVersion=>Dependency.IsValid&& !string.IsNullOrEmpty(Dependency.VersionRaw)?Dependency.Operator+Dependency.VersionRaw:null;}}

===== FILE Core/Mods/Collections/ModCollectionNodeState.cs =====
namespace Cthangover.Core.Mods{/// <summary> Availability of one mod in a <see cref="ModCollectionGraph"/> node, computed by <see cref="ModCollectionResolver"/> from the local registry, the disabled store and the merged online catalog. Drives node colors in the graph view and the install/enable decisions of <see cref="ModCollectionPlan"/>. </summary>
public enum ModCollectionNodeState{/// <summary>Installed locally, enabled, and (when the collection pins a version) satisfies the pin.</summary>
Installed,/// <summary>Installed locally but listed in <c>disabled_mods.json</c>.</summary>
Disabled,/// <summary>Not installed, but available for download in a catalog.</summary>
InCatalog,/// <summary>Not installed and unknown to every catalog — the plan cannot fix it.</summary>
Missing,/// <summary>Installed, but its version does not satisfy the version pinned by the collection.</summary>
VersionMismatch,}}

===== FILE Core/Mods/Collections/ModCollectionOrigin.cs =====
namespace Cthangover.Core.Mods{/// <summary> Where a <see cref="ModCollection"/> was loaded from. Determines merge precedence in <see cref="ModCollectionStore.LoadAll"/>: player-created collections override built-in ones, built-in ones override catalog ones, so a shipping mod can always fix or extend an online collection. </summary>
public enum ModCollectionOrigin{/// <summary>Shipped with the game as a package in <c>collections/</c>.</summary>
BuiltIn,/// <summary>Created, edited or downloaded by the player: a package in <c>user://collections/</c>.</summary>
User,/// <summary>Fetched from an online catalog.</summary>
Catalog,}}

===== FILE Core/Mods/Collections/ModCollectionPlan.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Mods{/// <summary> Action plan that turns a resolved <see cref="ModCollectionGraph"/> into concrete steps for the collections screen: which mods to download, which disabled mods to re-enable, and what blocks a full apply. The split keeps the decision logic testable — actual file writes and downloads stay in <c>ModDownloader</c> / <c>DisabledModsStore</c> / the UI. <para/> Enable list contains mods that are already on disk but disabled; mods in <see cref="Installed"/> state are left untouched. <see cref="Missing"/> and <see cref="Mismatches"/> are informational: the UI shows them as warnings and blocks applying while something is missing, because enabling a mod whose dependency cannot be loaded would only make the registry drop it at next boot. </summary>
public class ModCollectionPlan{/// <summary>Mods to download and install, in graph order (foundations first).</summary>
public List<ModCollectionInstallItem>Install{get;}=new();/// <summary>Ids of installed but disabled mods to remove from <c>disabled_mods.json</c>.</summary>
public List<string>Enable{get;}=new();/// <summary>Ids that are neither installed nor available in any catalog.</summary>
public List<string>Missing{get;}=new();/// <summary>Ids whose installed version does not satisfy the collection's pin, or that have no satisfying download.</summary>
public List<string>Mismatches{get;}=new();/// <summary>True when applying the collection would not change anything.</summary>
public bool IsEmpty=>Install.Count==0&&Enable.Count==0;/// <summary>True when the collection cannot be assembled completely.</summary>
public bool HasProblems=>Missing.Count>0||Mismatches.Count>0;/// <summary> Builds the plan for a resolved graph using the same merged catalog the downloader will read. Pass the catalog list the graph was resolved with so node states and chosen downloads stay consistent. </summary>
public static ModCollectionPlan Build(ModCollectionGraph graph,IReadOnlyList<CatalogExportEntry>catalogEntries){var plan=new ModCollectionPlan();if(graph==null)return plan;var catalogById=IndexCatalog(catalogEntries);foreach(var node in graph.Nodes){switch(node.State){case ModCollectionNodeState.InCatalog:AddInstall(plan,node,catalogById);break;case ModCollectionNodeState.Disabled:plan.Enable.Add(node.ModId);break;case ModCollectionNodeState.Missing:plan.Missing.Add(node.ModId);break;case ModCollectionNodeState.VersionMismatch:plan.Mismatches.Add(node.ModId);break;}}return plan;}private static void AddInstall(ModCollectionPlan plan,ModCollectionNode node,Dictionary<string,CatalogExportEntry>catalogById){if(!catalogById.TryGetValue(node.ModId,out var entry)){plan.Mismatches.Add(node.ModId);return;}var download=SelectDownload(entry,node.Dependency);if(download==null){plan.Mismatches.Add(node.ModId);return;}plan.Install.Add(new ModCollectionInstallItem{ModId=node.ModId,Version=download.Version,SourceUrl=FirstSource(entry),});}/// <summary> Picks the newest download that satisfies the collection's version pin. Downloads are ordered newest-first by <see cref="CatalogMerger"/>, so the first satisfying one is the best choice; without a pin the newest download is used as-is. </summary>
private static CatalogEntryDownload SelectDownload(CatalogExportEntry entry,ModDependency dependency){if(entry?.Downloads==null||entry.Downloads.Count==0)return null;if(!dependency.IsValid||string.IsNullOrEmpty(dependency.VersionRaw))return entry.Downloads[0];foreach(var download in entry.Downloads){if(!string.IsNullOrEmpty(download.Version)&&dependency.IsSatisfiedBy(download.Version)){return download;}}return null;}private static string FirstSource(CatalogExportEntry entry){if(entry.SourceUrls==null)return null;foreach(var url in entry.SourceUrls){if(!string.IsNullOrWhiteSpace(url))return url;}return null;}private static Dictionary<string,CatalogExportEntry>IndexCatalog(IReadOnlyList<CatalogExportEntry>catalogEntries){var index=new Dictionary<string,CatalogExportEntry>(System.StringComparer.OrdinalIgnoreCase);if(catalogEntries==null)return index;foreach(var entry in catalogEntries){if(!string.IsNullOrEmpty(entry?.Id)&& !index.ContainsKey(entry.Id))index[entry.Id]=entry;}return index;}}}

===== FILE Core/Mods/Collections/ModCollectionProblem.cs =====
namespace Cthangover.Core.Mods{/// <summary> One inconsistency found by <see cref="ModCollectionResolver"/> while expanding a collection pyramid. Problems are runtime-only: they are recomputed on every resolve from the live registry and the collection store, and the scheme editor renders them on the "Проверка" tab. Fields carry enough context to jump to the offending element: (<see cref="ParentCollectionId"/>, <see cref="Ref"/>) for includes, <see cref="CollectionId"/> for requirements and <see cref="ModId"/> for mod-level issues. <see cref="GroupKey"/> matches <see cref="ModCollectionGroup.Key"/> when the problem belongs to a rendered group. </summary>
public class ModCollectionProblem{/// <summary>Problem category; drives severity and the icon in the UI.</summary>
public ModCollectionProblemKind Kind{get;set;}/// <summary>Short localized-ready title, e.g. "Блок «rpg_2d» не найден".</summary>
public string Title{get;set;}/// <summary>Longer explanation with the fix hint.</summary>
public string Detail{get;set;}/// <summary>Collection the problem belongs to (requirement owner, cycle node, role host).</summary>
public string CollectionId{get;set;}/// <summary>Parent collection of a broken include; null for other kinds.</summary>
public string ParentCollectionId{get;set;}/// <summary>Raw include reference of a broken include; null for other kinds.</summary>
public string Ref{get;set;}/// <summary>Rendered group key when the problem can be highlighted on the canvas.</summary>
public string GroupKey{get;set;}/// <summary>Mod id for mod-level problems; null otherwise.</summary>
public string ModId{get;set;}}}

===== FILE Core/Mods/Collections/ModCollectionProblemKind.cs =====
namespace Cthangover.Core.Mods{/// <summary> Kinds of inconsistency <see cref="ModCollectionResolver"/> can find while expanding a collection pyramid. The scheme editor groups them by severity (errors block applying, warnings do not) and offers "show" navigation to the offending group or mod. </summary>
public enum ModCollectionProblemKind{/// <summary>An <c>includes[]</c> reference points at a collection nobody provides.</summary>
MissingCollection,/// <summary>Include chain references a collection already on the active stack.</summary>
Cycle,/// <summary>Two required includes claim the same role slot.</summary>
RoleConflict,/// <summary>A resolved collection requires a capability no block provides.</summary>
RequirementMissing,}}

===== FILE Core/Mods/Collections/ModCollectionResolver.cs =====
using System;using System.Collections.Generic;using System.Linq;namespace Cthangover.Core.Mods{/// <summary> Builds a <see cref="ModCollectionGraph"/> for a collection by combining four live sources: the installed-mod registry, the disabled-mods store, the merged online catalog cache and — for collections composed of other collections — the merged collection registry. The collection file itself only declares includes and mechanics; the dependency arrows come from the mods' own <c>depends</c>, so the graph always reflects reality even when a mod author changes dependencies without touching the collection. <para/> The resolver is pure and takes its sources as arguments, which keeps the graph testable without Godot. Includes are expanded recursively into <see cref="ModCollectionGraph.Groups"/>: each included block becomes one contour with its mods flattened inside (nested blocks collapse into the nearest ancestor so the editor always shows a two-level pyramid), while the root's own mechanics become leaf groups. Cycles, missing blocks, role conflicts and unmet capability requirements are reported as <see cref="ModCollectionProblem"/> instead of throwing, because they are exactly what the player has to fix on the scheme. </summary>
public static class ModCollectionResolver{/// <summary> Resolves a standalone collection without a collection registry. Include references are reported as missing blocks; this overload keeps existing callers and tests working unchanged. </summary>
public static ModCollectionGraph Resolve(ModCollection collection,IReadOnlyDictionary<string,IModInfo>localMods,IReadOnlyCollection<string>disabledIds,IReadOnlyList<CatalogExportEntry>catalogEntries,IReadOnlyCollection<string>sessionInstalled=null,Func<string,string>translator=null)=>Resolve(collection,null,localMods,disabledIds,catalogEntries,sessionInstalled,null,translator);/// <summary> Pure resolution entry point. <paramref name="collections"/> is the merged collection store keyed by id (user over built-in over catalog, as produced by <see cref="ModCollectionStore.LoadAll"/>); <paramref name="sessionInstalled"/> lets the UI treat mods installed during the current session as present even though the registry only learns about them after a restart. <paramref name="translator"/> is applied to group display names and descriptions after the usual locale/flat-field choice, letting the UI resolve <c>'@'</c>-prefixed translation keys (pass <see cref="CollectionText.Resolve"/> in Godot context); null keeps raw values, which keeps headless tests unchanged. </summary>
public static ModCollectionGraph Resolve(ModCollection collection,IReadOnlyDictionary<string,ModCollection>collections,IReadOnlyDictionary<string,IModInfo>localMods,IReadOnlyCollection<string>disabledIds,IReadOnlyList<CatalogExportEntry>catalogEntries,IReadOnlyCollection<string>sessionInstalled=null,string language=null,Func<string,string>translator=null){var graph=new ModCollectionGraph();if(collection==null)return graph;var catalogById=IndexCatalog(catalogEntries);var nodes=new Dictionary<string,ModCollectionNode>(StringComparer.OrdinalIgnoreCase);var edges=new HashSet<(string From,string To)>();var provides=new HashSet<string>(StringComparer.OrdinalIgnoreCase);var resolved=new List<ModCollection>();var mechanics=collection.Mechanics??new List<ModCollectionMechanic>();for(var i=0;i<mechanics.Count;i++){var mechanic=mechanics[i];if(mechanic==null)continue;if(string.IsNullOrEmpty(mechanic.Id))mechanic.Id=$"mechanic_{i}";}graph.Mechanics.AddRange(mechanics.Where(m=>m!=null));var context=new ResolutionContext{Graph=graph,Nodes=nodes,Edges=edges,Provides=provides,Resolved=resolved,Collections=collections,LocalMods=localMods,DisabledIds=disabledIds,SessionInstalled=sessionInstalled,CatalogById=catalogById,Language=language,Translator=translator,};AddProvides(collection,provides);resolved.Add(collection);ResolveIncludes(collection,context,new List<string>{collection.Id});foreach(var mechanic in graph.Mechanics){var selected=mechanic.IsSelected;var group=new ModCollectionGroup{Key=$"m:{collection.Id}:{mechanic.Id}",IsInclude=false,CollectionId=collection.Id,ParentCollectionId=collection.Id,MechanicId=mechanic.Id,Name=Translate(mechanic.GetLocalizedName(language),context.Translator),Description=Translate(mechanic.GetLocalizedDescription(language),context.Translator),Role=mechanic.Role,Optional=mechanic.Optional,Selected=selected,};graph.Groups.Add(group);// A turned-off contour stays on the canvas dimmed (no chips) so the player can always re-enable it; its mods must not leak into the node list or the install plan while it is off.
if(!selected)continue;foreach(var raw in mechanic.Mods??new List<string>())AddDeclaredMod(raw,collection.Id,mechanic,group,context);}AddDependencyClosure(graph,nodes,edges,localMods,disabledIds,sessionInstalled,catalogById);foreach(var(from,to)in edges)graph.Edges.Add(new ModCollectionEdge{FromModId=from,ToModId=to});CheckRequirements(graph,resolved,provides);return graph;}/// <summary> Mutable state shared by the recursive walk. A private container keeps the public resolver signature small while still passing a single argument through the recursion. </summary>
private sealed class ResolutionContext{public ModCollectionGraph Graph;public Dictionary<string,ModCollectionNode>Nodes;public HashSet<(string From,string To)>Edges;public HashSet<string>Provides;public List<ModCollection>Resolved;public IReadOnlyDictionary<string,ModCollection>Collections;public IReadOnlyDictionary<string,IModInfo>LocalMods;public IReadOnlyCollection<string>DisabledIds;public IReadOnlyCollection<string>SessionInstalled;public IReadOnlyDictionary<string,CatalogExportEntry>CatalogById;/// <summary>Locale used for localized group names/descriptions; null keeps flat fields.</summary>
public string Language;/// <summary> Optional display-text translator (resolves '@key' references); null keeps raw values so headless tests stay deterministic. </summary>
public Func<string,string>Translator;}/// <summary> Expands <see cref="ModCollection.Includes"/> of a parent into groups and flattens each child's subtree into its group. Role conflicts are checked per parent; cycles are detected through <paramref name="stack"/> so a malformed pair of documents degrades into a problem instead of a stack overflow. </summary>
private static void ResolveIncludes(ModCollection parent,ResolutionContext context,List<string>stack){var includes=parent.Includes??new List<ModCollectionInclude>();var roleOwners=new Dictionary<string,ModCollection>(StringComparer.OrdinalIgnoreCase);foreach(var include in includes){if(include==null||string.IsNullOrWhiteSpace(include.Ref))continue;var dependency=ModDependency.Parse(include.Ref);var childId=dependency.IsValid?dependency.ModId:include.Ref.Trim();var groupKey=$"i:{parent.Id}:{include.Ref}";if(childId==parent.Id||stack.Contains(childId,StringComparer.OrdinalIgnoreCase)){context.Graph.Problems.Add(new ModCollectionProblem{Kind=ModCollectionProblemKind.Cycle,Title=$"Cycle: '{parent.Name ?? parent.Id}' -> '{include.Ref}'",Detail="The include chain references a collection that is already being expanded.",CollectionId=parent.Id,ParentCollectionId=parent.Id,Ref=include.Ref,GroupKey=groupKey,});continue;}if(!TryGetCollection(context.Collections,childId,out var child)){context.Graph.Problems.Add(new ModCollectionProblem{Kind=ModCollectionProblemKind.MissingCollection,Title=$"Block '{childId}' is not available",Detail=$"Required by slot '{include.Role}' of '{parent.Name ?? parent.Id}'. No mod or catalog provides it.",CollectionId=parent.Id,ParentCollectionId=parent.Id,Ref=include.Ref,GroupKey=groupKey,});continue;}RegisterRole(parent,include,child,roleOwners,groupKey,context);var group=new ModCollectionGroup{Key=groupKey,IsInclude=true,CollectionId=child.Id,ParentCollectionId=parent.Id,Name=Translate(string.IsNullOrEmpty(include.Label)?child.GetLocalizedName(context.Language):include.Label,context.Translator),Description=Translate(string.IsNullOrEmpty(include.Note)?child.GetLocalizedDescription(context.Language):include.Note,context.Translator),Role=include.Role,Optional=include.Optional,Selected=include.IsSelected,};context.Graph.Groups.Add(group);if(!group.Selected)continue;FlattenCollection(child,group,context,stack);}}/// <summary> Adds one included collection's mods and nested includes to the given group, deduplicating by mod id and registering provides for the requirement check. </summary>
private static void FlattenCollection(ModCollection collection,ModCollectionGroup group,ResolutionContext context,List<string>stack){AddProvides(collection,context.Provides);if(!context.Resolved.Any(c=>string.Equals(c.Id,collection.Id,StringComparison.OrdinalIgnoreCase)))context.Resolved.Add(collection);var nextStack=new List<string>(stack){collection.Id};foreach(var mechanic in collection.Mechanics??new List<ModCollectionMechanic>()){if(mechanic==null|| !mechanic.IsSelected)continue;if(string.IsNullOrEmpty(mechanic.Id))mechanic.Id=$"mechanic_{mechanic.Mods?.Count ?? 0}";foreach(var raw in mechanic.Mods??new List<string>())AddDeclaredMod(raw,collection.Id,mechanic,group,context);}foreach(var include in collection.Includes??new List<ModCollectionInclude>()){if(include==null||string.IsNullOrWhiteSpace(include.Ref)|| !include.IsSelected)continue;var dependency=ModDependency.Parse(include.Ref);var childId=dependency.IsValid?dependency.ModId:include.Ref.Trim();if(childId==collection.Id||nextStack.Contains(childId,StringComparer.OrdinalIgnoreCase)){context.Graph.Problems.Add(new ModCollectionProblem{Kind=ModCollectionProblemKind.Cycle,Title=$"Cycle: '{collection.Name ?? collection.Id}' -> '{include.Ref}'",Detail="Nested include references a collection that is already being expanded.",CollectionId=collection.Id,ParentCollectionId=collection.Id,Ref=include.Ref,});continue;}if(TryGetCollection(context.Collections,childId,out var child))FlattenCollection(child,group,context,nextStack);}}/// <summary> Applies the optional translator to a display string. Without a translator (tests, headless tools) raw '@key' values stay verbatim; a translator returning null (missing translation) also keeps the raw value so group text never disappears. </summary>
private static string Translate(string raw,Func<string,string>translator){if(raw==null||translator==null)return raw;return translator(raw)??raw;}/// <summary> Records the role owner of a required include and reports a conflict when two required blocks claim the same slot. Optional blocks never conflict: the player may keep alternatives side by side until one is switched on. </summary>
private static void RegisterRole(ModCollection parent,ModCollectionInclude include,ModCollection child,Dictionary<string,ModCollection>roleOwners,string groupKey,ResolutionContext context){if(include.Optional||string.IsNullOrEmpty(include.Role))return;if(roleOwners.TryGetValue(include.Role,out var owner)){context.Graph.Problems.Add(new ModCollectionProblem{Kind=ModCollectionProblemKind.RoleConflict,Title=$"Slot '{include.Role}' is claimed twice",Detail=$"'{owner.Name ?? owner.Id}' and '{child.Name ?? child.Id}' are both required in '{parent.Name ?? parent.Id}'.",CollectionId=parent.Id,ParentCollectionId=parent.Id,Ref=include.Ref,GroupKey=groupKey,});return;}roleOwners[include.Role]=child;}/// <summary> Creates the node for a mod declared by a mechanic and appends it to the owning group. The first declaration owns the node (and its mechanic id) while later groups still list the mod, so removing it from the right mechanic stays possible from the scheme editor. </summary>
private static void AddDeclaredMod(string raw,string collectionId,ModCollectionMechanic mechanic,ModCollectionGroup group,ResolutionContext context){var dependency=ModDependency.Parse(raw);var modId=dependency.IsValid?dependency.ModId:raw?.Trim();if(string.IsNullOrEmpty(modId))return;if(group.Mods.Exists(r=>string.Equals(r.ModId,modId,StringComparison.OrdinalIgnoreCase)))return;group.Mods.Add(new ModCollectionModRef{ModId=modId,Role=mechanic.Role,CollectionId=collectionId,MechanicId=mechanic.Id,});if(context.Nodes.TryGetValue(modId,out var existing)){if(existing.IsExternal){existing.MechanicId=mechanic.Id;existing.IsExternal=false;}return;}var node=CreateNode(modId,mechanic.Id,dependency,false,context.LocalMods,context.DisabledIds,context.SessionInstalled,context.CatalogById);context.Nodes[modId]=node;context.Graph.Nodes.Add(node);}private static void AddProvides(ModCollection collection,HashSet<string>provides){foreach(var capability in collection.Provides??new List<string>()){if(!string.IsNullOrWhiteSpace(capability))provides.Add(capability.Trim());}}/// <summary> Reports collections whose <c>requires</c> are not satisfied by the union of <c>provides</c> of every resolved block. </summary>
private static void CheckRequirements(ModCollectionGraph graph,List<ModCollection>resolved,HashSet<string>provides){foreach(var collection in resolved){foreach(var requirement in collection.Requires??new List<string>()){var capability=ParseCapability(requirement);if(string.IsNullOrEmpty(capability)||provides.Contains(capability))continue;graph.Problems.Add(new ModCollectionProblem{Kind=ModCollectionProblemKind.RequirementMissing,Title=$"'{collection.Name ?? collection.Id}' requires '{capability}'",Detail="No resolved block provides this capability. Add a compatible block to the scheme.",CollectionId=collection.Id,});}}}private static string ParseCapability(string requirement){if(string.IsNullOrWhiteSpace(requirement))return null;var dependency=ModDependency.Parse(requirement);return dependency.IsValid?dependency.ModId:requirement.Trim();}private static bool TryGetCollection(IReadOnlyDictionary<string,ModCollection>collections,string id,out ModCollection collection){collection=null;if(collections==null||string.IsNullOrEmpty(id))return false;if(collections.TryGetValue(id,out collection)&&collection!=null)return true;foreach(var pair in collections){if(pair.Value!=null&&string.Equals(pair.Key,id,StringComparison.OrdinalIgnoreCase)){collection=pair.Value;return true;}}return false;}/// <summary> Walks the dependencies of every known node, adding edges and pulling unknown dependencies into the graph. The node list grows while it is being indexed, which naturally produces the transitive closure. </summary>
private static void AddDependencyClosure(ModCollectionGraph graph,Dictionary<string,ModCollectionNode>nodes,HashSet<(string From,string To)>edges,IReadOnlyDictionary<string,IModInfo>localMods,IReadOnlyCollection<string>disabledIds,IReadOnlyCollection<string>sessionInstalled,IReadOnlyDictionary<string,CatalogExportEntry>catalogById){for(var i=0;i<graph.Nodes.Count;i++){var node=graph.Nodes[i];if(!localMods.TryGetValue(node.ModId,out var info)||info is not ModInfo modInfo)continue;var dependencies=modInfo.Manifest?.DependencyIds;if(dependencies==null)continue;foreach(var dependencyId in dependencies){if(string.IsNullOrEmpty(dependencyId))continue;edges.Add((dependencyId,node.ModId));if(nodes.ContainsKey(dependencyId))continue;var external=CreateNode(dependencyId,null,default,true,localMods,disabledIds,sessionInstalled,catalogById);nodes[dependencyId]=external;graph.Nodes.Add(external);}}}private static ModCollectionNode CreateNode(string modId,string mechanicId,ModDependency dependency,bool external,IReadOnlyDictionary<string,IModInfo>localMods,IReadOnlyCollection<string>disabledIds,IReadOnlyCollection<string>sessionInstalled,IReadOnlyDictionary<string,CatalogExportEntry>catalogById){var node=new ModCollectionNode{ModId=modId,Title=modId,MechanicId=mechanicId,IsExternal=external,Dependency=dependency,};if(localMods.TryGetValue(modId,out var info)){if(!string.IsNullOrEmpty(info.Name))node.Title=info.Name;node.InstalledVersion=info.Version;if(disabledIds.Contains(modId)){node.State=ModCollectionNodeState.Disabled;}else if(dependency.IsValid&& !string.IsNullOrEmpty(dependency.VersionRaw)&& !dependency.IsSatisfiedBy(info.Version??"0.0.0.0")){node.State=ModCollectionNodeState.VersionMismatch;}else{node.State=ModCollectionNodeState.Installed;}return node;}if(sessionInstalled!=null&&sessionInstalled.Contains(modId)){node.State=ModCollectionNodeState.Installed;return node;}if(catalogById.TryGetValue(modId,out var entry)&&entry.Downloads!=null&&entry.Downloads.Count>0){node.State=ModCollectionNodeState.InCatalog;if(!string.IsNullOrEmpty(entry.Name))node.Title=entry.Name;return node;}node.State=ModCollectionNodeState.Missing;return node;}private static Dictionary<string,CatalogExportEntry>IndexCatalog(IReadOnlyList<CatalogExportEntry>catalogEntries){var index=new Dictionary<string,CatalogExportEntry>(StringComparer.OrdinalIgnoreCase);if(catalogEntries==null)return index;foreach(var entry in catalogEntries){if(!string.IsNullOrEmpty(entry?.Id)&& !index.ContainsKey(entry.Id))index[entry.Id]=entry;}return index;}}}

===== FILE Core/Mods/Collections/ModCollectionStore.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text.Json;using Cthangover.Core.Mods.Providers;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods{/// <summary> Loads and persists <see cref="ModCollection"/> definitions. A collection is its own distribution unit — a <b>package</b> next to the mods, not a file inside one: a folder or zip named after the collection id that holds <c>collection.json</c>, the cover image and an optional <c>locale/</c> with the collection's texts. A game is an assembly of mods by many authors, so no mod owns it; anyone can ship a game without touching the mods it uses. <list type="bullet"> <item>built-in packages — <c>collections/</c> of the game directory (<c>res://collections/</c>);</item> <item>the player's packages — <c>user://collections/</c>: edited and duplicated collections, downloaded ones;</item> <item>online catalogs — documents cached by <see cref="CatalogService"/> (no package: no cover or locale files).</item> </list> <see cref="LoadAll"/> merges the channels by id with explicit precedence: user &gt; built-in &gt; catalog, so a shipped game overrides an outdated online one and the player can edit any collection locally. Packages are read through <see cref="IModFileProvider"/>, so folders and zips behave the same. Mods do not ship collections. </summary>
public static class ModCollectionStore{/// <summary>Name of the document inside a package.</summary>
public const string DocumentName="collection.json";private const string BuiltInRoot="res://collections/";private const string UserRoot="user://collections/";private static string _builtInPath;private static string _userPath;/// <summary> Resolves the package roots once on the main thread before the background mod-load phase, mirroring <see cref="DisabledModsStore"/> — <c>ProjectSettings.GlobalizePath</c> is Godot API and is not available from worker threads. </summary>
public static void WarmUp(){_builtInPath=Resolve(BuiltInRoot,"collections");_userPath=Resolve(UserRoot,"user_collections");}private static string Resolve(string godotPath,string fallback)=>GameEnvironment.IsRuntime?ProjectSettings.GlobalizePath(godotPath):fallback;/// <summary>Absolute folder of the built-in packages.</summary>
public static string BuiltInPath=>_builtInPath??Resolve(BuiltInRoot,"collections");/// <summary>Absolute folder of the player's packages.</summary>
public static string UserPath=>_userPath??Resolve(UserRoot,"user_collections");/// <summary>The player's collections (edited, duplicated, downloaded); empty when the folder is absent.</summary>
public static List<ModCollection>LoadUser()=>LoadPackages(UserPath,ModCollectionOrigin.User);/// <summary>Collections shipped with the game.</summary>
public static List<ModCollection>LoadBuiltIn()=>LoadPackages(BuiltInPath,ModCollectionOrigin.BuiltIn);/// <summary> Replaces the player's collections with <paramref name="collections"/>: each becomes the package <c>user://collections/&lt;id&gt;/</c>, and player packages not in the list are removed. A collection that came from another package (a fork of a built-in game) takes that package's cover and <c>locale/</c> files along, so the copy shows the same. </summary>
public static void SaveUser(List<ModCollection>collections){try{Directory.CreateDirectory(UserPath);var keep=new HashSet<string>(StringComparer.OrdinalIgnoreCase);foreach(var collection in collections??new List<ModCollection>()){if(collection==null||string.IsNullOrEmpty(collection.Id))continue;keep.Add(collection.Id);WritePackage(collection);}foreach(var dir in Directory.GetDirectories(UserPath)){if(!keep.Contains(Path.GetFileName(dir)))Directory.Delete(dir,true);}}catch(Exception ex){GameLogger.Log("MOD_COLLECTIONS",$"Failed to save user collections: {ex.Message}",LogLevel.Error);}}/// <summary> Reads every package under <paramref name="root"/>: subfolders and <c>.zip</c> files holding a <see cref="DocumentName"/>. A broken package is logged and skipped so one bad collection cannot hide the others. </summary>
public static List<ModCollection>LoadPackages(string root,ModCollectionOrigin origin){var result=new List<ModCollection>();if(string.IsNullOrEmpty(root)|| !Directory.Exists(root))return result;foreach(var entry in Directory.GetFileSystemEntries(root).OrderBy(e=>e,StringComparer.Ordinal)){var name=Path.GetFileName(entry);IModFileProvider package;if(Directory.Exists(entry))package=new FolderModFileProvider(entry,name);else if(entry.EndsWith(".zip",StringComparison.OrdinalIgnoreCase))package=new ZipModFileProvider(entry,Path.GetFileNameWithoutExtension(name));else continue;var collection=Parse(package.ReadFileText(DocumentName),$"{name}/{DocumentName}");if(collection==null)continue;if(!string.Equals(collection.Id,package.Mod,StringComparison.OrdinalIgnoreCase))GameLogger.Log("MOD_COLLECTIONS",$"Package '{name}' holds collection '{collection.Id}'; name the package after the id",LogLevel.Warning);collection.Origin=origin;collection.Package=package;result.Add(collection);}return result;}/// <summary> Loads and merges collections from every channel, ordered by precedence (catalog, then built-in, then user — each step may replace an entry with the same id). Entries without an id are ignored. Reads no mod, so the boot pipeline can call it before the mod set is decided. </summary>
public static List<ModCollection>LoadAll(){var byId=new Dictionary<string,ModCollection>(StringComparer.OrdinalIgnoreCase);var catalog=CatalogService.CachedCollections;if(catalog!=null){foreach(var collection in catalog)AddIfValid(byId,collection,ModCollectionOrigin.Catalog,overwrite:false);}foreach(var collection in LoadBuiltIn())AddIfValid(byId,collection,ModCollectionOrigin.BuiltIn,overwrite:true);foreach(var collection in LoadUser())AddIfValid(byId,collection,ModCollectionOrigin.User,overwrite:true);return new List<ModCollection>(byId.Values);}/// <summary> True when the game or a catalog ships a collection with this id — the player's version then only overrides it, and "revert" restores the shipped one. </summary>
public static bool HasShippedVersion(string id){if(string.IsNullOrEmpty(id))return false;if(CatalogService.CachedCollections?.Any(c=>string.Equals(c?.Id,id,StringComparison.OrdinalIgnoreCase))==true)return true;return LoadBuiltIn().Any(c=>string.Equals(c.Id,id,StringComparison.OrdinalIgnoreCase));}/// <summary>Drops the player's package of a collection; returns true when one existed.</summary>
public static bool RemoveUser(string id){if(string.IsNullOrEmpty(id))return false;var removed=false;var dir=Path.Combine(UserPath,id);if(Directory.Exists(dir)){Directory.Delete(dir,true);removed=true;}var zip=dir+".zip";if(File.Exists(zip)){File.Delete(zip);removed=true;}return removed;}private static void WritePackage(ModCollection collection){var dir=Path.Combine(UserPath,collection.Id);Directory.CreateDirectory(dir);var source=collection.Package;var ownPackage=source is FolderModFileProvider folder&&string.Equals(Path.GetFullPath(folder.RootPath),Path.GetFullPath(dir),StringComparison.Ordinal);if(source!=null&& !ownPackage)CopyPackageFiles(source,dir,collection.Cover);var json=JsonSerializer.Serialize(collection,JsonOptionsProvider.WriteIndentedIgnoreNullOptions);File.WriteAllText(Path.Combine(dir,DocumentName),json);collection.Package=new FolderModFileProvider(dir,collection.Id);}private static void CopyPackageFiles(IModFileProvider source,string dir,string cover){if(!string.IsNullOrEmpty(cover))CopyFile(source,cover,dir);foreach(var entry in source.ListFiles("locale")){if(!entry.EndsWith("/",StringComparison.Ordinal))CopyFile(source,entry,dir);}}private static void CopyFile(IModFileProvider source,string path,string dir){var bytes=source.ReadFileBinary(path);if(bytes==null)return;var target=Path.Combine(dir,path.Replace('/',Path.DirectorySeparatorChar));Directory.CreateDirectory(Path.GetDirectoryName(target));File.WriteAllBytes(target,bytes);}private static void AddIfValid(Dictionary<string,ModCollection>byId,ModCollection collection,ModCollectionOrigin origin,bool overwrite){if(collection==null||string.IsNullOrEmpty(collection.Id)){GameLogger.Log("MOD_COLLECTIONS","Skipping collection without an id",LogLevel.Warning);return;}collection.Origin=origin;if(overwrite|| !byId.ContainsKey(collection.Id))byId[collection.Id]=collection;}private static ModCollection Parse(string json,string label){if(string.IsNullOrWhiteSpace(json)){GameLogger.Log("MOD_COLLECTIONS",$"Collection file '{label}' is missing or empty",LogLevel.Warning);return null;}try{var collection=JsonSerializer.Deserialize<ModCollection>(json,JsonOptionsProvider.ReadOptions);if(collection==null||string.IsNullOrEmpty(collection.Id)){GameLogger.Log("MOD_COLLECTIONS",$"Collection '{label}' has no id",LogLevel.Warning);return null;}return collection;}catch(JsonException ex){GameLogger.Log("MOD_COLLECTIONS",$"Failed to parse collection '{label}': {ex.Message}",LogLevel.Error);return null;}}}}

===== FILE Core/Mods/Compilation/GodotGeneratorHost.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Reflection;using Cthangover.Core.Utils;using Microsoft.CodeAnalysis;using Microsoft.CodeAnalysis.CSharp;namespace Cthangover.Core.Mods.Compilation{/// <summary> Host for the official Godot source generators inside runtime mod compilations: locates the generator assembly, keeps only its type list cached, and runs a fresh set of generator instances per compilation so every Godot-derived mod class gets its <c>[ScriptPath]</c> (plus the assembly marker) exactly like a normal <c>dotnet build</c>. Without this step engine lifecycle callbacks are never dispatched for mod nodes. </summary>
public static class GodotGeneratorHost{private static readonly object _generatorLock=new object();private static Type[]_godotGeneratorTypes;/// <summary> Locates the Godot source generator assembly shipped with the <c>godot.sourcegenerators</c> NuGet package (or copied next to the game binary). </summary>
private static string ResolveGeneratorDll(){var candidates=new List<string>{Path.Combine(AppContext.BaseDirectory,"Godot.SourceGenerators.dll"),};var packagesRoot=System.Environment.GetEnvironmentVariable("NUGET_PACKAGES");if(string.IsNullOrEmpty(packagesRoot))packagesRoot=Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),".nuget","packages");var sgRoot=Path.Combine(packagesRoot,"godot.sourcegenerators");try{if(Directory.Exists(sgRoot)){foreach(var version in Directory.GetDirectories(sgRoot).OrderByDescending(p=>p,StringComparer.OrdinalIgnoreCase))candidates.Add(Path.Combine(version,"analyzers","dotnet","cs","Godot.SourceGenerators.dll"));}}catch{}return candidates.FirstOrDefault(File.Exists);}/// <summary> Creates FRESH generator instances for one compilation. The Godot generators are single-shot: a reused <c>ISourceGenerator</c> instance silently stops emitting (notably <c>ScriptPathAttributeGenerator</c>) on every driver run after the first one, so only the type list is cached. </summary>
public static Microsoft.CodeAnalysis.ISourceGenerator[]GetGodotGenerators(){lock(_generatorLock){if(_godotGeneratorTypes==null){InstallRoslynAssemblyResolver();string dllPath=null;try{dllPath=ResolveGeneratorDll();if(string.IsNullOrEmpty(dllPath)){GameLogger.Log("MODS_COMPILE","ModCompiler: Godot.SourceGenerators.dll not found, mod scripts will lack lifecycle binding",LogLevel.Warning);_godotGeneratorTypes=Array.Empty<Type>();return Array.Empty<Microsoft.CodeAnalysis.ISourceGenerator>();}var generatorAssembly=Assembly.LoadFrom(dllPath);_godotGeneratorTypes=generatorAssembly.GetTypes().Where(t=> !t.IsAbstract&&typeof(Microsoft.CodeAnalysis.ISourceGenerator).IsAssignableFrom(t)).ToArray();GameLogger.Log("MODS_COMPILE",$"ModCompiler: Godot source generators loaded from '{dllPath}' ({_godotGeneratorTypes.Length} generator(s))");}catch(ReflectionTypeLoadException rtle){var details=rtle.LoaderExceptions!=null&&rtle.LoaderExceptions.Length>0?string.Join("\n  ",rtle.LoaderExceptions.Take(5).Select(le=>le.GetType().FullName+": "+le.Message)):"(no loader exceptions) "+rtle+"\n  at "+rtle.StackTrace;GameLogger.Log("MODS_COMPILE",$"ModCompiler: failed to load Godot source generators (dll '{dllPath}'):\n  {details}",LogLevel.Warning);_godotGeneratorTypes=Array.Empty<Type>();}catch(Exception ex){GameLogger.Log("MODS_COMPILE",$"ModCompiler: failed to load Godot source generators (dll '{dllPath}'): {ex.GetType().FullName}: {ex.Message}\n  at {ex.StackTrace}",LogLevel.Warning);_godotGeneratorTypes=Array.Empty<Type>();}}return _godotGeneratorTypes.Select(t=>(Microsoft.CodeAnalysis.ISourceGenerator)Activator.CreateInstance(t,nonPublic:true)).ToArray();}}/// <summary> The generator assembly is built against an older Microsoft.CodeAnalysis than the one the game ships. When the runtime fails to bind those references by version, redirect them to the already-loaded instances - CLR type identity then unifies and the generators run against the game's Roslyn. </summary>
private static void InstallRoslynAssemblyResolver(){AppDomain.CurrentDomain.AssemblyResolve+=(sender,args)=>{var name=new AssemblyName(args.Name).Name;if(name==null|| !name.StartsWith("Microsoft.CodeAnalysis",StringComparison.Ordinal))return null;var loaded=AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a=> !a.IsDynamic&&a.GetName().Name==name);if(loaded!=null)return loaded;var local=Path.Combine(AppContext.BaseDirectory,name+".dll");return File.Exists(local)?Assembly.LoadFrom(local):null;};}/// <summary> Runs the official Godot source generators over the mod compilation so every Godot-derived class gets <c>[ScriptPath]</c> (plus the assembly marker), exactly like the main project build. Without this, engine lifecycle callbacks are never dispatched for mod nodes. </summary>
public static CSharpCompilation RunGodotGenerators(Microsoft.CodeAnalysis.ISourceGenerator[]generators,CSharpCompilation compilation,string modId){if(generators==null||generators.Length==0)return compilation;try{var driver=CSharpGeneratorDriver.Create(generators,null,null,new ModAnalyzerConfigOptionsProvider(ModReferenceProvider.ProjectRoot));driver.RunGeneratorsAndUpdateCompilation(compilation,out var output,out var diagnostics);foreach(var d in diagnostics.Where(d=>d.Severity==DiagnosticSeverity.Error).Take(10))GameLogger.Log("MODS_COMPILE",$"Godot generator ({modId}): {d.GetMessage()}",LogLevel.Warning);var genResult=(CSharpCompilation)output;var newTrees=genResult.SyntaxTrees.Count()-compilation.SyntaxTrees.Count();GameLogger.Log("MODS_COMPILE",$"Godot generator ({modId}): {newTrees} generated tree(s)",LogLevel.Debug);foreach(var tree in genResult.SyntaxTrees.Skip(compilation.SyntaxTrees.Count()).Take(40))GameLogger.Log("MODS_COMPILE",$"Godot generator ({modId}): + '{tree.FilePath}'");return genResult;}catch(Exception ex){GameLogger.Log("MODS_COMPILE",$"RunGodotGenerators('{modId}') failed: {ex.Message}\n{ex.StackTrace}",LogLevel.Warning);return compilation;}}}}

===== FILE Core/Mods/Compilation/ModAnalyzerConfigOptions.cs =====
using System;using Microsoft.CodeAnalysis.Diagnostics;namespace Cthangover.Core.Mods.Compilation{/// <summary> Supplies <c>build_property.project_dir</c> to the Godot source generator so it computes script paths relative to the project root exactly like the main <c>dotnet build</c> does. </summary>
internal sealed class ModAnalyzerConfigOptions:AnalyzerConfigOptions{private readonly string _projectDir;public ModAnalyzerConfigOptions(string projectDir)=>_projectDir=projectDir??string.Empty;public override bool TryGetValue(string key,out string value){// Case-insensitive on purpose: MSBuild preserves the property name's casing in "build_property.<Name>" keys, and the Godot generators ask for 'GodotProjectDir'/'GodotProjectDirBase64' exactly as the Sdk spells them. Every key below maps to a real Sdk property.
switch(key?.ToLowerInvariant()){case"build_property.project_dir":case"build_property.project_directory":case"build_property.godotprojectdir":value=_projectDir;return true;case"build_property.godotprojectdirbase64":value=Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(_projectDir));return true;case"build_property.isgodottoolsproject":// The Sdk leaves this property empty for the game project itself; it is 'true' only for the editor tool assembly. ScriptPathAttributeGenerator and GodotPluginsInitializer bail out when it is 'true', which would strip [ScriptPathAttribute] from runtime-compiled mods and kill their [Signal]s, so mirror the game project here.
value=string.Empty;return true;case"build_property.godotsourcegenerators":case"build_property.godotdisabledsourcegenerators":// No generators disabled.
value=string.Empty;return true;}value=null;return false;}}}

===== FILE Core/Mods/Compilation/ModAnalyzerConfigOptionsProvider.cs =====
using Microsoft.CodeAnalysis;using Microsoft.CodeAnalysis.Diagnostics;namespace Cthangover.Core.Mods.Compilation{/// <summary> Analyzer config provider for runtime mod compilations: every syntax tree and additional file sees the same global options (the Godot generators only read global entries), so a single shared <see cref="ModAnalyzerConfigOptions"/> instance serves all lookups. </summary>
internal sealed class ModAnalyzerConfigOptionsProvider:AnalyzerConfigOptionsProvider{private readonly ModAnalyzerConfigOptions _options;public ModAnalyzerConfigOptionsProvider(string projectDir)=>_options=new ModAnalyzerConfigOptions(projectDir);public override AnalyzerConfigOptions GlobalOptions=>_options;public override AnalyzerConfigOptions GetOptions(SyntaxTree tree)=>_options;public override AnalyzerConfigOptions GetOptions(AdditionalText textFile)=>_options;}}

===== FILE Core/Mods/Compilation/ModAssemblyCache.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Reflection;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods.Compilation{/// <summary> Owner of the compiled-assembly cache at <c>user://mod_cache/assemblies/</c>: path resolution, cache-key construction, hit/stale handling, artifact storage and invalidation. Cache entries are named <c>&lt;modId&gt;_&lt;hash&gt;.dll</c> plus a paired portable PDB. The hash mixes the mod's own sources with its dependency signature, the kernel assembly MVID and a codegen marker, so any of those changing forces a recompile instead of a stale load. Every DLL is stored together with its PDB and loaded from disk (file-backed) so a debugger binds line-level breakpoints in mod code. </summary>
public static class ModAssemblyCache{/// <summary> Absolute path to the assembly cache directory (<c>user://mod_cache/assemblies/</c>). Resolved once at static init time with a fallback to the OS temp folder. </summary>
public static readonly string CacheRoot;static ModAssemblyCache(){if(GameEnvironment.IsRuntime){try{CacheRoot=ProjectSettings.GlobalizePath("user://mod_cache/assemblies/");}catch{CacheRoot=System.IO.Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),"cthangover","mod_cache","assemblies");}}else{CacheRoot=System.IO.Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),"cthangover","mod_cache","assemblies");}}/// <summary> The artifact (<c>&lt;id&gt;_&lt;hash&gt;.dll</c>) each mod compiled to in this process, recorded by <see cref="ModCompiler.EnsureCompiled"/>. It is the source of truth for dependents: their cache key includes it (<see cref="BuildDependencySignature"/>) and their compilation references exactly it (<see cref="ModReferenceProvider"/>) instead of guessing the newest file by timestamp — after a hot reload back to earlier sources the right artifact is the older file. </summary>
public static readonly System.Collections.Concurrent.ConcurrentDictionary<string,string>CurrentArtifacts=new(StringComparer.OrdinalIgnoreCase);/// <summary> Builds a deterministic signature of a mod's dependencies for the compilation cache key: every dependency id together with the version that was actually loaded (or <c>missing</c>). Renaming or upgrading a dependency therefore forces dependents to recompile against the new assembly instead of loading a stale cached DLL that links to the old assembly name. </summary>
public static string BuildDependencySignature(ModInfo modInfo,IDictionary<string,IModInfo>mods){var dependencies=modInfo?.Manifest?.DependencyIds;if(dependencies==null||dependencies.Count==0)return string.Empty;var parts=new List<string>();foreach(var depId in dependencies.Distinct().OrderBy(id=>id,StringComparer.OrdinalIgnoreCase)){var version=mods!=null&&mods.TryGetValue(depId,out var dependency)?dependency.Version:null;// The dependency's own compiled artifact (content hash) joins the key: a dependency whose sources changed is a new assembly, and its dependents must recompile against it — otherwise a hot reload of the dependency keeps dependents linked to members that no longer exist (MissingMethodException at runtime instead of a compile error).
var artifact=CurrentArtifacts.TryGetValue(depId,out var path)?"#"+Path.GetFileNameWithoutExtension(path):string.Empty;parts.Add($"{depId}@{version ?? "missing"}{artifact}");}return string.Join(";",parts);}/// <summary> Computes the cache path for one compilation: <c>&lt;CacheRoot&gt;/&lt;modId&gt;_&lt;hash&gt;.dll</c>. The hash covers the source list plus three extra factors: the dependency signature, the kernel assembly identity and the codegen marker. </summary> <param name="generatorsAvailable">Whether Godot source generators were resolved for this compilation — decides the codegen marker.</param>
public static string BuildCachePath(string modId,List<(string FilePath,string Source)>sourceList,string dependencySignature,bool generatorsAvailable){// Generated Godot script bindings change the assembly layout; the marker keeps cached DLLs without them from being reused. The marker also records whether generators were available: a cache entry built without them must never be reused once they become available.
var codegenMarker=generatorsAvailable?"godot-scriptpath":"legacy-no-generators";// Mix the core assembly identity into the cache key: when the kernel itself changes (e.g. a subsystem type moved out of Core), mods compiled against the previous core keep stale IL references to removed types and would fail at runtime with TypeLoadException. A new core MVID forces every mod to recompile against the updated kernel.
var coreSignature=typeof(ModRegistry).Assembly.ManifestModule.ModuleVersionId.ToString("N");// "pdb=named": the DLL's debug directory names its cache-file PDB (<id>_<hash>.pdb); entries naming "<id>.pdb" never bind symbols. "refs=depends": mods compile against their dependency closure only (ModCompiler.DependencyClosure); entries built against every mod could hide an undeclared dependency and must not be reused.
var hash=ModHasher.ComputeHash(sourceList,(dependencySignature??string.Empty)+";core="+coreSignature+";codegen="+codegenMarker+";pdb=named;refs=depends");return Path.Combine(CacheRoot,$"{modId}_{hash}.dll");}/// <summary> Applies the cache policy to a computed cache path. On a valid hit (DLL plus PDB present and <c>UseAssemblyCache</c> enabled) the cached assembly is loaded and returned; otherwise any stale artifact is deleted (setting requires recompilation, or the PDB is missing) and null is returned so the caller compiles. <paramref name="modId"/> routes the load into the mod's collectible context (null callers — test engine — keep the default context). </summary>
public static Assembly TryLoadCached(string cachedPath,string modId=null){if(!IsUsableHit(cachedPath))return null;GameLogger.Log("MODS_COMPILE",$"ModCompiler.Compile: loading cached {cachedPath}");// Disk load (not byte load) keeps the module file-backed, so the runtime finds the portable PDB written next to the DLL and a debugger binds line-level breakpoints in mod code.
return ModAssemblyLoader.LoadFromPath(cachedPath,modId)??ModAssemblyLoader.LoadFromBytes(File.ReadAllBytes(cachedPath));}/// <summary> The cache policy without the load: true when <paramref name="cachedPath"/> may be used as is (DLL plus PDB present and <c>UseAssemblyCache</c> enabled, or the artifact is the one a live mod context runs right now — rewriting a loaded, memory-mapped DLL fails on Windows and would only reproduce the same bytes). Otherwise stale files are deleted and false tells the caller to compile. </summary>
public static bool IsUsableHit(string cachedPath){// A DLL without its PDB (cache entry from before §9.2) is treated as stale so every mod gains debug symbols after one recompile.
var cachedPdbPath=Path.ChangeExtension(cachedPath,".pdb");if(!File.Exists(cachedPath))return false;if(File.Exists(cachedPdbPath)&&(ModConfig.Instance.UseAssemblyCache||ModAssemblyLoader.IsArtifactLoaded(cachedPath)))return true;File.Delete(cachedPath);if(File.Exists(cachedPdbPath))File.Delete(cachedPdbPath);GameLogger.Log("MODS_COMPILE",$"ModCompiler.Compile: cached {cachedPath} need delete by settings 'use_assembly_cache'",LogLevel.Warning);return false;}/// <summary> Writes a freshly emitted compilation into the cache: creates the cache directory if needed, then stores the DLL and its portable PDB as a name-paired pair — a later cache hit requires both files. </summary>
public static void Store(string cachedPath,MemoryStream assemblyStream,MemoryStream pdbStream){Directory.CreateDirectory(CacheRoot);assemblyStream.Seek(0,SeekOrigin.Begin);File.WriteAllBytes(cachedPath,assemblyStream.ToArray());pdbStream.Seek(0,SeekOrigin.Begin);File.WriteAllBytes(Path.ChangeExtension(cachedPath,".pdb"),pdbStream.ToArray());}/// <summary> Deletes all cached compilation output for a specific mod. Returns true if any files were deleted. </summary>
public static bool InvalidateCache(string modId){if(!Directory.Exists(CacheRoot))return false;var deleted=false;// File-backed (debuggable) assemblies memory-map their DLL/PDB, so Windows can refuse to delete the files of the mod that is currently loaded — hot-reload must survive that: a stale DLL is harmless because cache lookups and the reference scan always pick the newest artifact per mod id.
foreach(var file in Directory.GetFiles(CacheRoot,$"{modId}_*.*")){try{File.Delete(file);deleted=true;}catch(IOException ex){GameLogger.Log("MODS_COMPILE",$"InvalidateCache: '{file}' is locked (loaded mod), left in place: {ex.Message}",LogLevel.Warning);}}return deleted;}/// <summary> True when <paramref name="fileName"/> is a cached assembly of <paramref name="modId"/> itself, i.e. named <c>&lt;modId&gt;_&lt;hash&gt;.dll</c> where <c>&lt;hash&gt;</c> is the hex compilation-cache key (see <see cref="ModHasher.ComputeHash"/>). </summary>
public static bool IsSelfCacheDll(string fileName,string modId){if(string.IsNullOrEmpty(fileName)|| !fileName.EndsWith(".dll",StringComparison.OrdinalIgnoreCase))return false;var name=fileName.Substring(0,fileName.Length-4);if(!name.StartsWith(modId+"_",StringComparison.OrdinalIgnoreCase))return false;var hash=name.Substring(modId.Length+1);return hash.Length>0&&hash.All(char.IsAsciiHexDigit);}/// <summary> Parses a cache artifact name of the shape <c>&lt;modId&gt;_&lt;hash&gt;.dll</c>. The mod id itself may contain underscores, so the split happens at the last one; the hash must be all hex digits (see <see cref="ModHasher.ComputeHash"/>). Helper artifacts such as <c>tools_compile_test_&lt;hash&gt;.dll</c> parse to an id that is not a real mod, which keeps them out of the reference scan when the caller passes the known mod id set. </summary>
public static bool TryParseCacheDllName(string fileName,out string modId,out string hash){modId=null;hash=null;if(string.IsNullOrEmpty(fileName)|| !fileName.EndsWith(".dll",StringComparison.OrdinalIgnoreCase))return false;var name=fileName.Substring(0,fileName.Length-4);var separatorIndex=name.LastIndexOf('_');if(separatorIndex<=0||separatorIndex>=name.Length-1)return false;var idPart=name.Substring(0,separatorIndex);var hashPart=name.Substring(separatorIndex+1);if(!hashPart.All(char.IsAsciiHexDigit))return false;modId=idPart;hash=hashPart;return true;}}}

===== FILE Core/Mods/Compilation/ModCompileOrder.cs =====
using System.Collections.Generic;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods.Compilation{/// <summary> Compilation order for mods that carry C# sources: a depth-first topological sort over the <c>depends</c> declarations, restricted to the mods that are actually being compiled. A dependency that ships no code simply does not participate; cycles are broken with a warning instead of deadlocking boot. Deliberately not <see cref="ModLoadOrder"/>: that one implements the gameplay load order with manual overrides and fallbacks. Compilation needs the stricter depends-only ordering so a mod referencing another mod's types always compiles after its dependency. </summary>
public static class ModCompileOrder{/// <summary> Orders <paramref name="modsWithCode"/> so that every mod appears after the mods it declares in <c>depends</c> (among those present in the input list). The input list order breaks ties, which keeps the sort deterministic for a given discovery order. </summary>
public static List<ModInfo>TopologicalSort(List<ModInfo>modsWithCode){var result=new List<ModInfo>();var visited=new HashSet<string>();var visiting=new HashSet<string>();void Visit(ModInfo mod){if(visited.Contains(mod.Id))return;if(visiting.Contains(mod.Id)){GameLogger.Log("MODS_COMPILE",$"Circular dependency detected involving mod '{mod.Id}'",LogLevel.Warning);return;}visiting.Add(mod.Id);if(mod.Manifest.DependencyIds!=null){foreach(var depId in mod.Manifest.DependencyIds){var dep=modsWithCode.Find(m=>m.Id==depId);if(dep!=null)Visit(dep);}}visiting.Remove(mod.Id);visited.Add(mod.Id);result.Add(mod);}foreach(var mod in modsWithCode)Visit(mod);return result;}}}

===== FILE Core/Mods/Compilation/ModReferenceProvider.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Reflection;using Cthangover.Core.Actions;using Cthangover.Core.Utils;using Godot;using Microsoft.CodeAnalysis;namespace Cthangover.Core.Mods.Compilation{/// <summary> Assembly-reference discovery for mod compilations plus the warmed Godot path state the rest of the pipeline reads. <see cref="Collect"/> scans, in order: the runtime directories of every loaded assembly, the Godot build output directory, precompiled <c>mods/&lt;id&gt;/dll</c> folders and the compilation cache (newest artifact per known mod id). The scan is deliberately filtered — the inline comments explain why — because a naive directory scan would resurrect stale copies of mod assemblies and break dependent compilations with CS0433/CS0121. Path state (<see cref="WarmUpPaths"/>) must be warmed on the main thread before background compilation starts: compilation itself is pure .NET and thread-safe, but <c>ProjectSettings.GlobalizePath</c> is not allowed off the main thread, so resolved paths are cached here instead. </summary>
public static class ModReferenceProvider{private static string _resRoot;private static string _buildOutputDir;private static bool _pathsWarmed;/// <summary> Absolute path to the Godot project root (<c>res://</c> globalized), or null outside runtime and before <see cref="WarmUpPaths"/>. The Godot source generator consumes it as <c>build_property.project_dir</c> to compute each generated class's <c>res://</c> script path relative to the project root. </summary>
public static string ProjectRoot=>_resRoot;/// <summary> Resolves the Godot project root and Mono build output directory once on the main thread. Compilation may run on a background thread during boot, where <c>ProjectSettings.GlobalizePath</c> is not allowed, so the resolved paths are cached here and reused by <see cref="Collect"/>. </summary>
public static void WarmUpPaths(){if(_pathsWarmed)return;_pathsWarmed=true;_=ModAssemblyCache.CacheRoot;if(!GameEnvironment.IsRuntime)return;try{_resRoot=ProjectSettings.GlobalizePath("res://");foreach(var config in new[]{"Debug","Release"}){var buildDir=Path.Combine(_resRoot,".godot","mono","temp","bin",config);if(Directory.Exists(buildDir)){_buildOutputDir=buildDir;break;}}}catch{}}/// <summary> Builds the metadata reference set for one mod compilation: the runtime and GodotSharp basics, every DLL next to a loaded assembly, the build output directory, precompiled mod DLLs and the compilation cache. <paramref name="currentModId"/> excludes the mod's own (possibly stale) cache artifacts from its own references; <paramref name="knownModIds"/>, when provided, restricts the cache scan to at most the newest DLL per real mod id, ignoring foreign helper artifacts. </summary>
public static void Collect(List<MetadataReference>references,string currentModId=null,IReadOnlyCollection<string>knownModIds=null){AddAssemblyReference(references,typeof(object).Assembly);var basePath=Path.GetDirectoryName(typeof(object).Assembly.Location);var requiredAssemblies=new[]{"System.Runtime.dll","System.Collections.dll","System.Linq.dll","System.Text.Json.dll","GodotSharp.dll",};foreach(var asmName in requiredAssemblies){var path=Path.Combine(basePath,asmName);if(File.Exists(path))references.Add(MetadataReference.CreateFromFile(path));}AddAssemblyReference(references,typeof(Godot.Node).Assembly);GameLogger.Log("MODS_COMPILE","AddCoreReferences: scanning assembly directories for DLLs...");// Assembly cache directories are handled by the dedicated filtered scan below (newest DLL per known mod id). File-backed mod assemblies (loaded via LoadFromPath for debugger symbols, §9.2) expose those directories through Assembly.Location — adding them here unfiltered would resurrect stale copies and compile-test artifacts as references (CS0433/CS0121 in dependent mods). CacheRoot keeps the trailing separator from GlobalizePath, while Path.GetDirectoryName of a loaded mod assembly never returns one — an untrimmed comparison would never match and the AppDomain scan would resurrect every historical cache DLL as a reference (CS0121 ambiguous extension calls in mods with moved types).
var assemblyCacheDir=Path.TrimEndingDirectorySeparator(Path.GetFullPath(ModAssemblyCache.CacheRoot));var testAssemblyCacheDir=Path.GetFullPath(Path.Combine(assemblyCacheDir,"..","test_assemblies"));var searchedDirs=new HashSet<string>();foreach(var asm in AppDomain.CurrentDomain.GetAssemblies()){if(!asm.IsDynamic&& !string.IsNullOrEmpty(asm.Location)){var dir=Path.GetFullPath(Path.GetDirectoryName(asm.Location)??string.Empty);if(string.IsNullOrEmpty(dir))continue;if(dir.Equals(assemblyCacheDir,StringComparison.OrdinalIgnoreCase)||dir.Equals(testAssemblyCacheDir,StringComparison.OrdinalIgnoreCase))continue;if(searchedDirs.Add(dir)){foreach(var dll in Directory.GetFiles(dir,"*.dll"))TryAddReference(references,dll);}}}var buildOutputDir=ResolveBuildOutputDir();if(buildOutputDir!=null&&searchedDirs.Add(buildOutputDir)){GameLogger.Log("MODS_COMPILE",$"AddCoreReferences: scanning build output dir '{buildOutputDir}'");foreach(var dll in Directory.GetFiles(buildOutputDir,"*.dll"))TryAddReference(references,dll);}// Precompiled library mods (mods/<id>/dll/*.dll) are loaded from bytes at runtime, so their Assembly.Location is empty and the AppDomain scan above never sees their directories. Reference them explicitly so runtime-compiled dependants keep compiling (live2d_test needs the live2d library types).
var modsRoot=string.IsNullOrEmpty(_resRoot)?null:Path.Combine(_resRoot,"mods");if(modsRoot!=null&&Directory.Exists(modsRoot)&&searchedDirs.Add(modsRoot)){foreach(var modDir in Directory.GetDirectories(modsRoot)){// Precompiled libraries follow the same visibility rule as runtime-compiled mods: only the mod itself and its dependency closure may reference them.
var ownerId=Path.GetFileName(modDir);if(knownModIds!=null&& !string.Equals(ownerId,currentModId,StringComparison.OrdinalIgnoreCase)&& !knownModIds.Contains(ownerId,StringComparer.OrdinalIgnoreCase))continue;var precompiledDir=Path.Combine(modDir,"dll");if(!Directory.Exists(precompiledDir)|| !searchedDirs.Add(precompiledDir))continue;foreach(var dll in Directory.GetFiles(precompiledDir,"*.dll"))TryAddReference(references,dll);}}if(searchedDirs.Add(ModAssemblyCache.CacheRoot)&&Directory.Exists(ModAssemblyCache.CacheRoot)){GameLogger.Log("MODS_COMPILE",$"AddCoreReferences: scanning mod cache dir '{ModAssemblyCache.CacheRoot}'");// The cache legitimately holds several <id>_<hash>.dll copies per mod (every source change leaves the previous hash behind), and test runners drop helper artifacts there too (<id>_compile_test_<hash>.dll). Referencing more than one copy of the same mod's assembly would make every dependent compilation fail with CS0433 (the type exists in both assemblies), so when the caller knows the real mod ids only the newest artifact of each known mod is referenced and everything else is ignored.
var newestById=knownModIds!=null?new Dictionary<string,(string Path,DateTime Modified)>(StringComparer.OrdinalIgnoreCase):null;foreach(var dll in Directory.GetFiles(ModAssemblyCache.CacheRoot,"*.dll")){var fileName=Path.GetFileName(dll);// A mod's own stale cached DLL (<modId>_<oldHash>.dll) must not become a metadata reference of the mod being compiled: after a source change the fresh assembly would collide with the stale one and fail with CS0121 (ambiguous extension method calls). The prefix check is deliberately broader than IsSelfCacheDll: helper artifacts (<id>_compile_test_<hash>.dll) share the prefix and must equally never self-reference.
if(currentModId!=null&&fileName.StartsWith(currentModId+"_",StringComparison.OrdinalIgnoreCase))continue;if(newestById==null){TryAddReference(references,dll);continue;}if(!ModAssemblyCache.TryParseCacheDllName(fileName,out var ownerModId,out _)|| !knownModIds.Contains(ownerModId,StringComparer.OrdinalIgnoreCase))continue;var modified=File.GetLastWriteTimeUtc(dll);if(!newestById.TryGetValue(ownerModId,out var existing)||modified>existing.Modified)newestById[ownerModId]=(dll,modified);}if(newestById!=null){foreach(var entry in newestById){// The artifact this process compiled the dependency to wins over the newest file (see ModAssemblyCache.CurrentArtifacts).
var path=ModAssemblyCache.CurrentArtifacts.TryGetValue(entry.Key,out var current)&&File.Exists(current)?current:entry.Value.Path;TryAddReference(references,path);}}}var sdkAsm=typeof(IScenarioAction).Assembly;var coreAsm=typeof(ModRegistry).Assembly;AddAssemblyReference(references,sdkAsm);AddAssemblyReference(references,coreAsm);if(knownModIds==null&&references.Count>300)Console.WriteLine($"REFSCAN-BIG[{currentModId}]: from:\n"+System.Environment.StackTrace);GameLogger.Log("MODS_COMPILE",$"AddCoreReferences: sdkAsm.Location='{sdkAsm.Location}' (empty={string.IsNullOrEmpty(sdkAsm.Location)})");GameLogger.Log("MODS_COMPILE",$"AddCoreReferences: coreAsm.Location='{coreAsm.Location}' (empty={string.IsNullOrEmpty(coreAsm.Location)})");GameLogger.Log("MODS_COMPILE",$"AddCoreReferences: total references={references.Count}");foreach(var r in references){if(!r.Display?.Contains("mods/")??false)continue;GameLogger.Log("MODS_COMPILE",$"  ref: {r.Display}",LogLevel.Debug);}}/// <summary> Absolute path for a mod source file when it exists on disk inside the project. The Godot source generator derives each class's script path (<c>res://...</c>) from the syntax tree's file path, so the tree must be parsed with its real location inside the project; otherwise the generated <c>[ScriptPath]</c> points nowhere and lifecycle callbacks never fire. <para/> <paramref name="modRoot"/> is the mod's real directory (<see cref="IModFileProvider.RootPath"/>). It matters for mods whose id is not their folder name — a test suite <c>host__suite</c> lives in <c>mods/host/test_suites/suite/</c>; resolving it as <c>mods/host__suite/</c> missed and yielded <c>res://src/...</c>. Only directories inside the project are used (a zip or a <c>user://</c> folder has no <c>res://</c> path); otherwise the conventional <c>mods/&lt;modId&gt;/</c> location is tried. </summary>
public static string ResolveSourcePath(string modId,string filePath,string modRoot=null){var resRoot=_resRoot;if(string.IsNullOrEmpty(resRoot))return filePath;var normalized=filePath.Replace('\\','/');var projectRoot=Path.GetFullPath(resRoot);if(!string.IsNullOrEmpty(modRoot)&&Directory.Exists(modRoot)){var fromRoot=Path.GetFullPath(Path.Combine(modRoot,normalized));if(fromRoot.StartsWith(projectRoot,StringComparison.OrdinalIgnoreCase)&&File.Exists(fromRoot))return fromRoot;}var absolute=Path.GetFullPath(Path.Combine(resRoot,"mods",modId,normalized));return File.Exists(absolute)?absolute:filePath;}private static string ResolveBuildOutputDir(){if(!GameEnvironment.IsRuntime)return null;if(_resRoot==null)return null;if(_buildOutputDir==null)return null;return _buildOutputDir;}private static void TryAddReference(List<MetadataReference>references,string dllPath){if(!File.Exists(dllPath))return;try{AssemblyName.GetAssemblyName(dllPath);}catch{return;}try{references.Add(MetadataReference.CreateFromFile(dllPath));}catch{}}private static void AddAssemblyReference(List<MetadataReference>references,Assembly assembly){if(assembly==null||assembly.IsDynamic||string.IsNullOrEmpty(assembly.Location))return;try{references.Add(MetadataReference.CreateFromFile(assembly.Location));}catch{}}}}

===== FILE Core/Mods/Compilation/ModSourceCollector.cs =====
using System;using System.Collections.Generic;using System.Threading;namespace Cthangover.Core.Mods.Compilation{/// <summary> Source-collection step of the mod compilation pipeline. Expands the source patterns declared in a mod's <c>manifest.json</c> into a flat list of <c>(path, text)</c> pairs by walking the mod's <see cref="IModFileProvider"/>, so folder mods and zip mods are collected through the same code path. A pattern splits at its last <c>/</c> into a directory prefix and a suffix filter (<c>*.cs</c> by default); matching is suffix-based rather than glob-based because providers list entries as slash-separated relative paths. When the declared patterns match nothing, the whole mod root is scanned as a fallback so a mod that just drops <c>.cs</c> files anywhere still compiles. </summary>
public static class ModSourceCollector{/// <summary> Collects all source files of a mod: every pattern from <c>Manifest.Sources</c> is expanded in declaration order, each producing (provider path, file text) pairs. If nothing matched, the full-mod fallback scan runs, so the result is empty only for a mod that truly has no C# sources. Honors <paramref name="cancellationToken"/> between provider calls to keep boot cancellation responsive. </summary>
public static List<(string FilePath,string Source)>CollectSourceFiles(ModInfo modInfo,CancellationToken cancellationToken){var result=new List<(string,string)>();foreach(var pattern in modInfo.Manifest.Sources){cancellationToken.ThrowIfCancellationRequested();var prefix=pattern;var suffix="*.cs";var sepIndex=pattern.LastIndexOf('/');if(sepIndex>=0){prefix=pattern.Substring(0,sepIndex);suffix=pattern.Substring(sepIndex+1);}// The walk below is always recursive, so "src/**/*.cs" means "src"; a literal "**" directory never exists and would trigger the whole-mod fallback (pulling tests/ into the production assembly).
while(prefix=="**"||prefix.EndsWith("/**",StringComparison.Ordinal))prefix=prefix.Length>2?prefix.Substring(0,prefix.Length-3):string.Empty;CollectSourceFilesRecursive(modInfo.FileProvider,prefix,file=>{if(!file.EndsWith(".cs",StringComparison.OrdinalIgnoreCase))return false;var fileName=file.Replace('\\','/');var fileNamePart=fileName.Substring(fileName.LastIndexOf('/')+1);return suffix=="*"||suffix=="*.cs"||(suffix.StartsWith("*.")&&fileNamePart.EndsWith(suffix.Substring(1),StringComparison.OrdinalIgnoreCase));},result,cancellationToken);}if(result.Count==0){CollectSourceFilesRecursive(modInfo.FileProvider,"",file=>{return file.EndsWith(".cs",StringComparison.OrdinalIgnoreCase);},result,cancellationToken);}return result;}private static void CollectSourceFilesRecursive(IModFileProvider provider,string directory,Func<string,bool>fileFilter,List<(string,string)>result,CancellationToken cancellationToken){foreach(var entry in provider.ListFiles(directory)){cancellationToken.ThrowIfCancellationRequested();if(entry.EndsWith("/")){var subDir=entry.TrimEnd('/');CollectSourceFilesRecursive(provider,subDir,fileFilter,result,cancellationToken);}else if(fileFilter(entry)){var source=provider.ReadFileText(entry);result.Add((entry,source));}}}}}

===== FILE Core/Mods/CompileResult.cs =====
using System.Collections.Generic;using System.Reflection;namespace Cthangover.Core.Mods{/// <summary> Result of a Roslyn C# compilation attempt. Carries either the loaded <c>Assembly</c> on success or the first set of error messages on failure. Used both by <c>ModCompiler.CompileString</c> (ad-hoc tool-side compilation) and by the full mod build pipeline which logs errors to <c>GameLogger.CompilationErrors</c> for display in the in-game error panel. </summary>
public class CompileResult{/// <summary>True if Roslyn emitted the assembly without errors.</summary>
public bool Success{get;set;}/// <summary>The loaded assembly, or null if compilation failed.</summary>
public Assembly Assembly{get;set;}/// <summary>Compiler error messages (empty on success).</summary>
public List<string>Errors{get;set;}=new();}}

===== FILE Core/Mods/Consumers/AssemblyConsumerRegistry.cs =====
using System;using System.Collections.Generic;using System.Reflection;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods.Consumers{/// <summary> Ordered registry of <see cref="IAssemblyConsumer"/> implementations - the single point the assembly loader fans every mod assembly out through, and the single point unload passes run through in reverse. Kernel consumers are discovered once via reflection; mod assemblies may contribute additional consumers (deduplicated by <see cref="IAssemblyConsumer.Id"/> - a mod-provided consumer never shadows a kernel one). Ordering is by <see cref="IAssemblyConsumer.Order"/>, ties broken by id so the sequence is deterministic regardless of discovery order. Failure isolation mirrors the rest of the mod pipeline: one consumer's exception is logged and never prevents the others from running. </summary>
public static class AssemblyConsumerRegistry{private static readonly List<IAssemblyConsumer>consumers=new();private static readonly HashSet<string>consumerIds=new(StringComparer.OrdinalIgnoreCase);private static bool initialized;/// <summary>All consumers sorted by execution order. Snapshot copy.</summary>
public static IReadOnlyList<IAssemblyConsumer>Consumers{get{EnsureInitialized();return consumers.ToArray();}}/// <summary> Discovers kernel <see cref="IAssemblyConsumer"/> implementations once. Deliberately not tied to the mod boot pipeline: the loader needs the fan-out in every context, including unit tests that never boot. </summary>
public static void EnsureInitialized(){if(initialized)return;initialized=true;RegisterFromAssembly(typeof(AssemblyConsumerRegistry).Assembly);}/// <summary>Test/setup helper: forgets every consumer, kernel ones included.</summary>
public static void Clear(){consumers.Clear();consumerIds.Clear();initialized=false;}/// <summary> Discovers consumers in one assembly (the kernel scan, and every mod assembly through the loader). Returns the consumers that actually joined — duplicates by <see cref="IAssemblyConsumer.Id"/> are ignored — so the loader can replay the already registered assemblies to them. </summary>
public static List<IAssemblyConsumer>RegisterFromAssembly(Assembly assembly){var joined=new List<IAssemblyConsumer>();if(assembly==null)return joined;foreach(var consumer in Reflections.FindAndCreate<IAssemblyConsumer>(assembly)){if(Register(consumer))joined.Add(consumer);}return joined;}/// <summary> Registers one consumer; the first registration of an id wins, later duplicates (a mod re-contributing a kernel consumer, a kept mod re-registering during a reload) are ignored. Re-sorts after insertion. Returns whether the consumer joined. </summary>
public static bool Register(IAssemblyConsumer consumer){if(consumer==null)return false;if(!consumerIds.Add(consumer.Id)){GameLogger.Log("MODS_ASSEMBLY",$"AssemblyConsumerRegistry: duplicate consumer id '{consumer.Id}' ignored",LogLevel.Debug);return false;}consumers.Add(consumer);consumers.Sort(CompareConsumers);return true;}/// <summary> Delivers one already registered assembly to a consumer that joined later (the catch-up of <see cref="ModAssemblyLoader.RegisterAssembly"/>), isolated like the regular fan-out. </summary>
public static void CatchUp(IAssemblyConsumer consumer,Assembly assembly,string modId){try{consumer.OnAssemblyLoaded(assembly,modId);}catch(Exception ex){GameLogger.Log("MODS_ASSEMBLY",$"AssemblyConsumer '{consumer.Id}' failed on catch-up of mod '{modId}': {ex.Message}",LogLevel.Error);}}/// <summary> Drops consumers contributed by dying mod assemblies (a kept consumer instance pins its mod's load context). Returns how many were removed. </summary>
public static int RemoveOwned(Unload.AssemblyOwnership ownership){if(ownership==null||ownership.IsEmpty)return 0;var removed=0;for(var i=consumers.Count-1;i>=0;i--){if(!ownership.Owns(consumers[i].GetType()))continue;consumerIds.Remove(consumers[i].Id);consumers.RemoveAt(i);removed++;}return removed;}private static int CompareConsumers(IAssemblyConsumer a,IAssemblyConsumer b){var byOrder=a.Order.CompareTo(b.Order);return byOrder!=0?byOrder:string.CompareOrdinal(a.Id,b.Id);}/// <summary> Forward pass: delivers one assembly to every consumer in order. One consumer's failure is logged and isolated. Does not trigger kernel discovery: a cleared registry stays cleared (test isolation); production callers reach here only after <see cref="EnsureInitialized"/> ran through the load path. </summary>
public static void FanOutLoaded(Assembly assembly,string modId){foreach(var consumer in consumers.ToArray())RunConsumer(consumer,modId,c=>c.OnAssemblyLoaded(assembly,modId),"load");}/// <summary> Reverse pass: asks every consumer to release what it took from one assembly, in reverse consumer order. Never throws: unload continues past a failing consumer so the remaining ones still get a chance to release their references. Does not trigger kernel discovery (see <see cref="FanOutLoaded"/>). </summary>
public static void FanOutUnloading(Assembly assembly,string modId){for(var i=consumers.Count-1;i>=0;i--){var consumer=consumers[i];RunConsumer(consumer,modId,c=>c.OnAssemblyUnloading(assembly,modId),"unload");}}private static void RunConsumer(IAssemblyConsumer consumer,string modId,Action<IAssemblyConsumer>call,string direction){try{call(consumer);}catch(Exception ex){GameLogger.Log("MODS_ASSEMBLY",$"AssemblyConsumer '{consumer.Id}' failed on {direction} of mod '{modId}': {ex.Message}",LogLevel.Error);}}}}

===== FILE Core/Mods/Consumers/IAssemblyConsumer.cs =====
using System;using System.Reflection;namespace Cthangover.Core.Mods.Consumers{/// <summary> One reflection-driven subsystem that consumes mod assemblies. The declarative successor of the hard-coded fan-out list in <see cref="Cthangover.Core.Mods.ModAssemblyLoader.RegisterAssembly"/>: every subsystem that used to be one line there becomes a consumer class, discovered automatically in the kernel assembly, and new subsystems no longer edit shared files. The contract is symmetric on purpose - <see cref="OnAssemblyUnloading"/> is the reverse pass that makes collectible assembly unload possible. A consumer that cannot release everything it took (Godot nodes alive, delegates captured elsewhere) must log loudly here, because a silent leak keeps the <c>AssemblyLoadContext</c> alive forever and the unload silently degrades to the old "registry rewrite" behaviour. Consumers may also be contributed by mod assemblies themselves (discovered by <see cref="AssemblyConsumerRegistry.RegisterFromAssembly"/>), giving a mod the ability to own its own reflection registry - for example a registry of custom status effects - and automatically receive every mod assembly, without any kernel edit: the ones registered before it are replayed when it joins, later ones arrive through the regular fan-out. <see cref="ModTypeConsumer{T}"/> is the ready-made base for the common "collect every implementation of T" case. </summary>
public interface IAssemblyConsumer{/// <summary>Stable consumer id, used in logs and for duplicate detection.</summary>
string Id{get;}/// <summary> Explicit execution order, replacing the implicit order of the old fan-out lines. Lower runs earlier. Ranges: 0-99 infrastructure bridges (buffers), 100-899 normal registries, 900+ lifecycle orchestrators that must observe fully populated registries (the initializer registry runs last at 1000). </summary>
int Order{get;}/// <summary> Receives a newly loaded mod assembly. Must not throw for expected conditions (no matching types); the registry isolates unexpected failures per consumer so one broken subsystem cannot break the others. </summary>
void OnAssemblyLoaded(Assembly assembly,string modId);/// <summary> Reverse pass before an assembly is unloaded: drop every reference this consumer took through <see cref="OnAssemblyLoaded"/> for the given mod. Called in reverse consumer order (lifecycle orchestrators first). Must not throw; report failures through the log. </summary>
void OnAssemblyUnloading(Assembly assembly,string modId);}}

===== FILE Core/Mods/Consumers/ModAssetResolverConsumer.cs =====
using System.Reflection;namespace Cthangover.Core.Mods.Consumers{/// <summary> Registers <c>IModAssetResolver</c> implementations from mod assemblies into <see cref="ModManager"/>. Unload removes only the resolvers whose concrete types come from the unloading assembly. </summary>
public class ModAssetResolverConsumer:IAssemblyConsumer{/// <inheritdoc />
public string Id=>"mod_asset_resolvers";/// <inheritdoc />
public int Order=>100;/// <inheritdoc />
public void OnAssemblyLoaded(Assembly assembly,string modId)=>ModManager.Instance.RegisterResolversFromAssembly(assembly);/// <inheritdoc />
public void OnAssemblyUnloading(Assembly assembly,string modId)=>ModManager.Instance.UnregisterResolversFromAssembly(assembly);}}

===== FILE Core/Mods/Consumers/ModDiscoveryConsumer.cs =====
using System.Reflection;using Cthangover.Core.Mods.Discovery;namespace Cthangover.Core.Mods.Consumers{/// <summary> Registers mod-shipped <c>IModScanner</c>/<c>IModLoader</c> implementations into <see cref="ModDiscoveryRegistry"/>. </summary>
public class ModDiscoveryConsumer:IAssemblyConsumer{/// <inheritdoc />
public string Id=>"mod_discovery";/// <inheritdoc />
public int Order=>130;/// <inheritdoc />
public void OnAssemblyLoaded(Assembly assembly,string modId)=>ModDiscoveryRegistry.RegisterAssembly(assembly);/// <inheritdoc />
public void OnAssemblyUnloading(Assembly assembly,string modId)=>ModDiscoveryRegistry.UnregisterAssembly(assembly);}}

===== FILE Core/Mods/Consumers/ModInitializerConsumer.cs =====
using System.Reflection;namespace Cthangover.Core.Mods.Consumers{/// <summary> Delivers mod assemblies to <see cref="ModInitializerRegistry"/> — the lifecycle orchestrator that runs <c>OnModLoaded</c> catch-up. Order 1000: initializers must observe every registry already filled by the other consumers; on unload it runs first (reverse order) so mod code tears down before the registries drop its types. </summary>
public class ModInitializerConsumer:IAssemblyConsumer{/// <inheritdoc />
public string Id=>"mod_initializers";/// <inheritdoc />
public int Order=>1000;/// <inheritdoc />
public void OnAssemblyLoaded(Assembly assembly,string modId)=>ModInitializerRegistry.RegisterAssembly(assembly,modId);/// <inheritdoc />
public void OnAssemblyUnloading(Assembly assembly,string modId)=>ModInitializerRegistry.NotifyModUnloading(modId);}}

===== FILE Core/Mods/Consumers/ModSaveableConsumer.cs =====
using System.Reflection;namespace Cthangover.Core.Mods.Consumers{/// <summary> Registers <c>IModSaveable</c> implementations into <see cref="ModSaveableRegistry"/>. </summary>
public class ModSaveableConsumer:IAssemblyConsumer{/// <inheritdoc />
public string Id=>"mod_saveables";/// <inheritdoc />
public int Order=>110;/// <inheritdoc />
public void OnAssemblyLoaded(Assembly assembly,string modId)=>ModSaveableRegistry.RegisterAssembly(assembly,modId);/// <inheritdoc />
public void OnAssemblyUnloading(Assembly assembly,string modId)=>ModSaveableRegistry.UnregisterAssembly(assembly,modId);}}

===== FILE Core/Mods/Consumers/ModSettingsConsumer.cs =====
using System.Reflection;namespace Cthangover.Core.Mods.Consumers{/// <summary> Registers <c>IModSettings</c> implementations into <see cref="ModSettingsRegistry"/>. </summary>
public class ModSettingsConsumer:IAssemblyConsumer{/// <inheritdoc />
public string Id=>"mod_settings";/// <inheritdoc />
public int Order=>120;/// <inheritdoc />
public void OnAssemblyLoaded(Assembly assembly,string modId)=>ModSettingsRegistry.RegisterAssembly(assembly,modId);/// <inheritdoc />
public void OnAssemblyUnloading(Assembly assembly,string modId)=>ModSettingsRegistry.UnregisterAssembly(assembly,modId);}}

===== FILE Core/Mods/Consumers/ModTypeConsumer.cs =====
namespace Cthangover.Core.Mods.Consumers{using System.Collections.Generic;using System.Reflection;using Cthangover.Core.Utils;/// <summary> Base for a mod-owned plugin registry: "give me every <typeparamref name="T"/> any mod ships". A mod declares a subclass (public, parameterless) in its assembly and the kernel does the rest — the same consumer machinery the kernel's own registries use (<see cref="AssemblyConsumerRegistry"/>): <list type="bullet"> <item>every mod assembly the pipeline registers is scanned for public, non-abstract <typeparamref name="T"/> implementations, one instance each is created and handed to <see cref="Add"/>;</item> <item>assemblies registered before the consumer itself (dependencies, and the consumer's own assembly) are replayed to it when it joins, so load order does not matter;</item> <item>when a mod is unloaded (game switch, hot reload) each instance it contributed comes back through <see cref="Remove"/> — the registry stays exact without rescanning the AppDomain, and never re-registers a dying assembly.</item> </list> This replaces the per-mod pattern of looking an assembly up by mod id and rescanning every loaded assembly "as a safety net": the kernel stays the single place that knows which assemblies exist. </summary>
public abstract class ModTypeConsumer<T>:IAssemblyConsumer where T:class{private readonly Dictionary<Assembly,List<T>>created=new();/// <inheritdoc />
public abstract string Id{get;}/// <summary>Normal registry range (see <see cref="IAssemblyConsumer.Order"/>).</summary>
public virtual int Order=>500;/// <inheritdoc />
public void OnAssemblyLoaded(Assembly assembly,string modId){if(created.ContainsKey(assembly))return;var instances=new List<T>(Reflections.FindAndCreate<T>(assembly));created[assembly]=instances;foreach(var instance in instances)Add(instance,modId);}/// <inheritdoc />
public void OnAssemblyUnloading(Assembly assembly,string modId){if(!created.Remove(assembly,out var instances))return;foreach(var instance in instances)Remove(instance,modId);}/// <summary>A new <typeparamref name="T"/> from mod <paramref name="modId"/>.</summary>
protected abstract void Add(T instance,string modId);/// <summary>An instance previously passed to <see cref="Add"/> leaves with its mod.</summary>
protected abstract void Remove(T instance,string modId);}}

===== FILE Core/Mods/Consumers/ScenarioActionConsumer.cs =====
using System.Reflection;using Cthangover.Core.Factories.Impls;namespace Cthangover.Core.Mods.Consumers{/// <summary> Registers mod-shipped DSL scenario actions (<c>IScenarioAction</c>) into <see cref="ScenarioActionFactory"/>. </summary>
public class ScenarioActionConsumer:IAssemblyConsumer{/// <inheritdoc />
public string Id=>"scenario_actions";/// <inheritdoc />
public int Order=>180;/// <inheritdoc />
public void OnAssemblyLoaded(Assembly assembly,string modId)=>ScenarioActionFactory.Instance.RegisterAssembly(assembly);/// <inheritdoc />
public void OnAssemblyUnloading(Assembly assembly,string modId)=>ScenarioActionFactory.Instance.UnregisterAssembly(assembly);}}

===== FILE Core/Mods/Consumers/SceneEventConsumer.cs =====
using System.Reflection;using Cthangover.Core.Scenes;namespace Cthangover.Core.Mods.Consumers{/// <summary> Registers <c>SceneEventAttribute</c>-decorated executable events from mod assemblies into <see cref="SceneEventRegistry"/>. </summary>
public class SceneEventConsumer:IAssemblyConsumer{/// <inheritdoc />
public string Id=>"scene_events";/// <inheritdoc />
public int Order=>160;/// <inheritdoc />
public void OnAssemblyLoaded(Assembly assembly,string modId)=>SceneEventRegistry.RegisterAssembly(assembly);/// <inheritdoc />
public void OnAssemblyUnloading(Assembly assembly,string modId)=>SceneEventRegistry.UnregisterAssembly(assembly,modId);}}

===== FILE Core/Mods/Consumers/SectionParserConsumer.cs =====
using System.Reflection;using Cthangover.Core.Scenes.TscnParsing;namespace Cthangover.Core.Mods.Consumers{/// <summary> Registers mod-shipped .tscn section parsers into <see cref="SectionParserRegistry"/>. </summary>
public class SectionParserConsumer:IAssemblyConsumer{/// <inheritdoc />
public string Id=>"tscn_section_parsers";/// <inheritdoc />
public int Order=>140;/// <inheritdoc />
public void OnAssemblyLoaded(Assembly assembly,string modId)=>SectionParserRegistry.RegisterAssembly(assembly);/// <inheritdoc />
public void OnAssemblyUnloading(Assembly assembly,string modId)=>SectionParserRegistry.UnregisterAssembly(assembly);}}

===== FILE Core/Mods/Consumers/ToolConsumers.cs =====
using System.Reflection;using Cthangover.Core.UI.Tool;namespace Cthangover.Core.Mods.Consumers{/// <summary> Registers mod-shipped dev tools (<c>IToolProvider</c>) into <see cref="ToolFactory"/>. </summary>
public class ToolProviderConsumer:IAssemblyConsumer{/// <inheritdoc />
public string Id=>"tools";/// <inheritdoc />
public int Order=>170;/// <inheritdoc />
public void OnAssemblyLoaded(Assembly assembly,string modId)=>ToolFactory.Instance.RegisterAssembly(assembly);/// <inheritdoc />
public void OnAssemblyUnloading(Assembly assembly,string modId)=>ToolFactory.Instance.UnregisterAssembly(assembly);}/// <summary> Registers mod-shipped toolbar buttons (<c>IToolBoxButton</c>) into <see cref="ToolBoxButtonFactory"/>. </summary>
public class ToolBoxButtonConsumer:IAssemblyConsumer{/// <inheritdoc />
public string Id=>"toolbox_buttons";/// <inheritdoc />
public int Order=>171;/// <inheritdoc />
public void OnAssemblyLoaded(Assembly assembly,string modId)=>ToolBoxButtonFactory.Instance.RegisterAssembly(assembly);/// <inheritdoc />
public void OnAssemblyUnloading(Assembly assembly,string modId)=>ToolBoxButtonFactory.Instance.UnregisterAssembly(assembly);}}

===== FILE Core/Mods/Consumers/TscnBuildConsumers.cs =====
using System.Reflection;using Cthangover.Core.Scenes.TscnBuild;namespace Cthangover.Core.Mods.Consumers{/// <summary> Registers mod-shipped .tscn property resolvers into <see cref="PropertyResolverRegistry"/>. </summary>
public class PropertyResolverConsumer:IAssemblyConsumer{/// <inheritdoc />
public string Id=>"tscn_property_resolvers";/// <inheritdoc />
public int Order=>150;/// <inheritdoc />
public void OnAssemblyLoaded(Assembly assembly,string modId)=>PropertyResolverRegistry.RegisterAssembly(assembly);/// <inheritdoc />
public void OnAssemblyUnloading(Assembly assembly,string modId)=>PropertyResolverRegistry.UnregisterAssembly(assembly);}/// <summary> Registers mod-shipped .tscn sub-resource builders into <see cref="SubResourceBuilderRegistry"/>. </summary>
public class SubResourceBuilderConsumer:IAssemblyConsumer{/// <inheritdoc />
public string Id=>"tscn_sub_builders";/// <inheritdoc />
public int Order=>151;/// <inheritdoc />
public void OnAssemblyLoaded(Assembly assembly,string modId)=>SubResourceBuilderRegistry.RegisterAssembly(assembly);/// <inheritdoc />
public void OnAssemblyUnloading(Assembly assembly,string modId)=>SubResourceBuilderRegistry.UnregisterAssembly(assembly);}/// <summary> Registers mod-shipped .tscn ext-resource builders into <see cref="ExtResourceBuilderRegistry"/>. </summary>
public class ExtResourceBuilderConsumer:IAssemblyConsumer{/// <inheritdoc />
public string Id=>"tscn_ext_builders";/// <inheritdoc />
public int Order=>152;/// <inheritdoc />
public void OnAssemblyLoaded(Assembly assembly,string modId)=>ExtResourceBuilderRegistry.RegisterAssembly(assembly);/// <inheritdoc />
public void OnAssemblyUnloading(Assembly assembly,string modId)=>ExtResourceBuilderRegistry.UnregisterAssembly(assembly);}}

===== FILE Core/Mods/DisabledModsStore.cs =====
using System.Collections.Generic;using System.IO;using System.Text.Json;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods{/// <summary> Persistent storage for disabled mod IDs in <c>user://disabled_mods.json</c>. Used by <see cref="ModRegistry"/> to filter out disabled mods during loading and by the mod manager screen to display and toggle mod states. The file is a simple JSON array of mod ID strings. </summary>
public static class DisabledModsStore{private const string FilePath="user://disabled_mods.json";private static string _resolvedPath;/// <summary> Resolves the storage path once on the main thread. Called from <see cref="ModRegistry.PreparePaths"/> before the background mod-load phase, because <c>ProjectSettings.GlobalizePath</c> is Godot API and cannot be called from a worker thread. </summary>
public static void WarmUp(){_resolvedPath=GameEnvironment.IsRuntime?ProjectSettings.GlobalizePath(FilePath):"disabled_mods.json";}private static string ResolvedPath=>_resolvedPath??(GameEnvironment.IsRuntime?ProjectSettings.GlobalizePath(FilePath):"disabled_mods.json");/// <summary>Reads the disabled-mod id set; returns an empty set when the file is absent or broken.</summary>
public static HashSet<string>Load(){var path=ResolvedPath;if(!File.Exists(path))return new HashSet<string>();try{var json=File.ReadAllText(path);var list=JsonSerializer.Deserialize<List<string>>(json,JsonOptionsProvider.ReadOptions);return list!=null?new HashSet<string>(list):new HashSet<string>();}catch{return new HashSet<string>();}}/// <summary>Persists the disabled-mod id set as a sorted JSON array.</summary>
public static void Save(HashSet<string>disabledIds){var path=ResolvedPath;var dir=System.IO.Path.GetDirectoryName(path);if(!string.IsNullOrEmpty(dir)&& !Directory.Exists(dir))Directory.CreateDirectory(dir);var list=new List<string>(disabledIds);list.Sort();var json=JsonSerializer.Serialize(list,JsonOptionsProvider.WriteCompactOptions);File.WriteAllText(path,json);}/// <summary>Convenience check backed by <see cref="Load"/>; allocates a fresh set per call.</summary>
public static bool IsDisabled(string modId){return Load().Contains(modId);}/// <summary>Adds or removes a single id and persists the change; used by the mod manager toggle.</summary>
public static void SetEnabled(string modId,bool enabled){var set=Load();if(enabled)set.Remove(modId);else set.Add(modId);Save(set);}/// <summary> Forgets a mod id entirely. Called by <see cref="ModUninstaller"/> so a deleted mod does not leave a stale entry (which would otherwise disable a re-installed mod of the same id). </summary>
public static void Remove(string modId){var set=Load();if(set.Remove(modId))Save(set);}}}

===== FILE Core/Mods/Discovery/DiscoveredModEntry.cs =====
namespace Cthangover.Core.Mods.Discovery{/// <summary> A single mod entry discovered by an <see cref="IModScanner"/> during root enumeration. Carries the full <see cref="Url"/> (path or URI) that a matching <see cref="IModLoader"/> can resolve, plus a short <see cref="EntryName"/> used for logging and as a fallback mod ID when <c>manifest.json</c> does not specify one. </summary>
public class DiscoveredModEntry{/// <summary>Full filesystem path or URL to the mod entry.</summary>
public string Url{get;set;}/// <summary> Short human-readable name — folder name, zip filename, or last segment of a remote URL. Used in log messages and as a fallback mod ID. </summary>
public string EntryName{get;set;}}}

===== FILE Core/Mods/Discovery/IModDiscoveryProvider.cs =====
namespace Cthangover.Core.Mods.Discovery{/// <summary> Base contract for the strategy pattern that drives mod discovery. Both <see cref="IModScanner"/> (knows where to find mods) and <see cref="IModLoader"/> (knows how to open a specific mod entry) extend this interface so that <see cref="ModDiscoveryRegistry"/> can discover them via <c>Reflections.FindAndCreate</c> and apply uniform LIFO lookup semantics — the last-registered provider that answers <c>true</c> to <see cref="IsCanProvide"/> wins. </summary>
public interface IModDiscoveryProvider{/// <summary>Human-readable name used in log messages (e.g. "UserData", "Zip").</summary>
string Name{get;}/// <summary> Returns <c>true</c> if this provider can handle the given filesystem path or URL. For scanners this is an accessibility guard (does the directory exist? is the server reachable?); for loaders this is a type-dispatch check (is it a folder? a zip?). </summary>
bool IsCanProvide(string url);}}

===== FILE Core/Mods/Discovery/IModLoader.cs =====
namespace Cthangover.Core.Mods.Discovery{/// <summary> A strategy that can open a specific mod entry and create the appropriate <see cref="IModFileProvider"/>. After an <see cref="IModScanner"/> enumerates entries, each entry URL is tested against all registered loaders — the first one whose <see cref="IModDiscoveryProvider.IsCanProvide"/> returns <c>true</c> (checked in LIFO order) creates the provider, reads <c>manifest.json</c>, and produces a <c>ModCandidate</c>. Built-in loaders: <c>FolderModLoader</c> (directories) and <c>ZipModLoader</c> (.zip archives). Mods can add loaders for custom formats or remote downloads. </summary>
public interface IModLoader:IModDiscoveryProvider{/// <summary> Creates an <see cref="IModFileProvider"/> for the given mod entry. The caller is responsible for disposing it if the mod is ultimately skipped (deduplication loss, disabled, etc.). </summary> <param name="url">Full filesystem path or URL to the mod entry.</param> <param name="modName">Short name (folder name, zip filename) used as the provider's <c>Mod</c> identifier.</param>
IModFileProvider CreateProvider(string url,string modName);/// <summary> Returns the fallback mod ID to use when <c>manifest.json</c> is present but does not contain an <c>id</c> field. For folder mods this is the directory name; for zip mods it is the filename without the <c>.zip</c> extension. </summary> <param name="url">Full filesystem path or URL to the mod entry.</param> <param name="entryName">Short entry name from <see cref="DiscoveredModEntry.EntryName"/>.</param>
string GetFallbackModId(string url,string entryName);}}

===== FILE Core/Mods/Discovery/IModScanner.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Mods.Discovery{/// <summary> A strategy that knows about a source of mods. Each scanner declares its own root URLs via <see cref="GetRoots"/> — this is what replaces the old hardcoded <c>ResolveModsRoots()</c>. <see cref="ModRegistry.Initialize"/> iterates every scanner, calls <see cref="IModDiscoveryProvider.IsCanProvide"/> on each root as a guard, and then delegates to <see cref="Discover"/> for actual enumeration. New mod sources (HTTP catalogs, Steam Workshop folders, Git repos) are added by shipping an <c>IModScanner</c> implementation inside a mod DLL — <c>Reflections.FindAndCreate</c> picks it up automatically when the assembly is registered. </summary>
public interface IModScanner:IModDiscoveryProvider{/// <summary> Returns the root URLs this scanner knows about, together with their deduplication priorities. Called once per scanner during <see cref="ModRegistry.Initialize"/>. </summary>
IEnumerable<ModSourceRoot>GetRoots();/// <summary> Enumerates mod entries (folders, zip files, remote descriptors) available at the given root URL. The returned <see cref="DiscoveredModEntry"/> objects are passed to <see cref="IModLoader"/> strategies for provider creation. </summary> <param name="rootUrl">A root URL previously returned by <see cref="GetRoots"/>.</param>
IEnumerable<DiscoveredModEntry>Discover(string rootUrl);}}

===== FILE Core/Mods/Discovery/Loaders/FolderModLoader.cs =====
using System.IO;using Cthangover.Core.Mods.Providers;namespace Cthangover.Core.Mods.Discovery{/// <summary> Loads mods stored as plain directories on disk. <see cref="IsCanProvide"/> returns <c>true</c> only for paths where <see cref="Directory.Exists(string)"/> succeeds, so this loader claims every filesystem directory and creates a <see cref="FolderModFileProvider"/> — the only provider type that supports <c>GetFileSystemPath</c> and therefore Godot scene instantiation via <c>ResourceLoader</c>. </summary>
public class FolderModLoader:IModLoader{/// <inheritdoc />
public string Name=>"Folder";/// <summary> Returns <c>true</c> for any path that is an existing directory on disk. </summary>
public bool IsCanProvide(string url){return Directory.Exists(url);}/// <summary> Creates a <see cref="FolderModFileProvider"/> rooted at the given directory path. This provider type is the only one that supports <c>GetFileSystemPath</c>, enabling Godot scene instantiation from folder mods. </summary>
public IModFileProvider CreateProvider(string url,string modName){return new FolderModFileProvider(url,modName);}/// <summary> Uses the raw entry name (directory name) as the fallback mod ID. </summary>
public string GetFallbackModId(string url,string entryName){return entryName;}}}

===== FILE Core/Mods/Discovery/Loaders/ZipModLoader.cs =====
using System;using System.IO;using Cthangover.Core.Mods.Providers;namespace Cthangover.Core.Mods.Discovery{/// <summary> Loads mods stored as <c>.zip</c> archives. <see cref="IsCanProvide"/> matches any path ending with <c>.zip</c> (case-insensitive). Creates a <see cref="ZipModFileProvider"/> which builds an O(1) lookup of all zip entries on construction. Zip mods cannot serve Godot scenes directly — <c>GetFileSystemPath</c> returns <c>null</c>. Callers that need resource loading (factories, scene instantiation) must either extract assets to the cache or fall back to folder mods. </summary>
public class ZipModLoader:IModLoader{/// <inheritdoc />
public string Name=>"Zip";/// <summary> Returns <c>true</c> for any path that ends with <c>.zip</c> (case-insensitive comparison). </summary>
public bool IsCanProvide(string url){return url.EndsWith(".zip",StringComparison.OrdinalIgnoreCase);}/// <summary> Creates a <see cref="ZipModFileProvider"/> that opens the zip archive and builds an in-memory entry lookup. The archive remains open until <c>Dispose</c> is called. </summary>
public IModFileProvider CreateProvider(string url,string modName){return new ZipModFileProvider(url,modName);}/// <summary> Strips the <c>.zip</c> extension from the entry name to produce the fallback mod ID — the filename is typically the mod's canonical identifier. </summary>
public string GetFallbackModId(string url,string entryName){return Path.GetFileNameWithoutExtension(entryName);}}}

===== FILE Core/Mods/Discovery/ModDiscoveryRegistry.cs =====
using System;using System.Collections.Generic;using System.Reflection;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods.Discovery{/// <summary> Central registry that auto-discovers all <see cref="IModScanner"/> and <see cref="IModLoader"/> implementations via <c>Reflections.FindAndCreate</c> and provides LIFO-ordered lookup. Call <see cref="Initialize"/> once before mod discovery (called by <see cref="ModRegistry.Initialize"/>). When a mod assembly is loaded, <see cref="ModAssemblyLoader.RegisterAssembly"/> fans out to <see cref="RegisterAssembly"/> so that new scanners and loaders shipped inside the mod are registered — at the end of the list, meaning they are checked first (LIFO) and can override built-in providers. <b>Thread safety:</b> the provider lists are static and mutated by registration/unregistration/reset (the test suite resets them between classes) while parallel threads look up providers — the boot pipeline on a background thread, the mod-manager UI, test collections. Every mutation and every lookup goes through <see cref="gate"/>; public list access returns a snapshot so callers iterating <see cref="Scanners"/>/<see cref="Loaders"/> never race a concurrent reset (a wiped list mid-iteration used to surface as a rare NullReferenceException in <c>FindLoader</c> and <c>ModRoots.ResolveModsRoots</c>). </summary>
public static class ModDiscoveryRegistry{private static readonly object gate=new();private static readonly List<IModScanner>_scanners=new();private static readonly List<IModLoader>_loaders=new();private static bool _initialized;/// <summary> All registered scanners in registration order. When iterating for matching, walk the list in reverse (LIFO) so that mod-added scanners can override core scanners. Snapshot: safe to iterate while another thread registers or resets. </summary>
public static IReadOnlyList<IModScanner>Scanners{get{lock(gate)return _scanners.ToArray();}}/// <summary> All registered loaders in registration order. LIFO lookup: the last registered loader that answers <c>true</c> to <see cref="IModDiscoveryProvider.IsCanProvide"/> wins. Snapshot: safe to iterate while another thread registers or resets. </summary>
public static IReadOnlyList<IModLoader>Loaders{get{lock(gate)return _loaders.ToArray();}}/// <summary> Idempotent initialisation. Scans all currently loaded assemblies for <c>IModScanner</c> and <c>IModLoader</c> implementations and registers them. Called once by <see cref="ModRegistry"/>. </summary>
public static void Initialize(){// The scan runs under the lock and the flag flips only after the lists are filled: a concurrent caller waits instead of returning early into "initialized but still empty" (no scanner/loader found).
lock(gate){if(_initialized)return;_scanners.AddRange(Reflections.FindAndCreate<IModScanner>());_loaders.AddRange(Reflections.FindAndCreate<IModLoader>());_initialized=true;GameLogger.Log("MODS_DISCOVERY",$"Initialized: {_scanners.Count} scanner(s), {_loaders.Count} loader(s)");}}/// <summary> Called by <see cref="ModAssemblyLoader.RegisterAssembly"/> when a mod DLL is loaded. Discovers new scanner/loader types in the assembly and appends them to the registered lists. Because lookup walks the list from the end, these new providers are checked before built-in ones. </summary> <param name="assembly">The assembly to scan for <see cref="IModScanner"/> and <see cref="IModLoader"/> implementations.</param>
public static void RegisterAssembly(Assembly assembly){var scanners=Reflections.FindAndCreate<IModScanner>(assembly);var loaders=Reflections.FindAndCreate<IModLoader>(assembly);lock(gate){_scanners.AddRange(scanners);_loaders.AddRange(loaders);}}/// <summary> Removes scanners and loaders contributed by the given assembly — the unload pass of the assembly consumer pipeline. Matching by concrete type assembly keeps built-in providers untouched. </summary>
public static void UnregisterAssembly(Assembly assembly){lock(gate){_scanners.RemoveAll(s=>s.GetType().Assembly==assembly);_loaders.RemoveAll(l=>l.GetType().Assembly==assembly);}}/// <summary> Returns the first <see cref="IModScanner"/> (checked LIFO — from the end of the registration list) whose <see cref="IModDiscoveryProvider.IsCanProvide"/> returns <c>true</c> for the given root URL, or <c>null</c>. </summary> <param name="url">A root URL to test against all registered scanners.</param>
public static IModScanner FindScanner(string url){var scanners=Scanners;for(int i=scanners.Count-1;i>=0;i--){if(scanners[i].IsCanProvide(url))return scanners[i];}return null;}/// <summary> Returns the first <see cref="IModLoader"/> (checked LIFO) whose <see cref="IModDiscoveryProvider.IsCanProvide"/> returns <c>true</c> for the given mod entry URL, or <c>null</c>. </summary> <param name="url">A mod entry URL (folder path, zip file, etc.) to test against all registered loaders.</param>
public static IModLoader FindLoader(string url){var loaders=Loaders;for(int i=loaders.Count-1;i>=0;i--){if(loaders[i].IsCanProvide(url))return loaders[i];}return null;}}}

===== FILE Core/Mods/Discovery/ModSourceRoot.cs =====
namespace Cthangover.Core.Mods.Discovery{/// <summary> Describes a single source of mods — a root directory, remote catalog URL, or any other entry point that a <see cref="IModScanner"/> can enumerate. Each root carries a <see cref="Priority"/> used by <see cref="ModRegistry"/> for deduplication: when two mods with the same ID are discovered under different roots, the mod from the lower-priority root takes precedence. </summary>
public class ModSourceRoot{/// <summary>Filesystem path or URL to the mod source root.</summary>
public string Url{get;set;}/// <summary> Deduplication precedence. Lower value = higher priority. <c>0</c> is the user mods directory, <c>1</c> is the built-in game mods directory, higher values are for remote/external sources. </summary>
public int Priority{get;set;}}}

===== FILE Core/Mods/Discovery/Scanners/GameDirModScanner.cs =====
using System.Collections.Generic;using System.IO;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods.Discovery{/// <summary> Scans the built-in mods directory shipped with the game. Returns the project-local <c>mods/</c> folder in editor/test environments (priority 0), or the globalised <c>res://mods/</c> path in runtime builds (priority 1). Priority 1 means user-installed mods from <see cref="UserDataModScanner"/> override built-in mods with the same ID — users can replace or update shipped content without modifying game files. </summary>
public class GameDirModScanner:IModScanner{/// <inheritdoc />
public string Name=>"GameDir";/// <summary> Returns <c>true</c> if the given path is an existing directory. Acts as an accessibility guard — roots returned by <see cref="GetRoots"/> are skipped when the directory does not exist. </summary>
public bool IsCanProvide(string url){return Directory.Exists(url);}/// <summary> Returns the project-local <c>mods/</c> path in editor (priority 0), or the globalised <c>res://mods/</c> path in runtime builds (priority 1). Uses the standard ternary pattern to avoid calling <c>ProjectSettings.GlobalizePath</c> outside the Godot runtime. </summary>
public IEnumerable<ModSourceRoot>GetRoots(){if(GameEnvironment.IsRuntime){var path=ProjectSettings.GlobalizePath("res://mods/");yield return new ModSourceRoot{Url=path,Priority=1};}else{yield return new ModSourceRoot{Url="mods",Priority=0};}}/// <summary> Enumerates all filesystem entries (folders and files) inside the given root directory. Normalises backslashes to forward slashes for cross-platform consistency. </summary>
public IEnumerable<DiscoveredModEntry>Discover(string rootUrl){foreach(var entry in Directory.EnumerateFileSystemEntries(rootUrl)){yield return new DiscoveredModEntry{Url=entry.Replace('\\','/'),EntryName=Path.GetFileName(entry),};}}}}

===== FILE Core/Mods/Discovery/Scanners/UserDataModScanner.cs =====
using System.Collections.Generic;using System.IO;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods.Discovery{/// <summary> Scans the per-user mods directory (<c>user://mods/</c>) in runtime builds. This is where the mod manager downloads and installs mod subscriptions — it is the writable, user-specific mod source with the highest deduplication priority (0). Always returns its root from <see cref="GetRoots"/>; the <see cref="IsCanProvide"/> guard filters the root out when the filesystem path does not exist (non-runtime or user directory not yet created). Uses the standard ternary pattern to avoid calling <c>ProjectSettings.GlobalizePath</c> outside the Godot runtime. </summary>
public class UserDataModScanner:IModScanner{/// <inheritdoc />
public string Name=>"UserData";/// <summary> Returns <c>true</c> if the given path is an existing directory. Acts as an accessibility guard — roots returned by <see cref="GetRoots"/> are skipped when the filesystem path does not exist (mods directory was never created yet). </summary>
public bool IsCanProvide(string url){return Directory.Exists(url);}/// <summary> Returns the globalised <c>user://mods/</c> path when running inside Godot, or the raw <c>user://mods/</c> string as a fallback for editor and test environments. Always returns exactly one root with priority 0 (highest user precedence). </summary>
public IEnumerable<ModSourceRoot>GetRoots(){var path=GameEnvironment.IsRuntime?ProjectSettings.GlobalizePath("user://mods/"):"user://mods/";yield return new ModSourceRoot{Url=path,Priority=0};}/// <summary> Enumerates all filesystem entries (folders and files) inside the given root directory. Normalises backslashes to forward slashes for cross-platform consistency. </summary>
public IEnumerable<DiscoveredModEntry>Discover(string rootUrl){foreach(var entry in Directory.EnumerateFileSystemEntries(rootUrl)){yield return new DiscoveredModEntry{Url=entry.Replace('\\','/'),EntryName=Path.GetFileName(entry),};}}}}

===== FILE Core/Mods/EntityKindPeek.cs =====
using System;using System.Text;using System.Text.Json;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods{/// <summary> Reads the top-level <c>"Kind"</c> discriminator from a JSON text without deserialising the document. The entity resolver walks every <c>.json</c> file of every mod, and the overwhelming majority of those files are not entity data at all (manifests, collections, live2d models, scene definitions, patches). Peeking the first property with <see cref="Utf8JsonReader"/> keeps that discovery pass cheap and, more importantly, makes the routing decision before any DTO-specific parsing happens — a file is only ever deserialised as the one type its kind names. <para/> The peek is deliberately silent: files without a kind are simply not data, not errors. Callers report unknown kinds separately via <see cref="Factories.EntityKindRegistry.IsKnown"/>. </summary>
public static class EntityKindPeek{/// <summary> Attempts to read the root object's <c>"Kind"</c> string. Returns <c>false</c> for invalid JSON, non-object roots, missing kinds and non-string kinds. </summary> <param name="json">Raw file text, before include expansion — the kind always lives in the file itself.</param> <param name="kind">Normalised lowercase kind when the method returns <c>true</c>.</param>
public static bool TryRead(string json,out string kind){kind=null;if(string.IsNullOrWhiteSpace(json))return false;try{var bytes=Encoding.UTF8.GetBytes(json);var reader=new Utf8JsonReader(bytes,new JsonReaderOptions{CommentHandling=JsonCommentHandling.Skip,AllowTrailingCommas=true,});if(!reader.Read()||reader.TokenType!=JsonTokenType.StartObject)return false;while(reader.Read()){if(reader.TokenType==JsonTokenType.EndObject)return false;if(reader.TokenType!=JsonTokenType.PropertyName)return false;var propertyName=reader.GetString();if(!reader.Read())return false;if(string.Equals(propertyName,"Kind",StringComparison.OrdinalIgnoreCase)){if(reader.TokenType!=JsonTokenType.String)return false;kind=reader.GetString()?.Trim().ToLowerInvariant();return!string.IsNullOrEmpty(kind);}if(reader.TokenType==JsonTokenType.StartObject||reader.TokenType==JsonTokenType.StartArray)reader.Skip();}return false;}catch(JsonException){return false;}}}}

===== FILE Core/Mods/FileData.cs =====
using System;using System.Collections.Generic;using System.Text.Json.Serialization;using Cthangover.Core.Factories;namespace Cthangover.Core.Mods{/// <summary> Generic JSON wrapper for mod data files. Every entity JSON is a self-describing envelope: a top-level <c>"Kind"</c> string names the entity type (resolved through <see cref="EntityKindRegistry"/>) and <c>"Items"</c> carries the typed entries. The kind is what makes a file locatable regardless of which directory it lives in — the entity resolver scans all mod files and routes each one by its kind instead of assuming that a folder means a type. <para/> Files without a kind are not entity data (manifests, collections, scene definitions, patches) and are never parsed through this type. </summary>
[Serializable]public class FileData<T>where T:class,IIdentifiable{/// <summary> Canonical entity kind of this file, e.g. <c>"skill"</c>. Must match the <see cref="EntityKindAttribute"/> of <typeparamref name="T"/>; the entity resolver skips a file whose declared kind does not match the DTO it is being collected for. </summary>
[JsonPropertyName("Kind")]public string Kind{get;set;}/// <summary>The typed entries inside the {"Items": [...]} JSON envelope.</summary>
[JsonPropertyName("Items")]public List<T>Items{get;set;}}}

===== FILE Core/Mods/GDModRegistryService.cs =====
using Godot;namespace Cthangover.Core.Mods{/// <summary> Godot-facing bridge over <see cref="IModRegistry"/> for GDScript mods: exposed as a <c>[GlobalClass]</c> so scripts can introspect the loaded mod set without any C# — e.g. to check whether an optional companion mod is present before registering cross-mod behaviour. </summary>
[GlobalClass]public partial class GDModRegistryService:RefCounted{/// <summary>Registry this service delegates to; assigned by the loader.</summary>
internal IModRegistry Inner;/// <summary>Returns <c>true</c> when a mod with the given id is currently loaded and enabled.</summary>
public bool IsLoaded(string modId)=>Inner.Mods.ContainsKey(modId);/// <summary>Returns loaded mod ids in load order (dependencies first).</summary>
public string[]GetOrderedModIds(){var list=Inner.GetOrderedModIds();var result=new string[list.Count];for(var i=0;i<list.Count;i++)result[i]=list[i];return result;}}}

===== FILE Core/Mods/GDScriptHookRegistry.cs =====
using System.Collections.Generic;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods{/// <summary> Registry of <see cref="IGDScriptHook"/> implementations. Hooks are registered by mod initializers during assembly registration, which always happens before <see cref="ModGDScriptLoader"/> processes <c>.gd</c> sources. A later registration with the same <see cref="IGDScriptHook.Id"/> replaces the earlier one so mod reloads do not accumulate duplicates. </summary>
public static class GDScriptHookRegistry{private static readonly List<IGDScriptHook>hooks=new();/// <summary>Registers (or replaces by id) a GDScript hook.</summary>
public static void Register(IGDScriptHook hook){if(hook==null||string.IsNullOrEmpty(hook.Id))return;hooks.RemoveAll(existing=>existing.Id==hook.Id);hooks.Add(hook);GameLogger.Log("GDS_MOD",$"GDScriptHookRegistry: registered hook '{hook.Id}'");}/// <summary>Offers a compiled GDScript object to every registered hook.</summary>
public static void TryRegisterAll(GodotObject obj,string modId,string filePath){if(obj==null)return;foreach(var hook in hooks){try{hook.TryRegister(obj,modId,filePath);}catch(System.Exception ex){GameLogger.Log("GDS_MOD",$"GDScriptHookRegistry: hook '{hook.Id}' failed on '{filePath}' from mod '{modId}': {ex.Message}",LogLevel.Error);}}}}}

===== FILE Core/Mods/GDScriptModInitializer.cs =====
using Godot;namespace Cthangover.Core.Mods;/// <summary> Wraps a compiled GDScript object as an <see cref="IModInitializer"/> so a <c>.gd</c> file can receive the same lifecycle callbacks as a C# initializer. Each callback is forwarded only when the script actually defines the corresponding method, so a script may implement either or both hooks. </summary>
public class GDScriptModInitializer:IModInitializer{private readonly GodotObject _instance;/// <summary>Wraps the given compiled GDScript instance.</summary>
public GDScriptModInitializer(GodotObject instance){_instance=instance;}/// <summary>Calls <c>on_mod_loaded(modId)</c> on the script when it defines it.</summary>
public void OnModLoaded(string modId){if(_instance.HasMethod("on_mod_loaded"))_instance.Call("on_mod_loaded",modId);}/// <summary>Calls <c>on_mod_resources_ready()</c> on the script when it defines it.</summary>
public void OnModResourcesReady(){if(_instance.HasMethod("on_mod_resources_ready"))_instance.Call("on_mod_resources_ready");}}

===== FILE Core/Mods/HotReload/HotReloadSettings.cs =====
namespace Cthangover.Core.Mods.HotReload{using System;using System.Collections.Generic;/// <summary> When the mod hot reload watches files, and which file changes count. On by default only when the game runs from the Godot editor (the developer's play loop: edit a mod, see it a moment later); exported builds never watch. Automated runs (integration tests, atlases, mod unit tests) are excluded even from the editor, because they write mod files themselves and would reload under their own feet — a test that exercises hot reload opts in with <see cref="EnableFlag"/>. </summary>
public static class HotReloadSettings{/// <summary>User arg forcing the watcher on (any build, any run).</summary>
public const string EnableFlag="--hot-reload";/// <summary>User arg forcing the watcher off.</summary>
public const string DisableFlag="--no-hot-reload";private static readonly string[]AutomatedRunPrefixes={"--itest=","--run-tests","--timeout=","--choices=","--resource-atlas","--locale-atlas",};private static readonly string[]IgnoredExtensions={".uid",".import",".tmp",".swp",".bak","~",};private static readonly string[]IgnoredSegments={"/.godot/","/.git/","/.vs/","/.idea/","/bin/","/obj/",};/// <summary>Decides whether the watcher runs for this process.</summary>
public static bool IsEnabled(bool runFromEditor,IEnumerable<string>userArgs){var args=new List<string>(userArgs??Array.Empty<string>());if(args.Contains(DisableFlag))return false;if(args.Contains(EnableFlag))return true;if(!runFromEditor)return false;foreach(var arg in args){foreach(var prefix in AutomatedRunPrefixes){if(arg.StartsWith(prefix,StringComparison.Ordinal))return false;}}return true;}/// <summary> True for a change that can alter the mod set: sources, manifests, data, scenes, locale, assets. Editor bookkeeping (<c>.uid</c>, <c>.import</c>), editor swap files and build folders are ignored. </summary>
public static bool IsRelevantChange(string path){if(string.IsNullOrWhiteSpace(path))return false;var normalized=path.Replace('\\','/');foreach(var segment in IgnoredSegments){if(normalized.IndexOf(segment,StringComparison.OrdinalIgnoreCase)>=0)return false;}var name=normalized.Substring(normalized.LastIndexOf('/')+1);if(name.Length==0||name.StartsWith(".",StringComparison.Ordinal)||name.StartsWith("~",StringComparison.Ordinal))return false;foreach(var extension in IgnoredExtensions){if(name.EndsWith(extension,StringComparison.OrdinalIgnoreCase))return false;}return true;}}}

===== FILE Core/Mods/HotReload/ModHotReloadService.cs =====
namespace Cthangover.Core.Mods.HotReload{using System;using System.Collections.Generic;using System.Diagnostics;using System.IO;using System.Threading;using System.Threading.Tasks;using Cthangover.Core.Games;using Cthangover.Core.Mods.Pipeline;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Core.UI.Menu;using Cthangover.Core.Utils;using Godot;/// <summary> Hot reload of mods inside the running game: edit a mod (C#, data, scenes, locale) and the game picks it up without a restart. File system watchers on the mod roots mark the set dirty; once the files have been quiet for <see cref="DebounceSeconds"/> (editors save in bursts), <see cref="ReloadNowAsync"/> runs. <c>Ctrl+Shift+R</c> triggers the same reload by hand (watchers may miss events on network or WSL mounts). A reload compiles the new set in the background first: on a compile error the game keeps running untouched and the errors are logged. On success the running session is parked in a hidden save slot (<see cref="SaveService.HotReloadSlot"/>), the window switches to the transition screen, the mod set is replaced (only changed mods and their dependents reload; see <see cref="ModReloadPlan"/>), and the session is restored: the save is loaded and its scene entered again, or the main menu reopens when no game was running. </summary>
public partial class ModHotReloadService:Node{/// <summary>Quiet time after the last file event before a reload starts.</summary>
public const double DebounceSeconds=0.6;private readonly List<FileSystemWatcher>watchers=new();private readonly Stopwatch sinceLastChange=new();private int dirty;private string lastChangedPath;/// <summary>The installed service; null when hot reload is disabled for this process.</summary>
public static ModHotReloadService Instance{get;private set;}/// <summary> Adds the service under <c>/root</c> when <see cref="HotReloadSettings.IsEnabled"/> allows it. Called once the boot finished. </summary>
public static void InstallIfEnabled(SceneTree tree){if(Instance!=null||tree?.Root==null)return;if(!HotReloadSettings.IsEnabled(OS.HasFeature("editor"),OS.GetCmdlineUserArgs()))return;var service=new ModHotReloadService{Name="ModHotReload"};tree.Root.CallDeferred(Node.MethodName.AddChild,service);}/// <summary>Registers the instance and starts one recursive watcher per mod root resolved at boot.</summary>
public override void _EnterTree(){Instance=this;ProcessMode=ProcessModeEnum.Always;// A root that does not exist yet (user://mods before the first download) is created, so mods dropped there later are seen too.
foreach(var root in ModRegistry.Instance.RootPaths){Directory.CreateDirectory(root);Watch(root);}GameLogger.Log("MODS_RELOAD",$"Hot reload: watching {watchers.Count} mod root(s); Ctrl+Shift+R reloads by hand");}/// <summary>Stops the watchers.</summary>
public override void _ExitTree(){foreach(var watcher in watchers)watcher.Dispose();watchers.Clear();if(Instance==this)Instance=null;}/// <summary>Starts the reload on the main thread once the files have been quiet for <see cref="DebounceSeconds"/> and no other switch runs.</summary>
public override void _Process(double delta){if(Volatile.Read(ref dirty)==0||sinceLastChange.Elapsed.TotalSeconds<DebounceSeconds)return;if(GameSession.IsSwitching||ModManager.Instance.IsReloading||IsReloadRunning)return;Interlocked.Exchange(ref dirty,0);GameLogger.Log("MODS_RELOAD",$"Hot reload: mod files changed ({lastChangedPath}), reloading");_=ReloadNowAsync();}/// <summary>Ctrl+Shift+R: reload by hand.</summary>
public override void _UnhandledKeyInput(InputEvent@event){if(@event is InputEventKey{Pressed:true,Echo:false,Keycode:Key.R,CtrlPressed:true,ShiftPressed:true}){GetViewport().SetInputAsHandled();_=ReloadNowAsync();}}/// <summary>True while <see cref="ReloadNowAsync"/> runs.</summary>
public static bool IsReloadRunning{get;private set;}/// <summary> Reloads the current selection in place, preserving the session (see the class remarks). <paramref name="force"/> reloads mods even when their code did not change. Returns the verified result; a rejected reload (compile errors, another reload running) returns a result with <c>Applied == false</c> and leaves the game as it was. </summary>
public static async Task<ModReloadResult>ReloadNowAsync(IReadOnlyCollection<string>force=null){var tree=Engine.GetMainLoop()as SceneTree;var manager=ModManager.Instance;if(tree==null||IsReloadRunning||GameSession.IsSwitching||manager.IsReloading)return null;IsReloadRunning=true;try{var prep=await manager.PrepareReloadAsync(GameLaunchArgs.Current,force);if(!prep.Succeeded){var rejected=manager.ApplyReload(prep,tree);foreach(var error in rejected.Errors)GameLogger.Log("MODS_RELOAD","Hot reload rejected: "+error,LogLevel.Error);return await manager.VerifyReloadAsync(rejected,tree);}var inGame=tree.CurrentScene?.IsInGroup("main_event_chain")==true&&SceneManager.GetCurrentSceneName()!=null;if(inGame)SaveService.Save(SaveService.HotReloadSlot,rememberSession:false);ModSetTransitionScreen.Show(tree);// Let the engine free the previous scene before its mods go.
await tree.ToSignal(tree,SceneTree.SignalName.ProcessFrame);var result=manager.ApplyReload(prep,tree);await manager.VerifyReloadAsync(result,tree);if(inGame&&GameSession.LoadAndEnter(tree.Root.GetNode("SceneManager"),SaveService.HotReloadSlot))GameLogger.Log("MODS_RELOAD",$"Hot reload: session restored in scene '{SaveService.LastLoadedSceneName}'");else GameSession.OpenMainMenu(tree);if(inGame)SaveService.DeleteSave(SaveService.HotReloadSlot);return result;}catch(Exception ex){GameLogger.Log("MODS_RELOAD",$"Hot reload FAILED: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}",LogLevel.Error);GameSession.OpenMainMenu(tree);return null;}finally{IsReloadRunning=false;}}private void Watch(string root){try{var watcher=new FileSystemWatcher(root){IncludeSubdirectories=true,NotifyFilter=NotifyFilters.FileName|NotifyFilters.DirectoryName|NotifyFilters.LastWrite|NotifyFilters.Size,};watcher.Changed+=OnFileEvent;watcher.Created+=OnFileEvent;watcher.Deleted+=OnFileEvent;watcher.Renamed+=OnFileRenamed;watcher.EnableRaisingEvents=true;watchers.Add(watcher);}catch(Exception ex){GameLogger.Log("MODS_RELOAD",$"Hot reload: cannot watch '{root}': {ex.Message}",LogLevel.Warning);}}// Watcher callbacks run on thread-pool threads: they only flip the dirty flag; the reload itself starts from _Process on the main thread.
private void OnFileEvent(object sender,FileSystemEventArgs e)=>MarkDirty(e.FullPath);private void OnFileRenamed(object sender,RenamedEventArgs e)=>MarkDirty(e.FullPath);private void MarkDirty(string path){if(!HotReloadSettings.IsRelevantChange(path))return;lastChangedPath=path;sinceLastChange.Restart();Interlocked.Exchange(ref dirty,1);}}}

===== FILE Core/Mods/IGDScriptHook.cs =====
using Godot;namespace Cthangover.Core.Mods{/// <summary> Extension point for gameplay subsystems that can be implemented in GDScript. <see cref="ModGDScriptLoader"/> walks every <c>.gd</c> file a mod declares and hands each compiled object to the registered hooks; a hook recognizes its own method signatures (for example <c>get_action_id</c>/<c>execute</c>) and wraps the script in a C# adapter. This keeps the generic loader free of subsystem-specific method names — the owning mod registers the hook when it loads. </summary>
public interface IGDScriptHook{/// <summary>Stable id used to replace a hook when a mod reloads.</summary>
string Id{get;}/// <summary> Inspects the compiled GDScript object and registers an adapter if the script implements this hook's contract. Called for every <c>.gd</c> file of every mod; implementations should simply return when their expected methods are absent. </summary>
void TryRegister(GodotObject obj,string modId,string filePath);}}

===== FILE Core/Mods/IModFileProvider.cs =====
using System;using System.Collections.Generic;using System.IO;using Cthangover.Core.Utils.Types;namespace Cthangover.Core.Mods{/// <summary> Abstraction over mod storage backends. Every mod — whether a loose folder on disk or a compressed <c>.zip</c> archive — exposes its files through this single interface, so the entire asset pipeline (<c>ModManager</c>, factories, compilers) never branches on storage format. Extends <c>IDisposable</c> because zip archives hold native OS file handles that must be released on mod unload. Folder providers implement <c>Dispose</c> as a no-op. <c>GetFileSystemPath</c> returns <c>null</c> for zip providers — Godot's <c>ResourceLoader</c> and <c>PackedScene</c> loading require real filesystem paths, so callers that depend on these (e.g. <c>EffectFactory</c>) must guard against zip-only mods. </summary>
public interface IModFileProvider:IDisposable{/// <summary>Short name/ID of this mod (for logging and lookup).</summary>
string Mod{get;}/// <summary>Returns true if the given path exists inside this mod.</summary>
bool FileExists(string path);/// <summary> Enumerates all files and directories immediately under the given directory. Directories have a trailing <c>/</c> to let recursive callers branch without additional stat calls. </summary>
IEnumerable<string>ListFiles(string directory="");/// <summary>Reads a text file as UTF-8 string, or null if not found.</summary>
string ReadFileText(string path);/// <summary>Reads a binary file as byte array, or null if not found.</summary>
byte[]ReadFileBinary(string path);/// <summary>Opens a seekable stream to a file inside the mod.</summary>
Stream OpenStream(string path);/// <summary> Returns an absolute filesystem path for the given mod-relative path, or null if this provider cannot give filesystem access (zip mods return null here). </summary>
string GetFileSystemPath(string path);/// <summary> Resolves a relative include path (<c>../shared/data.json</c>) against the current file's directory to produce a canonical mod-relative path. </summary>
string ResolveIncludePath(string includePath,string currentPath);/// <summary> Filesystem path to the mod's root storage. For folder mods this is the directory path; for zip mods this is the .zip file path. Returns <c>null</c> for providers that are not disk-backed (test mocks, in-memory stubs). </summary>
string RootPath{get;}}}

===== FILE Core/Mods/IModInfo.cs =====
namespace Cthangover.Core.Mods{/// <summary> Public read-only view of a loaded mod's identity and capabilities. The <c>FileProvider</c> property exposes the storage backend so that <c>ModManager</c> can delegate file access through it — the mod info itself doesn't read files, it just carries the handle. </summary>
public interface IModInfo{/// <summary>Unique mod identifier.</summary>
public string Id{get;}/// <summary>Human-readable name from manifest.json.</summary>
public string Name{get;}/// <summary>Author credit string.</summary>
public string Author{get;}/// <summary>Short description from manifest.json.</summary>
public string Description{get;}/// <summary>UI-safe label: Name if present, otherwise Id.</summary>
public string DisplayTitle{get;}/// <summary> Short description in the current locale. Falls back to the raw <c>Description</c> when no translation key exists. </summary>
public string LocalizedDescription{get;}/// <summary>Semver mod version from manifest.json (e.g. "1.0.0"). Used by the save system to detect downgrades and trigger migrations when the installed version differs from the save.</summary>
public string Version{get;}/// <summary>The storage backend that reads this mod's files.</summary>
public IModFileProvider FileProvider{get;}}}

===== FILE Core/Mods/IModInitializer.cs =====
namespace Cthangover.Core.Mods{/// <summary> Lifecycle hook for mod code that needs to run logic during mod loading. Implementations are auto-discovered via reflection in <c>ModInitializerRegistry</c>. <c>OnModLoaded</c> is called for <b>every</b> mod, not just the mod that owns the initializer — this cross-mod notification pattern allows a single initializer to react to any mod being added, e.g. a compatibility patch that detects a specific companion mod and registers bridging behaviour. <c>OnModResourcesReady</c> fires once, after all mods have been fully loaded and their JSON resources are accessible through factories (CharacterFactory, ItemFactory, etc.). Use this for initialisation that depends on mod data, such as adding default starter characters to the party. </summary>
public interface IModInitializer{/// <summary> Called for <b>every</b> mod that loads — the <c>modId</c> parameter is the mod that just finished loading, which may or may not be the mod that owns this initializer. </summary>
void OnModLoaded(string modId);/// <summary> Called once after all mods have been loaded and their resources (JSON data) are accessible through factories. Use this for initialisation that requires game resources, e.g. adding default characters to the party roster. </summary>
void OnModResourcesReady();}}

===== FILE Core/Mods/IModRegistry.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Mods{/// <summary> Public read-only view of the mod registry. Exposes only the mod list — mutation and discovery logic is encapsulated in <c>ModRegistry</c> — so that external consumers (factories, UI) can enumerate loaded mods without being able to alter the registry state. </summary>
public interface IModRegistry{/// <summary> Read-only snapshot of currently loaded mods, keyed by mod ID. </summary>
IReadOnlyDictionary<string,IModInfo>Mods{get;}IReadOnlyList<string>GetOrderedModIds(bool filterDisabled=true);void InvalidateOrder();}}

===== FILE Core/Mods/IModSaveable.cs =====
using System;namespace Cthangover.Core.Mods{/// <summary> Interface for mods that need to persist custom data alongside the core game save. Implementations are auto-discovered via reflection in <see cref="ModSaveableRegistry"/> when a mod assembly is loaded. <b>Contract:</b> <list type="number"> <item><b>OnSave</b> — the mod receives a pre-filled slot (ModId, ModName, ModVersion set by the core) and writes its data, MinCoreVersion, IsRequired, and CustomData.</item> <item><b>OnLoad</b> — two variants: <list type="bullet"> <item><b>Preferred:</b> override <c>bool OnLoad(ModSlot, out string error)</c> — explicit contract, no exceptions.</item> <item><b>Legacy:</b> override <c>void OnLoadVoid(ModSlot)</c> — throw on error, core catches and reports.</item> </list> </item> </list> </summary>
public interface IModSaveable{/// <summary> Called by the core when saving the game. The mod receives a slot with <c>Metadata.ModId</c>, <c>Metadata.ModName</c>, and <c>Metadata.ModVersion</c> already filled by the core. The mod should set <c>MinCoreVersion</c>, <c>IsRequired</c>, <c>CustomData</c>, and write its state into <c>slot.Data</c>. </summary>
void OnSave(Cthangover.Core.Settings.ModSlot slot);/// <summary> Preferred entry point for loading. Called by the core after phase-1 validation. Return <c>false</c> with a descriptive <c>errorMessage</c> if the data is incompatible or a migration is impossible. The core logs and shows the message to the user. Default implementation delegates to <see cref="OnLoadVoid"/>. </summary>
bool OnLoad(Cthangover.Core.Settings.ModSlot slot,out string errorMessage){OnLoadVoid(slot);errorMessage=null;return true;}/// <summary> Legacy entry point for loading. Throw an exception with a descriptive message if the data is incompatible. Override <see cref="OnLoad(Cthangover.Core.Settings.ModSlot, out string)"/> instead for explicit error handling. The default throws <see cref="NotSupportedException"/> so that a mod must override at least one of the two methods. </summary> <summary> Called after a save is loaded that has no data for this mod — the mechanic was added to the game after the save was written (or its data was dormant/rejected). State is already at new-game defaults; override to grant what a running game needs (starter recipes, unlocked panels). Default: nothing. </summary>
void OnJoinedExistingGame(){}void OnLoadVoid(Cthangover.Core.Settings.ModSlot slot){throw new NotSupportedException("Override either OnLoad(ModSlot, out string errorMessage) "+"for explicit errors, or OnLoadVoid(ModSlot) for throw-based errors.");}}}

===== FILE Core/Mods/IModSettings.cs =====
using System.Collections.Generic;using Cthangover.Core.Settings;namespace Cthangover.Core.Mods{/// <summary> Interface for mods that expose user-configurable settings in the in-game settings menu. Implementations are auto-discovered via reflection in <see cref="ModSettingsRegistry"/> when a mod assembly is loaded. The contract mirrors <see cref="IModSaveable"/>: the mod only needs to declare its settings shape (<see cref="GetDefinitions"/>) and provide two symmetric methods for reading and writing values through a <see cref="DataBlob"/>. The core owns all persistence and UI rendering. Lifecycle: <list type="number"> <item>On boot, the core loads persisted data from disk and calls <see cref="ReadValues"/> so the mod applies its saved state.</item> <item>When the player opens the Settings menu, the core calls <see cref="GetDefinitions"/> to build the UI, then <see cref="WriteValues"/> to fill the controls with current values.</item> <item>When the player changes a setting, the core writes the new value into a <c>DataBlob</c>, persists it, and calls <see cref="ReadValues"/> so the mod reacts immediately.</item> </list> </summary>
public interface IModSettings{/// <summary> Returns descriptors for every setting this mod exposes. Called every time the settings UI builds the controls for this mod. The list order defines the UI row order. </summary>
IReadOnlyList<ModSettingDefinition>GetDefinitions();/// <summary> The mod serialises its current setting values into the supplied <c>blob</c>. The core uses this to populate the UI controls and to persist to disk. Use <c>blob.SetInt</c>, <c>blob.SetBool</c>, <c>blob.SetString</c>, etc. </summary>
void WriteValues(DataBlob blob);/// <summary> The mod reads setting values from the supplied <c>blob</c> and applies them. Called on boot (from persisted data) and on every UI change (for immediate apply). Use <c>blob.GetInt</c>, <c>blob.GetBool</c>, etc. with fallback defaults. </summary>
void ReadValues(DataBlob blob);}}

===== FILE Core/Mods/IModSettingsActions.cs =====
namespace Cthangover.Core.Mods{/// <summary> Optional companion of <see cref="IModSettings"/> for settings of type <see cref="SettingType.Action"/>: the settings screen shows them as buttons and calls <see cref="RunAction"/> on press (after the optional confirmation). </summary>
public interface IModSettingsActions{/// <summary>Runs the action with the given setting key; returns a short result message for the player.</summary>
string RunAction(string key);}}

===== FILE Core/Mods/IModUnloadAware.cs =====
namespace Cthangover.Core.Mods{/// <summary> Optional unload-aware extension of <see cref="IModInitializer"/>. Kept as a separate interface (rather than default methods on the main one) because Godot's source generators and the existing mod initializers must not be forced through an interface change: existing mods compile unchanged, unload-aware mods opt in by implementing this too. The unload pair completes the lifecycle for collectible assembly unload: <c>OnModUnloading</c> runs on the initializer of the mod being unloaded while its types are still alive (drop event bus subscriptions, close file handles, detach gameplay listeners); <c>OnModUnloaded</c> runs on everyone else so cross-mod references to the dying mod's types can be dropped. </summary>
public interface IModUnloadAware{/// <summary> Called on the initializer of the mod being unloaded, while its own types are still alive and callable. Release everything that could pin the assembly: event bus subscriptions, saved delegates, listeners in other subsystems. </summary>
void OnModUnloading();/// <summary> Called on every <b>other</b> initializer when a mod is being unloaded — the reverse of the cross-mod <c>OnModLoaded</c> notification. Drop references to the dying mod's types now. </summary>
void OnModUnloaded(string modId);}}

===== FILE Core/Mods/InstallHashCache.cs =====
namespace Cthangover.Core.Mods{using System;using System.Collections.Concurrent;using System.Collections.Generic;using System.IO;using System.Security.Cryptography;using System.Text;/// <summary> Process-wide cache of <see cref="ModInfo.InstallSha256"/>. The install hash reads every file of a mod (music included), and discovery computes it for every installed mod — at boot that is acceptable, but an in-process reload (game switch, hot reload) rediscovers everything and spent most of its time re-hashing unchanged mods. A cheap stamp of the mod's files (relative path, size and modification time of every file in a folder mod; size and time of a zip) decides whether the cached hash still applies; any change recomputes it. A provider whose root is not a file or folder on disk is always hashed. </summary>
public static class InstallHashCache{private static readonly ConcurrentDictionary<string,(string Stamp,string Hash)>Cache=new(StringComparer.OrdinalIgnoreCase);/// <summary>The install hash of <paramref name="provider"/>'s mod, reused while its files are unchanged.</summary>
public static string Get(IModFileProvider provider,List<string>ignorePatterns){var root=provider?.RootPath;var stamp=root!=null?Stamp(root,ignorePatterns):null;if(stamp!=null&&Cache.TryGetValue(root,out var cached)&&cached.Stamp==stamp)return cached.Hash;var hash=ModHasher.ComputeInstallSha256(provider,ignorePatterns);if(stamp!=null)Cache[root]=(stamp,hash);return hash;}/// <summary> The change stamp of a mod root: a folder's file list with sizes and modification times, or a zip's size and time; null when the root does not exist. </summary>
public static string Stamp(string root,List<string>ignorePatterns=null){var text=new StringBuilder(string.Join("|",ignorePatterns??new List<string>()));if(File.Exists(root)){var info=new FileInfo(root);return text.Append('#').Append(info.Length).Append(':').Append(info.LastWriteTimeUtc.Ticks).ToString();}if(!Directory.Exists(root))return null;var files=new List<string>(Directory.EnumerateFiles(root,"*",SearchOption.AllDirectories));files.Sort(StringComparer.Ordinal);foreach(var file in files){var info=new FileInfo(file);text.Append('\n').Append(Path.GetRelativePath(root,file)).Append('|').Append(info.Length).Append('|').Append(info.LastWriteTimeUtc.Ticks);}return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text.ToString())));}}}

===== FILE Core/Mods/LoadedModAssembly.cs =====
using System.Collections.Generic;using System.Reflection;namespace Cthangover.Core.Mods{/// <summary> Tracking record for one mod's loaded assemblies inside its <see cref="ModLoadContext"/>: which assemblies were fanned out to the consumer registry and under which mod id. The unload path walks these records — the exact reverse of what was registered gets unregistered, so a mod's presence in the registries is fully symmetric. </summary>
public sealed class LoadedModAssembly{/// <summary>Mod id the assemblies were registered under.</summary>
public string ModId;/// <summary>The mod's collectible context; null when loaded in the default context.</summary>
public ModLoadContext Context;/// <summary>Assemblies that went through the consumer fan-out, in registration order.</summary>
public List<Assembly>Assemblies=new();/// <summary>Files loaded into <see cref="Context"/> (see <see cref="ModAssemblyLoader.ArtifactsOf"/>).</summary>
public List<string>ArtifactPaths=new();}}

===== FILE Core/Mods/MainMenuEntry.cs =====
using System.Text.Json.Serialization;namespace Cthangover.Core.Mods{/// <summary> JSON DTO for one entry of a mod manifest's <c>main_menu</c> array — the declarative way a mod adds a button to the title screen and binds a scene to it. This is the main-menu counterpart of <see cref="UiEntry"/>: same manifest-driven philosophy, but a different surface. Main menu GUI lives in the native <c>main_menu.tscn</c> (which mods cannot patch), is built by <c>MenuRegistry</c> + <c>MenuBuilder</c> and is never hosted by <c>UiHost</c>. <para/> The target scene is a mod-relative <c>.tscn</c> resolved through <see cref="Scenes.ModScenePrefab"/>. It is added as a full-rect child of the main menu root, so it is a fullscreen overlay owned by the mod itself: it closes by freeing itself (or by another mod button replacing it). <see cref="Button"/> reuses <see cref="UiButtonEntry"/> for ordering and the translation key; its icon is optional here (plain menu buttons are text-only unless the mod ships an icon), unlike panel buttons in <see cref="UiEntry"/> where the icon is mandatory. </summary>
public class MainMenuEntry{/// <summary>Unique within the owning mod; used for validation and registry lookup.</summary>
[JsonPropertyName("id")]public string Id{get;set;}/// <summary>Mod-relative path to the .tscn implementing the button's screen (fullscreen Control).</summary>
[JsonPropertyName("scene_path")]public string ScenePath{get;set;}/// <summary>Button definition: <c>locale</c> is required, <c>icon</c> optional, <c>index</c> sets the vertical order.</summary>
[JsonPropertyName("button")]public UiButtonEntry Button{get;set;}/// <summary>Owning mod id, filled during collection.</summary>
[JsonIgnore]public string ModId{get;set;}/// <summary>Position of the owning mod in load order, filled during collection.</summary>
[JsonIgnore]public int ModOrderIndex{get;set;}}}

===== FILE Core/Mods/ModAssemblyLoader.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Reflection;using System.Threading;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Items;using Cthangover.Core.Mods.Consumers;using Cthangover.Core.Mods.Discovery;using Cthangover.Core.Scenes.TscnBuild;using Cthangover.Core.Scenes.TscnParsing;using Cthangover.Core.Bootstrap;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods{/// <summary> Loads compiled assemblies (DLLs) from mods and registers their types into every reflection-driven subsystem in the game. <b>Precompiled DLL discovery</b> — <c>LoadPrecompiledDlls</c> recursively scans each mod's <c>dll/</c> directory for <c>.dll</c> files, copies them to the assembly cache to give them a stable filesystem path, and loads each one. <b>Collectible contexts</b> — each mod's assemblies load into its own collectible <see cref="ModLoadContext"/> (see the type's remarks for the resolution rules). <see cref="LoadFromPath"/>/<see cref="LoadFromBytes"/> route through the context staged for the current compile batch; they keep working for callers outside the mod pipeline (test engine, in-game script editor) which land in the default context as before. <b>Registration fan-out</b> — <see cref="RegisterAssembly"/> is the single point through which every new assembly enters the game. The subsystem list is declarative: every consumer implements <see cref="IAssemblyConsumer"/> and is discovered in the kernel assembly; mods may contribute consumers of their own. Adding a new reflection-based plugin system to the game means adding one consumer class — no edits here. <b>Unload</b> — <see cref="UnloadMod"/> runs the reverse fan-out (<see cref="AssemblyConsumerRegistry.FanOutUnloading"/>), unregisters the script paths and asks the context to release. It is idempotent per mod and safe to call for unknown mod ids (no-op with a log line). </summary>
public static class ModAssemblyLoader{static ModAssemblyLoader(){AppDomain.CurrentDomain.AssemblyResolve+=OnModAssemblyResolve;}/// <summary>Tracked, fanned-out assemblies per mod id, keyed by mod id.</summary>
private static readonly Dictionary<string,LoadedModAssembly>loadedMods=new(StringComparer.OrdinalIgnoreCase);private static readonly object loaderLock=new();private static ModLoadContext ResolveDependencyContext(string assemblySimpleName){lock(loaderLock){// Dependency mods compile first (topological order) and their context is registered the moment their entry assembly loads, so by the time a dependent resolves, the dependency context already exists here.
return loadedMods.Values.Where(l=>l.Context!=null&&l.Context.HasAssembly(assemblySimpleName)).Select(l=>l.Context).FirstOrDefault();}}private static ModLoadContext AcquireContext(string modId){lock(loaderLock){if(loadedMods.TryGetValue(modId,out var existing))return existing.Context;// Collectible contexts are created only while the mod pipeline (boot or reload) drives the load. Ad-hoc compilation — the test engine, the in-game script editor, unit tests calling ModCompiler.Compile directly — stays in the default context: an unload nobody will ever call would only add a mid-unload assembly to later AppDomain scans.
if(!pipelineBatchActive)return null;// First assembly of the mod (compiled dll or first precompiled one): create its collectible context and register it, so the dependents resolve inter-mod types through it.
var context=new ModLoadContext(modId,ResolveDependencyContext);loadedMods[modId]=new LoadedModAssembly{ModId=modId,Context=context};Unload.GodotScriptBridgeUnload.Track(context);return context;}}/// <summary> Begins a pipeline-driven load batch: while active, mod assembly loads route into collectible contexts. Called by the boot pipeline around its load phases and by ModRegistry.Reload; ad-hoc loads outside the batch stay in the default context. </summary>
public static IDisposable BeginBatch(){lock(loaderLock)pipelineBatchActive=true;return new BatchScope();}/// <summary>Ends the load batch (see BeginBatch).</summary>
public static void EndBatch(){lock(loaderLock)pipelineBatchActive=false;}private static bool pipelineBatchActive;private sealed class BatchScope:IDisposable{public void Dispose()=>EndBatch();}/// <summary> Loads a cached mod assembly from its file path into the mod's collectible context. File-backed loading keeps the runtime-resolved portable PDB working, giving debuggers line-level breakpoints in mod code and stacks with file:line (plan-mod-testing.md §9.2). Returns null on failure — callers fall back to <see cref="LoadFromBytes"/>. </summary>
public static Assembly LoadFromPath(string dllPath){return LoadFromPath(dllPath,null);}/// <summary>Context-aware overload: routes the load into <paramref name="modId"/>'s context when the pipeline batch is active; outside a batch the load lands in the default context.</summary>
public static Assembly LoadFromPath(string dllPath,string modId){try{ModLoadContext context=null;string stagedName=null;if(modId!=null){context=AcquireContext(modId);if(context!=null){stagedName=context.StageAssembly(dllPath);lock(loaderLock){if(!loadedMods.TryGetValue(modId,out var record)||record.Context==null){if(record==null)loadedMods[modId]=record=new LoadedModAssembly{ModId=modId};record.Context=context;}if(!record.ArtifactPaths.Contains(dllPath,StringComparer.OrdinalIgnoreCase))record.ArtifactPaths.Add(dllPath);}}}// Staged by the assembly's own simple name, not the file name: cached compiled mods live in "<id>_<hash>.dll".
var assembly=context!=null?context.LoadStaged(stagedName):Assembly.LoadFrom(dllPath);if(assembly==null)throw new InvalidOperationException($"assembly '{stagedName}' was staged but did not resolve in context '{context?.Name}'");RegisterScriptPaths(assembly);GameLogger.Log("MODS_ASSEMBLY",$"ModAssemblyLoader.LoadFromPath: loaded '{assembly.GetName().Name}' (context={(context?.Name ?? "default")}, pdb={System.IO.File.Exists(System.IO.Path.ChangeExtension(dllPath, ".pdb"))})");return assembly;}catch(Exception ex){// Error, not warning: the byte fallback loses the PDB (no breakpoints, no file:line in stacks) and the collectible context (no unload), so a failure here must be visible.
GameLogger.Log("MODS_ASSEMBLY",$"ModAssemblyLoader.LoadFromPath: '{dllPath}' failed, falling back to byte load without symbols: {ex.Message}",LogLevel.Error);return null;}}/// <summary> Loads a mod's separately compiled test assembly (<c>{modId}__tests</c>) file-backed into its own collectible context. Tests reference the mod's production assembly, which lives in a collectible context; a default-context (non-collectible) assembly may not reference a collectible one, so the test assembly needs a context of its own that resolves mod assemblies through their contexts (type identity stays shared with the running mod). The context is not tracked for unload: test mode quits the process. Returns null on failure (logged as an error). </summary>
public static Assembly LoadTestAssembly(string dllPath,string modId){try{var context=new ModLoadContext(modId+"__tests",ResolveDependencyContext);var name=context.StageAssembly(dllPath);var assembly=context.LoadStaged(name)??throw new InvalidOperationException($"test assembly '{name}' did not resolve in its context");RegisterScriptPaths(assembly);GameLogger.Log("MODS_ASSEMBLY",$"ModAssemblyLoader.LoadTestAssembly: loaded '{name}' (context={context.Name}, pdb={File.Exists(Path.ChangeExtension(dllPath, ".pdb"))})");return assembly;}catch(Exception ex){GameLogger.Log("MODS_ASSEMBLY",$"ModAssemblyLoader.LoadTestAssembly: '{dllPath}' failed: {ex.Message}",LogLevel.Error);return null;}}/// <summary> Loads a <c>.dll</c> assembly from raw bytes. Returns null on failure — the caller is responsible for logging. </summary>
public static Assembly LoadFromBytes(byte[]raw){try{var assembly=Assembly.Load(raw);RegisterScriptPaths(assembly);return assembly;}catch(Exception ex){GameLogger.Log("MODS_ASSEMBLY",$"ModAssemblyLoader.LoadFromBytes: loading raw data fail: {ex.Message}\n{ex.StackTrace}");return null;}}/// <summary> Feeds the assembly to GodotSharp's script path database so classes carrying <see cref="Godot.ScriptPathAttribute"/> get their lifecycle callbacks (<c>_EnterTree</c>, <c>_Ready</c>, <c>_Process</c>, ...) dispatched by the engine. Godot only scans the project assembly on its own; runtime-loaded mod assemblies must be registered here. The call is a no-op for assemblies without the <see cref="Godot.AssemblyHasScriptsAttribute"/> marker. </summary>
private static void RegisterScriptPaths(Assembly assembly){if(assembly==null)return;try{Godot.Bridge.ScriptManagerBridge.LookupScriptsInAssembly(assembly);}catch(Exception ex){GameLogger.Log("MODS_ASSEMBLY",$"ModAssemblyLoader.RegisterScriptPaths: '{assembly.GetName().Name}' failed: {ex.Message}",LogLevel.Warning);}}/// <summary> Registers all types from a newly-loaded assembly into every reflection-based plugin system through the declarative consumer registry, and records the registration for the unload pass. </summary>
public static void RegisterAssembly(Assembly assembly,string modId){if(assembly==null)return;AssemblyConsumerRegistry.EnsureInitialized();AssemblyConsumerRegistry.FanOutLoaded(assembly,modId);List<(Assembly Assembly,string ModId)>registered;lock(loaderLock){if(!loadedMods.TryGetValue(modId,out var record))loadedMods[modId]=record=new LoadedModAssembly{ModId=modId};if(!record.Assemblies.Contains(assembly))record.Assemblies.Add(assembly);if(!registrationOrder.Contains((assembly,modId)))registrationOrder.Add((assembly,modId));registered=new List<(Assembly,string)>(registrationOrder);}// Mod-contributed consumers (ModTypeConsumer registries) join after the fan-out of the assembly that declared them and catch up on every assembly registered so far, their own included (mirroring the initializers' two-way OnModLoaded catch-up): a mod's registry sees every mod regardless of load order, and no mod has to look assemblies up itself.
foreach(var consumer in AssemblyConsumerRegistry.RegisterFromAssembly(assembly)){foreach(var(previous,previousModId)in registered)AssemblyConsumerRegistry.CatchUp(consumer,previous,previousModId);}}/// <summary>Assemblies in the order they went through <see cref="RegisterAssembly"/> (replayed to late consumers).</summary>
private static readonly List<(Assembly Assembly,string ModId)>registrationOrder=new();/// <summary> Unloads one mod through the full protocol of <see cref="Unload.ModUnloader"/> (reverse fan-out, engine objects, leftover references, context release). The context cannot be collected while anything still holds a reference — verify with the ticket the protocol returns; this convenience entry only reports whether there was anything collectible to release. </summary> <returns>Null on success, otherwise a diagnostic string.</returns>
public static string UnloadMod(string modId){if(string.IsNullOrEmpty(modId))return null;bool collectible;lock(loaderLock){if(!loadedMods.TryGetValue(modId,out var record))return null;collectible=record.Context!=null;}var tree=GameEnvironment.IsRuntime?Godot.Engine.GetMainLoop()as SceneTree:null;Unload.ModUnloader.Unload(new[]{modId},tree);return collectible?null:"loaded in default context (no collectible context recorded)";}/// <summary> Removes the records of <paramref name="modIds"/> from tracking and returns them in the given order — the first step of an unload (no dependent can resolve into a context once it left the table). </summary>
internal static List<LoadedModAssembly>Detach(IEnumerable<string>modIds){var result=new List<LoadedModAssembly>();lock(loaderLock){foreach(var id in modIds??Array.Empty<string>()){if(id!=null&&loadedMods.Remove(id,out var record)){result.Add(record);for(var i=registrationOrder.Count-1;i>=0;i--){if(record.Assemblies.Contains(registrationOrder[i].Assembly))registrationOrder.RemoveAt(i);}}}}return result;}/// <summary>Tracked records of <paramref name="modIds"/>, in the given order (unknown ids skipped).</summary>
internal static List<LoadedModAssembly>Records(IEnumerable<string>modIds){var result=new List<LoadedModAssembly>();lock(loaderLock){foreach(var id in modIds??Array.Empty<string>()){if(id!=null&&loadedMods.TryGetValue(id,out var record))result.Add(record);}}return result;}/// <summary> The files a mod's context runs (compiled cache artifact, precompiled copies). A reload compares them with what the mod compiles to now: equal files mean the code did not change and the context is kept. </summary>
public static IReadOnlyList<string>ArtifactsOf(string modId){lock(loaderLock)return loadedMods.TryGetValue(modId,out var record)?record.ArtifactPaths.ToList():new List<string>();}/// <summary>True when a live mod context runs <paramref name="dllPath"/>.</summary>
public static bool IsArtifactLoaded(string dllPath){lock(loaderLock){foreach(var record in loadedMods.Values){if(record.ArtifactPaths.Contains(dllPath,StringComparer.OrdinalIgnoreCase))return true;}return false;}}/// <summary>True when the mod has assemblies tracked for a possible unload.</summary>
public static bool IsModLoaded(string modId){lock(loaderLock)return loadedMods.ContainsKey(modId);}/// <summary>Snapshot of mod ids that currently have tracked assemblies (unload candidates).</summary>
public static IReadOnlyList<string>LoadedModIds{get{lock(loaderLock)return loadedMods.Keys.ToList();}}/// <summary>Test/setup helper: forgets every tracked mod without running the unload protocol.</summary>
public static void ClearTracking(){lock(loaderLock){loadedMods.Clear();registrationOrder.Clear();}}/// <summary> Scans each loaded mod's <c>dll/</c> directory for precompiled <c>.dll</c> files, copies them to the assembly cache, and loads each as an <see cref="Assembly"/> WITHOUT registering it. Registration fan-out (see <see cref="RegisterAssembly"/>) must be performed separately, on the main thread, after this returns — this keeps the file I/O and <c>Assembly.Load</c> work thread-safe for the background boot phase while deferring anything that touches Godot. </summary> <returns>A list of <c>(modId, assembly)</c> pairs for every loaded precompiled DLL, in discovery order.</returns>
public static List<(string ModId,Assembly Assembly)>LoadPrecompiledDlls(IEnumerable<IModInfo>mods,CancellationToken cancellationToken=default)=>LoadStagedDlls(StagePrecompiledDlls(mods,cancellationToken),cancellationToken);/// <summary> Copies each mod's <c>dll/*.dll</c> into the assembly cache under a content-addressed name (<c>&lt;mod&gt;_dll_&lt;hash&gt;_&lt;file&gt;</c>) without loading anything. The hash makes the path the identity of the library: an unchanged DLL keeps its path (an in-process reload keeps the mod's context), a changed one gets a new file instead of overwriting the one a live context has memory-mapped. </summary>
public static List<(string ModId,string CachedPath)>StagePrecompiledDlls(IEnumerable<IModInfo>mods,CancellationToken cancellationToken=default){var result=new List<(string ModId,string CachedPath)>();if(!Directory.Exists(ModCompiler.CacheRoot))Directory.CreateDirectory(ModCompiler.CacheRoot);foreach(var mod in mods){cancellationToken.ThrowIfCancellationRequested();var dllFiles=CollectDllFiles(mod.FileProvider,mod.Id);if(dllFiles.Count==0)continue;GameLogger.Log("MODS_ASSEMBLY",$"ModAssemblyLoader.StagePrecompiledDlls: found {dllFiles.Count} precompiled DLL(s) in mod '{mod.Id}'");foreach(var(filePath,bytes)in dllFiles){try{var hash=Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(bytes)).Substring(0,16).ToLowerInvariant();var cachedPath=Path.Combine(ModCompiler.CacheRoot,$"{mod.Id}_dll_{hash}_{Path.GetFileName(filePath)}");if(!File.Exists(cachedPath))File.WriteAllBytes(cachedPath,bytes);result.Add((mod.Id,cachedPath));}catch(Exception ex){GameLogger.CompilationErrors.Add($"Mod '{mod.Id}', DLL '{filePath}': {ex.Message}");GameLogger.Log("MODS_ASSEMBLY",$"ModAssemblyLoader.StagePrecompiledDlls: failed to stage '{filePath}' from mod '{mod.Id}': {ex.Message}",LogLevel.Error);}}}return result;}/// <summary> Loads DLLs staged by <see cref="StagePrecompiledDlls"/> into their mods' contexts WITHOUT registering them (registration is main-thread work). Returns <c>(modId, assembly)</c> pairs in staging order. </summary>
public static List<(string ModId,Assembly Assembly)>LoadStagedDlls(IEnumerable<(string ModId,string CachedPath)>staged,CancellationToken cancellationToken=default){var result=new List<(string ModId,Assembly Assembly)>();foreach(var(modId,cachedPath)in staged){cancellationToken.ThrowIfCancellationRequested();try{var assembly=LoadFromPath(cachedPath,modId)??LoadFromBytes(File.ReadAllBytes(cachedPath));if(assembly==null)continue;ModNativeLoader.InstallResolverOnAssembly(assembly);result.Add((modId,assembly));GameLogger.Log("MODS_ASSEMBLY",$"ModAssemblyLoader.LoadStagedDlls: loaded precompiled DLL '{Path.GetFileName(cachedPath)}' of mod '{modId}'");}catch(Exception ex){GameLogger.CompilationErrors.Add($"Mod '{modId}', DLL '{cachedPath}': {ex.Message}");GameLogger.Log("MODS_ASSEMBLY",$"ModAssemblyLoader.LoadStagedDlls: failed to load '{cachedPath}' of mod '{modId}': {ex.Message}",LogLevel.Error);}}return result;}private static List<(string FilePath,byte[]Bytes)>CollectDllFiles(IModFileProvider provider,string modId){var result=new List<(string,byte[])>();CollectDllFilesRecursive(provider,"dll",result);return result;}private static void CollectDllFilesRecursive(IModFileProvider provider,string directory,List<(string,byte[])>result){foreach(var entry in provider.ListFiles(directory)){if(entry.EndsWith("/")){var subDir=entry.TrimEnd('/');CollectDllFilesRecursive(provider,subDir,result);}else if(entry.EndsWith(".dll",StringComparison.OrdinalIgnoreCase)){var bytes=provider.ReadFileBinary(entry);if(bytes!=null)result.Add((entry,bytes));}}}private static Assembly OnModAssemblyResolve(object sender,ResolveEventArgs args){var name=new AssemblyName(args.Name).Name;foreach(var asm in AppDomain.CurrentDomain.GetAssemblies()){// Collectible (mod ALC) assemblies must never be handed out through the default-context hook: resolving a non-collectible assembly into a collectible one throws "Operation is not supported" (0x80131515). Inter-mod resolution is owned by ModLoadContext.Load instead.
if(asm.IsCollectible||asm.IsDynamic)continue;if(asm.GetName().Name==name)return asm;}return null;}}}

===== FILE Core/Mods/ModCatalogExporter.cs =====
using System.IO;using System.IO.Compression;using System.Text.Json;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods{/// <summary> Generates catalog JSON entries from installed mods. Two entry points: <see cref="ExportFromFolder"/> — for folder mods under development. Creates a .zip archive next to the mod folder (if one doesn't already exist), computes both SHA256 checksums, and writes a companion .json catalog entry file. <see cref="ExportFromZip"/> — for already-packaged zip mods. Computes checksums from the existing archive and writes the companion .json file. The output .json file is named after the archive (<c>mod_name.json</c>) and placed alongside it. This file is a ready-to-embed snippet for the <c>"mods"</c> array in the online catalog. </summary>
public static class ModCatalogExporter{/// <summary> Exports a catalog entry for a folder mod. If no zip archive for this mod exists in <paramref name="outputDir"/>, one is created via <c>ZipFile.CreateFromDirectory</c>. The companion .json file is then written alongside the zip. </summary> <param name="modInfo">Loaded mod info with manifest metadata.</param> <param name="entry">Populated catalog entry DTO (download URL, category, tags, etc. must be set by the caller).</param> <param name="outputDir">Directory where the zip and json will be placed (typically the mods root directory).</param> <returns>Absolute path to the written .json file.</returns>
public static string ExportFromFolder(ModInfo modInfo,CatalogExportEntry entry,string outputDir){var folderPath=modInfo.FileProvider.RootPath;var zipName=entry.Id+".zip";var zipPath=Path.Combine(outputDir,zipName);if(!File.Exists(zipPath)){Directory.CreateDirectory(outputDir);File.Delete(zipPath+".tmp");ZipFile.CreateFromDirectory(folderPath,zipPath,CompressionLevel.Optimal,false);GameLogger.Log("MODS_EXPORT",$"Created archive '{zipPath}' from folder mod '{entry.Id}'");}var downloadEntry=EnsureDownloadEntry(entry);downloadEntry.ArchiveSha256=ModHasher.ComputeArchiveSha256(zipPath);downloadEntry.InstallSha256=ModHasher.ComputeInstallSha256(modInfo.FileProvider,ModConfig.Instance.CatalogIgnorePatterns);downloadEntry.SizeBytes=new FileInfo(zipPath).Length;var jsonPath=Path.ChangeExtension(zipPath,".json");WriteJsonFile(jsonPath,entry);GameLogger.Log("MODS_EXPORT",$"Exported catalog entry for '{entry.Id}' → '{jsonPath}'");return jsonPath;}/// <summary> Exports a catalog entry for a zip mod. The archive already exists; only the companion .json file is written. </summary> <param name="modInfo">Loaded mod info with manifest metadata.</param> <param name="entry">Populated catalog entry DTO.</param> <param name="outputDir">Directory where the json will be placed (typically the mods root directory).</param> <returns>Absolute path to the written .json file.</returns>
public static string ExportFromZip(ModInfo modInfo,CatalogExportEntry entry,string outputDir){var zipPath=modInfo.FileProvider.RootPath;var downloadEntry=EnsureDownloadEntry(entry);downloadEntry.ArchiveSha256=ModHasher.ComputeArchiveSha256(zipPath);downloadEntry.InstallSha256=ModHasher.ComputeInstallSha256(modInfo.FileProvider,ModConfig.Instance.CatalogIgnorePatterns);downloadEntry.SizeBytes=new FileInfo(zipPath).Length;var jsonPath=Path.Combine(outputDir,Path.GetFileNameWithoutExtension(zipPath)+".json");WriteJsonFile(jsonPath,entry);GameLogger.Log("MODS_EXPORT",$"Exported catalog entry for '{entry.Id}' → '{jsonPath}'");return jsonPath;}private static CatalogEntryDownload EnsureDownloadEntry(CatalogExportEntry entry){entry.Downloads??=new System.Collections.Generic.List<CatalogEntryDownload>();var downloadEntry=entry.Downloads.Count>0?entry.Downloads[0]:null;if(downloadEntry==null|| !string.Equals(downloadEntry.Version,entry.Version,System.StringComparison.OrdinalIgnoreCase)){downloadEntry=new CatalogEntryDownload{Version=entry.Version};entry.Downloads.Add(downloadEntry);}return downloadEntry;}private static void WriteJsonFile(string path,CatalogExportEntry entry){var json=JsonSerializer.Serialize(entry,JsonOptionsProvider.WriteIndentedIgnoreNullOptions);File.WriteAllText(path,json);}}}

===== FILE Core/Mods/ModCompileArtifact.cs =====
namespace Cthangover.Core.Mods{using System.Collections.Generic;/// <summary> One mod compiled into the assembly cache but not loaded yet (<see cref="ModCompiler.PrepareAll"/>): the artifact path is the mod's code identity — same sources and dependencies give the same file — and the sources feed the script-type registry once the assembly loads. </summary>
public sealed class ModCompileArtifact{/// <summary>Records one compiled mod; <paramref name="artifactPath"/> is the cache file <c>&lt;id&gt;_&lt;hash&gt;.dll</c>.</summary>
public ModCompileArtifact(string modId,string artifactPath,List<(string FilePath,string Source)>sources){ModId=modId;ArtifactPath=artifactPath;Sources=sources;}/// <summary>The compiled mod.</summary>
public string ModId{get;}/// <summary>Content-addressed DLL in the assembly cache (PDB next to it).</summary>
public string ArtifactPath{get;}/// <summary>The sources it was compiled from, for <see cref="ScriptTypeRegistry.RegisterAssembly"/>.</summary>
public List<(string FilePath,string Source)>Sources{get;}}}

===== FILE Core/Mods/ModCompiler.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Reflection;using System.Threading;using Cthangover.Core.Bootstrap;using Cthangover.Core.Utils;using Cthangover.Core.Mods.Compilation;using Microsoft.CodeAnalysis;using Microsoft.CodeAnalysis.CSharp;namespace Cthangover.Core.Mods{/// <summary> Roslyn-based C# compiler for mod source files. The compilation pipeline is: 1. <b>Source collection</b> — walks each mod's declared source patterns (from <c>manifest.json</c>'s <c>sources</c> list), matching <c>.cs</c> files. Falls back to scanning the entire mod root if no patterns are declared, so a mod that just drops <c>.cs</c> files anywhere still compiles. 2. <b>Topological sort</b> — orders mods by their <c>depends</c> declarations so that a mod referencing another mod's types compiles after its dependency. Circular dependencies log a warning and break the cycle rather than deadlocking. 3. <b>Assembly reference discovery</b> — scans every loaded assembly's directory and the Godot build output directory for <c>.dll</c> files, adding them as metadata references so that mod code can call into any type in the game. 4. <b>SHA-256 output caching</b> — the compilation output is stored at <c>user://mod_cache/assemblies/{modId}_{hash}.dll</c>. On subsequent loads the hash is recomputed from source files; if it matches a cached DLL, recompilation is skipped entirely. The <c>use_assembly_cache</c> config toggle can force recompilation even when a cached DLL exists. 5. <b>Registration</b> — successful assemblies are passed to <c>ModAssemblyLoader.RegisterAssembly</c>, which fans them out to every reflection-based plugin registry in the game. <c>CompileString</c> is a separate entry point for ad-hoc code compilation (used by tools and the in-game script editor). This class is the thin orchestrator of the pipeline; each step lives in its own type in <see cref="Cthangover.Core.Mods.Compilation"/>: <see cref="ModSourceCollector"/>, <see cref="ModCompileOrder"/>, <see cref="ModReferenceProvider"/>, <see cref="ModAssemblyCache"/> and <see cref="GodotGeneratorHost"/>. </summary>
public static class ModCompiler{/// <summary> Absolute path to the assembly cache directory (<c>user://mod_cache/assemblies/</c>). Resolved once at static init time with a fallback to the OS temp folder. Storage and invalidation live in <see cref="ModAssemblyCache"/>; this wrapper keeps the historical <c>ModCompiler.CacheRoot</c> access path for the assembly loader and the mod test engine. </summary>
public static string CacheRoot=>ModAssemblyCache.CacheRoot;/// <summary> Compiles all mods that declare source files in their manifest and returns the resulting assemblies WITHOUT registering them. Mods are sorted topologically by dependencies before compilation; results are cached on disk by SHA-256 content hash. This is the pure-.NET phase of mod loading — it only does Roslyn compilation and file I/O, so it can run on a background thread during boot. Registration into the game's registries (which may instantiate Godot types) must happen later on the main thread via <see cref="ModAssemblyLoader.RegisterAssembly"/> and <see cref="ScriptTypeRegistry.RegisterAssembly"/>. </summary> <returns>A list of <c>(modId, assembly, sourceFiles)</c> tuples for every successfully compiled mod, in dependency order.</returns>
public static List<(string ModId,Assembly Assembly,List<(string FilePath,string Source)>Sources)>CompileAll(IDictionary<string,IModInfo>mods,CancellationToken cancellationToken=default){var result=new List<(string ModId,Assembly Assembly,List<(string FilePath,string Source)>Sources)>();foreach(var compiled in PrepareAll(mods,cancellationToken)){var assembly=ModAssemblyLoader.LoadFromPath(compiled.ArtifactPath,compiled.ModId)??ModAssemblyLoader.LoadFromBytes(File.ReadAllBytes(compiled.ArtifactPath));if(assembly==null)continue;result.Add((compiled.ModId,assembly,compiled.Sources));GameLogger.Log("MODS_COMPILE",$"ModCompiler.CompileAll: Compiled mod '{compiled.ModId}', {compiled.Sources.Count} source file(s)");}return result;}/// <summary> The compile half of <see cref="CompileAll"/>: every mod with sources is compiled into the cache in dependency order (each dependent against the artifact its dependency just produced) and nothing is loaded. Failed mods are left out of the result and reported through <see cref="GameLogger.CompilationErrors"/> (cleared first), which is how an in-process reload learns that it must not touch the running set. Pure .NET, safe on a background thread. </summary>
public static List<ModCompileArtifact>PrepareAll(IDictionary<string,IModInfo>mods,CancellationToken cancellationToken=default){var result=new List<ModCompileArtifact>();cancellationToken.ThrowIfCancellationRequested();GameLogger.CompilationErrors.Clear();if(!Directory.Exists(CacheRoot))Directory.CreateDirectory(CacheRoot);// Sorted over the whole set, then filtered: a dependency without code (shell2d_base) still orders its own dependencies (time_base) before the mods that reach them through it (audio_base).
var infos=new List<ModInfo>();foreach(var mod in mods.Values){if(mod is ModInfo{Manifest:not null}info)infos.Add(info);}var sorted=new List<ModInfo>();foreach(var info in ModCompileOrder.TopologicalSort(infos)){if(HasSources(info))sorted.Add(info);}if(sorted.Count==0)return result;var done=0;foreach(var modInfo in sorted){cancellationToken.ThrowIfCancellationRequested();// Fine-grained progress for the launcher boot bar: every mod is one step of the Discovering phase, cache hits included.
BootProgress.ReportSub(done++ /(float)sorted.Count);try{var sourceFiles=ModSourceCollector.CollectSourceFiles(modInfo,cancellationToken);if(sourceFiles.Count==0)continue;GameLogger.Log("MODS_COMPILE",$"ModCompiler.CompileAll: compiling mod '{modInfo.Id}' with {sourceFiles.Count} source file(s):");foreach(var(path,_)in sourceFiles)GameLogger.Log("MODS_COMPILE",$"  {path}",LogLevel.Debug);var dependencySignature=ModAssemblyCache.BuildDependencySignature(modInfo,mods);if(!string.IsNullOrEmpty(dependencySignature))GameLogger.Log("MODS_COMPILE",$"  dependencies: {dependencySignature}",LogLevel.Debug);// A mod whose dependency failed to compile would only produce cascading errors against a stale dependency artifact.
var failedDependency=FailedDependency(modInfo,mods,result);if(failedDependency!=null){GameLogger.CompilationErrors.Add($"Mod '{modInfo.Id}': dependency '{failedDependency}' did not compile");GameLogger.Log("MODS_COMPILE",$"ModCompiler.CompileAll: skipped '{modInfo.Id}': dependency '{failedDependency}' did not compile",LogLevel.Error);continue;}var visibleModIds=DependencyClosure(modInfo,mods);var artifact=EnsureCompiled(modInfo.Id,sourceFiles,dependencySignature,cancellationToken,visibleModIds,modInfo.FileProvider?.RootPath);if(artifact!=null)result.Add(new ModCompileArtifact(modInfo.Id,artifact,sourceFiles));}catch(OperationCanceledException)when(cancellationToken.IsCancellationRequested){throw;}catch(Exception ex){GameLogger.CompilationErrors.Add($"Mod '{modInfo.Id}': {ex.Message}");GameLogger.Log("MODS_COMPILE",$"ModCompiler.CompileAll: Failed to compile mod '{modInfo.Id}': {ex.Message}\n{ex.StackTrace}",LogLevel.Error);}}return result;}private static bool HasSources(IModInfo mod)=>mod is ModInfo{Manifest.Sources.Count:>0};/// <summary>A dependency (transitive) of <paramref name="modInfo"/> that has sources but is not among the compiled <paramref name="compiled"/>; null when all compiled.</summary>
private static string FailedDependency(ModInfo modInfo,IDictionary<string,IModInfo>mods,List<ModCompileArtifact>compiled){foreach(var id in DependencyClosure(modInfo,mods)){if(!mods.TryGetValue(id,out var dependency)|| !HasSources(dependency))continue;var found=false;foreach(var artifact in compiled){if(string.Equals(artifact.ModId,id,StringComparison.OrdinalIgnoreCase)){found=true;break;}}if(!found)return id;}return null;}/// <summary> Mods whose assemblies <paramref name="modInfo"/> may reference: the transitive closure of its manifest <c>depends</c>. Every other mod stays invisible to the compiler, so a type used without a matching dependency fails with a compile error (file:line) instead of compiling by accident of load order. </summary>
public static List<string>DependencyClosure(ModInfo modInfo,IDictionary<string,IModInfo>mods){var visible=new HashSet<string>(StringComparer.OrdinalIgnoreCase);var pending=new Stack<string>(modInfo?.Manifest?.DependencyIds??new List<string>());while(pending.Count>0){var id=pending.Pop();if(!visible.Add(id))continue;if(mods!=null&&mods.TryGetValue(id,out var dependency)&&dependency is ModInfo dependencyInfo){foreach(var next in dependencyInfo.Manifest?.DependencyIds??new List<string>())pending.Push(next);}}return visible.ToList();}/// <summary> Compiles a set of source files into an in-memory assembly. First checks the SHA-256 output cache; skips compilation if a cached DLL exists and <c>UseAssemblyCache</c> is true. </summary>
public static Assembly Compile(string modId,IEnumerable<(string FilePath,string Source)>sources,CancellationToken cancellationToken=default){return Compile(modId,sources,null,cancellationToken);}/// <summary> Same as <see cref="Compile(string, IEnumerable{(string FilePath, string Source)}, CancellationToken)"/>, but mixes <paramref name="dependencySignature"/> into the cache key. The signature describes the mod's dependencies (id + resolved version), so a renamed or version-bumped dependency invalidates the cached assembly even when this mod's own sources did not change. Without it, a cached DLL keeps linking against the old dependency assembly name and fails to load. When <paramref name="knownModIds"/> is provided (production CompileAll does this), the reference scan of the compilation cache references at most one DLL per known mod id and ignores artifacts of unknown origin (e.g. <c>&lt;id&gt;_compile_test_&lt;hash&gt;.dll</c> from the compile guard). </summary>
public static Assembly Compile(string modId,IEnumerable<(string FilePath,string Source)>sources,string dependencySignature,CancellationToken cancellationToken=default,IReadOnlyCollection<string>knownModIds=null){var cachedPath=EnsureCompiled(modId,sources,dependencySignature,cancellationToken,knownModIds);if(cachedPath==null)return null;// Disk load keeps the module file-backed so the adjacent PDB binds; byte load stays the fallback (plan §9.2). The modId overload routes the load into the mod's collectible context so a later unload can release it.
return ModAssemblyLoader.LoadFromPath(cachedPath,modId)??ModAssemblyLoader.LoadFromBytes(File.ReadAllBytes(cachedPath));}/// <summary> The compile half of <see cref="Compile(string, IEnumerable{(string FilePath, string Source)}, string, CancellationToken, IReadOnlyCollection{string})"/> without the load: makes sure the cache holds the assembly for these sources and returns its path (null on a compile error, which is logged and added to <see cref="GameLogger.CompilationErrors"/>). An in-process mod reload compiles every changed mod this way first and touches nothing loaded until all of them compiled, so a typo leaves the running game on the previous version. Records the result in <see cref="ModAssemblyCache.CurrentArtifacts"/> for the dependents. <paramref name="modRoot"/> is the mod's directory on disk; syntax trees are parsed with paths under it so the Godot generator emits a correct <c>[ScriptPath]</c> (see <see cref="ModReferenceProvider.ResolveSourcePath"/>). </summary>
public static string EnsureCompiled(string modId,IEnumerable<(string FilePath,string Source)>sources,string dependencySignature,CancellationToken cancellationToken=default,IReadOnlyCollection<string>knownModIds=null,string modRoot=null){var sourceList=sources.ToList();if(sourceList.Count==0)return null;cancellationToken.ThrowIfCancellationRequested();var generators=GodotGeneratorHost.GetGodotGenerators();var cachedPath=ModAssemblyCache.BuildCachePath(modId,sourceList,dependencySignature,generators!=null&&generators.Length>0);if(ModAssemblyCache.IsUsableHit(cachedPath)){ModAssemblyCache.CurrentArtifacts[modId]=cachedPath;return cachedPath;}var parseOptions=new CSharpParseOptions();// Encoding is mandatory for PDB emission: source strings read from mod providers carry no BOM, and Roslyn refuses to emit debug information for trees without an explicit encoding.
var syntaxTrees=sourceList.Select(s=>CSharpSyntaxTree.ParseText(s.Source,options:parseOptions,path:ModReferenceProvider.ResolveSourcePath(modId,s.FilePath,modRoot),encoding:System.Text.Encoding.UTF8,cancellationToken:cancellationToken)).ToList();var metadataReferences=new List<MetadataReference>();AddCoreReferences(metadataReferences,modId,knownModIds);var compilation=CSharpCompilation.Create(assemblyName:modId,syntaxTrees:syntaxTrees,references:metadataReferences,options:new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary).WithOptimizationLevel(OptimizationLevel.Debug));compilation=GodotGeneratorHost.RunGodotGenerators(generators,compilation,modId);cancellationToken.ThrowIfCancellationRequested();using var ms=new MemoryStream();// Portable PDB alongside the DLL: with the module file-backed on disk, the debugger resolves symbols by naming convention and stacks carry file:line (plan-mod-testing.md §9.2). Roslyn's default EmitOptions already emit PortablePdb when a pdb stream is provided.
using var pdb=new MemoryStream();// The debug directory must name the PDB file actually written next to the DLL: by default Roslyn records "<assemblyName>.pdb", but the cache stores "<id>_<hash>.pdb", so neither the runtime (file:line in stack traces) nor a debugger would find the symbols.
var result=compilation.Emit(ms,pdb,options:new Microsoft.CodeAnalysis.Emit.EmitOptions(debugInformationFormat:Microsoft.CodeAnalysis.Emit.DebugInformationFormat.PortablePdb,pdbFilePath:Path.ChangeExtension(cachedPath,".pdb")));cancellationToken.ThrowIfCancellationRequested();if(!result.Success){var errors=result.Diagnostics.Where(d=>d.Severity==DiagnosticSeverity.Error).Take(20).Select(FormatDiagnostic).ToList();GameLogger.CompilationErrors.Add($"Mod '{modId}' failed with {result.Diagnostics.Count(d => d.Severity == DiagnosticSeverity.Error)} error(s):\n  "+string.Join("\n  ",errors));var errCount=0;foreach(var d in result.Diagnostics.Where(d=>d.Severity==DiagnosticSeverity.Error).Take(20)){GameLogger.Log("MODS_COMPILE",$"  ERR{++errCount}: {FormatDiagnostic(d)}",LogLevel.Debug);}GameLogger.Log("MODS_COMPILE",$"ModCompiler.Compile: '{modId}' FAILED with {result.Diagnostics.Count(d => d.Severity == DiagnosticSeverity.Error)} error(s)",LogLevel.Error);return null;}ModAssemblyCache.Store(cachedPath,ms,pdb);GameLogger.Log("MODS_COMPILE",$"ModCompiler.Compile: Compilation of mod '{modId}' with {sourceList.Count} source files - success -> {cachedPath}");ModAssemblyCache.CurrentArtifacts[modId]=cachedPath;return cachedPath;}/// <summary> Formats a Roslyn diagnostic the way IDEs and terminals link it: <c>path(line,col): error CSxxxx: message</c> with 1-based line and column, instead of the default character-span form <c>SourceFile(path[3904..3923))</c>. </summary>
public static string FormatDiagnostic(Diagnostic d){var severity=d.Severity.ToString().ToLowerInvariant();if(!d.Location.IsInSource)return$"{severity} {d.Id}: {d.GetMessage()}";var span=d.Location.GetLineSpan();var start=span.StartLinePosition;return$"{span.Path}({start.Line + 1},{start.Character + 1}): {severity} {d.Id}: {d.GetMessage()}";}/// <summary> Compiles an arbitrary C# string into an assembly. Used by tools and the in-game script editor; does not check the output cache. Returns a <c>CompileResult</c> with errors on failure. </summary>
public static CompileResult CompileString(string code,string scriptId){var syntaxTree=CSharpSyntaxTree.ParseText(code,options:new CSharpParseOptions(preprocessorSymbols:new[]{"TOOLS","DEBUG","LOG_MODS_COMPILE"}),path:$"{scriptId}.cs");var references=new List<MetadataReference>();AddCoreReferences(references);var compilation=CSharpCompilation.Create(assemblyName:scriptId,syntaxTrees:new[]{syntaxTree},references:references,options:new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary).WithOptimizationLevel(OptimizationLevel.Debug));using var ms=new MemoryStream();var result=compilation.Emit(ms);if(!result.Success){var errors=new List<string>();foreach(var d in result.Diagnostics){if(d.Severity==DiagnosticSeverity.Error)errors.Add(d.GetMessage());}return new CompileResult{Success=false,Errors=errors};}var assembly=ModAssemblyLoader.LoadFromBytes(ms.ToArray());return new CompileResult{Success=true,Assembly=assembly};}/// <summary> Delegating entry point kept on <see cref="ModCompiler"/> because the mod test engine resolves it by name via reflection (<c>GetMethod("AddCoreReferences", NonPublic | Static)</c>) to compile test assemblies against exactly the same reference set as production mods. The implementation lives in <see cref="ModReferenceProvider.Collect"/>; the parameter shape must stay reflection-callable. </summary>
private static void AddCoreReferences(List<MetadataReference>references,string currentModId=null,IReadOnlyCollection<string>knownModIds=null){ModReferenceProvider.Collect(references,currentModId,knownModIds);}/// <summary> Resolves the Godot project root and Mono build output directory once on the main thread. Compilation may run on a background thread during boot, where <c>ProjectSettings.GlobalizePath</c> is not allowed, so the resolved paths are cached and reused by the reference scan. Must be called before the first background compilation. </summary>
public static void WarmUpPaths(){ModReferenceProvider.WarmUpPaths();}/// <summary> Deletes all cached compilation output for a specific mod. Returns true if any files were deleted. </summary>
public static bool InvalidateCache(string modId){return ModAssemblyCache.InvalidateCache(modId);}}}

===== FILE Core/Mods/ModConfig.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Text.Json;using System.Text.Json.Serialization;using Cthangover.Core.Bootstrap;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods{/// <summary> Singleton configuration bridge between the mod system and the JSON config file at <c>config/mod_config.json</c>. Centralises every mod-related tunable — which file extensions map to which asset pipeline, which resource groups are scanned for textures, whether compiled assembly caching is enabled, and per-factory LRU cache size overrides. The extension sets are pre-converted to <c>HashSet</c> on first access so that repeated lookups during file scanning are O(1) rather than O(n) list scans. <para/> The single instance is owned by the <see cref="AppServices"/> composition root; the static facade stays as the public API during migration, with laziness preserved by the owning <c>Lazy</c> slot. </summary>
public class ModConfig{/// <summary> Facade over the AppServices-owned instance; kept as the public API during migration. Thread safety and laziness come from the owning <c>Lazy</c> slot; the instance is loaded from config/mod_config.json on first access at runtime. </summary>
public static ModConfig Instance=>AppServices.Current.ModConfigService.Value;/// <summary> Creates the instance for the composition root: a fresh config with defaults, populated from config/mod_config.json when running inside the Godot engine. Outside the runtime (unit tests) Load returns early, so the instance carries pure defaults. </summary>
internal static ModConfig CreateInstance(){var config=new ModConfig();config.Load();return config;}/// <summary>File extensions treated as audio by the audio factories (.ogg, .wav).</summary>
[JsonPropertyName("audio_extensions")]public List<string>AudioExtensions{get;set;}=new(){".ogg",".wav"};/// <summary>File extensions treated as textures (.png, .jpg, .jpeg).</summary>
[JsonPropertyName("texture_extensions")]public List<string>TextureExtensions{get;set;}=new(){".png",".jpg",".jpeg"};/// <summary>File extensions treated as shaders (.gdshader, .gdshaderinclude).</summary>
[JsonPropertyName("shader_extensions")]public List<string>ShaderExtensions{get;set;}=new(){".gdshader",".gdshaderinclude"};/// <summary> Mod directory groups that are scanned for textures. Each group is a top-level directory inside a mod (e.g. "avatars", "items"). <c>CollectTextures</c> merges files from all these groups into a single flat namespace. </summary>
[JsonPropertyName("texture_groups")]public List<string>TextureGroups{get;set;}=new(){"ui","backgrounds","avatars","icons","items","characters","effects","skills"};/// <summary> When true, cached compilation output is reused across runs. When false, cached DLLs are deleted and recompiled every time — useful during mod development. </summary>
[JsonPropertyName("use_assembly_cache")]public bool UseAssemblyCache{get;set;}=true;/// <summary>Per-factory cache configuration (root path, size overrides).</summary>
[JsonPropertyName("cache")]public CacheConfig Cache{get;set;}=new();/// <summary> Master gate of the mod unit-test mode (plan-mod-testing.md §3.1/§5.2). When false (the default) <see cref="Cthangover.Core.Mods.Testing.ModTestEngine"/> is never activated by config alone: no test compilation, no <c>__tests</c> assemblies in memory. The CLI flag <c>--run-tests</c> overrides this setting for a single run. </summary>
[JsonPropertyName("run_mod_tests")]public bool RunModTests{get;set;}=false;/// <summary> Mod ids temporarily excluded from the mod unit-test run — lets an author disable a foreign broken mod from the shared run without editing that mod. Applies only in test mode. </summary>
[JsonPropertyName("mod_test_exclude")]public List<string>ModTestExclude{get;set;}=new();/// <summary> File path patterns to exclude when computing the mod's install SHA256 checksum for catalog export. Each pattern is a simple glob matched against path components. Default: <c>".*"</c> excludes all files and directories whose name starts with a dot (<c>.godot/</c>, <c>.import/</c>, <c>.gitignore</c>, etc.). </summary>
[JsonPropertyName("catalog_ignore_patterns")]public List<string>CatalogIgnorePatterns{get;set;}=new(){".*"};/// <summary> URLs of catalog JSON files to fetch when browsing online mods. Each URL should point to a JSON document following the catalog schema (a <c>CatalogRoot</c> envelope containing a <c>"mods"</c> array of <see cref="CatalogExportEntry"/> objects). Multiple URLs are merged and deduplicated by mod ID (highest version wins). </summary>
[JsonPropertyName("catalog_urls")]public List<string>CatalogUrls{get;set;}=new();private HashSet<string>textureExtensionSet;private HashSet<string>shaderExtensionSet;/// <summary> Returns a lazily-built <c>HashSet</c> of texture extensions for O(1) membership tests during file scanning. </summary>
public HashSet<string>GetTextureExtensionSet(){if(textureExtensionSet==null)textureExtensionSet=new HashSet<string>(TextureExtensions,StringComparer.OrdinalIgnoreCase);return textureExtensionSet;}/// <summary> Returns a lazily-built <c>HashSet</c> of shader extensions for O(1) membership tests during file scanning. </summary>
public HashSet<string>GetShaderExtensionSet(){if(shaderExtensionSet==null)shaderExtensionSet=new HashSet<string>(ShaderExtensions,StringComparer.OrdinalIgnoreCase);return shaderExtensionSet;}/// <summary> Parameterless constructor used by the JSON deserializer (<c>[JsonConstructor]</c>) when reading mod_config.json; all fields are populated through property setters. </summary>
[JsonConstructor]public ModConfig(){}private void Load(){if(!GameEnvironment.IsRuntime)return;var configPath=ProjectSettings.GlobalizePath("res://config/mod_config.json");if(!File.Exists(configPath)){GameLogger.Log("CONFIG","mod_config.json not found, using defaults",LogLevel.Error);return;}try{var json=File.ReadAllText(configPath);var parsed=JsonSerializer.Deserialize<ModConfig>(json,JsonOptionsProvider.ReadOptions);if(parsed==null)return;TextureExtensions=parsed.TextureExtensions??new List<string>();ShaderExtensions=parsed.ShaderExtensions??new List<string>();TextureGroups=parsed.TextureGroups??new List<string>();UseAssemblyCache=parsed.UseAssemblyCache;Cache=parsed.Cache??new CacheConfig();CatalogIgnorePatterns=parsed.CatalogIgnorePatterns??new List<string>{".*"};CatalogUrls=parsed.CatalogUrls??new List<string>();RunModTests=parsed.RunModTests;ModTestExclude=parsed.ModTestExclude??new List<string>();GameLogger.Log("CONFIG",$"Loaded mod_config.json: {TextureExtensions.Count} texture ext, {ShaderExtensions.Count} shader ext, {TextureGroups.Count} texture groups");}catch(Exception ex){GameLogger.Log("CONFIG",$"Failed to parse mod_config.json: {ex.Message}",LogLevel.Error);}}}}

===== FILE Core/Mods/ModConflictEntry.cs =====
namespace Cthangover.Core.Mods{/// <summary> A record of a conflict between two mods over a single resource. </summary>
public class ModConflictEntry{/// <summary>"character_property", "item_property", "texture", "interactive", "scene", "file"</summary>
public string ResourceType{get;set;}/// <summary>"Marao.Skills", "sword.png", "home_kitchen"</summary>
public string ResourceId{get;set;}/// <summary>The mod whose value became final.</summary>
public string WinningModId{get;set;}/// <summary>The mod whose value was overridden.</summary>
public string OverriddenModId{get;set;}/// <summary>true if arrays were concatenated, false if full replacement.</summary>
public bool WasConcatenated{get;set;}/// <summary>Optional explanation.</summary>
public string Note{get;set;}}}

===== FILE Core/Mods/ModConflictRegistry.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Bootstrap;namespace Cthangover.Core.Mods{/// <summary> Conflict registry between mods. ModManager records each resource override here. UI reads it to display to the player. <para/> Entries are filled lazily by ModManager during validation — there is no manifest source to re-read, so <see cref="Rebuild"/> only resets the table. The registry instance lives in <see cref="AppServices.Current"/>; the static members below are a facade delegating to it, so existing callers keep working unchanged. </summary>
public class ModConflictRegistry:IGameRegistry{private readonly object _lock=new();private readonly List<ModConflictEntry>_conflicts=new();/// <summary>Shared instance the static facade delegates to.</summary>
private static ModConflictRegistry Owned=>AppServices.Current.Conflicts;/// <summary> Creates an empty registry: cheap and side-effect free per the IGameRegistry contract. </summary>
internal ModConflictRegistry(){}/// <inheritdoc />
public string Id=>"mod_conflicts";/// <summary> Entries are recorded lazily by ModManager during validation — nothing to re-read from manifests, so rebuilding just resets the table. </summary>
public void Rebuild(){Reset();}/// <summary> Drops all recorded conflicts; the next validation pass refills them. Called on mod reload through AppServices.ClearRegistries. </summary>
void IGameRegistry.Clear(){Reset();}// ---- Static facade (legacy API, unchanged for callers) ------------ <summary>Snapshot of every recorded conflict (one entry per overridden resource).</summary>
public static IReadOnlyList<ModConflictEntry>Conflicts=>Owned.Snapshot();/// <summary> Records one resource override: <paramref name="winningModId"/>'s value became final and <paramref name="overriddenModId"/>'s was replaced. Array concatenations set <paramref name="wasConcatenated"/> so the UI can distinguish merged lists from full replacements. </summary>
public static void Record(string resourceType,string resourceId,string winningModId,string overriddenModId,bool wasConcatenated,string note=null)=>Owned.RecordConflict(resourceType,resourceId,winningModId,overriddenModId,wasConcatenated,note);/// <summary>Conflicts where the given mod's value won (it overrides others).</summary>
public static IReadOnlyList<ModConflictEntry>GetOverridesBy(string modId)=>Owned.SelectOverridesBy(modId);/// <summary>Conflicts where the given mod's value was overridden by another mod.</summary>
public static IReadOnlyList<ModConflictEntry>GetOverridesOf(string modId)=>Owned.SelectOverridesOf(modId);/// <summary>Drops all recorded conflicts; the next validation pass refills them.</summary>
public static void Clear()=>Owned.Reset();// ---- Instance implementation -------------------------------------- <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public void Reset(){lock(_lock)_conflicts.Clear();}/// <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public IReadOnlyList<ModConflictEntry>Snapshot(){lock(_lock)return _conflicts.ToList();}/// <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public void RecordConflict(string resourceType,string resourceId,string winningModId,string overriddenModId,bool wasConcatenated,string note=null){lock(_lock){_conflicts.Add(new ModConflictEntry{ResourceType=resourceType,ResourceId=resourceId,WinningModId=winningModId,OverriddenModId=overriddenModId,WasConcatenated=wasConcatenated,Note=note,});}}/// <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public IReadOnlyList<ModConflictEntry>SelectOverridesBy(string modId){lock(_lock)return _conflicts.Where(c=>c.WinningModId==modId).ToList();}/// <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public IReadOnlyList<ModConflictEntry>SelectOverridesOf(string modId){lock(_lock)return _conflicts.Where(c=>c.OverriddenModId==modId).ToList();}}}

===== FILE Core/Mods/ModDependency.cs =====
using System;using System.Text.RegularExpressions;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods{/// <summary> Parsed dependency from manifest.json "depends". String is parsed as: [modId][op][version]["?"] Where op is one of: ">=", "<=", ">", "<", "=", "^", "" (any version). "?" at the end — optional dependency: if mod is missing — not an error, but if present — version is checked. Version comparison — per-segment (paddedCompare). Segments may contain letters (1a, alpha-3, 1b.2.0). Each segment: if both parse as int — numeric comparison, otherwise string.CompareOrdinal. Both arrays are padded with "0" to equal length before comparison. </summary>
public readonly struct ModDependency{private static readonly Regex ParseRegex=new(@"^([a-zA-Z_][a-zA-Z0-9_]*)(>=|<=|>|<|=|\^)?([a-zA-Z0-9][a-zA-Z0-9-]*(?:\.[a-zA-Z0-9-]+)*)?(\?)?$",RegexOptions.Compiled);/// <summary>Dependency mod id (the part before any operator).</summary>
public string ModId{get;init;}/// <summary>Version operator: <c>">="</c>, <c>"&lt;="</c>, <c>"&gt;"</c>, <c>"&lt;"</c>, <c>"="</c>, <c>"^"</c>, or empty for "any version".</summary>
public string Operator{get;init;}/// <summary>Raw version string as written in the manifest (e.g. <c>"1.0"</c>), or null when unconstrained.</summary>
public string VersionRaw{get;init;}/// <summary>Version split on <c>.</c>; null when no version was given.</summary>
public string[]VersionSegments{get;}/// <summary>True for a <c>modId[op][version]?</c> dependency — missing mod is tolerated, a present one must still satisfy the pin.</summary>
public bool IsOptional{get;init;}/// <summary>True when the string parsed into a well-formed dependency.</summary>
public bool IsValid{get;}/// <summary>Parse failure reason; meaningful only when <see cref="IsValid"/> is false.</summary>
public string Error{get;}/// <summary>Creates the invalid default instance (see <see cref="Error"/>).</summary>
public ModDependency(){ModId=string.Empty;Operator=string.Empty;VersionRaw=null;VersionSegments=null;IsOptional=false;IsValid=false;Error="Default-constructed ModDependency is not valid";}private ModDependency(string modId,string op,string versionRaw,string[]segments,bool isOptional,bool isValid,string error){ModId=modId;Operator=op;VersionRaw=versionRaw;VersionSegments=segments;IsOptional=isOptional;IsValid=isValid;Error=error;}/// <summary> Parses a manifest dependency string of the form <c>modId[op][version][?]</c>. Returns an invalid dependency (with <see cref="Error"/> set) for null, empty or malformed input instead of throwing, so one bad line cannot break manifest loading. </summary>
public static ModDependency Parse(string raw){if(string.IsNullOrWhiteSpace(raw))return new ModDependency(string.Empty,string.Empty,null,null,false,false,"Dependency string is null or empty");var trimmed=raw.Trim();var match=ParseRegex.Match(trimmed);if(!match.Success){return new ModDependency(string.Empty,string.Empty,null,null,false,false,$"Invalid dependency format: '{trimmed}'");}var modId=match.Groups[1].Value;var op=match.Groups[2].Success?match.Groups[2].Value:string.Empty;var versionRaw=match.Groups[3].Success?match.Groups[3].Value:null;var isOptional=match.Groups[4].Success;string[]segments=null;if(versionRaw!=null){segments=versionRaw.Split('.');}return new ModDependency(modId,op,versionRaw,segments,isOptional,true,null);}/// <summary> Checks the given installed version against this dependency's pin. Comparison is per-segment with zero-padding; <c>=</c> compares every segment (padded with "0") and <c>^</c> compares only the pinned prefix. An empty operator accepts any version. </summary>
public bool IsSatisfiedBy(string actualVersion){if(!IsValid)return false;if(string.IsNullOrEmpty(Operator))return true;var actualSegments=ParseSegments(actualVersion);var requiredSegments=VersionSegments;if(requiredSegments==null||requiredSegments.Length==0)return Operator=="";switch(Operator){case">=":return VersionHelper.CompareSegments(actualSegments,requiredSegments)>=0;case"<=":return VersionHelper.CompareSegments(actualSegments,requiredSegments)<=0;case">":return VersionHelper.CompareSegments(actualSegments,requiredSegments)>0;case"<":return VersionHelper.CompareSegments(actualSegments,requiredSegments)<0;case"=":return ExactMatch(actualSegments,requiredSegments);case"^":return PrefixMatch(actualSegments,requiredSegments);default:return false;}}private static string[]ParseSegments(string version){if(string.IsNullOrWhiteSpace(version))return new[]{"0"};return version.Trim().Split('.');}private static bool ExactMatch(string[]actual,string[]required){var maxLen=Math.Max(actual.Length,required.Length);for(int i=0;i<maxLen;i++){var a=i<actual.Length?actual[i]:"0";var r=i<required.Length?required[i]:"0";if(!string.Equals(a,r,StringComparison.Ordinal))return false;}return true;}private static bool PrefixMatch(string[]actual,string[]required){for(int i=0;i<required.Length;i++){var a=i<actual.Length?actual[i]:"0";if(!string.Equals(a,required[i],StringComparison.Ordinal))return false;}return true;}/// <summary>Restores the canonical <c>modId[op][version][?]</c> string; empty when invalid.</summary>
public override string ToString(){if(!IsValid)return string.Empty;var result=ModId;if(!string.IsNullOrEmpty(Operator)&&VersionRaw!=null)result+=Operator+VersionRaw;if(IsOptional)result+="?";return result;}}}

===== FILE Core/Mods/ModDependencyListConverter.cs =====
using System;using System.Collections.Generic;using System.Text.Json;using System.Text.Json.Serialization;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods{/// <summary> JSON converter for List&lt;ModDependency&gt;. Reads a JSON array of strings, parses each via ModDependency.Parse(). Invalid strings are logged with a warning and added to the result with IsValid=false (the mod is processed in ValidateDependencies). On serialization, restores the raw string via ModDependency.ToString(). </summary>
public class ModDependencyListConverter:JsonConverter<List<ModDependency>>{/// <summary>Reads a JSON array of dependency strings, parsing each via <see cref="ModDependency.Parse"/>.</summary>
public override List<ModDependency>Read(ref Utf8JsonReader reader,Type typeToConvert,JsonSerializerOptions options){if(reader.TokenType!=JsonTokenType.StartArray)throw new JsonException("depends must be an array of strings");var result=new List<ModDependency>();while(reader.Read()&&reader.TokenType!=JsonTokenType.EndArray){var raw=reader.GetString();if(string.IsNullOrWhiteSpace(raw))continue;var dep=ModDependency.Parse(raw);if(!dep.IsValid){GameLogger.Log("MODS",$"Invalid dependency format: '{raw}' — {dep.Error}",LogLevel.Error);}result.Add(dep);}return result;}/// <summary>Writes each dependency back as its canonical string form.</summary>
public override void Write(Utf8JsonWriter writer,List<ModDependency>value,JsonSerializerOptions options){writer.WriteStartArray();foreach(var dep in value){writer.WriteStringValue(dep.ToString());}writer.WriteEndArray();}}}

===== FILE Core/Mods/ModDownloader.cs =====
using System;using System.Collections.Generic;using System.IO;using System.IO.Compression;using System.Linq;using System.Threading.Tasks;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods{/// <summary> Stages of a <see cref="ModDownloader.InstallAsync"/> run, reported through <see cref="ModDownloader.OnProgress"/> so the UI can render a progress bar with a human-readable message per stage. </summary>
public enum InstallStep{/// <summary>Fetching the .zip from a catalog mirror.</summary>
Downloading,/// <summary>Comparing the downloaded archive against <c>archive_sha256</c>.</summary>
VerifyingArchive,/// <summary>Extracting the archive into a staging directory.</summary>
Extracting,/// <summary>Recomputing the install hash of the extracted mod.</summary>
VerifyingInstall,/// <summary>Mod installed; a restart is required to load it.</summary>
Complete,/// <summary>Install aborted — see the reported message for the reason.</summary>
Failed,}/// <summary>Snapshot of the downloader state at one moment of an install.</summary>
public class InstallProgress{/// <summary>Current pipeline stage.</summary>
public InstallStep Step{get;set;}/// <summary>Completion fraction 0–1; 0 for stages without a measurable fraction.</summary>
public float Progress{get;set;}/// <summary>Human-readable status line for the UI (e.g. "Downloading... 42%").</summary>
public string Message{get;set;}}/// <summary> Downloads and installs mods from the online catalog into the user mods root (<c>user://mods/</c>). One <see cref="InstallAsync"/> run picks the catalog entry (by id and, when given, the source catalog that published it), streams the zip from the first reachable mirror, verifies <c>archive_sha256</c>, extracts through a staging directory (flattening a single wrapper folder), verifies <c>install_sha256</c>, and swaps the result into place. The new mod set only loads after a restart — like every other mod-set change. </summary>
public static class ModDownloader{private static readonly System.Net.Http.HttpClient _httpClient=new(){Timeout=TimeSpan.FromMinutes(5),DefaultRequestHeaders={{"User-Agent","Cthangover/1.0"}},};private static readonly HashSet<string>_installingMods=new();/// <summary>Fired on every progress report; carries the stage, fraction and message.</summary>
public static event Action<InstallProgress>OnProgress;/// <summary>Fired once when an install finishes; <c>true</c> on success, <c>false</c> on any failure.</summary>
public static event Action<string,bool>OnCompleted;/// <summary> Installs a mod from the catalog into the user mods root. Selects the entry (optionally from a specific source catalog), picks the download matching <paramref name="version"/> (or the newest), verifies the archive hash, extracts through a staging directory, and swaps it into place. Returns <c>false</c> and reports the reason via <see cref="OnCompleted"/>(false) on any failure; concurrent installs of the same mod are rejected. </summary> <param name="modId">Canonical mod id to install.</param> <param name="version">Specific version to install, or null for the newest download.</param> <param name="sourceUrl">Catalog URL that published the entry (disambiguates variant conflicts), or null.</param>
public static async Task<bool>InstallAsync(string modId,string version=null,string sourceUrl=null){if(string.IsNullOrEmpty(modId))return Fail(modId,"Mod ID is empty");if(_installingMods.Contains(modId))return false;_installingMods.Add(modId);try{CatalogService.LoadCache();var cached=CatalogService.CachedEntries;if(cached==null)return Fail(modId,"Catalog not loaded");var entry=CatalogMerger.FindEntry(cached,modId,sourceUrl);if(entry==null)return Fail(modId,"Mod not found in catalog");if(entry.Downloads==null||entry.Downloads.Count==0)return Fail(modId,"No download URLs available");var download=FindDownloadForVersion(entry.Downloads,version)??entry.Downloads[0];var tempFile=Path.Combine(Path.GetTempPath(),$"cthangover_dl_{modId}_{Guid.NewGuid():N}.zip");try{if(!await DownloadWithProgressAsync(download.Urls,tempFile))return Fail(modId,"All download mirrors failed");ReportProgress(InstallStep.VerifyingArchive,0,$"Verifying archive...");if(!string.IsNullOrEmpty(download.ArchiveSha256)){var archiveHash=ModHasher.ComputeArchiveSha256(tempFile);if(!string.Equals(archiveHash,download.ArchiveSha256,StringComparison.OrdinalIgnoreCase)){return Fail(modId,$"Archive SHA256 mismatch: expected {download.ArchiveSha256}, got {archiveHash}");}}ReportProgress(InstallStep.Extracting,0,$"Installing {modId}...");var targetDir=GetModTargetDir(modId);var stagingDir=targetDir+"_staging";if(Directory.Exists(stagingDir))Directory.Delete(stagingDir,recursive:true);ZipFile.ExtractToDirectory(tempFile,stagingDir);var stagingEntries=Directory.GetFileSystemEntries(stagingDir);if(stagingEntries.Length==1&&Directory.Exists(stagingEntries[0])&& !File.Exists(Path.Combine(stagingDir,"manifest.json"))){var wrapperDir=stagingEntries[0];var flatDir=stagingDir+"_flat";if(Directory.Exists(flatDir))Directory.Delete(flatDir,recursive:true);Directory.Move(wrapperDir,flatDir);Directory.Delete(stagingDir,recursive:true);stagingDir=flatDir;}if(!File.Exists(Path.Combine(stagingDir,"manifest.json"))){if(Directory.Exists(stagingDir))Directory.Delete(stagingDir,recursive:true);return Fail(modId,"Extracted zip does not contain a manifest.json");}if(Directory.Exists(targetDir))Directory.Delete(targetDir,recursive:true);Directory.Move(stagingDir,targetDir);ReportProgress(InstallStep.VerifyingInstall,0,$"Verifying install...");if(!string.IsNullOrEmpty(download.InstallSha256)){var installHash=ModHasher.ComputeInstallSha256(targetDir);if(!string.Equals(installHash,download.InstallSha256,StringComparison.OrdinalIgnoreCase)){GameLogger.Log("DOWNLOADER",$"Install SHA256 mismatch for '{modId}': expected {download.InstallSha256}, got {installHash}",LogLevel.Warning);}}GameLogger.Log("DOWNLOADER",$"Successfully installed mod '{modId}' to '{targetDir}'");}finally{if(File.Exists(tempFile))File.Delete(tempFile);}}catch(Exception ex){return Fail(modId,ex.Message);}finally{_installingMods.Remove(modId);}ReportProgress(InstallStep.Complete,1,"Install complete. Restart required.");OnCompleted?.Invoke(modId,true);return true;}private static async Task<bool>DownloadWithProgressAsync(List<string>urls,string destinationPath){foreach(var url in urls){if(string.IsNullOrWhiteSpace(url))continue;try{GameLogger.Log("DOWNLOADER",$"Downloading '{url}'...");using var response=await _httpClient.GetAsync(url,System.Net.Http.HttpCompletionOption.ResponseHeadersRead);response.EnsureSuccessStatusCode();var totalBytes=response.Content.Headers.ContentLength?? -1;using var stream=await response.Content.ReadAsStreamAsync();using var fileStream=File.Create(destinationPath);var buffer=new byte[8192];long totalRead=0;int bytesRead;while((bytesRead=await stream.ReadAsync(buffer,0,buffer.Length))>0){await fileStream.WriteAsync(buffer,0,bytesRead);totalRead+=bytesRead;if(totalBytes>0){var progress=Math.Min((float)totalRead/totalBytes,1f);ReportProgress(InstallStep.Downloading,progress,$"Downloading... {progress * 100:F0}%");}else{ReportProgress(InstallStep.Downloading,0,$"Downloading... {totalRead / 1024} KB");}}GameLogger.Log("DOWNLOADER",$"Downloaded {totalRead} bytes from '{url}'");return true;}catch(Exception ex){GameLogger.Log("DOWNLOADER",$"Download from '{url}' failed: {ex.Message}",LogLevel.Warning);}}return false;}private static string GetModTargetDir(string modId){if(GameEnvironment.IsRuntime){var modsRoot=ProjectSettings.GlobalizePath("user://mods/");if(!Directory.Exists(modsRoot))Directory.CreateDirectory(modsRoot);return Path.Combine(modsRoot,modId);}return Path.Combine("mods",modId);}private static bool Fail(string modId,string reason){ReportProgress(InstallStep.Failed,0,reason);GameLogger.Log("DOWNLOADER",$"Failed to install mod '{modId}': {reason}",LogLevel.Error);OnCompleted?.Invoke(modId,false);return false;}private static void ReportProgress(InstallStep step,float progress,string message){OnProgress?.Invoke(new InstallProgress{Step=step,Progress=progress,Message=message,});}private static CatalogEntryDownload FindDownloadForVersion(List<CatalogEntryDownload>downloads,string version){if(downloads==null||string.IsNullOrEmpty(version))return null;foreach(var d in downloads)if(string.Equals(d.Version,version,StringComparison.OrdinalIgnoreCase))return d;return null;}}}

===== FILE Core/Mods/ModFileEntry.cs =====
namespace Cthangover.Core.Mods{/// <summary> Immutable value type that ties a file path to its owning mod, used as the value type in all file-list caches inside <c>ModManager</c>. Marked <c>readonly struct</c> to avoid heap allocations during the hot scanning loop — these entries can number in the thousands across all mods. </summary>
public readonly struct FileEntry{/// <summary>The owning mod's unique identifier.</summary>
public readonly string ModId;/// <summary>Full path within the mod (e.g. "avatars/marao/smile.png").</summary>
public readonly string FullPath;/// <summary> Creates one immutable directory-listing entry. </summary> <param name="modId">Owning mod id, used for logging and routing.</param> <param name="fullPath">Mod-relative path with forward slashes.</param>
public FileEntry(string modId,string fullPath){ModId=modId;FullPath=fullPath;}}}

===== FILE Core/Mods/ModGDScriptLoader.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Actions;using Cthangover.Core.Events;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Items;using Cthangover.Core.Scenes.Parsing;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods;/// <summary> Loads and registers every <c>.gd</c> file declared by a mod's <c>gd_sources</c> manifest patterns. Each file is compiled through Godot's GDScript runtime (<c>GDScript.Reload</c>), instantiated once, and inspected for known method patterns: <c>on_mod_event_*</c> handlers subscribe to the string event bus, registered <see cref="IGDScriptHook"/>s get a chance to claim subsystem-specific contracts (battle executors, winground listeners), and the built-in patterns register scenario actions (<c>get_name</c>+<c>run</c>), custom conditions (<c>get_condition_name</c>+<c>evaluate</c>, inserted at the front of the leaf-parser list), item actions (<c>get_item_action_id</c>+<c>use</c>) and mod initializers (<c>on_mod_loaded</c>/<c>on_mod_resources_ready</c>). One script can implement several patterns at once. </summary>
public static class ModGDScriptLoader{private static readonly HashSet<string>loadedModIds=new(StringComparer.OrdinalIgnoreCase);/// <summary> Mods whose GDScripts were loaded in this process. GDScript registrations (actions, conditions, hooks, initializers) are not tracked per mod and cannot be withdrawn, so an in-process reload loads the scripts of a mod once and leaves them in place afterwards. </summary>
public static IReadOnlyCollection<string>LoadedModIds=>loadedModIds;/// <summary> Walks every mod's <c>gd_sources</c> patterns, compiles each matching <c>.gd</c> file, and auto-registers it into the subsystems whose method patterns it implements. Pattern matching is always recursive: the part of a pattern before the last <c>/</c> selects the root directory and the suffix only filters file names. </summary> <param name="mods">The finalized mod set (id → info) to process.</param>
public static void LoadGDScripts(IDictionary<string,IModInfo>mods){foreach(var kvp in mods){var modInfo=kvp.Value as ModInfo;var gdSources=modInfo?.Manifest?.GdSources;if(gdSources==null||gdSources.Count==0)continue;if(!loadedModIds.Add(kvp.Key))continue;foreach(var pattern in gdSources){CollectGdFiles(modInfo.FileProvider,pattern,(filePath,source)=>{GameLogger.Log("GDS_MOD",$"Loading GDScript '{filePath}' from mod '{modInfo.Id}'");var gds=new GDScript{SourceCode=source};if(gds.Reload()!=Error.Ok){GameLogger.Log("GDS_MOD",$"Failed to compile GDScript '{filePath}' in mod '{modInfo.Id}'",LogLevel.Error);return;}var instance=gds.New();var obj=instance.AsGodotObject();if(obj==null){GameLogger.Log("GDS_MOD",$"GDScript '{filePath}' in mod '{modInfo.Id}' did not produce a GodotObject",LogLevel.Error);return;}AutoRegisterEventHandlers(obj,modInfo.Id);GDScriptHookRegistry.TryRegisterAll(obj,modInfo.Id,filePath);if(obj.HasMethod("get_name")&&obj.HasMethod("run")){var name=obj.Call("get_name").AsString();if(!string.IsNullOrEmpty(name)){var action=new GDScriptScenarioAction(name,obj);ScenarioActionFactory.Instance.Register(action);GameLogger.Log("GDS_MOD",$"Registered GDScript action '{name}' from mod '{modInfo.Id}'");}}if(obj.HasMethod("get_condition_name")&&obj.HasMethod("evaluate")){var condName=obj.Call("get_condition_name").AsString();if(!string.IsNullOrEmpty(condName)){var leafParser=new GDLeafParser(condName,obj);LeafParserRegistry.RegisterFirst(leafParser);GameLogger.Log("GDS_MOD",$"Registered GDScript condition '{condName}' from mod '{modInfo.Id}'");}}if(obj.HasMethod("get_item_action_id")&&obj.HasMethod("use")){var itemActionId=obj.Call("get_item_action_id").AsString();if(!string.IsNullOrEmpty(itemActionId)){ItemActionBridge.Register(new GDItemAction(itemActionId,obj));GameLogger.Log("GDS_MOD",$"Registered GDScript item action '{itemActionId}' from mod '{modInfo.Id}'");}}if(obj.HasMethod("on_mod_loaded")||obj.HasMethod("on_mod_resources_ready")){var initializer=new GDScriptModInitializer(obj);ModInitializerRegistry.RegisterGDScript(initializer,kvp.Key);GameLogger.Log("GDS_MOD",$"Registered GDScript initializer for mod '{modInfo.Id}'");}});}}}private static void CollectGdFiles(IModFileProvider provider,string pattern,Action<string,string>onFile){var prefix=pattern;var suffix="*.gd";var sepIndex=pattern.LastIndexOf('/');if(sepIndex>=0){prefix=pattern.Substring(0,sepIndex);suffix=pattern.Substring(sepIndex+1);}CollectGdFilesRecursive(provider,prefix,file=>{if(!file.EndsWith(".gd",StringComparison.OrdinalIgnoreCase))return false;var fileName=file.Replace('\\','/');var fileNamePart=fileName.Substring(fileName.LastIndexOf('/')+1);return suffix=="*"||suffix=="*.gd"||(suffix.StartsWith("*.")&&fileNamePart.EndsWith(suffix.Substring(1),StringComparison.OrdinalIgnoreCase));},onFile);}private static void CollectGdFilesRecursive(IModFileProvider provider,string directory,Func<string,bool>fileFilter,Action<string,string>onFile){foreach(var entry in provider.ListFiles(directory)){if(entry.EndsWith("/")){var subDir=entry.TrimEnd('/');CollectGdFilesRecursive(provider,subDir,fileFilter,onFile);}else if(fileFilter(entry)){var source=provider.ReadFileText(entry);if(source!=null)onFile(entry,source);}}}private static void AutoRegisterEventHandlers(GodotObject obj,string modId){var methods=obj.GetMethodList();foreach(var methodDict in methods){var methodName=methodDict["name"].AsString();if(!methodName.StartsWith("on_mod_event_"))continue;var eventKey=methodName.Substring("on_mod_event_".Length);var bus=ModEventBus.Instance;if(bus==null)continue;bus.Subscribe(eventKey,new Godot.Callable(obj,methodName),modId:modId);GameLogger.Log("GDS_MOD",$"Auto-registered event handler '{eventKey}' -> {methodName} from mod '{modId}'");}}}

===== FILE Core/Mods/ModHasher.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Security.Cryptography;using System.Text;namespace Cthangover.Core.Mods{/// <summary> SHA256 checksum utility for mod catalog export. Two hash modes: <b>archive_sha256</b> — hash of the entire .zip file as it sits on the server. Used after download to verify file integrity (no corruption during transfer). <b>install_sha256</b> — hash of the mod's extracted content, computed by enumerating all files, sorting by relative path, hashing each as <c>SHA256(UTF8(path) + content)</c>, then hashing the concatenation of those per-file hashes. This produces a reproducible fingerprint that the game can compare against the locally-installed copy: if the local hash differs, the player has modified the mod and overwriting from the network may lose their changes. Files whose path contains any component starting with a dot (<c>.godot/</c>, <c>.import/</c>, <c>.gitignore</c>) are automatically excluded. Additional patterns from <see cref="ModConfig.CatalogIgnorePatterns"/> are also applied. </summary>
public static class ModHasher{/// <summary> Returns lowercase hex-encoded SHA256 of the file at <paramref name="filePath"/>. </summary>
public static string ComputeArchiveSha256(string filePath){using var stream=File.OpenRead(filePath);using var sha=SHA256.Create();var hash=sha.ComputeHash(stream);return BytesToHex(hash);}/// <summary> Computes a short SHA-256 hash from a set of source files for mod compilation caching. Concatenates each file's path and source text (ordered by path for determinism), then returns the first 16 hex characters of the SHA-256 hash. Used by <see cref="ModCompiler"/> to determine whether a mod needs recompilation. <paramref name="dependencySignature"/> is mixed into the hash so that a change in the mod's dependencies — for example renaming a dependency mod, which changes the assembly name its compiled code links against — invalidates the cache even when the mod's own sources are unchanged. </summary>
public static string ComputeHash(List<(string FilePath,string Source)>sources,string dependencySignature=null){using var sha=SHA256.Create();var sb=new StringBuilder();foreach(var(path,source)in sources.OrderBy(s=>s.FilePath)){sb.Append(path);sb.Append(':');sb.Append(source);}if(!string.IsNullOrEmpty(dependencySignature)){sb.Append("#deps:");sb.Append(dependencySignature);}var hashBytes=sha.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString()));return Convert.ToHexString(hashBytes).Substring(0,16).ToLowerInvariant();}/// <summary> Computes the reproducible install content hash from a folder on disk. Files are enumerated recursively; directories and entries matching ignore patterns are skipped. </summary>
public static string ComputeInstallSha256(string folderPath,List<string>ignorePatterns=null){var files=new List<string>(Directory.GetFiles(folderPath,"*",SearchOption.AllDirectories));return ComputeInstallSha256FromFiles(folderPath,files,ignorePatterns);}/// <summary> Computes the reproducible install content hash through an <see cref="IModFileProvider"/>. Works for both folder and zip providers. Recursively collects all files via <see cref="IModFileProvider.ListFiles"/>. </summary>
public static string ComputeInstallSha256(IModFileProvider provider,List<string>ignorePatterns=null){var allFiles=CollectAllFilesRecursive(provider,"");return ComputeInstallSha256FromProvider(provider,allFiles,ignorePatterns);}private static List<string>CollectAllFilesRecursive(IModFileProvider provider,string directory){var result=new List<string>();var entries=provider.ListFiles(directory);foreach(var entry in entries){if(entry.EndsWith("/")){var subDir=entry;result.AddRange(CollectAllFilesRecursive(provider,subDir));}else{result.Add(entry);}}return result;}private static string ComputeInstallSha256FromProvider(IModFileProvider provider,List<string>files,List<string>ignorePatterns){var sorted=files.Select(ModPath.Normalize).Where(f=> !ShouldIgnore(f,ignorePatterns)).OrderBy(f=>f,StringComparer.OrdinalIgnoreCase).ToList();using var sha=SHA256.Create();var hashes=new byte[sorted.Count*32];for(int i=0;i<sorted.Count;i++){var path=sorted[i];var content=provider.ReadFileBinary(path);var fileHash=HashFile(path,content,sha);Array.Copy(fileHash,0,hashes,i*32,32);}var final=sha.ComputeHash(hashes);return BytesToHex(final);}private static string ComputeInstallSha256FromFiles(string folderPath,List<string>files,List<string>ignorePatterns){var sorted=files.Select(f=>f.Replace('\\','/')).Select(f=>new{FullPath=f,RelativePath=GetRelativePath(folderPath,f)}).Where(x=> !ShouldIgnore(x.RelativePath,ignorePatterns)).OrderBy(x=>x.RelativePath,StringComparer.OrdinalIgnoreCase).ToList();using var sha=SHA256.Create();var hashes=new byte[sorted.Count*32];for(int i=0;i<sorted.Count;i++){var content=File.ReadAllBytes(sorted[i].FullPath);var fileHash=HashFile(sorted[i].RelativePath,content,sha);Array.Copy(fileHash,0,hashes,i*32,32);}var final=sha.ComputeHash(hashes);return BytesToHex(final);}private static byte[]HashFile(string relativePath,byte[]content,SHA256 sha){var pathBytes=Encoding.UTF8.GetBytes(relativePath);var combined=new byte[pathBytes.Length+content.Length];Array.Copy(pathBytes,0,combined,0,pathBytes.Length);Array.Copy(content,0,combined,pathBytes.Length,content.Length);return sha.ComputeHash(combined);}private static bool ShouldIgnore(string relativePath,List<string>ignorePatterns){var parts=relativePath.Split('/');foreach(var part in parts){if(part.Length==0)continue;if(part.StartsWith("."))return true;}if(ignorePatterns!=null){foreach(var pattern in ignorePatterns){if(MatchesWildcard(relativePath,pattern))return true;}}return false;}private static bool MatchesWildcard(string path,string pattern){return MatchesWildcardImpl(path,0,pattern,0);}private static bool MatchesWildcardImpl(string path,int pi,string pattern,int pj){while(pi<path.Length&&pj<pattern.Length&&pattern[pj]!='*'){if(pattern[pj]!='?'&&path[pi]!=pattern[pj])return false;pi++;pj++;}if(pj==pattern.Length)return pi==path.Length;if(pattern[pj]=='*'){pj++;if(pj==pattern.Length)return true;while(pi<path.Length){if(MatchesWildcardImpl(path,pi,pattern,pj))return true;pi++;}}return false;}private static string BytesToHex(byte[]bytes){var sb=new StringBuilder(bytes.Length*2);foreach(var b in bytes)sb.AppendFormat("{0:x2}",b);return sb.ToString();}private static string GetRelativePath(string basePath,string fullPath){var baseNormalized=basePath.Replace('\\','/').TrimEnd('/')+"/";var fullNormalized=fullPath.Replace('\\','/');if(fullNormalized.StartsWith(baseNormalized))return fullNormalized.Substring(baseNormalized.Length);return fullNormalized;}}}

===== FILE Core/Mods/ModInfo.cs =====
using System.Collections.Generic;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods{/// <summary> Runtime model for a loaded mod, combining the manifest metadata with the live <c>IModFileProvider</c> handle. <c>DisplayTitle</c> falls back to <c>Id</c> when <c>Name</c> is absent so that UI panels always have something human-readable to show even for minimally-authored mods. </summary>
public class ModInfo:IModInfo{/// <summary>Unique mod identifier (folder name or zip name minus extension).</summary>
public string Id{get;set;}/// <summary>Human-readable name from manifest.json.</summary>
public string Name{get;set;}/// <summary>Author credit from manifest.json.</summary>
public string Author{get;set;}/// <summary>Short description from manifest.json.</summary>
public string Description{get;set;}/// <summary>The storage backend handle — folder or zip — that reads this mod's files.</summary>
public IModFileProvider FileProvider{get;set;}/// <summary>Parsed manifest.json contents.</summary>
public ModManifest Manifest{get;set;}/// <summary>Semver mod version from manifest, forwarded to <see cref="IModInfo.Version"/> for save-system version checks.</summary>
public string Version=>Manifest?.Version;/// <summary> Reasons why this mod failed to load. Collected during <c>FilterDisabledMods</c> and <c>ValidateDependencies</c>. Each reason is a separate entry — never overwritten, only appended. UI shows the full list. </summary>
[System.Text.Json.Serialization.JsonIgnore]public List<string>LoadFailureReasons{get;set;}=new();/// <summary> SHA256 checksum of the mod's installed content, computed during discovery by <see cref="ModHasher.ComputeInstallSha256(IModFileProvider, List{string})"/>. Used to detect local modifications by comparing against the <c>install_sha256</c> value in the online catalog. Null when the mod failed to load or the computation was skipped. </summary>
[System.Text.Json.Serialization.JsonIgnore]public string InstallSha256{get;set;}/// <summary> Safe UI label: attempts translation via <c>mod_{Id}_name</c> key first, falls back to <c>Name</c>, then to <c>Id</c>. </summary>
public string DisplayTitle{get{if(!string.IsNullOrEmpty(Id)&&GameEnvironment.IsRuntime){try{var key=$"mod_{Id}_name";var translated=TranslationServer.Translate(key);if(translated!=key)return translated;}catch{// Godot not initialized — fall through to default logic
}}return string.IsNullOrEmpty(Name)?Id:Name;}}/// <summary> Short description in the current locale: attempts translation via <c>mod_{Id}_desc</c> key first, falls back to raw <c>Description</c>. </summary>
public string LocalizedDescription{get{if(!string.IsNullOrEmpty(Id)&&GameEnvironment.IsRuntime){try{var key=$"mod_{Id}_desc";var translated=TranslationServer.Translate(key);if(translated!=key)return translated;}catch{// Godot not initialized — fall through to default logic
}}return Description??string.Empty;}}}}

===== FILE Core/Mods/ModInitializerRegistry.cs =====
using System;using System.Collections.Generic;using System.Reflection;using Cthangover.Core.Events;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods{/// <summary> Lifecycle orchestrator for <c>IModInitializer</c> implementations. When an assembly is registered, two things happen: 1. Every <b>existing</b> initializer receives <c>OnModLoaded(newModId)</c> — so initializers loaded earlier become aware of the new mod. 2. Every <b>new</b> initializer receives <c>OnModLoaded(alreadyLoadedModId)</c> for each mod that was loaded before it — so a late-arriving initializer sees the complete mod set, not just the mods loaded after itself. This two-way catch-up guarantees that initializer state is consistent regardless of mod load order, which is essential since mod loading order depends on filesystem enumeration and dependency graphs, both of which are non-deterministic across platforms. </summary>
public static class ModInitializerRegistry{private static readonly List<IModInitializer>_initializers=new();private static readonly List<string>_loadedModIds=new();/// <summary> Discovers <c>IModInitializer</c> implementations in the assembly, creates instances, and performs the two-way catch-up notification so that every initializer sees every loaded mod regardless of registration order. </summary>
public static void RegisterAssembly(Assembly assembly,string modId){var newInitializers=new List<IModInitializer>();foreach(var init in Reflections.FindAndCreate<IModInitializer>(assembly)){try{newInitializers.Add(init);}catch(Exception ex){GameLogger.Log("MODS_INIT",$"Failed to create initializer for '{init.GetType().FullName}': {ex.Message}",LogLevel.Error);}}foreach(var init in _initializers){try{init.OnModLoaded(modId);}catch(Exception ex){GameLogger.Log("MODS_INIT",$"OnModLoaded failed for existing initializer on mod '{modId}': {ex.Message}",LogLevel.Error);}}foreach(var loadedId in _loadedModIds){foreach(var init in newInitializers){try{init.OnModLoaded(loadedId);}catch(Exception ex){GameLogger.Log("MODS_INIT",$"OnModLoaded failed for new initializer on mod '{loadedId}': {ex.Message}",LogLevel.Error);}}}_initializers.AddRange(newInitializers);if(!_loadedModIds.Contains(modId))_loadedModIds.Add(modId);ModEventBus.Publish(new ModLoadedEvent{ModId=modId});}/// <summary> Registers a manually-constructed <c>IModInitializer</c> (e.g. from a GDScript mod) and performs the two-way catch-up notification so it sees already-loaded mods. </summary>
public static void RegisterGDScript(IModInitializer initializer,string modId){foreach(var loadedId in _loadedModIds){try{initializer.OnModLoaded(loadedId);}catch(Exception ex){GameLogger.Log("MODS_INIT",$"OnModLoaded failed for GDScript initializer on mod '{loadedId}': {ex.Message}",LogLevel.Error);}}_initializers.Add(initializer);if(!_loadedModIds.Contains(modId))_loadedModIds.Add(modId);ModEventBus.Publish(new ModLoadedEvent{ModId=modId});}/// <summary> Unload pass for one mod: initializers implementing <see cref="IModUnloadAware"/> contributed by the mod's assembly get <c>OnModUnloading</c> (mod code tears down while its types are still alive), then every other unload-aware initializer is informed so cross-mod references can be dropped. Initializers contributed by the dying mod are removed afterwards. Failures are logged and isolated, mirroring the load path. </summary>
public static void NotifyModUnloading(string modId){foreach(var init in _initializers.ToArray()){if(init is not IModUnloadAware unloadAware)continue;var fromUnloadingMod=init.GetType().Assembly.GetName().Name==modId;try{if(fromUnloadingMod)unloadAware.OnModUnloading();else unloadAware.OnModUnloaded(modId);}catch(Exception ex){GameLogger.Log("MODS_INIT",$"Unload notification failed for initializer '{init.GetType().FullName}' (mod '{modId}'): {ex.Message}",LogLevel.Error);}}_initializers.RemoveAll(init=>init.GetType().Assembly.GetName().Name==modId);// The mod leaves the loaded set: initializers registered later must not get a catch-up OnModLoaded for it.
_loadedModIds.Remove(modId);}/// <summary> Notifies every registered <c>IModInitializer</c> that all mods and their JSON resources are fully loaded. Call this once, after <c>ModRegistry.Initialize()</c> has completed and factories can read mod data. </summary>
public static void NotifyResourcesReady(){foreach(var init in _initializers){try{init.OnModResourcesReady();}catch(Exception ex){GameLogger.Log("MODS_INIT",$"OnModResourcesReady failed: {ex.Message}",LogLevel.Error);}}ModEventBus.Publish(new ModResourcesReadyEvent());}}}

===== FILE Core/Mods/ModLoadContext.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Reflection;using System.Runtime.Loader;using System.Threading;namespace Cthangover.Core.Mods{/// <summary> One collectible <see cref="AssemblyLoadContext"/> per mod — the unit of assembly unload. Replaces the flat default-context load for mod assemblies: unloading a mod (reload, disable, uninstall) can now actually release its assemblies, JIT code and static state instead of rewriting registries over a still-loaded copy. Resolution rules, in order: 1. the mod's own staged assemblies (main dll and mod-shipped dll/), 2. assemblies of the mod's dependency mods — resolved through the dependency's own context so inter-mod types are reference-equal across both sides, 3. <c>null</c> — falls through to the default context, which holds the kernel and GodotSharp (this is what makes "mods can use core types" work without any special casing). File-backed loading keeps the debugger story of plan-mod-testing §9.2: a portable PDB written next to the dll binds by naming convention and breakpoints in mod code keep working. Unload is a protocol, not a button: the caller must run the consumer reverse fan-out (<c>AssemblyConsumerRegistry.FanOutUnloading</c>) and drop every external reference first; then <see cref="TryUnload"/> forces GC and asks the context to release. The returned diagnostic lists surviving assemblies when someone still holds a reference — that is the leak to fix, not a reason to retry. </summary>
public sealed class ModLoadContext:AssemblyLoadContext{private readonly Dictionary<string,string>assemblyPaths=new(StringComparer.OrdinalIgnoreCase);private readonly Func<string,ModLoadContext>dependencyResolver;private readonly string modId;/// <summary> Creates the context for one mod. <paramref name="dependencyResolver"/> maps a dependency mod id to its already-created context (or null when the dependency ships no assemblies of its own). </summary>
public ModLoadContext(string modId,Func<string,ModLoadContext>dependencyResolver):base("mod:"+modId,isCollectible:true){this.modId=modId;this.dependencyResolver=dependencyResolver;}/// <summary>Mod id this context belongs to; also the context name suffix.</summary>
public string ModId=>modId;/// <summary> True once <see cref="TryUnload"/> released the context. Its assemblies stay in <see cref="AppDomain.GetAssemblies"/> until the GC collects them, so every "scan all loaded assemblies" must skip them — a scan that still sees them re-registers the dying mod's types (and, after a reload, finds two assemblies with the same name). </summary>
public bool IsReleased{get;private set;}/// <summary>True when <paramref name="assembly"/> belongs to a released mod context.</summary>
public static bool IsReleasedAssembly(Assembly assembly)=>assembly!=null&&assembly.IsCollectible&&GetLoadContext(assembly)is ModLoadContext{IsReleased:true};/// <summary> Registers a file-backed assembly (main compiled dll or a mod-shipped precompiled one) by its simple name. Must happen before the first resolve. Returns the assembly's simple name — the key for <see cref="LoadStaged"/>. It differs from the file name for cached compiled mods (<c>cooking_&lt;hash&gt;.dll</c> holds <c>cooking</c>). </summary>
public string StageAssembly(string dllPath){var name=AssemblyName.GetAssemblyName(dllPath).Name;assemblyPaths[name]=dllPath;return name;}/// <summary>True when the mod staged an assembly with this simple name.</summary>
public bool HasAssembly(string simpleName)=>assemblyPaths.ContainsKey(simpleName);/// <summary> Loads a staged assembly file-backed. Called by the loader for the mod's entry assembly; other assemblies resolve lazily. </summary>
public Assembly LoadStaged(string simpleName){if(!assemblyPaths.TryGetValue(simpleName,out var path))return null;return LoadFromAssemblyPath(path);}/// <inheritdoc />
protected override Assembly Load(AssemblyName assemblyName){var name=assemblyName?.Name;if(string.IsNullOrEmpty(name))return null;// 1. Own assemblies.
if(assemblyPaths.TryGetValue(name,out var path))return LoadFromAssemblyPath(path);// 2. Dependency mod assemblies — through the dependency's context, so a type from the dependency is reference-equal on both sides.
var dependency=dependencyResolver?.Invoke(name);if(dependency!=null&&dependency.HasAssembly(name))return dependency.LoadStaged(name);// 3. Default context: kernel, GodotSharp, framework.
return null;}/// <inheritdoc />
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)// Native libraries keep the ModNativeLoader path; no custom probing.
=>IntPtr.Zero;/// <summary> Starts the unload: drops the path table so nothing resolves into the context any more, then asks the runtime to release it. Collection happens on a later GC once nothing refers into the context; verify it with a <c>WeakReference</c> held by the caller (<see cref="Cthangover.Core.Mods.Unload.ModUnloadTicket"/>, <see cref="CollectAndVerify"/>) — a weak reference taken inside this very frame would see <c>this</c> alive by definition. Always returns null (kept for the old contract). </summary>
public string TryUnload(){IsReleased=true;assemblyPaths.Clear();Unload();return null;}/// <summary> Collection verification for the unload protocol: after <see cref="TryUnload"/> and dropping every external reference, the caller's weak reference must die. Returns null when the context was collected, otherwise a diagnostic naming the surviving assemblies — the leak to fix. </summary>
public static string CollectAndVerify(WeakReference contextRef,string modId){for(var attempts=0;attempts<30&&contextRef.IsAlive;attempts++){GC.Collect(2,GCCollectionMode.Forced,blocking:true,compacting:true);GC.WaitForPendingFinalizers();// Yield: under parallel test load other threads keep the GC busy; a short pause lets pending collections settle.
if(contextRef.IsAlive)Thread.Sleep(10);}if(!contextRef.IsAlive)return null;var survivors=AppDomain.CurrentDomain.GetAssemblies().Where(a=>string.Equals(a.GetName().Name,modId,StringComparison.OrdinalIgnoreCase)).Select(a=>a.GetName().Name).Distinct().ToList();return survivors.Count>0?$"assemblies still alive: {string.Join(", ", survivors)}":"context still referenced (types or delegates held outside the registries)";}}}

===== FILE Core/Mods/ModLoadOrder.cs =====
using System;using System.Collections.Generic;using System.Linq;namespace Cthangover.Core.Mods{/// <summary> Computes deterministic mod load order from declared dependencies and optional manual ordering. The algorithm assigns each mod a <c>dependsLevel</c> — zero for mods without dependencies, one for mods that depend on level-zero mods, etc. Within the same level, mods are ordered by user-defined manual position (if any), then alphabetically by ID for determinism. The result is a list where every mod appears after all its transitive dependencies. </summary>
public static class ModLoadOrder{/// <summary> Computes a deterministic order for the given mods. <paramref name="mods"/> maps mod ID to a list of dependency IDs (from <c>manifest.json</c>'s <c>depends</c> field). <paramref name="manualOrder"/> is an optional user-defined list of mod IDs. Mods in the manual list are ordered by their position within it; mods not present are placed at the end. Manual ordering is secondary to dependencies — a mod cannot appear before its dependency regardless of manual position. </summary>
public static List<string>Compute(IReadOnlyDictionary<string,IReadOnlyList<string>>mods,List<string>manualOrder=null){var allIds=mods.Keys.ToList();if(allIds.Count==0)return new List<string>();var level=ComputeDependsLevels(mods,allIds);var manualIndex=new Dictionary<string,int>();if(manualOrder!=null){for(int i=0;i<manualOrder.Count;i++){if(!manualIndex.ContainsKey(manualOrder[i]))manualIndex[manualOrder[i]]=i;}}allIds.Sort((a,b)=>{var levelA=level.TryGetValue(a,out var la)?la:0;var levelB=level.TryGetValue(b,out var lb)?lb:0;var levelCmp=levelA.CompareTo(levelB);if(levelCmp!=0)return levelCmp;var manualA=manualIndex.TryGetValue(a,out var ma)?ma:int.MaxValue;var manualB=manualIndex.TryGetValue(b,out var mb)?mb:int.MaxValue;var manualCmp=manualA.CompareTo(manualB);if(manualCmp!=0)return manualCmp;return string.Compare(a,b,StringComparison.OrdinalIgnoreCase);});return allIds;}/// <summary> Computes the dependency depth for every mod. Returns a dictionary mapping mod ID to its <c>dependsLevel</c> — zero for root mods, incremented for each dependency layer. Circular dependencies are broken at zero with a warning delivered via <paramref name="onCycle"/>. </summary>
public static Dictionary<string,int>ComputeDependsLevels(IReadOnlyDictionary<string,IReadOnlyList<string>>mods,List<string>allIds,System.Action<string>onCycle=null){var result=new Dictionary<string,int>();int ComputeLevel(string modId,HashSet<string>visiting){if(result.TryGetValue(modId,out var cached))return cached;if(!visiting.Add(modId)){onCycle?.Invoke(modId);result[modId]=0;return 0;}if(!mods.TryGetValue(modId,out var deps)||deps==null||deps.Count==0){result[modId]=0;visiting.Remove(modId);return 0;}int maxDepth=0;foreach(var depId in deps){if(!mods.ContainsKey(depId))continue;var depDepth=ComputeLevel(depId,visiting);if(depDepth+1>maxDepth)maxDepth=depDepth+1;}result[modId]=maxDepth;visiting.Remove(modId);return maxDepth;}foreach(var id in allIds){if(!result.ContainsKey(id))ComputeLevel(id,new HashSet<string>());}return result;}}}

===== FILE Core/Mods/ModLoadOrderStore.cs =====
using System.Collections.Generic;using System.IO;using System.Text.Json;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods{/// <summary> Persists user-defined mod load order to <c>user://mod_load_order.json</c>. The stored list is a preference that is merged with mandatory dependency ordering from <c>ModManifest.Depends</c> — dependencies always override manual ordering. New mods not present in the stored list are inserted automatically at their alphabetically-appropriate position. </summary>
public static class ModLoadOrderStore{private const string FilePath="user://mod_load_order.json";/// <summary> Serialises file access. <c>user://mod_load_order.json</c> can be written from multiple contexts (mod bootstrap, save loading, parallel unit tests); unsynchronised <see cref="Save"/> calls throw <see cref="IOException"/> ("file in use by another process"). </summary>
private static readonly object _fileLock=new();/// <summary>Resolved storage path: globalised <c>user://mod_load_order.json</c> at runtime, relative fallback otherwise.</summary>
public static string Path=>GameEnvironment.IsRuntime?ProjectSettings.GlobalizePath(FilePath):"mod_load_order.json";/// <summary>Reads the saved manual order; returns an empty list when the file is absent or broken.</summary>
public static List<string>Load(){var path=Path;if(!File.Exists(path))return new List<string>();try{lock(_fileLock){var json=File.ReadAllText(path);var list=JsonSerializer.Deserialize<List<string>>(json,JsonOptionsProvider.ReadOptions);return list??new List<string>();}}catch{return new List<string>();}}/// <summary>Persists the manual order as a compact JSON array of mod ids.</summary>
public static void Save(List<string>orderedIds){var path=Path;var dir=System.IO.Path.GetDirectoryName(path);if(!string.IsNullOrEmpty(dir)&& !Directory.Exists(dir))Directory.CreateDirectory(dir);var json=JsonSerializer.Serialize(orderedIds,JsonOptionsProvider.WriteCompactOptions);lock(_fileLock){File.WriteAllText(path,json);}}/// <summary> Moves a mod up by one position in the saved order. If the mod is not in the list, it is appended first. </summary>
public static void MoveUp(string modId){var order=Load();var index=order.IndexOf(modId);if(index<0){order.Add(modId);Save(order);return;}if(index==0)return;(order[index-1],order[index])=(order[index],order[index-1]);Save(order);}/// <summary> Moves a mod down by one position in the saved order. If the mod is not in the list, it is appended first. </summary>
public static void MoveDown(string modId){var order=Load();var index=order.IndexOf(modId);if(index<0){order.Add(modId);Save(order);return;}if(index>=order.Count-1)return;(order[index+1],order[index])=(order[index],order[index+1]);Save(order);}/// <summary> Returns the existing user-defined manual order, removing IDs for mods that no longer exist. New mods (not in the saved order) are NOT added — they will be placed by alphabetical order in <see cref="ModLoadOrder.Compute"/>. Does NOT save to disk. </summary>
public static List<string>Sync(List<string>allModIds){var order=Load();order.RemoveAll(id=> !allModIds.Contains(id));return order;}/// <summary> Removes a mod id from the saved manual order and persists the change. Called by <see cref="ModUninstaller"/> after the mod's files are gone. </summary>
public static void Remove(string modId){var order=Load();if(order.Remove(modId))Save(order);}}}

===== FILE Core/Mods/ModManager.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Reflection;using System.Threading;using Cthangover.Core.Bootstrap;using Cthangover.Core.Factories;using Cthangover.Core.Interactive;using Cthangover.Core.Localization;using Cthangover.Core.Scenes;using Cthangover.Core.UI;using Cthangover.Core.Mods.Patches;using Cthangover.Core.Mods.Pipeline;using Cthangover.Core.Mods.Providers;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods{/// <summary> Central hub for all mod resource access. Every factory, every scene loader, every asset resolver ultimately goes through <c>ModManager</c> to find and read files. It wraps <c>ModRegistry</c> and delegates domain-specific resolution to specialised <c>IModAssetResolver</c> implementations. Resolution is delegated to a registry of pluggable resolvers — each asset type (shaders, textures, JSON entities, scenes, scenarios, interactives, wrappers, patches) has its own resolver with independent caching. New resolvers can be registered at runtime by mods via <c>RegisterResolver</c>. On <c>Reload</c>, every resolver's cache is flushed so that hot-reloaded mods are picked up without restarting the game. </summary>
public class ModManager{private static readonly Lazy<ModManager>instance=new(()=>new ModManager());/// <summary>Thread-safe singleton; construction discovers built-in resolvers via reflection.</summary>
public static ModManager Instance=>instance.Value;private readonly ModRegistry registry;private readonly ModResolutionContext context;private readonly Dictionary<string,IModAssetResolver>resolvers=new();private ModManager(){registry=ModRegistry.Instance;context=new ModResolutionContext(registry);foreach(var resolver in Reflections.FindAndCreate<IModAssetResolver>())RegisterResolver(resolver);context.SetResolvers(resolvers);}/// <summary>True once mod discovery has completed at least once.</summary>
public bool IsInitialized=>registry.IsInitialized;/// <summary>Read-only snapshot of loaded (enabled) mods keyed by mod id.</summary>
public IReadOnlyDictionary<string,IModInfo>Mods=>registry.Mods;/// <summary>Idempotent bootstrap; delegates to <see cref="ModRegistry.Initialize"/>.</summary>
public void Initialize(){registry.Initialize();}/// <summary> Launcher-boot phase 1 (main thread): resolves all Godot-dependent paths (mod roots, cache dirs, config) so the subsequent background phase never touches Godot API. See <see cref="ModRegistry.PreparePaths"/>. </summary>
public void PreparePaths(){registry.PreparePaths();}/// <summary> Launcher-boot phase 2 (background thread): discovery, hashing, native DLL loading, precompiled DLL loading and Roslyn compilation — pure .NET, no Godot API. See <see cref="ModRegistry.DiscoverAndCompile"/>. Cancelling <paramref name="cancellationToken"/> aborts the phase between mods and during source parsing; Roslyn's final emit cannot be interrupted, so cancellation is cooperative rather than immediate. </summary>
public void DiscoverAndCompile(CancellationToken cancellationToken=default){registry.DiscoverAndCompile(cancellationToken);}/// <summary> Launcher-boot phase 3 (main thread): registers compiled assemblies, loads GDScript mods and computes load order. See <see cref="ModRegistry.FinalizeGodotInit"/>. </summary>
public void FinalizeGodotInit(){registry.FinalizeGodotInit();}/// <summary>True while an in-process reload is being applied or verified; a second reload is refused meanwhile.</summary>
public bool IsReloading{get;private set;}/// <summary>The last applied (or rejected) in-process reload.</summary>
public ModReloadResult LastReload{get;private set;}/// <summary> Full synchronous reload of the current selection (every mod with code is released and loaded again) with every piece of derived state rebuilt; see <see cref="ApplyReload"/>. Kept for tooling and setups that change mods on disk and need the new set immediately. </summary>
public void Reload(){var tree=GameEnvironment.IsRuntime?Engine.GetMainLoop()as SceneTree:null;var prep=registry.Reloader.Prepare(Games.GameLaunchArgs.Current,new[]{"*"});ApplyPrepared(prep,tree,requireSuccess:false);}/// <summary> First half of an in-process reload, on a background thread: the mod set of <paramref name="selection"/> discovered and compiled, nothing running touched (<see cref="ModSetReloader.Prepare"/>). </summary>
public System.Threading.Tasks.Task<ModReloadPreparation>PrepareReloadAsync(Games.GameLaunchArgs selection,IReadOnlyCollection<string>force=null,CancellationToken cancellationToken=default)=>System.Threading.Tasks.Task.Run(()=>registry.Reloader.Prepare(selection,force,cancellationToken),cancellationToken);/// <summary> Second half, on the main thread: switches to the prepared set and rebuilds everything derived from it in the boot-phase order (resolver caches, the <c>IGameRegistry</c> set, initializers, mod settings, locale, the logical scene table, the session runtime). A failed preparation is discarded and reported without touching the running set. Callers leave the current scene first: nodes of released mods are freed by the unload. </summary>
public ModReloadResult ApplyReload(ModReloadPreparation prep,SceneTree tree)=>ApplyPrepared(prep,tree,requireSuccess:true);/// <summary> Waits (engine frames) until the contexts released by <paramref name="result"/> are collected, logs survivors as errors, then publishes <see cref="ModSetReloadedEvent"/>. </summary>
public async System.Threading.Tasks.Task<ModReloadResult>VerifyReloadAsync(ModReloadResult result,SceneTree tree){try{if(result.Applied&&tree!=null){result.Leaked=await result.Ticket.CollectAsync(tree);foreach(var leaked in result.Leaked)GameLogger.Log("MODS_RELOAD",$"Mod '{leaked}' was released but its code is still referenced (see MODS_UNLOAD above) — it stays in memory",LogLevel.Error);}Events.ModEventBus.Publish(new Events.ModSetReloadedEvent{Applied=result.Applied,Loaded=result.Plan?.Load.ToArray()??Array.Empty<string>(),Unloaded=result.Plan?.Unload.ToArray()??Array.Empty<string>(),Leaked=result.Leaked.ToArray(),Errors=result.Errors.ToArray(),});return result;}finally{IsReloading=false;}}private ModReloadResult ApplyPrepared(ModReloadPreparation prep,SceneTree tree,bool requireSuccess){if(IsReloading){registry.Reloader.Discard(prep);GameLogger.Log("MODS_RELOAD","Reload refused: another reload is in progress",LogLevel.Warning);return LastReload=new ModReloadResult(false,null,null,new[]{"another reload is in progress"});}if(requireSuccess&& !prep.Succeeded){registry.Reloader.Discard(prep);return LastReload=new ModReloadResult(false,null,null,prep.Errors);}IsReloading=true;foreach(var resolver in resolvers.Values)resolver.ClearCache();AppServices.Current.ClearRegistries();UiHost.Instance?.ClearAll();var result=registry.Reloader.Apply(prep,tree);// Rebind: same relative order as the boot phases after the mod set is final (Registries → ResourcesReady → ModSettings → Locale → Scenes → runtime), with the exception that UiHost panels stay cleared until the next shell entry.
foreach(var resolver in resolvers.Values)resolver.ClearCache();AppServices.Current.RebuildRegistries();ModInitializerRegistry.NotifyResourcesReady();ModSettingsRegistry.LoadAll();LocaleLoader.LoadCurrentLanguage();Scenes.SceneManager.RescanScenes();Settings.GameData.Instance?.ResetRuntime("mod set reloaded");GameLogger.Log("MODS","Reload complete: registries, initializers, settings, locale, scenes and runtime rebuilt");if(tree==null)IsReloading=false;return LastReload=result;}/// <summary>Initializes the resolver with the shared context and stores it under its <see cref="IModAssetResolver.Id"/>.</summary> <summary>Initializes the resolver with the shared context and stores it under its <see cref="IModAssetResolver.Id"/>.</summary>
public void RegisterResolver(IModAssetResolver resolver){resolver.Initialize(context);resolvers[resolver.Id]=resolver;}/// <summary>Discovers <see cref="IModAssetResolver"/> implementations in a mod assembly and registers them.</summary>
public void RegisterResolversFromAssembly(Assembly assembly){foreach(var resolver in Reflections.FindAndCreate<IModAssetResolver>(assembly))RegisterResolver(resolver);}/// <summary> Removes resolvers whose concrete type comes from the given assembly — the unload pass of the assembly consumer pipeline. Also flushes the resolver caches so no cached asset keeps references into unloading mod types. </summary>
public void UnregisterResolversFromAssembly(Assembly assembly){foreach(var id in resolvers.Where(kvp=>kvp.Value.GetType().Assembly==assembly).Select(kvp=>kvp.Key).ToList()){try{resolvers[id].ClearCache();}catch(Exception ex){GameLogger.Log("MODS",$"UnregisterResolversFromAssembly: ClearCache for '{id}' failed: {ex.Message}",LogLevel.Warning);}resolvers.Remove(id);}}public T GetResolver<T>()where T:class,IModAssetResolver{return resolvers.Values.OfType<T>().FirstOrDefault();}/// <summary>Looks up a loaded mod by id; triggers initialization if needed.</summary>
public IModInfo GetMod(string id){return context.GetMod(id);}/// <summary>Checks a mod-relative path inside the given mod's provider.</summary>
public bool FileExists(string modId,string path){return context.FileExists(modId,path);}/// <summary>Shallow listing of a mod directory; entries ending with a slash are directories.</summary>
public IEnumerable<string>ListFiles(string modId,string directory=""){return context.ListFiles(modId,directory);}/// <summary>Reads a UTF-8 text file from the mod, or null when missing.</summary>
public string ReadFileText(string modId,string path){return context.ReadFileText(modId,path);}/// <summary>Reads a binary file from the mod, or null when missing.</summary>
public byte[]ReadFileBinary(string modId,string path){return context.ReadFileBinary(modId,path);}/// <summary>Opens a read-only stream to a mod file; caller disposes. Null when missing.</summary>
public Stream OpenStream(string modId,string path){return context.OpenStream(modId,path);}/// <summary>Absolute filesystem path for folder mods; null for zip mods and missing files.</summary>
public string GetFileSystemPath(string modId,string path){return context.GetFileSystemPath(modId,path);}/// <summary>Reads a text file with every include directive recursively expanded.</summary>
public string ReadResolvedText(string modId,string path){return context.ReadResolvedText(modId,path);}/// <summary>Same as <see cref="ReadResolvedText(string, string)"/> but reads through an explicit provider.</summary>
public string ReadResolvedText(string modId,string path,IModFileProvider providerOverride){return context.ReadResolvedText(modId,path,providerOverride);}/// <summary>Reads a JSON file (with include expansion) and deserializes it; null on any error.</summary>
public T ReadJson<T>(string modId,string path)where T:class{return context.ReadJson<T>(modId,path);}}}

===== FILE Core/Mods/ModManifest.cs =====
using System.Collections.Generic;using System.Linq;using System.Text.Json.Serialization;namespace Cthangover.Core.Mods{/// <summary> JSON DTO for the <c>manifest.json</c> file required inside every mod. <c>Id</c> is the canonical mod identifier used for deduplication and dependency resolution. When two mods declare the same <c>Id</c>, the one with the higher <c>Version</c> wins and the lower one is ignored. Falls back to the filesystem entry name when absent. <c>Sources</c> declares glob-style paths to C# files that <c>ModCompiler</c> should compile; <c>Depends</c> lists versioned dependencies (format: modId[op][version][?]) that affect topological compile order and assembly reference resolution; <c>ConflictsWith</c> declares mutually exclusive mods; <c>Ui</c> declares panels and overlays anchored to shell slots (collected by <c>UiRegistry</c>), and <c>MainMenu</c> adds title-screen buttons opening fullscreen mod scenes. </summary>
public class ModManifest{/// <summary> Canonical mod identifier. Used for deduplication (higher <c>Version</c> wins) and in <c>depends</c> lists of other mods. When absent, the filesystem entry name is used instead. </summary>
[JsonPropertyName("id")]public string Id{get;set;}/// <summary>Human-readable mod name (shown in UI).</summary>
[JsonPropertyName("name")]public string Name{get;set;}/// <summary>Mod author credit string.</summary>
[JsonPropertyName("author")]public string Author{get;set;}/// <summary>Short description of what the mod does.</summary>
[JsonPropertyName("description")]public string Description{get;set;}/// <summary> Optional catalog category (or combination) used to group the mod in the online catalog. Serialized as a string via <c>JsonStringEnumConverter</c> — a single name (e.g. <c>"gameplay"</c>) or a comma-separated list for mods that belong to several categories (e.g. <c>"library, gameplay"</c>). Omitted means <see cref="CategoryType.Other"/>. The Catalog Export dialog pre-checks these categories for <see cref="CatalogExportEntry.Category"/>. </summary>
[JsonPropertyName("category")][JsonConverter(typeof(JsonStringEnumConverter))]public CategoryType?Category{get;set;}/// <summary> Glob-style paths to C# source files (e.g. "scripts/*.cs", "src/"). <c>ModCompiler</c> collects and compiles them. </summary>
[JsonPropertyName("sources")]public List<string>Sources{get;set;}/// <summary> Glob-style paths to GDScript source files (e.g. "scripts/*.gd", "gd/**/*.gd"). <c>ModGDScriptLoader</c> collects, compiles via Godot's GDScript runtime, and registers as scenario actions or mod initializers. </summary>
[JsonPropertyName("gd_sources")]public List<string>GdSources{get;set;}/// <summary> Glob-style paths to C# test files (e.g. "tests/*.cs"). Read ONLY by <c>ModTestEngine</c> in the explicit test mode (<c>--run-tests</c> / <c>run_mod_tests</c>) and compiled into a separate <c>{modId}__tests</c> assembly; the production pipeline (<c>ModCompiler.CompileAll</c>) never touches this field, so tests stay off the player's path (plan-mod-testing.md §3.1). </summary>
[JsonPropertyName("test_sources")]public List<string>TestSources{get;set;}/// <summary> Integration test suites hosted by this mod (GUI drivers, demo content, test-only depends). Each suite lives in its own folder (<c>test_suites/&lt;id&gt;/</c> with a regular <c>manifest.json</c>) and is mounted as the virtual mod <c>&lt;host&gt;__&lt;id&gt;</c> only when one of its <c>activate</c> user-arg prefixes is on the command line (or <c>--with-test-suites</c>); a normal launch and <c>--all-mods</c> never see it. See <see cref="ModTestSuites"/>. </summary>
[JsonPropertyName("test_suites")]public List<ModTestSuite>TestSuites{get;set;}/// <summary> Test fixture: never part of <c>--all-mods</c> (or a game); loaded only when named explicitly in <c>--with-mods=</c>, so its patches and demo data cannot leak into unrelated tests. </summary>
[JsonPropertyName("fixture")]public bool Fixture{get;set;}/// <summary> Glob-style paths to native shared libraries (e.g. "bin/*.dll", "native/*.so", "lib/*.dylib"). <c>ModNativeLoader</c> collects platform-appropriate files, copies them to a cache directory, and registers a <c>DllImportResolver</c> on the core assembly so that P/Invoke declarations resolve against mod-provided native code. </summary>
[JsonPropertyName("depends_native")]public List<string>DependsNative{get;set;}/// <summary> Versioned dependencies. Each entry is a string in format "modId[op][version][?]". Parsed into <see cref="ModDependency"/> via <see cref="ModDependencyListConverter"/>. Affects topological compile order and version validation. </summary>
[JsonPropertyName("depends")][JsonConverter(typeof(ModDependencyListConverter))]public List<ModDependency>Depends{get;set;}/// <summary> Convenience property: only the valid dependency mod IDs, for algorithms that don't need version operators. </summary>
[JsonIgnore]public List<string>DependencyIds=>Depends?.Where(d=>d.IsValid).Select(d=>d.ModId).ToList();/// <summary> Mutually exclusive mod IDs. If both mods are active, both are disabled with a conflict warning. </summary>
[JsonPropertyName("conflicts_with")]public List<string>ConflictsWith{get;set;}/// <summary>Semver mod version (e.g. "1.0.0"). Used by the save system to detect downgrades and trigger migrations.</summary>
[JsonPropertyName("version")]public string Version{get;set;}/// <summary> Mod-provided GUI declarations. Each entry is either a toolbar-toggled panel or a code-driven overlay, anchored to a named slot of the shell. Collected and validated by <c>UiRegistry</c>. </summary>
[JsonPropertyName("ui")]public List<UiEntry>Ui{get;set;}/// <summary> Mod-provided buttons for the title screen. Each entry adds a button to the main menu that opens a fullscreen mod scene. Collected and validated by <c>MenuRegistry</c>; built once per menu instantiation by <c>MenuBuilder</c>. Only enabled mods contribute buttons. </summary>
[JsonPropertyName("main_menu")]public List<MainMenuEntry>MainMenu{get;set;}}}

===== FILE Core/Mods/ModNativeLoader.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Reflection;using System.Runtime.InteropServices;using System.Threading;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods{/// <summary> Loads native shared libraries from mod <c>depends_native</c> manifest declarations and wires them into the P/Invoke resolution chain via <c>NativeLibrary.SetDllImportResolver</c>. <b>Discovery</b> — iterates every mod whose manifest declares <c>depends_native</c> glob patterns, collects platform-appropriate files (.dll / .so / .dylib), and copies them to a stable cache directory so that even zip-based mods can serve native code. <b>Resolution</b> — a single <c>DllImportResolver</c> is installed on the core project assembly. When the runtime searches for a native library named in a <c>[DllImport]</c> attribute, the resolver checks the accumulated cache and returns the handle. Falls through to default resolution for libraries not found in the cache. <b>Placement</b> — called from <c>ModRegistry.Initialize()</c> before any managed code loads, so that P/Invoke calls in static constructors or type initializers never see a missing library. <b>Lifecycle</b> — loaded handles are tracked for cleanup during <c>ModRegistry.Reload()</c>. Each native library is loaded once; if multiple mods provide the same library name, the first one wins and duplicates are logged as warnings. </summary>
public static class ModNativeLoader{/// <summary> Absolute path to the native library cache directory (<c>user://mod_cache/native/</c>). Resolved once at static init with an OS temp fallback for non-runtime contexts. </summary>
public static readonly string CacheRoot;private static readonly Dictionary<string,string>_nameToPath=new();private static readonly Dictionary<string,IntPtr>_loadedHandles=new();private static bool _resolverInstalled;static ModNativeLoader(){if(GameEnvironment.IsRuntime){try{CacheRoot=ProjectSettings.GlobalizePath("user://mod_cache/native/");}catch{CacheRoot=Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),"cthangover","mod_cache","native");}}else{CacheRoot=Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),"cthangover","mod_cache","native");}}/// <summary> Scans each loaded mod's <c>depends_native</c> patterns for platform-native shared libraries, copies them to the cache, and registers a <c>DllImportResolver</c> on the core assembly. Must be called before any managed code that triggers P/Invoke resolution — typically from <c>ModRegistry.Initialize()</c> right before precompiled DLL loading. </summary>
public static void LoadNativeDlls(IEnumerable<IModInfo>mods,CancellationToken cancellationToken=default){if(!Directory.Exists(CacheRoot))Directory.CreateDirectory(CacheRoot);foreach(var mod in mods){cancellationToken.ThrowIfCancellationRequested();var modInfo=mod as ModInfo;var nativePatterns=modInfo?.Manifest?.DependsNative;if(nativePatterns==null||nativePatterns.Count==0)continue;var nativeFiles=CollectNativeFiles(mod.FileProvider,nativePatterns);if(nativeFiles.Count==0)continue;GameLogger.Log("MODS_NATIVE",$"ModNativeLoader.LoadNativeDlls: found {nativeFiles.Count} native library(s) in mod '{mod.Id}'");foreach(var(filePath,bytes)in nativeFiles){cancellationToken.ThrowIfCancellationRequested();try{var fileName=Path.GetFileName(filePath);var libraryName=NormalizeLibraryName(fileName);var cachedPath=Path.Combine(CacheRoot,$"{mod.Id}_native_{fileName}");// Checked before the write: a library registered earlier (a previous mod, or this mod before an in-process reload) is loaded by the OS and its file must not be rewritten.
if(_nameToPath.TryGetValue(libraryName,out var registered)&&registered==cachedPath)continue;if(registered!=null){GameLogger.Log("MODS_NATIVE",$"ModNativeLoader.LoadNativeDlls: native library '{libraryName}' already registered "+$"(from a previous mod), skipping duplicate from mod '{mod.Id}'",LogLevel.Warning);continue;}File.WriteAllBytes(cachedPath,bytes);_nameToPath[libraryName]=cachedPath;GameLogger.Log("MODS_NATIVE",$"ModNativeLoader.LoadNativeDlls: registered native library '{libraryName}' "+$"from mod '{mod.Id}' -> '{cachedPath}'");}catch(Exception ex){GameLogger.Log("MODS_NATIVE",$"ModNativeLoader.LoadNativeDlls: failed to cache native library '{filePath}' "+$"from mod '{mod.Id}': {ex.Message}",LogLevel.Error);}}}if(_nameToPath.Count>0)EnsureResolverInstalled();}/// <summary> Releases all native library handles loaded through the resolver. Called during <c>ModRegistry.Reload()</c> to allow DLL files to be overwritten on the next load cycle. </summary>
public static void FreeAll(){foreach(var kvp in _loadedHandles){try{NativeLibrary.Free(kvp.Value);}catch{}}_loadedHandles.Clear();_nameToPath.Clear();}private static void EnsureResolverInstalled(){if(_resolverInstalled)return;_resolverInstalled=true;System.Runtime.Loader.AssemblyLoadContext.Default.ResolvingUnmanagedDll+=OnResolvingUnmanagedDll;GameLogger.Log("MODS_NATIVE","ModNativeLoader: global native DLL resolver installed");}/// <summary> Installs the DllImportResolver on a specific assembly. Call after loading a precompiled mod assembly that may contain [DllImport] attributes. </summary>
public static void InstallResolverOnAssembly(Assembly assembly){if(_nameToPath.Count==0)return;NativeLibrary.SetDllImportResolver(assembly,OnResolvingUnmanagedDllAssembly);}private static IntPtr OnResolvingUnmanagedDllAssembly(string libraryName,Assembly assembly,DllImportSearchPath?searchPath){libraryName=NormalizeLibraryName(libraryName);if(_loadedHandles.TryGetValue(libraryName,out var cachedHandle))return cachedHandle;if(!_nameToPath.TryGetValue(libraryName,out var cachedPath))return IntPtr.Zero;if(NativeLibrary.TryLoad(cachedPath,out var handle)){_loadedHandles[libraryName]=handle;GameLogger.Log("MODS_NATIVE",$"ModNativeLoader: loaded '{libraryName}' from '{cachedPath}'");return handle;}return IntPtr.Zero;}private static IntPtr OnResolvingUnmanagedDll(Assembly assembly,string libraryName){libraryName=NormalizeLibraryName(libraryName);if(_loadedHandles.TryGetValue(libraryName,out var cachedHandle))return cachedHandle;if(!_nameToPath.TryGetValue(libraryName,out var cachedPath))return IntPtr.Zero;if(NativeLibrary.TryLoad(cachedPath,out var handle)){_loadedHandles[libraryName]=handle;GameLogger.Log("MODS_NATIVE",$"ModNativeLoader: loaded '{libraryName}' from '{cachedPath}'");return handle;}GameLogger.Log("MODS_NATIVE",$"ModNativeLoader: failed to load '{libraryName}' from '{cachedPath}'",LogLevel.Error);return IntPtr.Zero;}/// <summary> Platform-neutral key for a native library: no extension and no Unix <c>lib</c> prefix. <c>[DllImport("Live2DCubismCore")]</c> must find <c>Live2DCubismCore.dll</c> on Windows as well as <c>libLive2DCubismCore.so</c> / <c>.dylib</c> on Linux and macOS. </summary>
public static string NormalizeLibraryName(string name){if(string.IsNullOrEmpty(name))return name;var fileName=Path.GetFileName(name);foreach(var ext in new[]{".dll",".so",".dylib"}){if(fileName.EndsWith(ext,StringComparison.OrdinalIgnoreCase)){fileName=fileName.Substring(0,fileName.Length-ext.Length);break;}}if(fileName.StartsWith("lib",StringComparison.Ordinal)&&fileName.Length>3)fileName=fileName.Substring(3);return fileName;}private static List<(string FilePath,byte[]Bytes)>CollectNativeFiles(IModFileProvider provider,List<string>patterns){var result=new List<(string,byte[])>();foreach(var pattern in patterns){var prefix=pattern;var suffix="*";var sepIndex=pattern.LastIndexOf('/');if(sepIndex>=0){prefix=pattern.Substring(0,sepIndex);suffix=pattern.Substring(sepIndex+1);}CollectNativeFilesRecursive(provider,prefix,file=>{if(!IsPlatformFile(file))return false;if(suffix=="*")return true;var fileName=file.Replace('\\','/');var fileNamePart=fileName.Substring(fileName.LastIndexOf('/')+1);return suffix=="*"||(suffix.StartsWith("*.")&&fileNamePart.EndsWith(suffix.Substring(1),StringComparison.OrdinalIgnoreCase));},result);}return result;}private static void CollectNativeFilesRecursive(IModFileProvider provider,string directory,Func<string,bool>fileFilter,List<(string,byte[])>result){foreach(var entry in provider.ListFiles(directory)){if(entry.EndsWith("/")){var subDir=entry.TrimEnd('/');CollectNativeFilesRecursive(provider,subDir,fileFilter,result);}else if(fileFilter(entry)){var bytes=provider.ReadFileBinary(entry);if(bytes!=null)result.Add((entry,bytes));}}}private static bool IsPlatformFile(string fileName){if(RuntimeInformation.IsOSPlatform(OSPlatform.Windows))return fileName.EndsWith(".dll",StringComparison.OrdinalIgnoreCase);if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))return fileName.EndsWith(".so",StringComparison.OrdinalIgnoreCase);if(RuntimeInformation.IsOSPlatform(OSPlatform.OSX))return fileName.EndsWith(".dylib",StringComparison.OrdinalIgnoreCase);return false;}}}

===== FILE Core/Mods/ModOverlays.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Core.Mods{/// <summary> Content layers mounted on top of the loaded mod set without reloading it. An overlay is a plain <see cref="IModFileProvider"/> registered under an id that is not a mod id (e.g. the scenario studio's per-game edit layer in <c>user://studio/</c>); content resolvers that support overlays (<see cref="Resolvers.ScenarioResolver"/>, <see cref="Resolvers.SceneDefinitionResolver"/>) read them next to the mods, and overlay scenarios override mod scenarios of the same name. <para/> Mounting does not invalidate anything by itself: the owner calls the resolvers' invalidation after it changes overlay files, exactly as it would after editing a folder mod. A full mod reload keeps the overlays — they belong to the session, not to the mod set. </summary>
public static class ModOverlays{private static readonly List<KeyValuePair<string,IModFileProvider>>overlays=new();/// <summary>Mounted overlays in mount order.</summary>
public static IReadOnlyList<KeyValuePair<string,IModFileProvider>>All=>overlays;/// <summary>Mounts <paramref name="provider"/> as overlay <paramref name="id"/>, replacing an overlay with the same id.</summary>
public static void Mount(string id,IModFileProvider provider){if(string.IsNullOrEmpty(id)||provider==null)throw new ArgumentException("overlay id and provider are required");Unmount(id);overlays.Add(new KeyValuePair<string,IModFileProvider>(id,provider));}/// <summary>Removes overlay <paramref name="id"/>; returns false when it was not mounted.</summary>
public static bool Unmount(string id){for(var i=0;i<overlays.Count;i++){if(overlays[i].Key!=id)continue;overlays.RemoveAt(i);return true;}return false;}/// <summary>Finds the provider of overlay <paramref name="id"/>.</summary>
public static bool TryGet(string id,out IModFileProvider provider){foreach(var overlay in overlays){if(overlay.Key!=id)continue;provider=overlay.Value;return true;}provider=null;return false;}}}

===== FILE Core/Mods/ModPath.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Core.Mods{/// <summary> Canonical path arithmetic shared by every <see cref="IModFileProvider"/>. Mod-relative paths are always stored and compared in one canonical form: forward slashes, no leading root marker, no <c>.</c>/<c>..</c> segments, no trailing slash. Only providers convert to the OS-native form, and only at the filesystem boundary. The previous per-provider normalisation diverged — folder providers converted to <see cref="System.IO.Path.DirectorySeparatorChar"/> while zip providers kept forward slashes and trimmed leading slashes — so the same mod could behave differently depending on whether it shipped as a folder or a zip (most visibly: <c>${../shared/x.json}</c> includes resolved against the real filesystem for folders, but failed for zips because the <c>..</c> was never collapsed). Centralising here removes that class of bug: a path produced by one provider can be handed to any other component without re-normalisation. Escaping the mod root is neutralised rather than rejected: a leading <c>/</c>, a drive prefix (<c>C:/</c>), or a <c>..</c> that would pop past the root simply becomes a relative path inside the mod, matching the fail-closed behaviour of zip lookup. </summary>
public static class ModPath{/// <summary> Converts a path to canonical mod-relative form: backslashes become forward slashes, empty/current-directory segments are dropped, <c>..</c> pops the previous segment (ignored at the root), and a leading root or drive marker is removed. Returns an empty string for null, empty, or root-only input. </summary>
public static string Normalize(string path){if(string.IsNullOrEmpty(path))return"";var segments=path.Replace('\\','/').Split('/');var result=new List<string>(segments.Length);foreach(var segment in segments){if(segment.Length==0||segment==".")continue;if(segment==".."){if(result.Count>0)result.RemoveAt(result.Count-1);continue;}if(result.Count==0&&segment.EndsWith(":",StringComparison.Ordinal))continue;result.Add(segment);}return string.Join("/",result);}/// <summary> Canonical directory form: <see cref="Normalize"/> plus a trailing <c>/</c>. Empty input stays empty so that root listings keep the no-prefix contract. </summary>
public static string NormalizeDirectory(string path){var normalized=Normalize(path);return normalized.Length==0?"":normalized+"/";}/// <summary> Returns the canonical directory containing <paramref name="path"/>, or an empty string when the path has no directory component. </summary>
public static string GetDirectory(string path){var normalized=Normalize(path);var lastSlash=normalized.LastIndexOf('/');return lastSlash<0?"":normalized.Substring(0,lastSlash);}/// <summary> Resolves a relative include path (<c>../shared/data.json</c>) against the directory of <paramref name="currentPath"/> and returns the canonical result. This is the single implementation behind <see cref="IModFileProvider.ResolveIncludePath"/> for both folder and zip providers, so include directives cannot behave differently based on storage format. </summary>
public static string ResolveRelative(string includePath,string currentPath){var directory=GetDirectory(currentPath);if(directory.Length==0)return Normalize(includePath);return Normalize(directory+"/"+includePath);}}}

===== FILE Core/Mods/ModPathCase.cs =====
using System;using System.Collections.Concurrent;using System.IO;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods{/// <summary> Single case policy for mod-relative paths: a reference must match the on-disk (or in-archive) spelling exactly. Windows and macOS file systems forgive a wrong case, Linux, CI and exported <c>.pck</c> files do not — so a mod that only works on the author's machine is caught on every platform instead of breaking after export. Providers use <see cref="FindOnDisk"/> to learn the real spelling and <see cref="ReportMismatch"/> to name the correct path in the log, then treat the request as a miss. </summary>
public static class ModPathCase{private static readonly ConcurrentDictionary<string,byte>Reported=new(StringComparer.Ordinal);/// <summary> Walks <paramref name="relative"/> (canonical <see cref="ModPath"/> form) under <paramref name="root"/> segment by segment and returns its on-disk spelling, preferring an exact match per segment and falling back to a case-insensitive one. Returns <c>null</c> when nothing matches or the entry kind (file vs directory) differs. </summary>
public static string FindOnDisk(string root,string relative,bool directory){if(string.IsNullOrEmpty(relative))return directory&&Directory.Exists(root)?string.Empty:null;var segments=relative.Split('/');var actual=new string[segments.Length];var current=root;for(var i=0;i<segments.Length;i++){if(!Directory.Exists(current))return null;string match=null;foreach(var entry in Directory.EnumerateFileSystemEntries(current)){var name=Path.GetFileName(entry);if(string.Equals(name,segments[i],StringComparison.Ordinal)){match=name;break;}if(match==null&&string.Equals(name,segments[i],StringComparison.OrdinalIgnoreCase))match=name;}if(match==null)return null;actual[i]=match;current=Path.Combine(current,match);}var exists=directory?Directory.Exists(current):File.Exists(current);return exists?string.Join("/",actual):null;}/// <summary> Logs a case-only mismatch once per mod and path, so a hot lookup loop cannot flood the log while the author still sees the fix. </summary>
public static void ReportMismatch(string mod,string requested,string actual){if(!Reported.TryAdd($"{mod}|{requested}",0))return;GameLogger.Log("MODS",$"[{mod}] path case mismatch: '{requested}' is spelled '{actual}' — fix the reference "+"(paths are case-sensitive on Linux and in exported .pck)",LogLevel.Error);}}}

===== FILE Core/Mods/ModRegistry.cs =====
namespace Cthangover.Core.Mods{using System;using System.Collections.Generic;using System.Threading;using Cthangover.Core.Events;using Cthangover.Core.Mods.Pipeline;using Cthangover.Core.Utils;/// <summary> Mod discovery and index — the singleton facade of the mod subsystem. Owns the mod dictionaries (<c>mods</c>, <c>AllDiscoveredMods</c>) and the <c>initialized</c> flag, and delegates the actual work to the collaborators in <c>Cthangover.Core.Mods.Pipeline</c>: boot-phase orchestration (<see cref="ModBootPipeline"/>), per-root discovery and deduplication (<see cref="ModDiscoveryService"/>), disabled/dependency filtering (<see cref="ModSetFilter"/>) and load-order computation (<see cref="ModLoadOrderService"/>); static root helpers live in <see cref="ModRoots"/>. <b>Deduplication:</b> when two mods share the same <see cref="ModManifest.Id"/> (or fall back to the same filesystem name), the one from the higher-priority root wins, and on equal priority the higher <see cref="ModManifest.Version"/>; the loser is skipped with a warning and its file provider is disposed. Mods without a version are treated as <c>"0.0.0.0"</c>. After discovery, the pipeline delegates to <c>ModAssemblyLoader</c> for precompiled DLLs and then to <c>ModCompiler</c> for source-based mods — the ordering matters because source mods may depend on APIs defined in precompiled DLLs. <c>Reload</c> clears state and re-runs discovery; <c>ModManager</c> hooks this to also flush its own caches. </summary>
public class ModRegistry:IModRegistry{private static readonly Lazy<ModRegistry>instance=new(()=>new ModRegistry());/// <summary>Thread-safe singleton.</summary>
public static ModRegistry Instance=>instance.Value;private readonly Dictionary<string,IModInfo>mods=new();private readonly Dictionary<string,IModInfo>allDiscoveredMods=new();private readonly Dictionary<string,int>_existingPriorities=new();/// <summary> True once discovery has run at least once. Published by the pipeline with a volatile write only after every game registry has been filled (<c>ModBootPipeline.FinalizeGodotInit</c>), so external readers can trust it as a readiness signal. Field name is load- bearing: test setups reset this flag via reflection. </summary>
private bool initialized;private readonly ModBootPipeline pipeline;private readonly ModLoadOrderService loadOrder;private readonly ModSetReloader reloader;private ModRegistry(){Mods=mods;pipeline=new ModBootPipeline(this,mods,allDiscoveredMods,_existingPriorities);loadOrder=new ModLoadOrderService(this,pipeline,mods,allDiscoveredMods);reloader=new ModSetReloader(this,pipeline,mods,allDiscoveredMods,_existingPriorities);}/// <summary>In-process replacement of the running mod set (game switch, hot reload).</summary>
public ModSetReloader Reloader=>reloader;/// <summary>Filesystem paths of the scanner roots resolved at boot (watched by the hot reload).</summary>
internal List<string>RootPaths{get{var paths=new List<string>();foreach(var(_,url,_)in pipeline.ResolvedRoots)paths.Add(url);return paths;}}/// <summary>Primary mods root (the first accessible scanner root, lowest priority number).</summary>
public static string ModsPath=>ModRoots.ModsPath;/// <summary>All currently accessible mods roots with their dedup priorities; used by the uninstaller's writable-root check.</summary>
public static List<(string Path,int Priority)>AllModsRoots=>ModRoots.AllModsRoots;/// <summary>True once discovery has run at least once.</summary>
public bool IsInitialized=>Volatile.Read(ref initialized);/// <inheritdoc />
public IReadOnlyDictionary<string,IModInfo>Mods{get;}/// <summary> Snapshot of all discovered mods before disabled-filtering, intended for the mods configuration UI to display the full list regardless of enable state. </summary>
public IReadOnlyDictionary<string,IModInfo>AllDiscoveredMods=>allDiscoveredMods;/// <summary> Volatile access to <c>initialized</c> for the boot pipeline: the flag stays owned by the registry (external readers observe it via <see cref="IsInitialized"/>), while the pipeline performs the early-out read in <c>Initialize</c> and the readiness publish in <c>FinalizeGodotInit</c> at exactly the original points. </summary>
internal bool InitializedFlag{get=>Volatile.Read(ref initialized);set=>Volatile.Write(ref initialized,value);}/// <summary> Idempotent bootstrap: scans the mods directory, creates providers, reads manifests, resolves duplicate IDs by version, loads precompiled DLLs, then compiles source-based mods. Returns immediately on subsequent calls. During a launcher boot the pipeline belongs to Boot alone and other callers are refused — see <see cref="ModBootPipeline.IsLauncherBootWindow"/> for the exact policy. </summary>
public void Initialize()=>pipeline.Initialize();/// <summary> Resolves everything that requires Godot API on the main thread (scanner roots, cache dirs, config) so the background phase never touches Godot. Idempotent. See <see cref="ModBootPipeline.PreparePaths"/>. </summary>
public void PreparePaths()=>pipeline.PreparePaths();/// <summary> Godot-free phase: scans the pre-resolved roots and compiles mods — pure .NET, safe on a background thread. See <see cref="ModBootPipeline.DiscoverAndCompile"/>. </summary>
public void DiscoverAndCompile(CancellationToken cancellationToken=default)=>pipeline.DiscoverAndCompile(cancellationToken);/// <summary> Godot-bound phase: registers compiled assemblies, loads GDScript mods, computes the load order and publishes readiness. See <see cref="ModBootPipeline.FinalizeGodotInit"/>. </summary>
public void FinalizeGodotInit()=>pipeline.FinalizeGodotInit();/// <summary> Full reload for the current selection: every mod with code is released and loaded again from freshly compiled sources (see <see cref="ModSetReloader"/>). Mods that fail to compile are left out, as on a cold boot. The caller (typically <c>ModManager.Reload</c>) is responsible for rebuilding the derived state. </summary>
public void Reload(){var prep=reloader.Prepare(Games.GameLaunchArgs.Current,new[]{"*"});var tree=GameEnvironment.IsRuntime?Godot.Engine.GetMainLoop()as Godot.SceneTree:null;reloader.Apply(prep,tree);}/// <summary> Invalidates the cached ordered mod list so the next call to <see cref="GetOrderedModIds"/> recomputes it. Called by the UI after the user changes the manual load order. </summary>
public void InvalidateOrder()=>loadOrder.InvalidateOrder();/// <summary> Looks up a mod by ID. Triggers <c>Initialize</c> if not yet done, so callers don't need a separate guard. </summary>
public IModInfo GetMod(string id){Initialize();mods.TryGetValue(id,out var mod);return mod;}/// <summary> Returns mod IDs ordered by <c>depends</c>, then by the user's manual order; semantics, caching and boot-window guards are documented on <see cref="ModLoadOrderService.GetOrderedModIds"/>. </summary>
public IReadOnlyList<string>GetOrderedModIds(bool filterDisabled=true)=>loadOrder.GetOrderedModIds(filterDisabled);}}

===== FILE Core/Mods/ModRoots.cs =====
namespace Cthangover.Core.Mods{using System.Collections.Generic;using System.Linq;using Cthangover.Core.Mods.Discovery;/// <summary> Static helpers for resolving the mods roots (the scanned sources of mods, e.g. <c>user://mods/</c> and the game-dir <c>mods/</c>). Extracted from <see cref="ModRegistry"/> so tools and test setups can query roots without touching the registry's singleton state: these lookups only initialise the discovery registry and probe the filesystem — they never trigger mod loading or the boot pipeline. Threading contract: <c>GetRoots</c>/<c>IsCanProvide</c> may globalise paths through <c>ProjectSettings</c>, so call these members from the Godot main thread (callers — mod manager UI, uninstaller, test setups — already do). The boot pipeline resolves the same roots once per launch in <c>ModBootPipeline.PreparePaths</c> and never uses this class. <see cref="ModRegistry.ModsPath"/> and <see cref="ModRegistry.AllModsRoots"/> remain as delegating static properties for source compatibility with existing callers. </summary>
public static class ModRoots{/// <summary>Primary mods root (the first accessible scanner root, lowest priority number).</summary>
public static string ModsPath=>ResolveModsRoots().FirstOrDefault().Path;/// <summary>All currently accessible mods roots with their dedup priorities; used by the uninstaller's writable-root check.</summary>
public static List<(string Path,int Priority)>AllModsRoots=>ResolveModsRoots();/// <summary> Re-resolves every registered scanner's roots on each call (no caching — callers may run after new scanners were registered by freshly loaded mod assemblies, which must be visible immediately). A root is listed only when its scanner still answers <see cref="IModDiscoveryProvider.IsCanProvide"/>, so roots whose backing directory disappeared drop out of the result. </summary>
public static List<(string Path,int Priority)>ResolveModsRoots(){var roots=new List<(string Path,int Priority)>();ModDiscoveryRegistry.Initialize();foreach(var scanner in ModDiscoveryRegistry.Scanners){foreach(var root in scanner.GetRoots()){if(scanner.IsCanProvide(root.Url))roots.Add((root.Url,root.Priority));}}return roots;}}}

===== FILE Core/Mods/ModSaveableRegistry.cs =====
using System;using System.Collections.Generic;using System.Reflection;using Cthangover.Core.Settings;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods{/// <summary> Registry of <see cref="IModSaveable"/> implementations. When a mod assembly is loaded via <see cref="ModAssemblyLoader.RegisterAssembly"/>, types are scanned and instances created. On save, <see cref="CollectSaveData"/> creates a pre-filled <see cref="ModSlot"/> for each registered mod — the core sets <c>ModId</c>, <c>ModName</c>, and <c>ModVersion</c> so the mod does not need to — then calls <see cref="IModSaveable.OnSave"/>. On load, <see cref="DispatchLoad"/> distributes slots from the save file via <see cref="IModSaveable.OnLoad"/>, preferring the explicit <c>bool OnLoad(ModSlot, out string)</c> overload and falling back to exception-based <c>OnLoadVoid</c>. </summary>
public static class ModSaveableRegistry{private static readonly List<RegisteredSaveable>_saveables=new();private class RegisteredSaveable{public IModSaveable Instance;public string ModId;}/// <summary> Discovers <see cref="IModSaveable"/> implementations in the assembly and registers them under the given modId. </summary>
public static void RegisterAssembly(Assembly assembly,string modId){foreach(var instance in Reflections.FindAndCreate<IModSaveable>(assembly)){try{_saveables.Add(new RegisteredSaveable{Instance=instance,ModId=modId});GameLogger.Log("MODS_SAVE",$"Registered IModSaveable '{instance.GetType().FullName}' for mod '{modId}'",LogLevel.Debug);}catch(Exception ex){GameLogger.Log("MODS_SAVE",$"Failed to create IModSaveable '{instance.GetType().FullName}': {ex.Message}",LogLevel.Error);}}}/// <summary> Removes every saveable registered for <paramref name="modId"/> from the given assembly — the unload pass of the assembly consumer pipeline. Matching by both mod id and declaring assembly keeps the removal exact even when a mod ships multiple assemblies or another mod overrode the same saveable contract. </summary>
public static void UnregisterAssembly(Assembly assembly,string modId){_saveables.RemoveAll(e=>e.ModId==modId&&e.Instance.GetType().Assembly==assembly);}/// <summary> Collects save data from all registered mods. For each one, creates a <see cref="ModSlot"/> with <c>Metadata.ModId</c>, <c>Metadata.ModName</c>, and <c>Metadata.ModVersion</c> pre-filled from <see cref="IModInfo"/>, then calls <see cref="IModSaveable.OnSave"/> and stores the slot keyed by ModId. </summary>
public static Dictionary<string,ModSlot>CollectSaveData(){var result=new Dictionary<string,ModSlot>();foreach(var entry in _saveables){try{var modInfo=ModRegistry.Instance.GetMod(entry.ModId);var slot=new ModSlot{Metadata=new ModMetadata{ModId=entry.ModId,ModName=modInfo?.DisplayTitle??entry.ModId,ModVersion=modInfo?.Version??"0.0.0",}};entry.Instance.OnSave(slot);result[entry.ModId]=slot;}catch(Exception ex){GameLogger.Log("MODS_SAVE",$"OnSave failed for mod '{entry.ModId}': {ex.Message}",LogLevel.Error);}}return result;}/// <summary> Dispatches data slots to mods on load. For each registered mod whose ID is present in the loaded slot dictionary, calls <see cref="IModSaveable.OnLoad(Cthangover.Core.Settings.ModSlot, out string)"/>. If the mod does not override the bool overload, the default implementation delegates to <see cref="IModSaveable.OnLoadVoid"/> — exceptions from either path are caught and returned as errors. Returns an empty list on success. </summary>
public static List<string>DispatchLoad(Dictionary<string,ModSlot>modData,ICollection<string>failedModIds=null){var errors=new List<string>();if(modData==null)return errors;foreach(var entry in _saveables){if(!modData.TryGetValue(entry.ModId,out var slot))continue;try{if(entry.Instance.OnLoad(slot,out var error)){GameLogger.Log("MODS_SAVE",$"OnLoad succeeded for mod '{entry.ModId}'");}else{var modName=slot.Metadata?.ModName??entry.ModId;errors.Add($"Mod '{entry.ModId}' ({modName}): {error ?? "unspecified error"}");failedModIds?.Add(entry.ModId);}}catch(Exception ex){var modName=slot.Metadata?.ModName??entry.ModId;var errorMsg=$"Mod '{entry.ModId}' ({modName}): {ex.Message}";GameLogger.Log("MODS_SAVE",$"OnLoad failed: {errorMsg}",LogLevel.Error);errors.Add(errorMsg);failedModIds?.Add(entry.ModId);}}return errors;}/// <summary> Tells every saveable whose mod had no restored slot that it joined an existing game (<see cref="IModSaveable.OnJoinedExistingGame"/>). </summary>
public static void NotifyJoined(IEnumerable<string>restoredModIds){var restored=new HashSet<string>(restoredModIds??Array.Empty<string>(),StringComparer.Ordinal);foreach(var entry in _saveables){if(restored.Contains(entry.ModId))continue;try{entry.Instance.OnJoinedExistingGame();GameLogger.Log("MODS_SAVE",$"Mod '{entry.ModId}' joined an existing game (no data in the save)");}catch(Exception ex){GameLogger.Log("MODS_SAVE",$"OnJoinedExistingGame failed for mod '{entry.ModId}': {ex.Message}",LogLevel.Error);}}}}}

===== FILE Core/Mods/ModScenes.cs =====
using System;using System.Collections.Generic;using Godot;namespace Cthangover.Core.Scenes{/// <summary> Utility that recursively collects all <c>.tscn</c> files under <c>res://scenes</c> and returns them sorted by name. Used by tooling and scene selection UI. </summary>
public static class ModScenes{/// <summary> Returns all .tscn files under res://scenes as (Name, Path) tuples, sorted alphabetically by name. </summary>
public static List<(string Name,string Path)>CollectTscnFiles(){var result=new List<(string,string)>();Scan("res://scenes",result);result.Sort((a,b)=>string.Compare(a.Item1,b.Item1,StringComparison.OrdinalIgnoreCase));return result;}private static void Scan(string dir,List<(string,string)>result){using var d=DirAccess.Open(dir);if(d==null)return;d.ListDirBegin();var name=d.GetNext();while(name!=""){if(d.CurrentIsDir()&&name!="."&&name!="..")Scan(dir.PathJoin(name),result);else if(name.EndsWith(".tscn",StringComparison.OrdinalIgnoreCase))result.Add((name,dir.PathJoin(name)));name=d.GetNext();}d.ListDirEnd();}}}

===== FILE Core/Mods/ModSettingDefinition.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Mods{/// <summary> Declares a single setting that a mod exposes in the settings UI. <c>Key</c> is the unique identifier used to read/write values in <see cref="DataBlob"/>; <c>Name</c> is a localisation key (resolved via <c>TranslationServer.Translate</c>) shown as the label. <c>Type</c> determines which Godot control is spawned in the UI: <list type="bullet"> <item><b>Bool</b> → <c>CheckButton</c></item> <item><b>Integer</b> → <c>SpinBox</c> (no decimals)</item> <item><b>Float</b> → <c>SpinBox</c> (with decimals)</item> <item><b>String</b> → <c>LineEdit</c></item> <item><b>Enum</b> → <c>OptionButton</c> (populated from <c>Options</c>)</item> <item><b>Slider</b> → <c>HSlider</c> + value label (bound by <c>Min</c>/<c>Max</c>/<c>Step</c>)</item> </list> <c>DefaultValue</c> is a string representation of the initial value, used as a fallback when no persisted value exists and for the Reset button. </summary>
public class ModSettingDefinition{/// <summary>Unique setting key within this mod (e.g. "atb_speed").</summary>
public string Key{get;set;}/// <summary> Localisation key shown as the label next to the control. The mod must provide the translation via its <c>locale/</c> files. </summary>
public string Name{get;set;}/// <summary>Widget type spawned in the UI for this setting.</summary>
public SettingType Type{get;set;}/// <summary> String representation of the default value. Used when no persisted value exists and when the user presses Reset. Interpreted by the UI according to <c>Type</c>. </summary>
public string DefaultValue{get;set;}/// <summary> Applicable only when <c>Type == SettingType.Enum</c>. Maps internal values (e.g. "fast") to localisation keys for display (e.g. "mod_ff/atb_fast"). </summary>
public Dictionary<string,string>Options{get;set;}/// <summary>Applicable only when <c>Type == SettingType.Slider</c>.</summary>
public float Min{get;set;}/// <summary>Applicable only when <c>Type == SettingType.Slider</c>.</summary>
public float Max{get;set;}/// <summary>Applicable only when <c>Type == SettingType.Slider</c>.</summary>
public float Step{get;set;}/// <summary> <c>SettingType.Action</c> only: button caption (locale key). The action runs through <see cref="IModSettingsActions.RunAction"/>. </summary>
public string ActionText{get;set;}/// <summary><c>SettingType.Action</c> only: confirmation question (locale key); null runs immediately.</summary>
public string ConfirmText{get;set;}}/// <summary> Widget type for a mod setting. Determines which Godot control is spawned and how the value is read/written. </summary>
public enum SettingType{Bool,Integer,Float,String,Enum,Slider,/// <summary>A button that runs a mod command (no stored value), e.g. purging data.</summary>
Action}}

===== FILE Core/Mods/ModSettingsRegistry.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Reflection;using System.Text;using System.Text.Json;using Cthangover.Core.Settings;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods{/// <summary> Registry of <see cref="IModSettings"/> implementations. When a mod assembly is loaded, types implementing the interface are scanned and instantiated via reflection, mirroring the pattern used by <see cref="ModSaveableRegistry"/>. Persistence: each mod's settings are stored as a JSON-serialised <see cref="DataBlob"/> at <c>user://mod_settings/{modId}.json</c>. The core owns persistence entirely; the mod only defines settings and reads/writes values through the blob API. <b>Thread safety:</b> all I/O is synchronous on the main thread. </summary>
public static class ModSettingsRegistry{private const string SettingsDir="user://mod_settings";private static readonly List<RegisteredModSettings>_entries=new();private class RegisteredModSettings{public IModSettings Instance;public string ModId;}/// <summary> Discovers <see cref="IModSettings"/> implementations in the assembly and registers them under the given modId. </summary>
public static void RegisterAssembly(Assembly assembly,string modId){foreach(var instance in Reflections.FindAndCreate<IModSettings>(assembly)){try{_entries.Add(new RegisteredModSettings{Instance=instance,ModId=modId});GameLogger.Log("MODS_SETTINGS",$"Registered IModSettings '{instance.GetType().FullName}' for mod '{modId}'",LogLevel.Debug);}catch(Exception ex){GameLogger.Log("MODS_SETTINGS",$"Failed to create IModSettings '{instance.GetType().FullName}': {ex.Message}",LogLevel.Error);}}}/// <summary> Removes every settings entry registered for <paramref name="modId"/> from the given assembly — the unload pass of the assembly consumer pipeline. Persisted settings on disk are intentionally kept: a reinstalled mod picks its values back up. </summary>
public static void UnregisterAssembly(Assembly assembly,string modId){_entries.RemoveAll(e=>e.ModId==modId&&e.Instance.GetType().Assembly==assembly);}/// <summary> Returns the mod IDs of all registered mods that implement <see cref="IModSettings"/>, sorted by their display title. </summary>
public static IReadOnlyList<string>GetModsWithSettings(){return _entries.Select(e=>e.ModId).Distinct().OrderBy(id=>GetDisplayTitle(id)).ToList();}/// <summary> Returns the human-readable display title for a mod, falling back to the mod ID if the mod is not found. </summary>
public static string GetDisplayTitle(string modId){var mod=ModRegistry.Instance.GetMod(modId);return mod?.DisplayTitle??modId;}/// <summary> Returns setting descriptors for the given mod. Returns an empty list if the mod has no settings registered. </summary>
public static IReadOnlyList<ModSettingDefinition>GetDefinitions(string modId){var entry=_entries.Find(e=>e.ModId==modId);if(entry==null)return Array.Empty<ModSettingDefinition>();try{return entry.Instance.GetDefinitions()??Array.Empty<ModSettingDefinition>();}catch(Exception ex){GameLogger.Log("MODS_SETTINGS",$"GetDefinitions failed for mod '{modId}': {ex.Message}",LogLevel.Error);return Array.Empty<ModSettingDefinition>();}}/// <summary> Runs a <see cref="SettingType.Action"/> setting of the mod and returns the message to show (already translated by the mod or a locale key), or null when the mod has no such action. </summary>
public static string RunAction(string modId,string key){var entry=_entries.Find(e=>e.ModId==modId&&e.Instance is IModSettingsActions);if(entry==null)return null;try{return((IModSettingsActions)entry.Instance).RunAction(key);}catch(Exception ex){GameLogger.Log("MODS_SETTINGS",$"Action '{key}' failed for mod '{modId}': {ex.Message}",LogLevel.Error);return ex.Message;}}/// <summary> Reads the mod's current values into a <see cref="DataBlob"/>. Returns an empty blob on failure. </summary>
public static DataBlob WriteCurrentValues(string modId){var entry=_entries.Find(e=>e.ModId==modId);if(entry==null)return new DataBlob();try{var blob=new DataBlob();entry.Instance.WriteValues(blob);return blob;}catch(Exception ex){GameLogger.Log("MODS_SETTINGS",$"WriteValues failed for mod '{modId}': {ex.Message}",LogLevel.Error);return new DataBlob();}}/// <summary> Applies values from a <see cref="DataBlob"/> to the mod. </summary>
public static void ApplyValues(string modId,DataBlob blob){var entry=_entries.Find(e=>e.ModId==modId);if(entry==null||blob==null)return;try{entry.Instance.ReadValues(blob);}catch(Exception ex){GameLogger.Log("MODS_SETTINGS",$"ReadValues failed for mod '{modId}': {ex.Message}",LogLevel.Error);}}/// <summary> Loads persisted settings for a single mod from disk. Returns a <see cref="DataBlob"/> with the saved values, or an empty blob if no file exists. </summary>
public static DataBlob LoadModSettings(string modId){var path=GetSettingsPath(modId);if(GameEnvironment.IsRuntime){if(!Godot.FileAccess.FileExists(path))return new DataBlob();}else{if(!File.Exists(path))return new DataBlob();}try{string json;if(GameEnvironment.IsRuntime){using var file=Godot.FileAccess.Open(path,Godot.FileAccess.ModeFlags.Read);json=file.GetAsText();}else{json=File.ReadAllText(path);}if(string.IsNullOrEmpty(json))return new DataBlob();var payload=Encoding.UTF8.GetBytes(json);return new DataBlob(payload);}catch(Exception ex){GameLogger.Log("MODS_SETTINGS",$"Failed to load settings for mod '{modId}': {ex.Message}",LogLevel.Error);return new DataBlob();}}/// <summary> Persists a <see cref="DataBlob"/> to disk for a single mod. </summary>
public static void SaveModSettings(string modId,DataBlob blob){if(blob==null)return;EnsureDirectoryExists();var path=GetSettingsPath(modId);try{var json=Encoding.UTF8.GetString(blob.GetBytes());if(GameEnvironment.IsRuntime){using var file=Godot.FileAccess.Open(path,Godot.FileAccess.ModeFlags.Write);file.StoreString(json);}else{File.WriteAllText(path,json);}}catch(Exception ex){GameLogger.Log("MODS_SETTINGS",$"Failed to save settings for mod '{modId}': {ex.Message}",LogLevel.Error);}}/// <summary> Deletes persisted settings for a single mod (used by the Reset button). After deletion the mod keeps its current in-memory values — the user must reopen settings to see defaults. </summary>
public static void DeleteModSettings(string modId){var path=GetSettingsPath(modId);try{if(GameEnvironment.IsRuntime){if(Godot.FileAccess.FileExists(path))DirAccess.RemoveAbsolute(ProjectSettings.GlobalizePath(path));}else{if(File.Exists(path))File.Delete(path);}}catch(Exception ex){GameLogger.Log("MODS_SETTINGS",$"Failed to delete settings for mod '{modId}': {ex.Message}",LogLevel.Error);}}/// <summary> Loads persisted settings for every registered mod and applies them via <see cref="IModSettings.ReadValues"/>. Called once at boot, after all mods have been loaded. </summary>
public static void LoadAll(){if(_entries.Count==0)return;EnsureDirectoryExists();foreach(var entry in _entries){var blob=LoadModSettings(entry.ModId);if(blob.GetBytes().Length==0)continue;try{entry.Instance.ReadValues(blob);GameLogger.Log("MODS_SETTINGS",$"Loaded settings for mod '{entry.ModId}'");}catch(Exception ex){GameLogger.Log("MODS_SETTINGS",$"ReadValues failed during LoadAll for mod '{entry.ModId}': {ex.Message}",LogLevel.Error);}}}private static string GetSettingsPath(string modId){var safe=new string(modId.Where(c=>char.IsLetterOrDigit(c)||c=='_'||c=='-').ToArray());var fileName=$"{safe}.json";if(GameEnvironment.IsRuntime)return Path.Combine(SettingsDir,fileName);return Path.Combine("mod_settings",fileName);}private static void EnsureDirectoryExists(){if(GameEnvironment.IsRuntime){var abs=ProjectSettings.GlobalizePath(SettingsDir);if(!Directory.Exists(abs))Directory.CreateDirectory(abs);}else{if(!Directory.Exists("mod_settings"))Directory.CreateDirectory("mod_settings");}}}}

===== FILE Core/Mods/ModTestSuite.cs =====
using System.Collections.Generic;using System.Text.Json.Serialization;namespace Cthangover.Core.Mods{/// <summary> One <c>test_suites</c> entry of a host manifest: the suite id, its folder (default <c>test_suites/&lt;id&gt;</c>, holding a regular <c>manifest.json</c> plus sources and content) and the user-arg prefixes that mount it (e.g. <c>--itest=interactives</c>). </summary>
public sealed class ModTestSuite{[JsonPropertyName("id")]public string Id{get;set;}/// <summary>Mod-relative folder of the suite; <c>test_suites/&lt;id&gt;</c> when omitted.</summary>
[JsonPropertyName("path")]public string Path{get;set;}/// <summary>User-arg prefixes (after <c>--</c>) that mount the suite; any match is enough.</summary>
[JsonPropertyName("activate")]public List<string>Activate{get;set;}[JsonIgnore]public string Folder=>string.IsNullOrWhiteSpace(Path)?$"{ModTestSuites.FolderName}/{Id}":Path.Trim().TrimEnd('/');}}

===== FILE Core/Mods/ModTestSuites.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text.Json;using Cthangover.Core.Mods.Providers;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods{/// <summary> Mounts the integration test suites of discovered mods (<see cref="ModManifest.TestSuites"/>). A suite is a folder inside its host (<c>test_suites/&lt;id&gt;/</c>) with an ordinary <c>manifest.json</c> (sources, gd_sources, ui, depends, …) and content laid out like a mod. When active it becomes the virtual mod <c>&lt;host&gt;__&lt;id&gt;</c> that implicitly depends on its host, so compilation, load order, resource loaders and the atlases treat it like any mod — one that sits above its host and may depend on mods the host itself must not (a test-only <c>depends</c>, without cycles). <para/> Gate: a suite is mounted only when a user arg starts with one of its <c>activate</c> prefixes, or with <see cref="AllFlag"/> (atlases). Without that the suite folder is inert data of the host: a normal launch, a game and <c>--all-mods</c> never load it. </summary>
public static class ModTestSuites{/// <summary>Default parent folder of suites inside the host mod.</summary>
public const string FolderName="test_suites";/// <summary>Mounts every suite (used by the resource and locale atlases).</summary>
public const string AllFlag="--with-test-suites";private const string Separator="__";private static readonly List<string>activeIds=new();/// <summary>Virtual mod ids mounted by the last <see cref="Mount"/>; added to a game/front-end set like <c>--with-mods</c>.</summary>
public static IReadOnlyList<string>ActiveIds=>activeIds;/// <summary>Virtual mod id of a suite: <c>&lt;host&gt;__&lt;suite&gt;</c>.</summary>
public static string ModIdOf(string hostId,string suiteId)=>hostId+Separator+suiteId;/// <summary>True when <paramref name="userArgs"/> mount the suite. Pure.</summary>
public static bool IsActive(ModTestSuite suite,IEnumerable<string>userArgs){if(suite==null||string.IsNullOrWhiteSpace(suite.Id))return false;var args=userArgs as ICollection<string> ??(userArgs??Array.Empty<string>()).ToList();if(args.Contains(AllFlag))return true;return(suite.Activate??new List<string>()).Where(p=> !string.IsNullOrWhiteSpace(p)).Any(prefix=>args.Any(a=>a.StartsWith(prefix,StringComparison.Ordinal)));}/// <summary> Adds the active suites of <paramref name="mods"/> as virtual mods. Runs once per boot right after discovery, before the set filters. </summary>
public static void Mount(IDictionary<string,IModInfo>mods,IReadOnlyCollection<string>userArgs){activeIds.Clear();foreach(var host in mods.Values.OfType<ModInfo>().ToList()){foreach(var suite in host.Manifest?.TestSuites??new List<ModTestSuite>()){if(!IsActive(suite,userArgs))continue;var info=CreateSuiteMod(host,suite);if(info==null)continue;if(mods.ContainsKey(info.Id)){GameLogger.Log("MODS_REGISTRY",$"Test suite '{info.Id}' clashes with an installed mod id; skipped",LogLevel.Error);info.FileProvider.Dispose();continue;}mods[info.Id]=info;activeIds.Add(info.Id);GameLogger.Log("MODS_REGISTRY",$"Test suite '{info.Id}' mounted from '{host.Id}/{suite.Folder}'");}}}/// <summary> Builds the virtual mod of one suite, or null when its folder or manifest is missing. Pure apart from reading the host's files. </summary>
public static ModInfo CreateSuiteMod(ModInfo host,ModTestSuite suite){var id=ModIdOf(host.Id,suite.Id);var folder=suite.Folder;IModFileProvider provider=host.FileProvider?.RootPath!=null&&Directory.Exists(host.FileProvider.RootPath)?new FolderModFileProvider(Path.Combine(host.FileProvider.RootPath,folder),id):new SubdirModFileProvider(host.FileProvider,folder,id);if(!provider.FileExists("manifest.json")){GameLogger.Log("MODS_REGISTRY",$"Test suite '{id}': '{host.Id}/{folder}/manifest.json' not found",LogLevel.Error);provider.Dispose();return null;}ModManifest manifest;try{manifest=JsonSerializer.Deserialize<ModManifest>(provider.ReadFileText("manifest.json"),JsonOptionsProvider.ReadOptions)??new ModManifest();}catch(JsonException ex){GameLogger.Log("MODS_REGISTRY",$"Test suite '{id}': bad manifest.json: {ex.Message}",LogLevel.Error);provider.Dispose();return null;}manifest.Id=id;manifest.Version=string.IsNullOrEmpty(manifest.Version)?host.Manifest?.Version:manifest.Version;manifest.Name=string.IsNullOrEmpty(manifest.Name)?$"{host.Name ?? host.Id} — test {suite.Id}":manifest.Name;manifest.Author??=host.Author;manifest.Depends??=new List<ModDependency>();if(manifest.Depends.All(d=> !string.Equals(d.ModId,host.Id,StringComparison.OrdinalIgnoreCase)))manifest.Depends.Insert(0,ModDependency.Parse(host.Id));manifest.TestSuites=null;// suites do not nest
return new ModInfo{Id=id,Name=manifest.Name,Author=manifest.Author,Description=manifest.Description,FileProvider=provider,Manifest=manifest,};}}}

===== FILE Core/Mods/ModUninstaller.cs =====
using System;using System.IO;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods{/// <summary> Deletes an installed mod and cleans up every piece of per-mod state that would otherwise outlive it. Deletion is intentionally conservative: only mods whose files live in a writable mods root (the player's <c>user://mods</c> in runtime, the project <c>mods/</c> folder in the editor) can be removed — shipped <c>res://mods</c> content is never touched. <para/> Running mods are not unloaded: like enable/disable and install, deletion takes full effect after a restart. The caller is expected to be a pre-game UI (the mod manager) where no gameplay code reads the deleted mod's files. The provider is disposed before the delete so zip-backed mods release their file handle on Windows. </summary>
public static class ModUninstaller{/// <summary> True when the mod is known, lives under a writable mods root and can therefore be deleted by <see cref="Uninstall"/>. </summary>
public static bool CanUninstall(string modId)=>CanUninstall(ModRegistry.Instance.GetMod(modId));/// <summary>Provider overload used by tests and callers that already resolved the mod.</summary>
public static bool CanUninstall(IModInfo modInfo){var rootPath=modInfo?.FileProvider?.RootPath;if(string.IsNullOrEmpty(rootPath))return false;return IsWritableModsRoot(GetParentPath(rootPath));}/// <summary> Removes the mod's files and all persisted per-mod state. Returns false (with a logged reason) when the mod is unknown, not deletable, or the filesystem refuses the delete. </summary>
public static bool Uninstall(string modId){var modInfo=ModRegistry.Instance.GetMod(modId);if(modInfo==null){GameLogger.Log("UNINSTALLER",$"Uninstall('{modId}'): mod not found",LogLevel.Error);return false;}if(!CanUninstall(modInfo)){GameLogger.Log("UNINSTALLER",$"Uninstall('{modId}'): mod is not in a writable mods root",LogLevel.Warning);return false;}var rootPath=modInfo.FileProvider.RootPath;modInfo.FileProvider.Dispose();if(!DeleteModFiles(rootPath)){GameLogger.Log("UNINSTALLER",$"Uninstall('{modId}'): failed to delete '{rootPath}'",LogLevel.Error);return false;}DisabledModsStore.Remove(modId);ModLoadOrderStore.Remove(modId);ModSettingsRegistry.DeleteModSettings(modId);ModCompiler.InvalidateCache(modId);// Best-effort collectible unload: if no scene/UI objects hold the mod's types (the mod-manager UI is a pre-game context), the assemblies are released right here; otherwise the restart note below stays the honest statement.
var unloadDiagnostic=ModAssemblyLoader.UnloadMod(modId);var unloadNote=unloadDiagnostic==null?"assemblies released":$"restart required ({unloadDiagnostic})";GameLogger.Log("UNINSTALLER",$"Uninstalled mod '{modId}' from '{rootPath}'; {unloadNote}");return true;}/// <summary> Deletes a mod's directory or zip archive located at <paramref name="rootPath"/>. Exposed separately so the raw filesystem operation can be tested without a live registry. </summary>
public static bool DeleteModFiles(string rootPath){if(string.IsNullOrWhiteSpace(rootPath))return false;try{if(Directory.Exists(rootPath)){Directory.Delete(rootPath,recursive:true);return true;}if(File.Exists(rootPath)){File.Delete(rootPath);return true;}}catch(Exception ex){GameLogger.Log("UNINSTALLER",$"DeleteModFiles('{rootPath}') failed: {ex.Message}",LogLevel.Error);return false;}return false;}private static bool IsWritableModsRoot(string rootPath){if(string.IsNullOrEmpty(rootPath))return false;var normalized=NormalizePath(rootPath);foreach(var root in ModRegistry.AllModsRoots){if(!string.Equals(NormalizePath(root.Path),normalized,StringComparison.OrdinalIgnoreCase))continue;if(GameEnvironment.IsRuntime&&root.Priority>0)return false;return true;}return false;}private static string GetParentPath(string path){var trimmed=path.TrimEnd('/','\\');var parent=Path.GetDirectoryName(trimmed);return string.IsNullOrEmpty(parent)?trimmed:parent;}private static string NormalizePath(string path){var slashed=(path??string.Empty).Replace('\\','/').TrimEnd('/');try{return Path.GetFullPath(slashed).Replace('\\','/').TrimEnd('/');}catch(Exception){return slashed;}}}}

===== FILE Core/Mods/Patches/ArrayMergeHelper.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Text.Json;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods.Patches{/// <summary> Internal helper for JSON array merge operations. All methods are pure string-in/string-out — no dependency on <c>PropertyData</c> or any other runtime state. </summary>
internal static class ArrayMergeHelper{internal enum ArrayOperation{Default,Set,Add,Remove}internal static void ParseArrayOperation(string key,out string baseKey,out ArrayOperation op){if(key.Length>4&&key.EndsWith("$set",StringComparison.Ordinal)){baseKey=key.Substring(0,key.Length-4);op=ArrayOperation.Set;}else if(key.Length>4&&key.EndsWith("$add",StringComparison.Ordinal)){baseKey=key.Substring(0,key.Length-4);op=ArrayOperation.Add;}else if(key.Length>7&&key.EndsWith("$remove",StringComparison.Ordinal)){baseKey=key.Substring(0,key.Length-7);op=ArrayOperation.Remove;}else{baseKey=key;op=ArrayOperation.Default;}}/// <summary> Merges <paramref name="existingRaw"/> and <paramref name="incomingRaw"/> into a single JSON array string. If <paramref name="identityKey"/> is provided and both arrays contain objects, elements are matched by that key and merged per-field. Otherwise simple concatenation is used. Returns <paramref name="incomingRaw"/> when there is no existing value or it is not a JSON array. </summary>
internal static string AddToArray(string existingRaw,string incomingRaw,string identityKey){if(string.IsNullOrEmpty(existingRaw)|| !existingRaw.StartsWith("[",StringComparison.Ordinal))return incomingRaw;try{return identityKey!=null?MergeObjectArrays(existingRaw,incomingRaw,identityKey):MergeJsonArrays(existingRaw,incomingRaw);}catch(JsonException ex){Core.Utils.GameLogger.Log("MODS",$"AddToArray: failed to merge, falling back to set: {ex.Message}",Core.Utils.LogLevel.Warning);return incomingRaw;}}/// <summary> Removes elements present in <paramref name="removeRaw"/> from <paramref name="existingRaw"/>. If <paramref name="identityKey"/> is provided, elements in object arrays are matched by that key's value; otherwise raw text comparison is used. Returns <paramref name="existingRaw"/> unchanged when there is no existing value or it is not a JSON array. </summary>
internal static string RemoveFromArray(string existingRaw,string removeRaw,string identityKey){if(string.IsNullOrEmpty(existingRaw)|| !existingRaw.StartsWith("[",StringComparison.Ordinal))return existingRaw;try{return identityKey!=null?RemoveFromObjectArray(existingRaw,removeRaw,identityKey):RemoveFromSimpleArray(existingRaw,removeRaw);}catch(JsonException ex){Core.Utils.GameLogger.Log("MODS",$"RemoveFromArray: failed to process: {ex.Message}",Core.Utils.LogLevel.Warning);return existingRaw;}}internal static string MergeJsonArrays(string existingRaw,string incomingRaw){var items=new List<string>();using(var docA=JsonDocument.Parse(existingRaw,JsonOptionsProvider.DocumentOptions)){foreach(var el in docA.RootElement.EnumerateArray())items.Add(el.GetRawText());}using(var docB=JsonDocument.Parse(incomingRaw,JsonOptionsProvider.DocumentOptions)){foreach(var el in docB.RootElement.EnumerateArray())items.Add(el.GetRawText());}return"["+string.Join(",",items)+"]";}private static string MergeObjectArrays(string existingRaw,string incomingRaw,string identityKey){var elements=new List<JsonElement>();using(var docA=JsonDocument.Parse(existingRaw,JsonOptionsProvider.DocumentOptions))foreach(var el in docA.RootElement.EnumerateArray())elements.Add(el.Clone());using var docB=JsonDocument.Parse(incomingRaw,JsonOptionsProvider.DocumentOptions);foreach(var incoming in docB.RootElement.EnumerateArray()){if(incoming.ValueKind!=JsonValueKind.Object|| !incoming.TryGetProperty(identityKey,out var incomingKey)){elements.Add(incoming.Clone());continue;}var incomingKeyRaw=incomingKey.GetRawText();var found=false;for(var i=0;i<elements.Count;i++){if(elements[i].ValueKind!=JsonValueKind.Object|| !elements[i].TryGetProperty(identityKey,out var existingKey))continue;if(existingKey.GetRawText()!=incomingKeyRaw)continue;elements[i]=MergeObjectFields(elements[i],incoming);found=true;break;}if(!found)elements.Add(incoming.Clone());}var parts=new List<string>();foreach(var el in elements)parts.Add(el.GetRawText());return"["+string.Join(",",parts)+"]";}private static JsonElement MergeObjectFields(JsonElement baseElement,JsonElement overlay){using var stream=new MemoryStream();using(var writer=new Utf8JsonWriter(stream)){writer.WriteStartObject();var written=new HashSet<string>();foreach(var prop in overlay.EnumerateObject()){writer.WritePropertyName(prop.Name);prop.Value.WriteTo(writer);written.Add(prop.Name);}foreach(var prop in baseElement.EnumerateObject()){if(!written.Contains(prop.Name)){writer.WritePropertyName(prop.Name);prop.Value.WriteTo(writer);}}writer.WriteEndObject();}var doc=JsonDocument.Parse(stream.ToArray(),JsonOptionsProvider.DocumentOptions);return doc.RootElement.Clone();}private static string RemoveFromSimpleArray(string existingRaw,string incomingRaw){using var docExisting=JsonDocument.Parse(existingRaw,JsonOptionsProvider.DocumentOptions);using var docRemove=JsonDocument.Parse(incomingRaw,JsonOptionsProvider.DocumentOptions);var toRemove=new HashSet<string>();foreach(var el in docRemove.RootElement.EnumerateArray())toRemove.Add(el.GetRawText());var remaining=new List<string>();foreach(var el in docExisting.RootElement.EnumerateArray()){if(!toRemove.Contains(el.GetRawText()))remaining.Add(el.GetRawText());}return"["+string.Join(",",remaining)+"]";}private static string RemoveFromObjectArray(string existingRaw,string incomingRaw,string identityKey){using var docExisting=JsonDocument.Parse(existingRaw,JsonOptionsProvider.DocumentOptions);using var docRemove=JsonDocument.Parse(incomingRaw,JsonOptionsProvider.DocumentOptions);var toRemove=new HashSet<string>();foreach(var el in docRemove.RootElement.EnumerateArray()){if(el.ValueKind==JsonValueKind.Object&&el.TryGetProperty(identityKey,out var keyProp))toRemove.Add(keyProp.GetRawText());}var remaining=new List<string>();foreach(var el in docExisting.RootElement.EnumerateArray()){if(el.ValueKind==JsonValueKind.Object&&el.TryGetProperty(identityKey,out var keyProp)&&toRemove.Contains(keyProp.GetRawText()))continue;remaining.Add(el.GetRawText());}return"["+string.Join(",",remaining)+"]";}}}

===== FILE Core/Mods/Patches/IMergeable.cs =====
namespace Cthangover.Core.Mods.Patches{/// <summary> Marks a class as supporting identity-key-based array merge. When a patch uses <c>$add</c> or <c>$remove</c> on a registered array property, elements are matched by the value of <see cref="MergeKeyProperty"/> instead of raw JSON text comparison, enabling per-field property overwrite. </summary>
public interface IMergeable{/// <summary> The name of the property used as the identity key for element matching during array merge operations. </summary>
string MergeKeyProperty{get;}}}

===== FILE Core/Mods/Patches/JsonPatchMerger.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Text.Json;using Cthangover.Core.Mods;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods.Patches{/// <summary> Merges mod patches into a raw entity JSON string before deserialisation. Operates purely at the JSON-text level. <para> <b>Identity-key detection for object arrays:</b> 1. Hardcoded fields: properties whose item type implements <see cref="IMergeable"/> contribute their <c>MergeKeyProperty</c> via <see cref="BuildMergeKeys{T}"/>. 2. Extra-property fields (mod-defined): identity key is detected automatically from the first element of the incoming array: <c>"Id"</c> or the first property ending in <c>"Id"</c>. 3. If nothing matches — raw-text concatenation is used. </para> </summary>
public class JsonPatchMerger{/// <summary> Scans <typeparamref name="T"/> via reflection and returns a map from property name to identity-key for every list-typed property whose item type implements <see cref="IMergeable"/>. </summary>
public static Dictionary<string,string>BuildMergeKeys<T>()where T:class{var keys=new Dictionary<string,string>();foreach(var prop in typeof(T).GetProperties()){var itemType=GetListItemType(prop.PropertyType);if(itemType==null)continue;if(!typeof(IMergeable).IsAssignableFrom(itemType))continue;try{var mergeable=(IMergeable)Activator.CreateInstance(itemType);if(!string.IsNullOrEmpty(mergeable.MergeKeyProperty))keys[prop.Name]=mergeable.MergeKeyProperty;}catch{// type without parameterless ctor — skip
}}return keys;}/// <summary> Applies all relevant patches for <paramref name="entityId"/> into <paramref name="entityJson"/> and returns the merged JSON. </summary> <param name="identityKeys">Optional per-property identity-key map (from <see cref="BuildMergeKeys{T}"/>). For keys not in this map, identity is auto-detected from the incoming array structure.</param>
public string Merge(string entityJson,string entityId,Dictionary<string,List<PropertyPatch>>allPatches,Dictionary<string,string>identityKeys=null){if(allPatches==null||allPatches.Count==0)return entityJson;using var doc=JsonDocument.Parse(entityJson,JsonOptionsProvider.DocumentOptions);var root=doc.RootElement.Clone();if(allPatches.TryGetValue("*",out var wildcardPatches)){foreach(var patch in wildcardPatches)root=ApplyPatch(root,patch,identityKeys);}if(allPatches.TryGetValue(entityId,out var specificPatches)){foreach(var patch in specificPatches)root=ApplyPatch(root,patch,identityKeys);}return root.GetRawText();}private JsonElement ApplyPatch(JsonElement target,PropertyPatch patch,Dictionary<string,string>identityKeys){if(patch.ExtraProperties==null||patch.ExtraProperties.Count==0)return target;using var stream=new System.IO.MemoryStream();using(var writer=new Utf8JsonWriter(stream)){WritePatchedObject(writer,target,patch.ExtraProperties,identityKeys);}var doc=JsonDocument.Parse(stream.ToArray(),JsonOptionsProvider.DocumentOptions);return doc.RootElement.Clone();}private static void WritePatchedObject(Utf8JsonWriter writer,JsonElement source,Dictionary<string,object>patchProps,Dictionary<string,string>identityKeys){writer.WriteStartObject();var patchedKeys=new HashSet<string>();var patchedValues=new Dictionary<string,string>();foreach(var kvp in patchProps){if(!(kvp.Value is JsonElement incomingValue))continue;ArrayMergeHelper.ParseArrayOperation(kvp.Key,out var baseKey,out var op);if(op==ArrayMergeHelper.ArrayOperation.Default||incomingValue.ValueKind!=JsonValueKind.Array){writer.WritePropertyName(baseKey);incomingValue.WriteTo(writer);patchedKeys.Add(baseKey);patchedValues.Remove(baseKey);continue;}var identityKey=ResolveIdentityKey(baseKey,incomingValue,source,identityKeys);var existingRaw=patchedValues.TryGetValue(baseKey,out var pv)?pv:source.TryGetProperty(baseKey,out var ep)&&ep.ValueKind==JsonValueKind.Array?ep.GetRawText():null;var incomingRaw=incomingValue.GetRawText();string result=null;switch(op){case ArrayMergeHelper.ArrayOperation.Add:result=ArrayMergeHelper.AddToArray(existingRaw,incomingRaw,identityKey);break;case ArrayMergeHelper.ArrayOperation.Remove:result=ArrayMergeHelper.RemoveFromArray(existingRaw,incomingRaw,identityKey);break;case ArrayMergeHelper.ArrayOperation.Set:result=incomingRaw;break;}if(result!=null)patchedValues[baseKey]=result;patchedKeys.Add(baseKey);}foreach(var kv in patchedValues)WriteRawJsonProperty(writer,kv.Key,kv.Value);foreach(var prop in source.EnumerateObject()){if(!patchedKeys.Contains(prop.Name)){writer.WritePropertyName(prop.Name);prop.Value.WriteTo(writer);}}writer.WriteEndObject();}/// <summary> Resolves the identity key for a property: first checks <paramref name="identityKeys"/>, then auto-detects from the first element of <paramref name="incomingValue"/>. Returns null if no identity key can be determined. </summary>
private static string ResolveIdentityKey(string propertyName,JsonElement incomingValue,JsonElement source,Dictionary<string,string>identityKeys){if(identityKeys!=null&&identityKeys.TryGetValue(propertyName,out var key))return key;if(incomingValue.ValueKind==JsonValueKind.Array)return DetectIdentityKey(incomingValue);if(source.TryGetProperty(propertyName,out var existingArr)&&existingArr.ValueKind==JsonValueKind.Array)return DetectIdentityKey(existingArr);return null;}/// <summary> Auto-detects the identity key from a JSON array by inspecting the first object element. Checks for <c>"Id"</c> first, then any property ending in <c>"Id"</c>. Returns null for non-object arrays. </summary>
public static string DetectIdentityKey(JsonElement array){if(array.ValueKind!=JsonValueKind.Array)return null;foreach(var el in array.EnumerateArray()){if(el.ValueKind!=JsonValueKind.Object)return null;if(el.TryGetProperty("Id",out _))return"Id";foreach(var prop in el.EnumerateObject()){if(prop.Name.EndsWith("Id",StringComparison.OrdinalIgnoreCase))return prop.Name;}return null;}return null;}private static Type GetListItemType(Type propertyType){if(!propertyType.IsGenericType)return null;var genericDef=propertyType.GetGenericTypeDefinition();if(genericDef==typeof(List<>)||genericDef==typeof(IList<>)||genericDef==typeof(IEnumerable<>))return propertyType.GetGenericArguments()[0];return null;}private static void WriteRawJsonProperty(Utf8JsonWriter writer,string name,string rawJson){writer.WritePropertyName(name);using var doc=JsonDocument.Parse(rawJson,JsonOptionsProvider.DocumentOptions);doc.RootElement.WriteTo(writer);}}}

===== FILE Core/Mods/Pipeline/ModBootPipeline.cs =====
namespace Cthangover.Core.Mods.Pipeline{using System.Collections.Generic;using System.Reflection;using System.Threading;using Cthangover.Core.Bootstrap;using Cthangover.Core.Mods.Discovery;using Cthangover.Core.Utils;/// <summary> Orchestrates the boot pipeline of the mod subsystem — the three phases <see cref="PreparePaths"/> → <see cref="DiscoverAndCompile"/> → <see cref="FinalizeGodotInit"/>, plus the synchronous <see cref="Initialize"/> that runs all three. Owns the pipeline lock, the re-entrancy marker, the resolved scanner roots and the staged assembly lists; <see cref="ModRegistry"/> stays the singleton facade and delegates its boot-phase methods here. Phase split follows the thread contract: <see cref="PreparePaths"/> is the only phase allowed to touch the Godot API (it runs on the main thread), <see cref="DiscoverAndCompile"/> is pure .NET for the background thread, and <see cref="FinalizeGodotInit"/> is Godot-bound again. The mod dictionaries themselves stay in <see cref="ModRegistry"/> — they are passed in at construction, so the facade can keep exposing <c>Mods</c>/<c>AllDiscoveredMods</c> and reset them on <c>Reload</c> without any extra plumbing. </summary>
public class ModBootPipeline{private readonly ModRegistry registry;private readonly Dictionary<string,IModInfo>mods;private readonly Dictionary<string,IModInfo>allDiscoveredMods;private readonly Dictionary<string,int>existingPriorities;/// <summary> Serialises the discovery/compile/finalize pipeline. The synchronous <see cref="Initialize"/> holds it for the whole pipeline so a second caller blocks and then observes the initialized flag instead of running a competing load; the launcher-boot path takes it per phase. <see cref="pipelineRunning"/> lets a re-entrant call from inside the pipeline (e.g. GetOrderedModIds during finalize) return without recursing. <c>ModRegistry.Reload</c> enters the same lock through <see cref="PipelineLock"/> — monitors are re-entrant, so the nested Initialize inside Reload does not deadlock. </summary>
private readonly object pipelineLock=new();private bool pipelineRunning;private bool pathsPrepared;/// <summary> Roots resolved on the main thread during <see cref="PreparePaths"/>. Discovery itself is pure file I/O, but scanners resolve their roots via <c>ProjectSettings.GlobalizePath</c>, which is Godot-API and must stay on the main thread — so roots are captured here once, then consumed by <see cref="DiscoverAndCompile"/> on the background thread. </summary>
private readonly List<(IModScanner Scanner,string Url,int Priority)>resolvedRoots=new();/// <summary>Precompiled DLL assemblies collected during <see cref="DiscoverAndCompile"/> and registered by <see cref="FinalizeGodotInit"/>.</summary>
private List<(string ModId,Assembly Assembly)>_precompiledAssemblies=new();/// <summary>C# source assemblies compiled during <see cref="DiscoverAndCompile"/> and registered by <see cref="FinalizeGodotInit"/>.</summary>
private List<(string ModId,Assembly Assembly,List<(string FilePath,string Source)>Sources)>_compiledAssemblies=new();/// <summary> Creates the pipeline for one <see cref="ModRegistry"/>. The dictionaries are shared by reference (never copied): the pipeline fills them, the facade exposes and clears them. </summary>
public ModBootPipeline(ModRegistry registry,Dictionary<string,IModInfo>mods,Dictionary<string,IModInfo>allDiscoveredMods,Dictionary<string,int>existingPriorities){this.registry=registry;this.mods=mods;this.allDiscoveredMods=allDiscoveredMods;this.existingPriorities=existingPriorities;}/// <summary> The single home of the boot-window policy. During a launcher boot the pipeline belongs to Boot alone: it drives <see cref="PreparePaths"/>, <see cref="DiscoverAndCompile"/> (on a background thread) and <see cref="FinalizeGodotInit"/> in order, so the heavy compile phase never blocks the main thread's rendering. While the window is open (<c>BootController.IsLauncherBoot</c>, before <c>Boot.ModsReady</c>), outside callers must neither trigger nor consume the half-built mod set — any such call is a boot-order bug. Re-entrant calls from the pipeline itself are exempt: <see cref="pipelineRunning"/> marks them (GetOrderedModIds during FinalizeGodotInit). The window ends at ModsReady: Boot itself consumes mod data (EnsureRuntime, starter inventory seeding) from that phase on, so data becomes consumable there. Both <see cref="Initialize"/> and GetOrderedModIds consult exactly this property, keeping the refusal semantics identical in both places. </summary>
public bool IsLauncherBootWindow=>BootController.IsLauncherBoot&& !Boot.ModsReady&& !pipelineRunning;/// <summary> Idempotent bootstrap: runs the whole pipeline (<see cref="PreparePaths"/> → <see cref="DiscoverAndCompile"/> → <see cref="FinalizeGodotInit"/>) unless discovery already ran or is running. Returns immediately on subsequent calls. Synchronous entry point for <c>Boot.RunSync</c>, tests and tooling; the launcher boot instead drives the phases individually. Any other caller reaching here inside the boot window is refused with an error in the log (see <see cref="IsLauncherBootWindow"/>) — lazy self-initialization outside these paths is deliberately forbidden. </summary>
public void Initialize(){if(registry.InitializedFlag)return;// Boot-window refusal — the policy lives in IsLauncherBootWindow; this is the only place that logs the refusal, because this is the only entry that would otherwise self-boot the pipeline.
if(IsLauncherBootWindow){GameLogger.Log("MODS_REGISTRY","Initialize refused: launcher boot owns the mod pipeline "+$"(Boot.Phase={Boot.Phase}). Consume mod data only after Boot.ModsReady, "+"or add a readiness guard like the audio mod's music player does.",LogLevel.Error);return;}lock(pipelineLock){if(registry.InitializedFlag||pipelineRunning)return;pipelineRunning=true;try{using(ModAssemblyLoader.BeginBatch()){PreparePaths();DiscoverAndCompile();FinalizeGodotInit();}}finally{pipelineRunning=false;}}}/// <summary> Resolves everything that requires Godot API on the main thread and is needed by the background compile phase: <list type="bullet"> <item>mod discovery registry (scanner/loader reflection discovery)</item> <item>scanner roots via <c>ProjectSettings.GlobalizePath</c></item> <item>compiler/native-loader cache roots and Mono build output dir</item> </list> Idempotent; safe to call before <see cref="DiscoverAndCompile"/>. </summary>
public void PreparePaths(){lock(pipelineLock){if(pathsPrepared)return;pathsPrepared=true;ModDiscoveryRegistry.Initialize();ModCompiler.WarmUpPaths();_=ModNativeLoader.CacheRoot;_=ModConfig.Instance;DisabledModsStore.WarmUp();ModCollectionStore.WarmUp();Games.GameLaunchArgs.WarmUp();CollectionLayoutStore.WarmUp();resolvedRoots.Clear();foreach(var scanner in ModDiscoveryRegistry.Scanners){foreach(var root in scanner.GetRoots())resolvedRoots.Add((scanner,root.Url,root.Priority));}}}/// <summary> Godot-free phase: scans the pre-resolved roots, reads manifests, hashes mods, filters/validates them, loads native libraries, loads precompiled DLLs and compiles C# source mods — all pure .NET (System.IO, Roslyn, Assembly.Load) with no Godot API. Runs on a background thread during launcher boot. Collected assemblies are stored internally and registered later by <see cref="FinalizeGodotInit"/>. </summary>
public void DiscoverAndCompile(CancellationToken cancellationToken=default){lock(pipelineLock){cancellationToken.ThrowIfCancellationRequested();// Launcher-driven boot: the batch opens here (the sync path opened it around the whole pipeline) so the background compile routes its assemblies into collectible contexts.
ModAssemblyLoader.BeginBatch();try{// The mods dictionary is being rebuilt — any previously cached load order (possibly computed from an empty registry during the boot window) is now stale and must be recomputed.
registry.InvalidateOrder();var anyRootFound=false;foreach(var(scanner,url,priority)in resolvedRoots){cancellationToken.ThrowIfCancellationRequested();if(!scanner.IsCanProvide(url)){GameLogger.Log("MODS_REGISTRY",$"Scanner '{scanner.Name}' skipped root '{url}': not available",LogLevel.Debug);continue;}anyRootFound=true;GameLogger.Log("MODS_REGISTRY",$"Scanning root '{url}' with '{scanner.Name}' priority={priority}");ModDiscoveryService.DiscoverMods(mods,existingPriorities,scanner,url,priority,cancellationToken);}if(!anyRootFound){GameLogger.Log("MODS_REGISTRY","No mods roots found, skipping mod loading",LogLevel.Error);GameLogger.Log("MODS_REGISTRY",$"ModRegistry.Initialize: END (no mods root), {mods.Count} mod(s)");return;}// Test suites become virtual mods only behind their activate args.
ModTestSuites.Mount(mods,new List<string>(Games.GameLaunchArgs.Current.UserArgs));foreach(var kvp in mods)allDiscoveredMods[kvp.Key]=kvp.Value;ModSetFilter.FilterDisabledMods(mods);ModSetFilter.FilterToActiveGame(mods);ModSetFilter.ValidateDependencies(mods);ModNativeLoader.LoadNativeDlls(mods.Values,cancellationToken);_precompiledAssemblies=ModAssemblyLoader.LoadPrecompiledDlls(mods.Values,cancellationToken);_compiledAssemblies=ModCompiler.CompileAll(mods,cancellationToken);}finally{ModAssemblyLoader.EndBatch();}}}/// <summary> Godot-bound phase, must run on the main thread after <see cref="DiscoverAndCompile"/>: registers compiled/precompiled assemblies into the game's registries (reflection fan-out that may instantiate Godot types), loads GDScript mods, and computes the mod load order. Completes the pipeline started by <see cref="Initialize"/>. </summary>
public void FinalizeGodotInit(){lock(pipelineLock){if(registry.InitializedFlag)return;// Mark the pipeline as running for the duration of the finalize: GetOrderedModIds below re-enters Initialize, and the launcher-boot refusal guard must exempt the pipeline's own re-entrant calls (pipelineRunning marks them).
pipelineRunning=true;try{foreach(var(modId,assembly)in _precompiledAssemblies)ModAssemblyLoader.RegisterAssembly(assembly,modId);foreach(var(modId,assembly,sourceFiles)in _compiledAssemblies){ModAssemblyLoader.RegisterAssembly(assembly,modId);ScriptTypeRegistry.RegisterAssembly(assembly,modId,sourceFiles);}ModGDScriptLoader.LoadGDScripts(mods);// Discard any order computed during the launcher boot window: a main-thread caller can reach GetOrderedModIds while the background compile is still filling the registry, and that partial order must not survive into the finalized mod set.
registry.InvalidateOrder();var orderedIds=registry.GetOrderedModIds();var orderedList=new List<(string ModId,int Priority)>();for(var i=0;i<orderedIds.Count;i++)orderedList.Add((orderedIds[i],i));ScriptTypeRegistry.SetModOrder(orderedList);// Publish readiness only after every registry is populated: a concurrent reader must never observe IsInitialized while scripts/GDScripts are still missing. The volatile write is ordered, so readiness becomes visible to other threads only once every registry above is fully filled.
registry.InitializedFlag=true;GameLogger.Log("MODS_REGISTRY",$"ModRegistry.Initialize: loaded {mods.Count} mod(s)");foreach(var kvp in mods)GameLogger.Log("MODS_REGISTRY",$"  [{kvp.Key}] '{kvp.Value.Name}' v{kvp.Value.Version ?? "?"} provider={kvp.Value.FileProvider.GetType().Name}",LogLevel.Debug);}finally{pipelineRunning=false;}}}/// <summary> The pipeline's serialization lock, re-entered by <c>ModRegistry.Reload</c>: a reload must be mutually exclusive with the boot phases (monitors are re-entrant, so Reload calling Initialize under this lock is safe). Not intended for any other caller — everyone else goes through the phase methods. </summary>
internal object PipelineLock=>pipelineLock;/// <summary>Scanner roots resolved by <see cref="PreparePaths"/>; an in-process reload rescans them (<see cref="ModSetReloader.Prepare"/>).</summary>
internal IReadOnlyList<(IModScanner Scanner,string Url,int Priority)>ResolvedRoots=>resolvedRoots;/// <summary> Discards the assemblies staged by <see cref="DiscoverAndCompile"/> (the <c>ModRegistry.Reload</c> path): both collections are replaced with fresh empty lists exactly as in a fresh registry, so a later finalize cannot double-register stale assemblies. </summary>
internal void ResetStagedAssemblies(){_precompiledAssemblies=new List<(string ModId,Assembly Assembly)>();_compiledAssemblies=new List<(string ModId,Assembly Assembly,List<(string FilePath,string Source)>Sources)>();}}}

===== FILE Core/Mods/Pipeline/ModCandidate.cs =====
namespace Cthangover.Core.Mods.Pipeline{using Cthangover.Core.Mods.Discovery;/// <summary> Holds a discovered mod candidate before deduplication: one filesystem entry of a scanner root, already materialised into an <see cref="IModFileProvider"/> with a parsed manifest, but not yet decided against same-id candidates from other roots (the decision — root priority first, then version — happens in <c>ModDiscoveryService.RegisterCandidates</c>). A struct on purpose: candidates are short-lived loop-local values and only the winning candidate's <see cref="Info"/> (with its provider) survives into the registry; the losers' providers are disposed during deduplication so zips and folder handles never leak. </summary>
public struct ModCandidate{/// <summary>Manifest id, or the loader's filesystem-derived fallback id when the manifest omits it.</summary>
public string Id;/// <summary>Runtime model built from the manifest; owns the provider the registry will keep on win.</summary>
public ModInfo Info;/// <summary>Raw filesystem entry name (folder or file name) as reported by the scanner, for log messages.</summary>
public string FilesystemEntryName;/// <summary>Provider handle used to read the manifest; disposed if this candidate loses deduplication.</summary>
public IModFileProvider Provider;/// <summary>Parsed manifest.json; <c>null</c> candidates never reach deduplication.</summary>
public ModManifest Manifest;/// <summary>Dedup priority of the root this candidate was found in (lower wins, mirrors scanner priorities).</summary>
public int RootPriority;}}

===== FILE Core/Mods/Pipeline/ModDiscoveryService.cs =====
namespace Cthangover.Core.Mods.Pipeline{using System;using System.Collections.Generic;using System.Text.Json;using System.Threading;using Cthangover.Core.Mods.Discovery;using Cthangover.Core.Utils;/// <summary> Discovery mechanics for one scanner root: enumerates entries, finds a matching <see cref="IModLoader"/>, materialises a <see cref="IModFileProvider"/>, reads and parses <c>manifest.json</c>, computes the install hash, then deduplicates same-id mods across roots (root priority first, version second — a mod shipped in <c>user://mods/</c> overrides its game-dir namesake). Deliberately pure .NET (file I/O through the provider, Roslyn-free, no Godot API) so it runs on the background thread during launcher boot; the roots themselves must have been resolved on the main thread beforehand (<c>ModBootPipeline.PreparePaths</c>). The target dictionaries are owned by <see cref="ModRegistry"/> and passed in per call: the service is stateless, so a reload simply clears the registry's dictionaries without any extra reset plumbing here. </summary>
public static class ModDiscoveryService{/// <summary> Scans a single root already resolved by <c>ModBootPipeline.PreparePaths</c> and registers every viable candidate into <paramref name="mods"/>. Individual unreadable entries are logged and skipped; only a failing scanner enumeration aborts the whole root (with an error log), keeping one broken entry from hiding all other mods. </summary> <param name="mods">Registry dictionary to fill (enabled mod set).</param> <param name="existingPriorities">Root priority per already-registered mod id; drives the priority half of deduplication.</param> <param name="scanner">Scanner that owns the root.</param> <param name="modsRoot">Root URL to enumerate.</param> <param name="rootPriority">Dedup priority of the root.</param> <param name="cancellationToken">Cooperative cancellation between entries.</param>
public static void DiscoverMods(Dictionary<string,IModInfo>mods,Dictionary<string,int>existingPriorities,IModScanner scanner,string modsRoot,int rootPriority,CancellationToken cancellationToken){var candidates=new List<ModCandidate>();try{foreach(var entry in scanner.Discover(modsRoot)){cancellationToken.ThrowIfCancellationRequested();if(entry.EntryName==".gdignore"||entry.EntryName==".gitkeep")continue;var loader=ModDiscoveryRegistry.FindLoader(entry.Url);if(loader==null)continue;var candidate=TryLoadModFromLoader(loader,entry.Url,entry.EntryName);if(candidate.HasValue){var c=candidate.Value;c.RootPriority=rootPriority;candidates.Add(c);}}}catch(OperationCanceledException)when(cancellationToken.IsCancellationRequested){throw;}catch(Exception ex){GameLogger.Log("MODS_REGISTRY",$"Failed to scan directory '{modsRoot}': {ex.Message}",LogLevel.Error);}RegisterCandidates(mods,existingPriorities,candidates);}/// <summary> Deduplicates candidates against what is already registered. Two axes, checked in this order: a strictly higher root priority always wins (regardless of version); equal priority falls back to version comparison — only a strictly newer version replaces. Every losing candidate's provider (and, on replacement, the displaced mod's provider) is disposed, so no zip handle or directory watcher leaks. First candidate seen for an id simply registers and records its root priority for later comparisons. </summary>
private static void RegisterCandidates(Dictionary<string,IModInfo>mods,Dictionary<string,int>existingPriorities,List<ModCandidate>candidates){foreach(var candidate in candidates){if(mods.TryGetValue(candidate.Id,out var existing)){var existingInfo=(ModInfo)existing;if(candidate.RootPriority<existingPriorities.GetValueOrDefault(candidate.Id,int.MaxValue)){GameLogger.Log("MODS_REGISTRY",$"Mod '{candidate.Id}' ({candidate.FilesystemEntryName}) v{candidate.Info.Version} "+$"replaces v{existingInfo.Version} from '{existingInfo.Id}' (higher-priority source)",LogLevel.Warning);existingInfo.FileProvider.Dispose();mods[candidate.Id]=candidate.Info;existingPriorities[candidate.Id]=candidate.RootPriority;}else if(candidate.RootPriority==existingPriorities.GetValueOrDefault(candidate.Id,int.MaxValue)){var cmp=VersionHelper.Compare(candidate.Info.Version??"0.0.0.0",existingInfo.Version??"0.0.0.0");if(cmp>0){GameLogger.Log("MODS_REGISTRY",$"Mod '{candidate.Id}' ({candidate.FilesystemEntryName}) v{candidate.Info.Version} "+$"replaces older v{existingInfo.Version} from '{existingInfo.Id}'",LogLevel.Warning);existingInfo.FileProvider.Dispose();mods[candidate.Id]=candidate.Info;}else{var reason=cmp==0?$"same version {candidate.Info.Version}":$"older version {candidate.Info.Version} (already have {existingInfo.Version})";GameLogger.Log("MODS_REGISTRY",$"Skipping mod '{candidate.Id}' ({candidate.FilesystemEntryName}): {reason}",LogLevel.Warning);candidate.Provider.Dispose();}}else{GameLogger.Log("MODS_REGISTRY",$"Skipping mod '{candidate.Id}' ({candidate.FilesystemEntryName}): lower-priority source",LogLevel.Warning);candidate.Provider.Dispose();}}else{mods[candidate.Id]=candidate.Info;existingPriorities[candidate.Id]=candidate.RootPriority;}}}/// <summary> Loads one entry through its loader: creates the provider, reads the manifest, builds the <see cref="ModInfo"/> and computes the install SHA256 (a hashing failure only logs — the mod stays loadable). Returns <c>null</c> for entries without a manifest or on any loader/manifest exception; the provider is disposed on those paths so a skipped entry never leaks its handle. </summary>
private static ModCandidate?TryLoadModFromLoader(IModLoader loader,string url,string entryName){try{var fileProvider=loader.CreateProvider(url,entryName);var manifest=ReadManifest(fileProvider);if(manifest==null){GameLogger.Log("MODS_REGISTRY",$"Skipping '{entryName}': no manifest found",LogLevel.Error);fileProvider.Dispose();return null;}var id= !string.IsNullOrEmpty(manifest.Id)?manifest.Id:loader.GetFallbackModId(url,entryName);var info=new ModInfo{Id=id,Name=manifest.Name,Author=manifest.Author,Description=manifest.Description,FileProvider=fileProvider,Manifest=manifest,};try{info.InstallSha256=InstallHashCache.Get(fileProvider,ModConfig.Instance.CatalogIgnorePatterns);}catch(Exception ex){GameLogger.Log("MODS_REGISTRY",$"Failed to compute install SHA256 for mod '{id}': {ex.Message}",LogLevel.Warning);}return new ModCandidate{Id=id,Info=info,FilesystemEntryName=entryName,Provider=fileProvider,Manifest=manifest,};}catch(Exception ex){GameLogger.Log("MODS_REGISTRY",$"Failed to load mod '{entryName}' via {loader.Name}: {ex.Message}",LogLevel.Error);return null;}}/// <summary> Reads and deserialises <c>manifest.json</c> through the provider. Returns <c>null</c> when the file is absent, empty, or invalid JSON (parse failure is logged with the provider-agnostic message — the entry name is not known here), which makes the whole entry skipped by <see cref="TryLoadModFromLoader"/>. </summary>
private static ModManifest ReadManifest(IModFileProvider provider){if(!provider.FileExists("manifest.json"))return null;var json=provider.ReadFileText("manifest.json");if(string.IsNullOrEmpty(json))return null;try{return JsonSerializer.Deserialize<ModManifest>(json,JsonOptionsProvider.ReadOptions);}catch(JsonException ex){GameLogger.Log("MODS_REGISTRY",$"Failed to parse manifest.json: {ex.Message}",LogLevel.Error);}return null;}}}

===== FILE Core/Mods/Pipeline/ModLoadOrderService.cs =====
namespace Cthangover.Core.Mods.Pipeline{using System.Collections.Generic;using System.Linq;using Cthangover.Core.Utils;/// <summary> Computes and caches the mod load order: a topological sort over manifest dependencies, refined by the user's saved manual order (<c>ModLoadOrderStore</c>) with an alphabetical fallback for determinism. Split out of <see cref="ModRegistry"/> because the cache has its own lifecycle — it is invalidated on every registry rebuild (discover/reload) and on user re-ordering, and it must never be persisted from inside the launcher boot window (see <see cref="GetOrderedModIds"/>). One instance per <see cref="ModRegistry"/>; the mod dictionaries are shared by reference with the registry, so freshly discovered or reloaded mods are visible on the next computation. </summary>
public class ModLoadOrderService{private readonly ModRegistry registry;private readonly ModBootPipeline pipeline;private readonly Dictionary<string,IModInfo>mods;private readonly Dictionary<string,IModInfo>allDiscoveredMods;/// <summary>Cached order for the enabled (filtered) set; <c>null</c> when stale.</summary>
private List<string>orderedModIds;/// <summary> Creates the service for one <see cref="ModRegistry"/>. The pipeline reference is only used to read the boot-window state (<see cref="ModBootPipeline.IsLauncherBootWindow"/>) so the guard semantics stay identical to the one in Initialize — the policy itself lives in the pipeline, not here. </summary>
public ModLoadOrderService(ModRegistry registry,ModBootPipeline pipeline,Dictionary<string,IModInfo>mods,Dictionary<string,IModInfo>allDiscoveredMods){this.registry=registry;this.pipeline=pipeline;this.mods=mods;this.allDiscoveredMods=allDiscoveredMods;}/// <summary> Invalidates the cached ordered mod list so the next call to <see cref="GetOrderedModIds"/> recomputes it. Called by the UI after the user changes the manual load order, and by the pipeline/Reload whenever the mod set is rebuilt. </summary>
public void InvalidateOrder(){orderedModIds=null;}/// <summary> Returns mod IDs ordered by <c>depends</c> (topological sort), then by user-defined manual order, with alphabetical fallback for determinism. Dependencies always precede their dependents — manual ordering only applies between unrelated mods. When <paramref name="filterDisabled"/> is <c>true</c> (default), the result is cached and persisted, but only once the registry has been finalized: during the launcher boot window a main-thread caller would otherwise freeze an order computed from the half-built mod set for the rest of the session — and overwrite the saved manual order with an empty list. When <paramref name="filterDisabled"/> is <c>false</c>, the order is computed from <see cref="ModRegistry.AllDiscoveredMods"/> every call (no caching) and is NOT persisted to <see cref="ModLoadOrderStore"/> — this variant is intended for the mod configuration UI. </summary>
public IReadOnlyList<string>GetOrderedModIds(bool filterDisabled=true){registry.Initialize();// Initialize above already refused and logged when the launcher boot owns the pipeline. Computing here would return a misleading empty order built from a half-built registry, so refuse as well. The pipeline's own re-entrant call from FinalizeGodotInit is exempt — pipelineRunning marks it, exactly as in Initialize. Ends at ModsReady: the starter-inventory seeding legitimately reads the (now final) mod set during that phase.
if(pipeline.IsLauncherBootWindow)return new List<string>();if(filterDisabled&&orderedModIds!=null)return orderedModIds;var source=filterDisabled?mods:allDiscoveredMods;var dependsMap=new Dictionary<string,IReadOnlyList<string>>();foreach(var kvp in source){var manifest=((ModInfo)kvp.Value).Manifest;dependsMap[kvp.Key]=manifest?.DependencyIds??new List<string>();}var manualOrder=ModLoadOrderStore.Sync(source.Keys.ToList());var result=ModLoadOrder.Compute(dependsMap,manualOrder);if(filterDisabled&&registry.IsInitialized){orderedModIds=result;ModLoadOrderStore.Save(orderedModIds);}GameLogger.Log("MODS_REGISTRY",$"GetOrderedModIds(filterDisabled={filterDisabled}): {result.Count} mod(s) ordered");for(int i=0;i<result.Count;i++)GameLogger.Log("MODS_REGISTRY",$"  [{i}] {result[i]}",LogLevel.Debug);return result;}}}

===== FILE Core/Mods/Pipeline/ModReloadPlan.cs =====
namespace Cthangover.Core.Mods.Pipeline{using System;using System.Collections.Generic;using System.Linq;/// <summary> Decides what an in-process mod-set reload does with each mod that has code. A mod's code identity is the set of files its context runs — the content-addressed compile artifact and precompiled DLL copies — so "did it change" is a plain set comparison, no timestamps involved. <list type="bullet"> <item><see cref="Unload"/> — loaded mods that leave the set, changed, were forced, or depend (transitively) on any of those: their context resolves dependency types through the old dependency context, so they must reload with it. Dependents first.</item> <item><see cref="Keep"/> — loaded mods whose code and dependencies are unchanged: the context, its static state and its live nodes survive; the registries are rebuilt, so they re-register.</item> <item><see cref="Load"/> — mods whose code loads into a fresh context (new to the set or reloaded), in dependency order.</item> </list> </summary>
public sealed class ModReloadPlan{private ModReloadPlan(List<string>unload,List<string>keep,List<string>load){Unload=unload;Keep=keep;Load=load;}/// <summary>Mods whose contexts are released, in unload order (reverse load order).</summary>
public IReadOnlyList<string>Unload{get;}/// <summary>Loaded mods that stay, in target order.</summary>
public IReadOnlyList<string>Keep{get;}/// <summary>Mods that load into a new context, in target order.</summary>
public IReadOnlyList<string>Load{get;}/// <summary> Computes the plan. </summary> <param name="loadedOrder">Mods with a live context, in the order they loaded.</param> <param name="loadedArtifacts">Files each loaded mod's context runs.</param> <param name="targetOrder">Mods with code in the new set, in dependency order.</param> <param name="targetArtifacts">Files each target mod compiled/staged to now.</param> <param name="dependencies">Direct <c>depends</c> of the target mods.</param> <param name="forceReload">Mods to reload even when unchanged; <c>*</c> forces every loaded mod.</param>
public static ModReloadPlan Compute(IReadOnlyList<string>loadedOrder,IReadOnlyDictionary<string,IReadOnlyCollection<string>>loadedArtifacts,IReadOnlyList<string>targetOrder,IReadOnlyDictionary<string,IReadOnlyCollection<string>>targetArtifacts,IReadOnlyDictionary<string,IReadOnlyCollection<string>>dependencies,IReadOnlyCollection<string>forceReload){var comparer=StringComparer.OrdinalIgnoreCase;var loaded=(loadedOrder??Array.Empty<string>()).Distinct(comparer).ToList();var target=(targetOrder??Array.Empty<string>()).Distinct(comparer).ToList();var targetSet=new HashSet<string>(target,comparer);var force=new HashSet<string>(forceReload??Array.Empty<string>(),comparer);var forceAll=force.Contains("*");var leaving=new HashSet<string>(comparer);foreach(var id in loaded){if(!targetSet.Contains(id)||forceAll||force.Contains(id)|| !SameFiles(Lookup(loadedArtifacts,id),Lookup(targetArtifacts,id)))leaving.Add(id);}// Cascade to dependents until nothing changes: a kept mod must not keep resolving into a released dependency context.
bool grew;do{grew=false;foreach(var id in loaded){if(leaving.Contains(id))continue;if(Lookup(dependencies,id).Any(leaving.Contains)){leaving.Add(id);grew=true;}}}while(grew);var loadedSet=new HashSet<string>(loaded,comparer);var unload=loaded.Where(leaving.Contains).Reverse().ToList();var keep=new List<string>();var load=new List<string>();foreach(var id in target){if(loadedSet.Contains(id)&& !leaving.Contains(id))keep.Add(id);else load.Add(id);}return new ModReloadPlan(unload,keep,load);}private static IReadOnlyCollection<string>Lookup(IReadOnlyDictionary<string,IReadOnlyCollection<string>>map,string id){if(map==null)return Array.Empty<string>();if(map.TryGetValue(id,out var value)&&value!=null)return value;foreach(var pair in map){if(string.Equals(pair.Key,id,StringComparison.OrdinalIgnoreCase))return pair.Value??Array.Empty<string>();}return Array.Empty<string>();}private static bool SameFiles(IReadOnlyCollection<string>a,IReadOnlyCollection<string>b){var left=new HashSet<string>(a.Select(NormalizePath),StringComparer.OrdinalIgnoreCase);return left.SetEquals(b.Select(NormalizePath));}private static string NormalizePath(string path)=>(path??string.Empty).Replace('\\','/');}}

===== FILE Core/Mods/Pipeline/ModReloadPreparation.cs =====
namespace Cthangover.Core.Mods.Pipeline{using System.Collections.Generic;using Cthangover.Core.Games;/// <summary> The first, side-effect-free half of an in-process mod-set reload (<see cref="ModSetReloader.Prepare"/>): the new mod set discovered and filtered for <see cref="Selection"/>, every mod compiled into the cache and every precompiled DLL staged — but nothing loaded, unloaded or activated. <see cref="Succeeded"/> decides whether the running set may be replaced; a failed preparation is discarded and the game keeps running the previous code. </summary>
public sealed class ModReloadPreparation{internal ModReloadPreparation(GameLaunchArgs selection,IReadOnlyCollection<string>force){Selection=selection;Force=force??new List<string>();}/// <summary>The launch selection the new set was computed for.</summary>
public GameLaunchArgs Selection{get;}/// <summary>Mods reloaded even when unchanged (<c>*</c> = every mod).</summary>
public IReadOnlyCollection<string>Force{get;}/// <summary>Compile and staging errors; empty when the set may be applied.</summary>
public List<string>Errors{get;}=new();/// <summary>True when every mod compiled and staged: only then may <see cref="ModSetReloader.Apply"/> replace the running set.</summary>
public bool Succeeded=>Errors.Count==0;internal Dictionary<string,IModInfo>Mods{get;}=new();internal Dictionary<string,IModInfo>AllDiscovered{get;}=new();internal Dictionary<string,int>Priorities{get;}=new();internal ActiveGameChoice Choice{get;set;}=ActiveGameChoice.None;internal List<ModCompileArtifact>Compiled{get;set;}=new();internal List<(string ModId,string CachedPath)>Precompiled{get;set;}=new();/// <summary><see cref="Compilation.ModAssemblyCache.CurrentArtifacts"/> before the compile, restored when the preparation is discarded.</summary>
internal Dictionary<string,string>PreviousArtifacts{get;set;}=new();}}

===== FILE Core/Mods/Pipeline/ModReloadResult.cs =====
namespace Cthangover.Core.Mods.Pipeline{using System.Collections.Generic;using Cthangover.Core.Mods.Unload;/// <summary> Outcome of an in-process mod-set reload. <see cref="Applied"/> is false when the preparation failed (compile errors) or another reload was running — the running set is then untouched. <see cref="Leaked"/> is filled by the asynchronous verification: mods whose old context survived collection (a reference nobody released; the log of the unload names the candidates). </summary>
public sealed class ModReloadResult{internal ModReloadResult(bool applied,ModReloadPlan plan,ModUnloadTicket ticket,IReadOnlyList<string>errors){Applied=applied;Plan=plan;Ticket=ticket??new ModUnloadTicket(null);Errors=errors??new List<string>();}/// <summary>True when the process now runs the new set; false leaves the previous set untouched.</summary>
public bool Applied{get;}/// <summary>What happened to each mod with code; null when nothing was applied.</summary>
public ModReloadPlan Plan{get;}/// <summary>Weak references to the released contexts; verification turns it into <see cref="Leaked"/>.</summary>
public ModUnloadTicket Ticket{get;}/// <summary>Why the reload was rejected (compile errors, a reload already running); empty when applied.</summary>
public IReadOnlyList<string>Errors{get;}/// <summary>Mods whose released context is still alive after verification.</summary>
public IReadOnlyList<string>Leaked{get;internal set;}=new List<string>();}}

===== FILE Core/Mods/Pipeline/ModSetFilter.cs =====
namespace Cthangover.Core.Mods.Pipeline{using System.Collections.Generic;using Cthangover.Core.Utils;using Godot;/// <summary> Pure set-filters over the discovered mod dictionary, run once by <c>ModBootPipeline.DiscoverAndCompile</c> after all roots are scanned: <see cref="FilterDisabledMods"/> removes user-disabled mods and transitively their dependents; <see cref="ValidateDependencies"/> removes mods whose declared dependencies are malformed, missing, or version-mismatched. Both record a human-readable reason on every removed mod's <c>ModInfo.LoadFailureReasons</c> (appended, never overwritten — the mod manager UI shows the full list) and keep the whole mod visible in <c>AllDiscoveredMods</c>, so filtering only affects the loadable set, never the discovery record. Stateless by design: the dictionary is passed in, reasons are stamped on the <see cref="ModInfo"/> objects themselves, and translation goes through <see cref="Tr"/> so the phase stays safe on a background thread during launcher boot. </summary>
public static class ModSetFilter{/// <summary> Removes disabled mods from <paramref name="mods"/> and then, in a fixpoint loop, every mod that (transitively) depends on a removed one — an optional dependency on a disabled mod does NOT disable the dependent. The loop is needed because dependents may appear before their dependencies in dictionary order; it repeats until no new mod is marked, so chains of arbitrary depth collapse. </summary>
public static void FilterDisabledMods(IDictionary<string,IModInfo>mods){var disabled=DisabledModsStore.Load();if(disabled.Count==0)return;var toRemove=new HashSet<string>();foreach(var id in mods.Keys){if(disabled.Contains(id)){toRemove.Add(id);var info=(ModInfo)mods[id];info.LoadFailureReasons.Add(string.Format(Tr("mods/disabled_reason"),id));}}bool changed;do{changed=false;foreach(var kvp in mods){if(toRemove.Contains(kvp.Key))continue;var manifest=((ModInfo)kvp.Value).Manifest;var depIds=manifest?.DependencyIds;if(depIds==null||depIds.Count==0)continue;foreach(var depId in depIds){if(toRemove.Contains(depId)){toRemove.Add(kvp.Key);var info=(ModInfo)kvp.Value;info.LoadFailureReasons.Add(string.Format(Tr("mods/dep_disabled_reason"),depId));GameLogger.Log("MODS_REGISTRY",$"Mod '{kvp.Key}' disabled because dependency '{depId}' is disabled",LogLevel.Warning);changed=true;break;}}}}while(changed);foreach(var id in toRemove){mods.Remove(id);GameLogger.Log("MODS_REGISTRY",$"Mod '{id}' filtered out (disabled)",LogLevel.Message);}}/// <summary> Validates every remaining mod's <c>depends</c> entries and removes mods whose dependencies cannot be satisfied: a malformed dependency spec, a missing required mod (optional dependencies are skipped), or a loaded version failing the declared constraint. First failing dependency per mod wins (loop breaks); the reason carries both the requirement and the actually loaded version. </summary>
public static void ValidateDependencies(IDictionary<string,IModInfo>mods){var toRemove=new HashSet<string>();foreach(var kvp in mods){var modId=kvp.Key;var manifest=((ModInfo)kvp.Value).Manifest;if(manifest?.Depends==null)continue;foreach(var dep in manifest.Depends){if(!dep.IsValid){toRemove.Add(modId);var info=(ModInfo)kvp.Value;info.LoadFailureReasons.Add(string.Format(Tr("mods/invalid_dep_format"),dep.Error));GameLogger.Log("MODS_REGISTRY",$"Mod '{modId}' has invalid dependency: {dep.Error}",LogLevel.Error);break;}if(!mods.ContainsKey(dep.ModId)){if(dep.IsOptional)continue;toRemove.Add(modId);var info=(ModInfo)kvp.Value;info.LoadFailureReasons.Add(string.Format(Tr("mods/required_mod_not_loaded"),dep.ModId));GameLogger.Log("MODS_REGISTRY",$"Mod '{modId}' requires '{dep.ModId}' but it is not loaded",LogLevel.Error);break;}if(!dep.IsSatisfiedBy(mods[dep.ModId].Version??"0.0.0.0")){toRemove.Add(modId);var info=(ModInfo)kvp.Value;var actualVersion=mods[dep.ModId].Version??"0.0.0.0";info.LoadFailureReasons.Add(string.Format(Tr("mods/version_mismatch"),$"{dep.ModId}{dep.Operator}{dep.VersionRaw}",actualVersion));GameLogger.Log("MODS_REGISTRY",$"Mod '{modId}' requires {dep.ModId}{dep.Operator}{dep.VersionRaw} but loaded version is {actualVersion}",LogLevel.Error);break;}}}if(toRemove.Count==0)return;foreach(var id in toRemove){mods.Remove(id);GameLogger.Log("MODS_REGISTRY",$"Mod '{id}' filtered out (dependency validation failed)",LogLevel.Message);}}/// <summary> Keeps only the mods of the game chosen with <c>--game=&lt;id&gt;</c> and makes it the <see cref="Games.ActiveGame"/> (boot path); see <see cref="SelectActiveGame"/>. </summary>
public static void FilterToActiveGame(IDictionary<string,IModInfo>mods)=>SelectActiveGame(mods,Games.GameLaunchArgs.Current).Apply();/// <summary> Keeps only the mods of the game in <paramref name="args"/>: its collection's resolved graph, every front-end collection and the <c>--with-mods</c> extras (see <see cref="Games.GameModSet"/>). The set is recomputed from the current collection every time, so the player's edits apply on the next boot or game switch. Without <c>--game</c> (or with <c>--all-mods</c>) nothing is filtered; an unknown game id logs an error and also loads every mod, so a stale shortcut still starts. Returns the choice without applying it (an in-process reload applies it only once the new set compiled). </summary>
public static Games.ActiveGameChoice SelectActiveGame(IDictionary<string,IModInfo>mods,Games.GameLaunchArgs args){if(args.Mode==Games.GameLaunchMode.AllMods){RemoveFixtures(mods,args.WithMods);return Games.ActiveGameChoice.None;}// Mounted test suites ride along like --with-mods (with their depends).
var extras=new List<string>(args.WithMods);extras.AddRange(ModTestSuites.ActiveIds);var discovered=new Dictionary<string,IModInfo>(mods,System.StringComparer.OrdinalIgnoreCase);var collections=new Dictionary<string,ModCollection>(System.StringComparer.OrdinalIgnoreCase);foreach(var collection in ModCollectionStore.LoadAll())collections[collection.Id]=collection;if(args.Mode==Games.GameLaunchMode.Frontend){var frontend=Games.GameModSet.ComputeFrontend(collections,discovered,DisabledModsStore.Load(),extras);if(frontend==null){GameLogger.Log("MODS_REGISTRY","No front-end collection installed; loading every mod",LogLevel.Error);return Games.ActiveGameChoice.None;}RemoveOutside(mods,frontend.ModIds,"front-end");return new Games.ActiveGameChoice(frontend,frontend:true);}var set=Games.GameModSet.Compute(args.GameId,collections,discovered,DisabledModsStore.Load(),extras);if(set==null){GameLogger.Log("MODS_REGISTRY",$"Game '{args.GameId}' is not a known game collection; loading every mod",LogLevel.Error);return Games.ActiveGameChoice.None;}foreach(var missing in set.MissingMods)GameLogger.Log("MODS_REGISTRY",$"Game '{args.GameId}' needs mod '{missing}', which is not installed",LogLevel.Error);foreach(var problem in set.Problems)GameLogger.Log("MODS_REGISTRY",$"Game '{args.GameId}': {problem.Title}",LogLevel.Warning);RemoveOutside(mods,set.ModIds,args.GameId);return new Games.ActiveGameChoice(set,frontend:false);}/// <summary> <c>--all-mods</c> leaves out test fixtures (<see cref="ModManifest.Fixture"/>) unless they are named in <c>--with-mods</c>: their data would leak into every unrelated dev launch and test. </summary>
public static void RemoveFixtures(IDictionary<string,IModInfo>mods,IReadOnlyCollection<string>withMods){var wanted=new HashSet<string>(withMods??new List<string>(),System.StringComparer.OrdinalIgnoreCase);foreach(var id in new List<string>(mods.Keys)){if(mods[id]is not ModInfo{Manifest.Fixture:true}info||wanted.Contains(id))continue;info.LoadFailureReasons.Add($"test fixture: loaded only with --with-mods={id}");mods.Remove(id);GameLogger.Log("MODS_REGISTRY",$"Fixture '{id}' left out (name it in --with-mods to load it)");}}private static void RemoveOutside(IDictionary<string,IModInfo>mods,ICollection<string>keep,string owner){var excluded=new List<string>();foreach(var id in new List<string>(mods.Keys)){if(keep.Contains(id))continue;((ModInfo)mods[id]).LoadFailureReasons.Add(string.Format(Tr("mods/not_in_game_reason"),owner));mods.Remove(id);excluded.Add(id);}GameLogger.Log("MODS_REGISTRY",$"Mod set '{owner}': {mods.Count} mod(s) loaded, {excluded.Count} left out ({string.Join(", ", excluded)})");}/// <summary> Translates a localization key only on the Godot main thread. The discovery/validation phase may run on a background thread during boot (before any locale is loaded anyway), so <c>TranslationServer.Translate</c> must not be called there — the raw key is returned instead. </summary>
private static string Tr(string key){if(!GameEnvironment.IsMainThread)return key;return TranslationServer.Translate(key);}}}

===== FILE Core/Mods/Pipeline/ModSetReloader.cs =====
namespace Cthangover.Core.Mods.Pipeline{using System;using System.Collections.Generic;using System.Reflection;using System.Threading;using Cthangover.Core.Games;using Cthangover.Core.Mods.Compilation;using Cthangover.Core.Mods.Unload;using Cthangover.Core.Utils;using Godot;/// <summary> Replaces the running mod set inside the process — the mechanism behind switching games without a restart and behind hot reload of edited mods. Two halves with different thread and failure contracts: <list type="number"> <item><see cref="Prepare"/> (pure .NET, background-safe): discovery and filtering for a launch selection into fresh dictionaries, compilation of every mod into the cache (dependents against the artifacts their dependencies produced right now), staging of precompiled DLLs. Nothing running is touched, so a compile error simply discards the result.</item> <item><see cref="Apply"/> (main thread): computes the <see cref="ModReloadPlan"/> from code identities, releases the mods that leave or changed through the full <see cref="ModUnloader"/> protocol, re-registers the kept mods (their context, statics and live nodes survive), swaps the registry dictionaries, activates the new game choice, loads the new contexts and registers everything in dependency order — the same order a cold boot uses.</item> </list> Derived state that is not per-assembly (registries rebuilt from data, locale, scenes, runtime) is rebuilt by the caller, <see cref="ModManager"/>, around <see cref="Apply"/>. </summary>
public sealed class ModSetReloader{private readonly ModRegistry registry;private readonly ModBootPipeline pipeline;private readonly Dictionary<string,IModInfo>mods;private readonly Dictionary<string,IModInfo>allDiscoveredMods;private readonly Dictionary<string,int>existingPriorities;internal ModSetReloader(ModRegistry registry,ModBootPipeline pipeline,Dictionary<string,IModInfo>mods,Dictionary<string,IModInfo>allDiscoveredMods,Dictionary<string,int>existingPriorities){this.registry=registry;this.pipeline=pipeline;this.mods=mods;this.allDiscoveredMods=allDiscoveredMods;this.existingPriorities=existingPriorities;}/// <summary> Discovers, filters and compiles the mod set of <paramref name="selection"/> without loading it. Safe on a background thread. Check <see cref="ModReloadPreparation.Succeeded"/> before applying. </summary>
public ModReloadPreparation Prepare(GameLaunchArgs selection,IReadOnlyCollection<string>force,CancellationToken cancellationToken=default){var prep=new ModReloadPreparation(selection,force);lock(pipeline.PipelineLock){prep.PreviousArtifacts=new Dictionary<string,string>(ModAssemblyCache.CurrentArtifacts,StringComparer.OrdinalIgnoreCase);foreach(var(scanner,url,priority)in pipeline.ResolvedRoots){cancellationToken.ThrowIfCancellationRequested();if(scanner.IsCanProvide(url))ModDiscoveryService.DiscoverMods(prep.Mods,prep.Priorities,scanner,url,priority,cancellationToken);}ModTestSuites.Mount(prep.Mods,new List<string>(selection.UserArgs));foreach(var kvp in prep.Mods)prep.AllDiscovered[kvp.Key]=kvp.Value;ModSetFilter.FilterDisabledMods(prep.Mods);prep.Choice=ModSetFilter.SelectActiveGame(prep.Mods,selection);ModSetFilter.ValidateDependencies(prep.Mods);prep.Precompiled=ModAssemblyLoader.StagePrecompiledDlls(prep.Mods.Values,cancellationToken);prep.Compiled=ModCompiler.PrepareAll(prep.Mods,cancellationToken);prep.Errors.AddRange(GameLogger.CompilationErrors);}if(prep.Succeeded)GameLogger.Log("MODS_RELOAD",$"Prepared mod set: {prep.Mods.Count} mod(s), {prep.Compiled.Count} compiled, {prep.Precompiled.Count} precompiled DLL(s)");else GameLogger.Log("MODS_RELOAD",$"Mod set preparation FAILED with {prep.Errors.Count} error(s); the running set stays",LogLevel.Error);return prep;}/// <summary> Throws away a preparation that will not be applied: its file providers are disposed and the artifact table goes back to the files the running contexts were compiled against. </summary>
public void Discard(ModReloadPreparation prep){if(prep==null)return;ModAssemblyCache.CurrentArtifacts.Clear();foreach(var kvp in prep.PreviousArtifacts)ModAssemblyCache.CurrentArtifacts[kvp.Key]=kvp.Value;foreach(var mod in prep.AllDiscovered.Values){if(!mods.TryGetValue(mod.Id,out var running)|| !ReferenceEquals(running.FileProvider,mod.FileProvider))mod.FileProvider?.Dispose();}}/// <summary> Switches the process to the prepared set. Main thread only, outside any signal emission of a node that may be freed. Returns the plan and the unload ticket for verification. </summary>
public ModReloadResult Apply(ModReloadPreparation prep,SceneTree tree){lock(pipeline.PipelineLock){var targetOrder=TargetOrder(prep);var loadedOrder=LoadedOrder();var plan=ModReloadPlan.Compute(loadedOrder,LoadedArtifacts(loadedOrder),targetOrder,TargetArtifacts(prep),TargetDependencies(prep),prep.Force);GameLogger.Log("MODS_RELOAD",$"Plan: unload [{string.Join(", ", plan.Unload)}], keep [{string.Join(", ", plan.Keep)}], load [{string.Join(", ", plan.Load)}]");var ticket=ModUnloader.Unload(plan.Unload,tree);var keptInUnloadOrder=new List<string>(plan.Keep);keptInUnloadOrder.Reverse();ModUnloader.UnregisterKept(keptInUnloadOrder);SwapMods(prep);var load=new HashSet<string>(plan.Load,StringComparer.OrdinalIgnoreCase);var loaded=LoadNewContexts(prep,load);RegisterInOrder(prep,targetOrder,load,loaded);LoadNewGdScripts(prep);registry.InvalidateOrder();var orderedIds=registry.GetOrderedModIds();var order=new List<(string ModId,int Priority)>();for(var i=0;i<orderedIds.Count;i++)order.Add((orderedIds[i],i));ScriptTypeRegistry.SetModOrder(order);registry.InitializedFlag=true;var game=ActiveGame.Id??(ActiveGame.IsFrontendOnly?"<front-end>":"<all mods>");GameLogger.Log("MODS_RELOAD",$"Mod set applied: {mods.Count} mod(s), game='{game}'");return new ModReloadResult(true,plan,ticket,null);}}private void SwapMods(ModReloadPreparation prep){foreach(var old in allDiscoveredMods.Values){if(!prep.AllDiscovered.TryGetValue(old.Id,out var replacement)|| !ReferenceEquals(replacement.FileProvider,old.FileProvider))old.FileProvider?.Dispose();}mods.Clear();foreach(var kvp in prep.Mods)mods[kvp.Key]=kvp.Value;allDiscoveredMods.Clear();foreach(var kvp in prep.AllDiscovered)allDiscoveredMods[kvp.Key]=kvp.Value;existingPriorities.Clear();foreach(var kvp in prep.Priorities)existingPriorities[kvp.Key]=kvp.Value;GameLaunchArgs.SetCurrent(prep.Selection);prep.Choice.Apply();registry.InvalidateOrder();}/// <summary>Loads precompiled DLLs and compiled artifacts of the mods in <paramref name="load"/> into new contexts, without registering them.</summary>
private static Dictionary<string,List<Assembly>>LoadNewContexts(ModReloadPreparation prep,HashSet<string>load){var nativeMods=new List<IModInfo>();foreach(var mod in prep.Mods.Values){if(load.Contains(mod.Id))nativeMods.Add(mod);}ModNativeLoader.LoadNativeDlls(nativeMods);var staged=new List<(string ModId,string CachedPath)>();foreach(var entry in prep.Precompiled){if(load.Contains(entry.ModId))staged.Add(entry);}var loaded=new Dictionary<string,List<Assembly>>(StringComparer.OrdinalIgnoreCase);using(ModAssemblyLoader.BeginBatch()){foreach(var(modId,assembly)in ModAssemblyLoader.LoadStagedDlls(staged))Add(loaded,modId,assembly);foreach(var compiled in prep.Compiled){if(!load.Contains(compiled.ModId))continue;var assembly=ModAssemblyLoader.LoadFromPath(compiled.ArtifactPath,compiled.ModId);if(assembly!=null)Add(loaded,compiled.ModId,assembly);else GameLogger.Log("MODS_RELOAD",$"Mod '{compiled.ModId}' compiled but did not load from '{compiled.ArtifactPath}'",LogLevel.Error);}}return loaded;}/// <summary>Fans every target assembly out in dependency order: fresh ones from <paramref name="loaded"/>, kept ones from their live contexts.</summary>
private static void RegisterInOrder(ModReloadPreparation prep,List<string>targetOrder,HashSet<string>load,Dictionary<string,List<Assembly>>loaded){var sources=new Dictionary<string,List<(string FilePath,string Source)>>(StringComparer.OrdinalIgnoreCase);foreach(var compiled in prep.Compiled)sources[compiled.ModId]=compiled.Sources;foreach(var modId in targetOrder){var assemblies=new List<Assembly>();if(load.Contains(modId)){if(loaded.TryGetValue(modId,out var fresh))assemblies.AddRange(fresh);}else{foreach(var record in ModAssemblyLoader.Records(new[]{modId}))assemblies.AddRange(record.Assemblies);}foreach(var assembly in assemblies){ModAssemblyLoader.RegisterAssembly(assembly,modId);if(sources.TryGetValue(modId,out var modSources)&&assembly.GetName().Name==modId)ScriptTypeRegistry.RegisterAssembly(assembly,modId,modSources);}}}/// <summary>GDScripts load once per mod and process (their registrations cannot be withdrawn); mods that left keep theirs until restart.</summary>
private static void LoadNewGdScripts(ModReloadPreparation prep){var fresh=new Dictionary<string,IModInfo>(StringComparer.OrdinalIgnoreCase);foreach(var kvp in prep.Mods){if(!Contains(ModGDScriptLoader.LoadedModIds,kvp.Key))fresh[kvp.Key]=kvp.Value;}ModGDScriptLoader.LoadGDScripts(fresh);foreach(var id in ModGDScriptLoader.LoadedModIds){if(!prep.Mods.ContainsKey(id))GameLogger.Log("MODS_RELOAD",$"GDScript registrations of mod '{id}' stay until the next restart (GDScript mods are not unloadable)",LogLevel.Warning);}}/// <summary>Mods with live contexts in their current load order (dependencies first).</summary>
private List<string>LoadedOrder(){var loaded=new HashSet<string>(ModAssemblyLoader.LoadedModIds,StringComparer.OrdinalIgnoreCase);var order=new List<string>();foreach(var id in registry.GetOrderedModIds()){if(loaded.Remove(id))order.Add(id);}order.AddRange(loaded);return order;}private static Dictionary<string,IReadOnlyCollection<string>>LoadedArtifacts(List<string>loadedOrder){var result=new Dictionary<string,IReadOnlyCollection<string>>(StringComparer.OrdinalIgnoreCase);foreach(var id in loadedOrder)result[id]=ModAssemblyLoader.ArtifactsOf(id);return result;}/// <summary>Target mods that have code (compiled or precompiled), in dependency order.</summary>
private static List<string>TargetOrder(ModReloadPreparation prep){var withCode=new HashSet<string>(StringComparer.OrdinalIgnoreCase);foreach(var compiled in prep.Compiled)withCode.Add(compiled.ModId);foreach(var(modId,_)in prep.Precompiled)withCode.Add(modId);var infos=new List<ModInfo>();foreach(var mod in prep.Mods.Values){if(mod is ModInfo{Manifest:not null}info)infos.Add(info);}var order=new List<string>();foreach(var info in ModCompileOrder.TopologicalSort(infos)){if(withCode.Contains(info.Id))order.Add(info.Id);}return order;}private static Dictionary<string,IReadOnlyCollection<string>>TargetArtifacts(ModReloadPreparation prep){var paths=new Dictionary<string,List<string>>(StringComparer.OrdinalIgnoreCase);foreach(var(modId,path)in prep.Precompiled)AddPath(paths,modId,path);foreach(var compiled in prep.Compiled)AddPath(paths,compiled.ModId,compiled.ArtifactPath);var result=new Dictionary<string,IReadOnlyCollection<string>>(StringComparer.OrdinalIgnoreCase);foreach(var kvp in paths)result[kvp.Key]=kvp.Value;return result;}private static Dictionary<string,IReadOnlyCollection<string>>TargetDependencies(ModReloadPreparation prep){var result=new Dictionary<string,IReadOnlyCollection<string>>(StringComparer.OrdinalIgnoreCase);foreach(var mod in prep.Mods.Values){if(mod is ModInfo info)result[info.Id]=info.Manifest?.DependencyIds??new List<string>();}return result;}private static bool Contains(IReadOnlyCollection<string>ids,string id){foreach(var candidate in ids){if(string.Equals(candidate,id,StringComparison.OrdinalIgnoreCase))return true;}return false;}private static void AddPath(Dictionary<string,List<string>>map,string modId,string path){if(!map.TryGetValue(modId,out var list))map[modId]=list=new List<string>();list.Add(path);}private static void Add(Dictionary<string,List<Assembly>>map,string modId,Assembly assembly){if(!map.TryGetValue(modId,out var list))map[modId]=list=new List<Assembly>();list.Add(assembly);}}}

===== FILE Core/Mods/PropertyPatch.cs =====
using System.Collections.Generic;using System.Text.Json.Serialization;using Cthangover.Core.Factories;namespace Cthangover.Core.Mods{/// <summary> One JSON property patch targeting an existing entity: the <c>ID</c> names the entity and every entry of <c>ExtraProperties</c> overrides or extends one property. Collected by <see cref="Resolvers.Impls.PatchResolver"/> from <c>patches/&lt;kind&gt;/</c> directories (and flat <c>patches/&lt;kind&gt;.json</c> files) and merged into the base entity by <see cref="Resolvers.Impls.EntityResolver"/>. Array-valued properties are concatenated across patches instead of overwritten, so several mods can extend the same list (e.g. extra skills). </summary>
public class PropertyPatch:IIdentifiable{/// <summary>ID of the entity this patch modifies.</summary>
[JsonPropertyName("Id")]public string ID{get;set;}/// <summary>Property overrides keyed by property name; arrays concatenate, scalars replace.</summary>
[JsonExtensionData]public Dictionary<string,object>ExtraProperties{get;set;}}}

===== FILE Core/Mods/Providers/FolderModFileProvider.cs =====
using System;using System.Collections.Concurrent;using System.Collections.Generic;using System.IO;namespace Cthangover.Core.Mods.Providers{/// <summary> Mod file provider backed by a real directory on disk. All paths are relative to the mod root and use the canonical form defined by <see cref="ModPath"/> (forward slashes, no root or <c>..</c> escape); conversion to the OS-native separator happens only at the <see cref="System.IO.Path"/> boundary. Trailing <c>/</c> in <see cref="ListFiles"/> output distinguishes directories from files so that recursive scanners can branch without stat-calling every entry. <see cref="GetFileSystemPath"/> returns the absolute path — the critical capability that enables Godot's <c>ResourceLoader</c> and <c>PackedScene</c> instantiation from folder mods. Zip providers return <c>null</c> here, making this the only provider type that supports scene-based assets. <see cref="Dispose"/> is a no-op because directories don't need cleanup. Lookups follow <see cref="ModPathCase"/>: a path must match the on-disk spelling exactly on every OS, so a wrong-case reference fails on the author's Windows machine the same way it fails on Linux and in zip mods. Verified spellings are cached to keep repeated lookups cheap. </summary>
public class FolderModFileProvider:IModFileProvider{/// <inheritdoc />
public string Mod{get;}/// <inheritdoc />
public string RootPath{get;}private readonly ConcurrentDictionary<string,byte>verifiedFiles=new(StringComparer.Ordinal);/// <summary> Creates a provider rooted at the given directory. <paramref name="modName"/> becomes the <see cref="Mod"/> value used in log messages and lookup keys. </summary> <param name="rootPath">Absolute or relative path to the mod root directory on disk.</param> <param name="modName">Short identifier for this mod.</param>
public FolderModFileProvider(string rootPath,string modName){this.RootPath=rootPath;this.Mod=modName;}/// <summary> No-op: directories do not hold native handles and need no explicit cleanup. </summary>
public void Dispose(){}/// <summary> Checks whether a file exists at the given mod-relative path with exactly this spelling (see <see cref="ModPathCase"/>). </summary>
public bool FileExists(string path){return ResolveFile(path)!=null;}/// <summary> Returns a shallow listing of the given directory inside the mod. Directories are suffixed with <c>/</c> so that recursive callers can distinguish them from files without additional stat calls. </summary> <param name="directory">Mod-relative directory path. Empty string for the mod root.</param>
public IEnumerable<string>ListFiles(string directory=""){var relativeDir=ModPath.Normalize(directory);var actualDir=ModPathCase.FindOnDisk(RootPath,relativeDir,directory:true);if(actualDir==null)yield break;if(!string.Equals(actualDir,relativeDir,StringComparison.Ordinal)){ModPathCase.ReportMismatch(Mod,relativeDir,actualDir);yield break;}var dir=Path.Combine(RootPath,relativeDir);foreach(var entry in Directory.EnumerateFileSystemEntries(dir,"*",SearchOption.TopDirectoryOnly)){var relative=ModPath.Normalize(Path.GetRelativePath(RootPath,entry));if(Directory.Exists(entry))relative+="/";yield return relative;}}/// <summary> Reads the full contents of a text file at the given mod-relative path as a UTF-8 string. Returns <c>null</c> if the file does not exist. </summary>
public string ReadFileText(string path){var fullPath=ResolveFile(path);return fullPath!=null?File.ReadAllText(fullPath):null;}/// <summary> Reads the full binary content of a file inside the mod. Returns <c>null</c> if the file does not exist. </summary>
public byte[]ReadFileBinary(string path){var fullPath=ResolveFile(path);return fullPath!=null?File.ReadAllBytes(fullPath):null;}/// <summary> Opens a read-only <see cref="Stream"/> for the given mod-relative path. Returns <c>null</c> if the file does not exist. The caller is responsible for disposing the stream. </summary>
public Stream OpenStream(string path){var fullPath=ResolveFile(path);return fullPath!=null?File.OpenRead(fullPath):null;}/// <summary> Returns the absolute filesystem path for the given mod-relative path, or <c>null</c> if the file does not exist. This is the only provider type that returns non-null here, which makes folder mods the only mods capable of serving Godot scenes via <c>ResourceLoader</c>. </summary>
public string GetFileSystemPath(string path){var fullPath=ResolveFile(path);return fullPath!=null?Path.GetFullPath(fullPath):null;}/// <summary> Resolves a relative include path (<c>../shared/data.json</c>) against the directory of <paramref name="currentPath"/> to produce a canonical mod-relative path. Delegates to <see cref="ModPath.ResolveRelative"/> so folder and zip mods resolve includes identically. </summary>
public string ResolveIncludePath(string includePath,string currentPath){return ModPath.ResolveRelative(includePath,currentPath);}/// <summary> Returns the full path of an existing file whose spelling matches <paramref name="path"/> exactly, or <c>null</c>. A case-only mismatch is reported once and treated as a miss. </summary>
private string ResolveFile(string path){var relative=ModPath.Normalize(path);var fullPath=Path.Combine(RootPath,relative);if(verifiedFiles.ContainsKey(relative))return File.Exists(fullPath)?fullPath:null;var actual=ModPathCase.FindOnDisk(RootPath,relative,directory:false);if(actual==null)return null;if(!string.Equals(actual,relative,StringComparison.Ordinal)){ModPathCase.ReportMismatch(Mod,relative,actual);return null;}verifiedFiles.TryAdd(relative,0);return fullPath;}}}

===== FILE Core/Mods/Providers/SubdirModFileProvider.cs =====
using System.Collections.Generic;using System.IO;using System.Linq;namespace Cthangover.Core.Mods.Providers{/// <summary> A mod-relative sub-folder of another provider seen as a mod root (test suites of zip mods). Paths are prefixed on the way in and the prefix is stripped from listings; the inner provider stays owned by its mod, so <see cref="Dispose"/> does not dispose it. </summary>
public sealed class SubdirModFileProvider:IModFileProvider{private readonly IModFileProvider inner;private readonly string prefix;public SubdirModFileProvider(IModFileProvider inner,string folder,string modName){this.inner=inner;prefix=ModPath.Normalize(folder).TrimEnd('/')+"/";Mod=modName;}public string Mod{get;}/// <summary>Not disk-backed on its own: a sub-folder of a zip has no root of its own.</summary>
public string RootPath=>null;public bool FileExists(string path)=>inner.FileExists(Full(path));public IEnumerable<string>ListFiles(string directory="")=>inner.ListFiles(Full(directory).TrimEnd('/')).Where(e=>e.StartsWith(prefix,System.StringComparison.Ordinal)).Select(e=>e.Substring(prefix.Length));public string ReadFileText(string path)=>inner.ReadFileText(Full(path));public byte[]ReadFileBinary(string path)=>inner.ReadFileBinary(Full(path));public Stream OpenStream(string path)=>inner.OpenStream(Full(path));public string GetFileSystemPath(string path)=>inner.GetFileSystemPath(Full(path));public string ResolveIncludePath(string includePath,string currentPath)=>ModPath.ResolveRelative(includePath,currentPath);public void Dispose(){}private string Full(string path)=>prefix+ModPath.Normalize(path??string.Empty);}}

===== FILE Core/Mods/Providers/ZipModFileProvider.cs =====
using System;using System.Collections.Generic;using System.IO;using System.IO.Compression;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods.Providers{/// <summary> Mod file provider backed by a <c>.zip</c> archive. On construction the entire zip entry table is flattened into an exact-case dictionary — this trades startup memory for O(1) lookups on every subsequent file access call, which dominates because factories repeatedly check file existence during cache-population scans. <see cref="ListFiles"/> simulates a shallow directory listing by parsing the flat zip entry list: entries whose relative path contains a <c>/</c> after the prefix become synthetic directory entries (trailing <c>/</c>), while entries at the prefix level become file entries. This preserves the same recursive-walk interface that <see cref="FolderModFileProvider"/> provides natively. <see cref="GetFileSystemPath"/> always returns <c>null</c> — Godot cannot load resources or scenes directly from within a zip, so callers that need filesystem paths (e.g. <c>EffectFactory</c>, shader loading) must extract files to the cache or skip zip mods entirely. Lookups follow <see cref="ModPathCase"/>: the spelling must match the archive entry exactly, like folder mods on Linux; a case-only mismatch is reported with the correct path and treated as a miss. </summary>
public class ZipModFileProvider:IModFileProvider{private readonly ZipArchive archive;private readonly Dictionary<string,ZipArchiveEntry>entryLookup;private readonly Dictionary<string,string>spellingLookup;/// <inheritdoc />
public string Mod{get;}/// <inheritdoc />
public string RootPath{get;}/// <summary> Opens the zip archive at <paramref name="zipPath"/> and builds an O(1) exact-case lookup dictionary from every entry's full path. The zip remains open until <see cref="Dispose"/> is called — callers must dispose the provider to release the native file handle. </summary> <param name="zipPath">Absolute or relative path to the <c>.zip</c> file.</param> <param name="modName">Short identifier for this mod, used in log messages and as the <see cref="Mod"/> value.</param>
public ZipModFileProvider(string zipPath,string modName){this.RootPath=zipPath;this.Mod=modName;archive=ZipFile.OpenRead(zipPath);entryLookup=new Dictionary<string,ZipArchiveEntry>(StringComparer.Ordinal);spellingLookup=new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);foreach(var e in archive.Entries){var name=ModPath.Normalize(e.FullName);if(name.Length>0){entryLookup[name]=e;spellingLookup[name]=name;}}GameLogger.Log("MODS_ZIP",$"Opened zip '{Path.GetFileName(zipPath)}': {entryLookup.Count} entries total");foreach(var e in archive.Entries)GameLogger.Log("MODS_ZIP",$"  entry: '{e.FullName}' length={e.Length}",LogLevel.Debug);}/// <summary> Closes the underlying <see cref="ZipArchive"/> and releases the native file handle. After disposal, all read operations will fail. </summary>
public void Dispose(){archive?.Dispose();}/// <summary> Checks whether the given path exists as a zip entry using the exact-case lookup dictionary built during construction. </summary>
public bool FileExists(string path){return TryGetEntry(ModPath.Normalize(path),out _);}/// <summary> Returns a simulated shallow directory listing by grouping zip entries. Entries at the given directory level become file entries; entries one level deeper become synthetic directory entries suffixed with <c>/</c>. This preserves the same recursive-walk contract that <see cref="FolderModFileProvider"/> provides natively. </summary> <param name="directory">Mod-relative directory path. Empty string for the archive root.</param>
public IEnumerable<string>ListFiles(string directory=""){var prefix=ModPath.NormalizeDirectory(directory);var seen=new HashSet<string>();var yieldList=new List<string>();foreach(var entry in archive.Entries){var rawName=entry.FullName.Replace('\\','/');var isDir=rawName.EndsWith("/");var fullName=ModPath.Normalize(rawName);if(string.IsNullOrEmpty(prefix)||fullName.StartsWith(prefix,StringComparison.Ordinal)){var suffix=string.IsNullOrEmpty(prefix)?fullName:fullName.Substring(prefix.Length);if(string.IsNullOrEmpty(suffix))continue;var idx=suffix.IndexOf('/');if(idx>=0){var dirPart=prefix+suffix.Substring(0,idx+1);if(seen.Add(dirPart))yieldList.Add(dirPart);}else if(isDir){var dirPart=prefix+suffix+"/";if(seen.Add(dirPart))yieldList.Add(dirPart);}else{var filePath=prefix+suffix;if(seen.Add(filePath))yieldList.Add(filePath);}}}foreach(var item in yieldList)yield return item;}/// <summary> Reads the full text content of a zip entry as a UTF-8 string. Returns <c>null</c> if the entry does not exist. </summary>
public string ReadFileText(string path){var normalized=ModPath.Normalize(path);if(!TryGetEntry(normalized,out var entry))return null;GameLogger.Log("MODS_ZIP",$"ReadFileText('{path}') norm='{normalized}' => found ({entry.Length} bytes)");using var reader=new StreamReader(entry.Open());return reader.ReadToEnd();}/// <summary> Reads the full binary content of a zip entry. Returns <c>null</c> if the entry does not exist. </summary>
public byte[]ReadFileBinary(string path){var normalized=ModPath.Normalize(path);if(!TryGetEntry(normalized,out var entry))return null;GameLogger.Log("MODS_ZIP",$"ReadFileBinary('{path}') norm='{normalized}' => found ({entry.Length} bytes)");using var stream=entry.Open();using var ms=new MemoryStream();stream.CopyTo(ms);return ms.ToArray();}/// <summary> Opens a read-only stream to the zip entry. Returns <c>null</c> if the entry does not exist. The caller is responsible for disposing the stream. </summary>
public Stream OpenStream(string path){var normalized=ModPath.Normalize(path);if(!TryGetEntry(normalized,out var entry))return null;GameLogger.Log("MODS_ZIP",$"OpenStream('{path}') norm='{normalized}' => found");return entry.Open();}/// <summary> Always returns <c>null</c>. Zip mods cannot provide filesystem paths — Godot's <c>ResourceLoader</c> requires real filesystem paths, so callers dependent on scene instantiation must use folder mods or extract assets to the cache first. </summary>
public string GetFileSystemPath(string path){return null;}/// <summary> Resolves a relative include path (<c>../shared/data.json</c>) against the directory of <paramref name="currentPath"/> to produce a canonical mod-relative path. Delegates to <see cref="ModPath.ResolveRelative"/> so zip and folder mods resolve includes identically. </summary>
public string ResolveIncludePath(string includePath,string currentPath){var result=ModPath.ResolveRelative(includePath,currentPath);GameLogger.Log("MODS_ZIP",$"ResolveIncludePath(include='{includePath}', current='{currentPath}') => '{result}'");return result;}/// <summary> Exact-case entry lookup; a case-only mismatch is reported through <see cref="ModPathCase.ReportMismatch"/> and treated as a miss. </summary>
private bool TryGetEntry(string normalized,out ZipArchiveEntry entry){if(entryLookup.TryGetValue(normalized,out entry))return true;if(spellingLookup.TryGetValue(normalized,out var actual))ModPathCase.ReportMismatch(Mod,normalized,actual);return false;}}}

===== FILE Core/Mods/Resolvers/IModAssetResolver.cs =====
namespace Cthangover.Core.Mods.Resolvers{/// <summary> Contract for a pluggable asset resolver — a subsystem that collects and resolves typed assets from loaded mods. Each resolver has a unique <see cref="Id"/> (e.g. "textures", "shaders") and is initialised with a <see cref="ModResolutionContext"/> that provides access to the mod registry and cross-resolver lookup via <see cref="ModResolutionContext.GetResolver{T}"/>. Implementations are auto-discovered by <c>Reflections.FindAndCreate</c> and registered in <see cref="ModManager"/>. Mod DLLs can ship custom resolvers that will be picked up automatically when the assembly is loaded. </summary>
public interface IModAssetResolver{/// <summary> Unique identifier for this resolver, used as a key in the resolver dictionary inside <see cref="ModManager"/>. </summary>
string Id{get;}/// <summary> Called once during mod loading to provide the resolver with access to the mod registry, file I/O helpers, and other resolvers. Must be called before <c>Collect</c> or <c>Resolve</c> methods. </summary>
void Initialize(ModResolutionContext context);/// <summary> Clears all cached data collected from mods. Called on mod reload so that the next collection picks up changes from newly loaded or unloaded mods. </summary>
void ClearCache();}}

===== FILE Core/Mods/Resolvers/Impls/EntityResolver.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Text.Json;using Cthangover.Core.Bootstrap;using Cthangover.Core.Factories;using Cthangover.Core.Mods.Patches;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods.Resolvers{/// <summary> Single collector for every JSON-defined entity in the game. Unlike the directory-scoped resolver it replaces, this one recursively walks <b>all</b> files of <b>all</b> mods once, peeks each file's top-level <c>"Kind"</c> (see <see cref="EntityKindPeek"/>), and routes the file to the kind's cache. The kind — not the directory — decides the owner, so the old failure mode where <c>characters/actions.json</c> was parsed as a skill and killed with an enum error cannot happen: a file is only deserialised as the one DTO its kind names. <para/> Collection follows the established override semantics: mods are visited in dependency order, later mods override earlier ones by entity ID, and property patches for the kind (<c>patches/&lt;kind&gt;/</c> and <c>patches/&lt;kind&gt;.json</c>) are merged into each surviving entity. The scan and the parsed per-kind dictionaries are cached until <see cref="ClearCache"/> — one directory walk serves every factory instead of one walk per type. </summary>
public class EntityResolver:ModAssetResolver{/// <inheritdoc />
public override string Id=>"entities";/// <summary>One data file queued during the scan, remembered with its owning mod for later reading.</summary>
private sealed class EntityFile{public string ModId;public string Path;}private bool _scanned;private readonly Dictionary<string,List<EntityFile>>_filesByKind=new();private readonly Dictionary<string,object>_collected=new();/// <summary> Returns all entities of the DTO <typeparamref name="T"/>. The kind is read from the DTO's <see cref="EntityKindAttribute"/>, files are matched during the shared scan, and each file is deserialised as <see cref="FileData{T}"/>. The resulting dictionary is cached per kind and returned as-is; callers treat entries as immutable. </summary>
public Dictionary<string,T>Collect<T>()where T:class,IIdentifiable{Context.Initialize();var kind=EntityKindRegistry.KindOf<T>();if(string.IsNullOrEmpty(kind)){GameLogger.Log("ENTITIES",$"Collect<{typeof(T).Name}>: DTO has no [EntityKind] attribute, cannot resolve its files",LogLevel.Error);return new Dictionary<string,T>();}if(_collected.TryGetValue(kind,out var cached))return(Dictionary<string,T>)cached;EnsureScanned();var result=new Dictionary<string,T>();if(_filesByKind.TryGetValue(kind,out var files)){foreach(var file in files){ReadFile(file,kind,result);}}ApplyPatches(kind,result);GameLogger.Log("MODS",result.Count>0?$"CollectEntities<{typeof(T).Name}>('{kind}') → {result.Count} item(s)":$"CollectEntities<{typeof(T).Name}>('{kind}') — no data found",result.Count>0?LogLevel.Debug:LogLevel.Error);_collected[kind]=result;return result;}private void ReadFile<T>(EntityFile file,string kind,Dictionary<string,T>result)where T:class,IIdentifiable{var provider=Context.GetMod(file.ModId)?.FileProvider;if(provider==null)return;var jsonText=Context.ReadResolvedText(file.ModId,file.Path,provider);if(jsonText==null){GameLogger.Log("MODS",$"ReadResolvedText returned null for '{file.ModId}/{file.Path}'",LogLevel.Error);return;}try{var fileData=JsonSerializer.Deserialize<FileData<T>>(jsonText,JsonOptionsProvider.ReadOptions);if(fileData?.Items==null||fileData.Items.Count==0)return;if(!string.Equals(fileData.Kind,kind,StringComparison.OrdinalIgnoreCase)){GameLogger.Log("MODS",$"'{file.ModId}/{file.Path}' declares kind '{fileData.Kind}' but was routed as '{kind}'",LogLevel.Error);return;}foreach(var item in fileData.Items){if(string.IsNullOrEmpty(item.ID)){GameLogger.Log("MODS",$"entity without Id in '{file.ModId}/{file.Path}' (kind={kind})",LogLevel.Error);continue;}if(result.ContainsKey(item.ID)){GameLogger.Log("MODS",$"'{item.ID}' ({kind}) from mod '{file.ModId}' overrides previous definition",LogLevel.Warning);AppServices.Current.Conflicts.RecordConflict(kind,item.ID,file.ModId,null,false);}result[item.ID]=item;}}catch(JsonException ex){GameLogger.Log("MODS",$"JSON parse failed for '{file.ModId}/{file.Path}' (kind={kind}): {ex.Message}",LogLevel.Error);}}/// <summary> One-time recursive discovery pass. Every <c>.json</c> file is peeked for its kind; files without one are ignored as non-data, unknown kinds are reported as authoring errors. Paths are sorted inside a mod so override order within one mod is deterministic. </summary>
private void EnsureScanned(){if(_scanned)return;_scanned=true;_filesByKind.Clear();EntityKindRegistry.Refresh();foreach(var modId in Context.GetOrderedModIds()){var provider=Context.GetMod(modId)?.FileProvider;if(provider==null)continue;var paths=new List<string>();CollectJsonPaths(provider,string.Empty,paths);paths.Sort(StringComparer.OrdinalIgnoreCase);foreach(var path in paths){if(!EntityKindPeek.TryRead(provider.ReadFileText(path),out var kind))continue;if(!EntityKindRegistry.IsKnown(kind)){GameLogger.Log("ENTITIES",$"unknown entity kind '{kind}' in '{modId}/{path}'",LogLevel.Error);continue;}if(!_filesByKind.TryGetValue(kind,out var list)){list=new List<EntityFile>();_filesByKind[kind]=list;}list.Add(new EntityFile{ModId=modId,Path=path});}}}private static void CollectJsonPaths(IModFileProvider provider,string directory,List<string>result){foreach(var entry in provider.ListFiles(directory)){if(entry.EndsWith("/")){var child=entry.TrimEnd('/');CollectJsonPaths(provider,child,result);continue;}if(entry.EndsWith(".json",StringComparison.OrdinalIgnoreCase))result.Add(entry);}}/// <summary> Merges kind-scoped property patches into the collected entities. The patch key is the entity kind (e.g. <c>patches/character.json</c>), which is what stops a character patch from being applied to skills and actions the way the old shared <c>patches/characters/</c> key did. </summary>
private void ApplyPatches<T>(string kind,Dictionary<string,T>result)where T:class,IIdentifiable{var patchResolver=Context.GetResolver<PatchResolver>();if(patchResolver==null)return;var patches=patchResolver.Collect(kind);if(patches==null||patches.Count==0)return;GameLogger.Log("MODS",$"Applying patches to kind '{kind}' ({patches.Count} id set(s))");var merger=new JsonPatchMerger();var mergeKeys=JsonPatchMerger.BuildMergeKeys<T>();var opts=JsonOptionsProvider.ReadOptions;var ids=new List<string>(result.Keys);foreach(var id in ids){var itemJson=JsonSerializer.Serialize(result[id],opts);var mergedJson=merger.Merge(itemJson,id,patches,mergeKeys);result[id]=JsonSerializer.Deserialize<T>(mergedJson,opts);}}/// <inheritdoc />
public override void ClearCache(){_scanned=false;_filesByKind.Clear();_collected.Clear();EntityKindRegistry.Refresh();}}/// <summary> Static convenience accessor for <see cref="EntityResolver"/>, proxying through <see cref="ModManager.Instance"/>'s resolver lookup. </summary>
public static class Entities{/// <summary>Collects every entity of the DTO's kind from all loaded mods (later mods override by id).</summary>
public static Dictionary<string,T>Collect<T>()where T:class,IIdentifiable=>ModManager.Instance.GetResolver<EntityResolver>()?.Collect<T>()??new Dictionary<string,T>();}}

===== FILE Core/Mods/Resolvers/Impls/FileListResolver.cs =====
using System.Collections.Concurrent;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Bootstrap;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods.Resolvers{/// <summary> Provides a generic file-list collection grouped by directory name. Iterates all loaded mods in dependency order, recursively collecting every file under a given directory group. Later mods override earlier ones for files with the same relative key (last-in-wins deduplication). Used as a building block by other resolvers — <see cref="ShaderResolver"/> and <see cref="TextureResolver"/> both delegate to this resolver for raw file discovery, then apply their own filtering and processing. </summary>
public class FileListResolver:ModAssetResolver{/// <inheritdoc />
public override string Id=>"file_list";/// <summary> Per-group file cache. <see cref="ConcurrentDictionary"/> because <see cref="Collect"/> may be entered concurrently (factories, parallel tests); the inner dictionaries are built locally and only published after they are complete, so they stay read-only in the cache. A plain dictionary here used to corrupt its internal state when two threads collected the same group at once. </summary>
private readonly ConcurrentDictionary<string,Dictionary<string,FileEntry>>_cache=new();/// <summary> Collects all files under the given directory group (e.g. "shaders", "textures/portraits") from every loaded mod. Files are keyed by their path relative to the group directory. Later mods in load order override earlier ones with the same relative key. Caches the result per group. </summary>
public Dictionary<string,FileEntry>Collect(string group){Context.Initialize();if(_cache.TryGetValue(group,out var cached))return cached;GameLogger.Log("MODS",$"CollectFileList('{group}'): CACHE MISS, collecting from {Context.Mods.Count} mod(s)...");var result=new Dictionary<string,FileEntry>();var prefix=ModPath.NormalizeDirectory(group);foreach(var modId in Context.GetOrderedModIds()){var provider=Context.Mods[modId].FileProvider;var before=result.Count;CollectGroupFiles(provider,modId,group,prefix,result);if(result.Count>before){GameLogger.Log("MODS",$"mod '{modId}' contributed {result.Count - before} file(s) to group '{group}'");}}_cache[group]=result;GameLogger.Log("MODS",$"CollectFileList('{group}') → {result.Count} file(s), keys=[{string.Join(", ", result.Keys)}]");return result;}/// <inheritdoc />
public override void ClearCache(){_cache.Clear();}private static void CollectGroupFiles(IModFileProvider provider,string modId,string dir,string prefix,Dictionary<string,FileEntry>result){var entries=provider.ListFiles(dir).ToList();GameLogger.Log("MODS",$"[{modId}]  CollectGroupFiles: dir='{dir}', provider entries count={entries.Count}, prefix='{prefix}'",LogLevel.Debug);foreach(var entry in entries){if(entry.EndsWith("/")){var subDir=entry.TrimEnd('/');GameLogger.Log("MODS",$"    -> subdir '{subDir}'",LogLevel.Debug);CollectGroupFiles(provider,modId,subDir,prefix,result);}else if(entry.StartsWith(prefix)){var key=entry.Substring(prefix.Length);if(result.ContainsKey(key)){GameLogger.Log("MODS",$"conflict: '{key}' in mod '{modId}' overrides previous entry",LogLevel.Warning);AppServices.Current.Conflicts.RecordConflict("file",key,modId,result[key].ModId,false);}result[key]=new FileEntry(modId,entry);GameLogger.Log("MODS",$"    + file '{key}' <- {entry} (mod={modId})",LogLevel.Debug);}else{GameLogger.Log("MODS",$"    SKIP '{entry}': does not start with prefix '{prefix}'",LogLevel.Warning);}}}}/// <summary> Static convenience accessor for <see cref="FileListResolver"/>, proxying through <see cref="ModManager.Instance.GetResolver{T}"/>. </summary>
public static class Files{/// <summary>Collects one directory group's files from all mods, keyed by group-relative path (last mod wins).</summary>
public static Dictionary<string,FileEntry>Collect(string group)=>ModManager.Instance.GetResolver<FileListResolver>()?.Collect(group);}}

===== FILE Core/Mods/Resolvers/Impls/GDScriptModAssetResolver.cs =====
using Godot;namespace Cthangover.Core.Mods.Resolvers{/// <summary> Bridge that wraps a GDScript object as an <see cref="IModAssetResolver"/>. Enables GDScript mods to provide custom asset resolvers without writing C# — the GDScript object must expose <c>resolver_id</c> as a property and may implement <c>initialize()</c> and <c>clear_cache()</c> methods. Unlike C# resolvers discovered via reflection, GDScript resolvers are manually instantiated and registered by <see cref="ModGDScriptLoader"/>. </summary>
public class GDScriptModAssetResolver:IModAssetResolver{private readonly GodotObject _instance;/// <summary> The resolver ID, read from the GDScript object's <c>resolver_id</c> property. Used as the key in the resolver dictionary inside <see cref="ModManager"/>. </summary>
public string Id=>(string)_instance.Get("resolver_id");/// <summary> Creates a bridge wrapping the given GDScript object. The object must have a <c>resolver_id</c> property and may optionally implement <c>initialize</c> and <c>clear_cache</c>. </summary>
public GDScriptModAssetResolver(GodotObject instance){_instance=instance;}/// <summary> Calls the GDScript object's <c>initialize()</c> method if present. Unlike the C# interface, the context is not passed to GDScript — GDScript resolvers access mod data through their own mechanisms. </summary>
public void Initialize(ModResolutionContext context){if(_instance.HasMethod("initialize"))_instance.Call("initialize");}/// <summary> Calls the GDScript object's <c>clear_cache()</c> method if present. </summary>
public void ClearCache(){if(_instance.HasMethod("clear_cache"))_instance.Call("clear_cache");}}}

===== FILE Core/Mods/Resolvers/Impls/PatchResolver.cs =====
using System;using System.Collections.Concurrent;using System.Collections.Generic;using System.Linq;using System.Text.Json;using Cthangover.Core.Factories;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods.Resolvers{/// <summary> Collects and merges JSON property patches from mods. Patches are organised per entity kind (e.g. "item", "character") and stored in <c>patches/{kind}/</c> directories. Each patch specifies a target entity ID, a property path, and the value to apply. Also reads flat patch files named <c>patches/{kind}.json</c> for simpler single-file patch sets. The collected patches are applied by <see cref="EntityResolver"/> after collecting base entity data — patches from later mods override earlier ones. </summary>
public class PatchResolver:ModAssetResolver{/// <inheritdoc />
public override string Id=>"patches";/// <summary> Per-entity-type patch cache. <see cref="ConcurrentDictionary"/> because resolution may be triggered concurrently (e.g. by parallel unit tests bootstrapping mods through <see cref="Context.Initialize"/>) — a plain dictionary corrupts under concurrent writes. </summary>
private readonly ConcurrentDictionary<string,Dictionary<string,List<PropertyPatch>>>_cache=new();/// <summary> Collects all patches for the given entity type from every loaded mod. Searches both <c>patches/{entityType}/</c> directories and <c>patches/{entityType}.json</c> flat files. Patches are grouped by target entity ID. Caches per type. </summary>
public Dictionary<string,List<PropertyPatch>>Collect(string entityType){Context.Initialize();if(_cache.TryGetValue(entityType,out var cached))return cached;var result=new Dictionary<string,List<PropertyPatch>>();var prefix=ModPath.NormalizeDirectory($"patches/{entityType}");foreach(var modId in Context.GetOrderedModIds()){var provider=Context.Mods[modId].FileProvider;foreach(var entry in provider.ListFiles($"patches/{entityType}")){if(entry.EndsWith("/"))continue;var relativePath=entry.StartsWith(prefix)?entry.Substring(prefix.Length):entry;if(!relativePath.EndsWith(".json",StringComparison.OrdinalIgnoreCase))continue;var text=Context.ReadResolvedText(modId,entry,provider);if(text==null)continue;try{LoadPatchesFromJson(entityType,modId,entry,text,result);}catch(JsonException ex){GameLogger.Log("MODS",$"Failed to parse patches for '{entityType}' from mod '{modId}/{entry}': {ex.Message}",LogLevel.Error);}}var flatFileName=$"{entityType}.json";foreach(var entry in provider.ListFiles("patches")){if(entry.EndsWith("/"))continue;var relative=entry.StartsWith("patches/")?entry.Substring("patches/".Length):entry;if(!relative.Equals(flatFileName,StringComparison.OrdinalIgnoreCase))continue;var text=Context.ReadResolvedText(modId,entry,provider);if(text==null)continue;try{LoadPatchesFromJson(entityType,modId,entry,text,result);}catch(JsonException ex){GameLogger.Log("MODS",$"Failed to parse patches for '{entityType}' from mod '{modId}/{entry}': {ex.Message}",LogLevel.Error);}}}_cache[entityType]=result;return result;}/// <inheritdoc />
public override void ClearCache(){_cache.Clear();}private static void LoadPatchesFromJson(string entityType,string modId,string entry,string text,Dictionary<string,List<PropertyPatch>>result){var fileData=JsonSerializer.Deserialize<FileData<PropertyPatch>>(text,JsonOptionsProvider.ReadOptions);if(fileData?.Items==null)return;foreach(var patch in fileData.Items){if(string.IsNullOrEmpty(patch.ID))continue;if(!result.TryGetValue(patch.ID,out var list)){list=new List<PropertyPatch>();result[patch.ID]=list;}list.Add(patch);}}}/// <summary> Static convenience accessor for <see cref="PatchResolver"/>, proxying through <see cref="ModManager.Instance.GetResolver{T}"/>. </summary>
public static class Patches{/// <summary>Collects the patches for one entity kind, grouped by target entity id.</summary>
public static Dictionary<string,List<PropertyPatch>>Collect(string entityType)=>ModManager.Instance.GetResolver<PatchResolver>()?.Collect(entityType);}}

===== FILE Core/Mods/Resolvers/Impls/ScenarioResolver.cs =====
using System.Collections.Generic;using System.IO;using System.Linq;using Cthangover.Core.Scenes;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods.Resolvers{/// <summary> Collects and resolves scenario script files (<c>.scenario</c>) from mods. Scenarios are Lua-like scripts that drive gameplay events on specific scenes. Each <c>.scenario</c> file contains YAML-like metadata headers followed by the script body. <see cref="CollectDefinitions"/> scans every mod's <c>scenarios/</c> folder recursively and groups scenario definitions by their target scene name. Resolution returns the script text for a given scenario ID, with <c>${include}</c> directives already expanded. A scenario is identified by its file name (completion history, <c>after</c> links, the studio). Adding content means a new name; a mod <b>replaces</b> another mod's scenario by shipping the same name — mods are read in load order (dependencies first), so the later one wins, the same rule as files and entities. The replacement may even move the scenario to another scene. Two mods not linked by <c>depends</c> that ship one name have no defined order; the resource atlas reports that. Overlays (<see cref="ModOverlays"/>) win over every mod: that is how an edit layer changes a scenario of a read-only zip mod. </summary>
public class ScenarioResolver:ModAssetResolver{/// <inheritdoc />
public override string Id=>"scenarios";private Dictionary<string,List<ScenarioDefinition>>_defCache;/// <summary> Collects all scenario definitions from every loaded mod, grouped by target scene name. Each definition contains the scenario name, scene, mod ID, file path, priority, dependencies, conditions, and other metadata parsed from the <c>.scenario</c> file header. </summary>
public Dictionary<string,List<ScenarioDefinition>>CollectDefinitions(){Context.Initialize();if(_defCache!=null)return _defCache;var result=new Dictionary<string,List<ScenarioDefinition>>();foreach(var overlay in ModOverlays.All)CollectScenarioDefsRecursive(overlay.Value,overlay.Key,"scenarios",result,null);var overridden=new HashSet<string>();foreach(var list in result.Values){foreach(var def in list)overridden.Add(def.Name);}foreach(var modId in Context.GetOrderedModIds()){var provider=Context.Mods[modId].FileProvider;CollectScenarioDefsRecursive(provider,modId,"scenarios",result,overridden);}DropReplaced(result);_defCache=result;GameLogger.Log("MODS",$"CollectScenarioDefinitions → {result.Count} scene(s) with scenarios");return result;}/// <summary> Keeps only the last definition of every scenario name, across all scenes: definitions are added in load order, so the last one comes from the mod loaded later — the one that depends on (and replaces) the earlier one. Scenes left without scenarios are removed. </summary>
public static void DropReplaced(Dictionary<string,List<ScenarioDefinition>>result){var winners=new Dictionary<string,ScenarioDefinition>();foreach(var list in result.Values){foreach(var def in list)winners[def.Name]=def;}foreach(var scene in result.Keys.ToList()){var list=result[scene];for(var i=list.Count-1;i>=0;i--){var winner=winners[list[i].Name];if(winner==list[i])continue;GameLogger.Log("MODS",$"scenario '{list[i].Name}' from mod '{list[i].ModId}' is replaced by mod '{winner.ModId}'");list.RemoveAt(i);}if(list.Count==0)result.Remove(scene);}}/// <summary> Resolves a scenario by its ID (name), returning the full script text with <c>${include}</c> directives expanded. Searches across all mods' scenario definitions. </summary>
public string Resolve(string scenarioId){if(string.IsNullOrEmpty(scenarioId))return null;var defs=CollectDefinitions();foreach(var list in defs.Values){foreach(var def in list){if(def.Name==scenarioId){var text=ReadText(def.ModId,def.FilePath);if(text==null){GameLogger.Log("MODS",$"ResolveScenario: '{scenarioId}' from mod '{def.ModId}' could not be read",LogLevel.Error);return null;}return text;}}}GameLogger.Log("MODS",$"ResolveScenario: '{scenarioId}' not found in any mod",LogLevel.Error);return null;}/// <summary> Resolves a scenario using an explicit provider override. Useful when the scenario must be read from an unpacked or temporary file source rather than the mod's built-in provider. </summary>
public string Resolve(string scenarioId,IModFileProvider provider){if(string.IsNullOrEmpty(scenarioId))return null;var defs=CollectScenarioDefsFromProvider(provider,"scenarios");foreach(var def in defs){if(def.Name==scenarioId){var text=Context.ReadResolvedText("__override__",def.FilePath,provider);if(text==null){GameLogger.Log("MODS",$"ResolveScenario: '{scenarioId}' could not be read from override provider",LogLevel.Error);return null;}return text;}}GameLogger.Log("MODS",$"ResolveScenario: '{scenarioId}' not found in override provider",LogLevel.Error);return null;}/// <summary> Reads a scenario file of a mod or of an overlay (by its id) with includes expanded; null when the file is missing. </summary>
public string ReadText(string modId,string path){if(ModOverlays.TryGet(modId,out var overlay))return Context.ReadResolvedText(modId,path,overlay);return Context.ReadResolvedText(modId,path);}/// <inheritdoc />
public override void ClearCache(){_defCache=null;}private static List<ScenarioDefinition>CollectScenarioDefsFromProvider(IModFileProvider provider,string dir){var result=new List<ScenarioDefinition>();CollectScenarioDefsFromProviderRecursive(provider,"__override__",dir,result);return result;}private static void CollectScenarioDefsFromProviderRecursive(IModFileProvider provider,string modId,string dir,List<ScenarioDefinition>result){foreach(var entry in provider.ListFiles(dir)){if(entry.EndsWith("/")){var subDir=entry.TrimEnd('/');CollectScenarioDefsFromProviderRecursive(provider,modId,subDir,result);continue;}if(!entry.EndsWith(".scenario",System.StringComparison.OrdinalIgnoreCase))continue;var text=provider.ReadFileText(entry);if(text==null)continue;var(metadata,_)=Scenarios.ScenarioMetadata.ParseMetadata(text);if(!metadata.TryGetValue("scene",out var sceneName)||string.IsNullOrEmpty(sceneName))continue;var name=Path.GetFileNameWithoutExtension(entry.Replace('\\','/').Split('/')[^1]);result.Add(new ScenarioDefinition{Name=name,Scene=sceneName,ModId=modId,FilePath=entry,});}}private void CollectScenarioDefsRecursive(IModFileProvider provider,string modId,string dir,Dictionary<string,List<ScenarioDefinition>>result,HashSet<string>overridden){foreach(var entry in provider.ListFiles(dir)){if(entry.EndsWith("/")){var subDir=entry.TrimEnd('/');CollectScenarioDefsRecursive(provider,modId,subDir,result,overridden);continue;}if(!entry.EndsWith(".scenario",System.StringComparison.OrdinalIgnoreCase))continue;var text=provider.ReadFileText(entry);if(text==null)continue;var(metadata,_)=Scenarios.ScenarioMetadata.ParseMetadata(text);if(!metadata.TryGetValue("scene",out var sceneName)||string.IsNullOrEmpty(sceneName))continue;var name=Path.GetFileNameWithoutExtension(entry.Replace('\\','/').Split('/')[^1]);if(overridden!=null&&overridden.Contains(name)){GameLogger.Log("MODS",$"scenario '{name}' from mod '{modId}' is overridden by an overlay");continue;}var def=new ScenarioDefinition{Name=name,Scene=sceneName,ModId=modId,FilePath=entry,Priority=metadata.TryGetValue("priority",out var p)&&int.TryParse(p,out var prio)?prio:0,After=metadata.TryGetValue("after",out var after)?after:null,Condition=metadata.TryGetValue("condition",out var cond)?cond:null,LightUseTime=metadata.TryGetValue("light_use_time",out var l)&&bool.TryParse(l,out var b)?b:null,SaveAllowed=metadata.TryGetValue("save_allowed",out var sa)&&bool.TryParse(sa,out var sab),IsOneRun=metadata.TryGetValue("is_one_run",out var ior)&&bool.TryParse(ior,out var iorb),};if(!result.TryGetValue(sceneName,out var list)){list=new List<ScenarioDefinition>();result[sceneName]=list;}list.Add(def);GameLogger.Log("MODS",$"scenario '{name}' -> scene '{sceneName}' from mod '{modId}' ({entry})");}}}/// <summary> Static convenience accessor for <see cref="ScenarioResolver"/>, proxying through <see cref="ModManager.Instance.GetResolver{T}"/>. </summary>
public static class ScenarioDefs{/// <summary>Collects scenario definitions from every mod, grouped by target scene name.</summary>
public static Dictionary<string,List<ScenarioDefinition>>CollectDefinitions()=>ModManager.Instance.GetResolver<ScenarioResolver>()?.CollectDefinitions();/// <summary>Returns the scenario script text with includes expanded, or null when unknown.</summary>
public static string Resolve(string id)=>ModManager.Instance.GetResolver<ScenarioResolver>()?.Resolve(id);/// <summary>Resolves through an explicit provider instead of the mod's own one.</summary>
public static string Resolve(string id,IModFileProvider provider)=>ModManager.Instance.GetResolver<ScenarioResolver>()?.Resolve(id,provider);/// <summary>Reads a scenario file of a mod or an overlay with includes expanded; null when missing.</summary>
public static string ReadText(string modId,string path)=>ModManager.Instance.GetResolver<ScenarioResolver>()?.ReadText(modId,path);/// <summary> Drops the collected definitions so edited scenario files (headers, new or removed files, overlay changes) are seen by the next scene composition. Running events keep the text they were built with. </summary>
public static void Invalidate()=>ModManager.Instance.GetResolver<ScenarioResolver>()?.ClearCache();}}

===== FILE Core/Mods/Resolvers/Impls/SceneDefinitionResolver.cs =====
using System.Collections.Generic;using System.Linq;using System.Text.Json;using Cthangover.Core.Bootstrap;using Cthangover.Core.Scenes;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods.Resolvers{/// <summary> Collects scene definitions from mods. A scene definition describes a playable scene — its name, default ambient, default scenario, and backgrounds. Mods can define scenes in <c>scenes/</c> via <c>.json</c> files. When multiple mods define the same scene name, the first mod's definition is the base, and subsequent mods merge their values on top — enabling mods to extend a base scene with additional backgrounds or change the default scenario. Overlays (<see cref="ModOverlays"/>) are read after the mods, so an overlay can add new scenes or extend existing ones the same way. </summary>
public class SceneDefinitionResolver:ModAssetResolver{/// <inheritdoc />
public override string Id=>"scene_definitions";private Dictionary<string,List<SceneDefinition>>_cache;/// <summary> Collects all scene definitions from every loaded mod. The first mod that defines a scene name becomes the base; subsequent mods merge extra backgrounds and override the default ambient and scenario via <c>MergeSceneDef</c>. Caches the result. </summary>
public Dictionary<string,List<SceneDefinition>>Collect(){Context.Initialize();if(_cache!=null)return _cache;var result=new Dictionary<string,List<SceneDefinition>>();var foundAny=false;foreach(var modId in Context.GetOrderedModIds()){var provider=Context.Mods[modId].FileProvider;CollectSceneDefsRecursive(provider,modId,"scenes",result,ref foundAny);}foreach(var overlay in ModOverlays.All)CollectSceneDefsRecursive(overlay.Value,overlay.Key,"scenes",result,ref foundAny);if(!foundAny){GameLogger.Log("MODS","CollectSceneDefinitions — no scene definitions found",LogLevel.Error);}_cache=result;GameLogger.Log("MODS",$"CollectSceneDefinitions → {result.Count} scene(s)");return result;}/// <inheritdoc />
public override void ClearCache(){_cache=null;}private void CollectSceneDefsRecursive(IModFileProvider provider,string modId,string dir,Dictionary<string,List<SceneDefinition>>result,ref bool foundAny){foreach(var entry in provider.ListFiles(dir)){if(entry.EndsWith("/")){var subDir=entry.TrimEnd('/');CollectSceneDefsRecursive(provider,modId,subDir,result,ref foundAny);continue;}if(!entry.EndsWith(".json",System.StringComparison.OrdinalIgnoreCase))continue;var text=Context.ReadResolvedText(modId,entry,provider);if(text==null)continue;try{var def=JsonSerializer.Deserialize<SceneDefinition>(text,JsonOptionsProvider.ReadOptions);if(def==null||string.IsNullOrEmpty(def.Name))continue;def.ModId=modId;foundAny=true;if(!result.TryGetValue(def.Name,out var list)){list=new List<SceneDefinition>();result[def.Name]=list;list.Add(def);}else{var baseDef=list[0];MergeSceneDef(baseDef,def);GameLogger.Log("MODS",$"scene '{def.Name}' from mod '{modId}' merged with base from '{baseDef.ModId}'");}GameLogger.Log("MODS",$"scene '{def.Name}' from mod '{modId}' ({entry})");}catch(JsonException ex){GameLogger.Log("MODS",$"JSON parse failed for scene '{modId}/{entry}': {ex.Message}",LogLevel.Error);}}}private static void MergeSceneDef(SceneDefinition baseDef,SceneDefinition incoming){if(!string.IsNullOrEmpty(incoming.DefaultAmbient))baseDef.DefaultAmbient=incoming.DefaultAmbient;if(!string.IsNullOrEmpty(incoming.DefaultScenario))baseDef.DefaultScenario=incoming.DefaultScenario;if(incoming.DefaultBackground?.Count>0){baseDef.DefaultBackground??=new List<string>();baseDef.DefaultBackground.AddRange(incoming.DefaultBackground);}}}/// <summary> Mod-derived registry of scene definitions. The collection itself is delegated to <see cref="SceneDefinitionResolver"/> through <see cref="ModManager.Instance.GetResolver{T}"/>; this class adds an instance-level cache so repeated collects do not re-resolve. <para/> The registry instance lives in <see cref="AppServices.Current"/> (AppServices.SceneDefs) and participates in the data-driven rebuild loop; the static members below are a facade delegating to it, so existing callers keep working unchanged. </summary>
public class SceneDefinitions:IGameRegistry{private Dictionary<string,List<SceneDefinition>>cache;/// <summary>Shared instance the static facade delegates to.</summary>
private static SceneDefinitions Owned=>AppServices.Current.SceneDefs;/// <summary> Creates an empty registry: cheap and side-effect free per the IGameRegistry contract. </summary>
internal SceneDefinitions(){}/// <inheritdoc />
public string Id=>"scene_defs";/// <summary> Re-collects all scene definitions from every loaded mod, dropping the cached result first so a hot-reloaded mod set is picked up. The resolver re-reads only if its own cache was flushed (ModManager.Reload does that before the registries are rebuilt). </summary>
public void Rebuild(){Clear();CollectAll();}/// <summary>Drops the cached collection; the next collect re-resolves.</summary>
public void Clear(){cache=null;}/// <summary> Collects all scene definitions from every loaded mod, caching the result. The first mod that defines a scene name becomes the base; subsequent mods merge their values on top. Returns null when no <see cref="SceneDefinitionResolver"/> is registered. </summary>
public Dictionary<string,List<SceneDefinition>>CollectAll(){if(cache!=null)return cache;var collected=ModManager.Instance.GetResolver<SceneDefinitionResolver>()?.Collect();if(collected==null)return null;cache=collected;return cache;}// ---- Static facade (legacy API, unchanged for callers) ------------ <summary>Legacy entry point; delegates to the shared instance.</summary>
public static Dictionary<string,List<SceneDefinition>>Collect()=>Owned.CollectAll();}}

===== FILE Core/Mods/Resolvers/Impls/ShaderResolver.cs =====
using System;using System.Collections.Concurrent;using System.Collections.Generic;using Cthangover.Core.Mods.Caches;using Cthangover.Core.Mods.Providers;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods.Resolvers{/// <summary> Collects and resolves <c>.gdshader</c> files from mods. Uses <see cref="FileListResolver"/> to get the raw list of shader files grouped by logical name, then resolves each one to a Godot <see cref="Shader"/> resource. For folder mods the shader is loaded directly via <c>GD.Load</c> from the filesystem path. For zip mods the shader is extracted to the mod cache first via <see cref="ModCacheManager.ExtractShaders"/>. Caching is two-layer: a resolved-cache for already-loaded <see cref="Shader"/> instances, and the disk-based mod cache for extracted files. </summary>
public class ShaderResolver:ModAssetResolver{/// <inheritdoc />
public override string Id=>"shaders";/// <summary> Collected shader file entries. The reference is written once after the build completes, hence <c>volatile</c> so other threads observe the fully-populated dictionary instead of a stale null. </summary>
private volatile Dictionary<string,FileEntry>_fileCache;/// <summary> Resolved Godot shaders, keyed by logical name. <see cref="ConcurrentDictionary"/> because <see cref="Resolve"/> and <see cref="ClearCache"/> may run from different threads; a plain dictionary corrupts under concurrent writes. </summary>
private readonly ConcurrentDictionary<string,Shader>_resolvedCache=new();/// <summary> Collects all shader file entries from mods via <see cref="FileListResolver"/>. Filters to <c>.gdshader</c> files only (excluding <c>.gdshaderinclude</c>). Caches the result so subsequent calls return the same dictionary. </summary>
public Dictionary<string,FileEntry>Collect(){Context.Initialize();if(_fileCache!=null)return _fileCache;var fileListResolver=Context.GetResolver<FileListResolver>();var raw=fileListResolver?.Collect("shaders")??new Dictionary<string,FileEntry>();var result=new Dictionary<string,FileEntry>();foreach(var kvp in raw){if(kvp.Key.EndsWith(".gdshader",StringComparison.OrdinalIgnoreCase)&& !kvp.Key.EndsWith(".gdshaderinclude",StringComparison.OrdinalIgnoreCase)){var shaderName=kvp.Key.Substring(0,kvp.Key.Length-".gdshader".Length);result[shaderName]=kvp.Value;}}_fileCache=result;GameLogger.Log("MODS",$"CollectShaders → {result.Count} shader(s)");return result;}/// <summary> Resolves a shader by logical name. Returns the Godot <see cref="Shader"/> resource, loading from disk cache or extracting from the mod provider as needed. </summary>
public Shader Resolve(string shaderName){var cached=TryGetCached(shaderName);if(cached!=null)return cached;if(ModCacheManager.IsShaderCached(shaderName)){var cachedPath=ModCacheManager.GetShaderCachePath(shaderName);var cachedShader=GD.Load<Shader>(cachedPath);if(cachedShader!=null){_resolvedCache[shaderName]=cachedShader;return cachedShader;}}var shaders=Collect();if(!shaders.TryGetValue(shaderName,out var entry)){GameLogger.Log("MODS",$"ResolveShader: '{shaderName}' not found in any mod",LogLevel.Error);return null;}var mod=Context.GetMod(entry.ModId);if(mod==null){GameLogger.Log("MODS",$"ResolveShader: owning mod '{entry.ModId}' of '{shaderName}' is not loaded",LogLevel.Error);return null;}if(mod.FileProvider is FolderModFileProvider){var fsPath=mod.FileProvider.GetFileSystemPath(entry.FullPath);if(fsPath==null){GameLogger.Log("MODS",$"ResolveShader: '{shaderName}' file '{entry.FullPath}' missing on disk in mod '{entry.ModId}'",LogLevel.Error);return null;}var shader=GD.Load<Shader>(fsPath);if(shader!=null)_resolvedCache[shaderName]=shader;return shader;}ModCacheManager.ExtractShaders(entry.ModId,mod.FileProvider);if(ModCacheManager.IsShaderCached(shaderName)){var extractedPath=ModCacheManager.GetShaderCachePath(shaderName);var extractedShader=GD.Load<Shader>(extractedPath);if(extractedShader!=null)_resolvedCache[shaderName]=extractedShader;return extractedShader;}GameLogger.Log("MODS",$"ResolveShader: '{shaderName}' failed to extract from mod '{entry.ModId}'",LogLevel.Error);return null;}/// <summary> Returns a previously resolved shader from the in-memory cache, validated with <c>GodotObject.IsInstanceValid</c>. Returns <c>null</c> if not cached or the cached instance was freed. </summary>
public Shader TryGetCached(string shaderName){if(_resolvedCache.TryGetValue(shaderName,out var cached)&&GodotObject.IsInstanceValid(cached))return cached;return null;}/// <inheritdoc />
public override void ClearCache(){_fileCache=null;_resolvedCache.Clear();}}/// <summary> Static convenience accessor for <see cref="ShaderResolver"/>, proxying through <see cref="ModManager.Instance.GetResolver{T}"/>. </summary>
public static class Shaders{/// <summary>Collects the .gdshader file entries of all mods, keyed by logical name.</summary>
public static Dictionary<string,FileEntry>Collect()=>ModManager.Instance.GetResolver<ShaderResolver>()?.Collect();/// <summary>Loads (and caches) the named shader, extracting zip mods to the cache as needed.</summary>
public static Shader Resolve(string name)=>ModManager.Instance.GetResolver<ShaderResolver>()?.Resolve(name);/// <summary>Returns the already-resolved shader instance, or null when not cached.</summary>
public static Shader TryGetCached(string name)=>ModManager.Instance.GetResolver<ShaderResolver>()?.TryGetCached(name);}}

===== FILE Core/Mods/Resolvers/Impls/TextureResolver.cs =====
using System;using System.Collections.Concurrent;using System.Collections.Generic;using System.IO;using Cthangover.Core.Bootstrap;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods.Resolvers{/// <summary> Collects and resolves texture files (PNG, JPG, WebP) from mods. Textures are grouped into logical directories defined by <see cref="ModConfig.TextureGroups"/> (e.g. "textures/portraits", "textures/backgrounds"). Files are collected via <see cref="FileListResolver"/>, filtered by the configured texture extensions, and deduplicated — later mods in load order override earlier ones with the same logical name. Resolution decodes the raw image bytes into a Godot <see cref="ImageTexture"/> and caches the result in memory. </summary>
public class TextureResolver:ModAssetResolver{/// <inheritdoc />
public override string Id=>"textures";/// <summary> Collected file entries. The reference is written once after the build completes, hence <c>volatile</c> so other threads observe the fully-populated dictionary instead of a stale null. </summary>
private volatile Dictionary<string,FileEntry>_fileCache;/// <summary> Resolved Godot textures, keyed by logical name. <see cref="ConcurrentDictionary"/> because <see cref="Resolve"/> and <see cref="ClearCache"/> may run from different threads; a plain dictionary corrupts under concurrent writes. </summary>
private readonly ConcurrentDictionary<string,Texture2D>_resolvedCache=new();/// <summary> Collects all texture file entries from every configured texture group directory. Deduplicates by logical name (filename without extension) — later mods in load order win. Caches the result for subsequent calls. </summary>
public Dictionary<string,FileEntry>Collect(){Context.Initialize();if(_fileCache!=null)return _fileCache;var result=new Dictionary<string,FileEntry>();var textureGroups=ModConfig.Instance.TextureGroups;var textureExts=ModConfig.Instance.GetTextureExtensionSet();var fileListResolver=Context.GetResolver<FileListResolver>();if(fileListResolver==null){_fileCache=result;return result;}foreach(var group in textureGroups){var files=fileListResolver.Collect(group);foreach(var kvp in files){var ext=Path.GetExtension(kvp.Key);if(textureExts.Contains(ext)){var textureName=Path.GetFileNameWithoutExtension(kvp.Key);if(result.ContainsKey(textureName)){GameLogger.Log("MODS",$"texture conflict: '{textureName}' from mod '{kvp.Value.ModId}' overrides previous",LogLevel.Warning);AppServices.Current.Conflicts.RecordConflict("texture",textureName,kvp.Value.ModId,result[textureName].ModId,false);}result[textureName]=kvp.Value;}}}_fileCache=result;GameLogger.Log("MODS",$"CollectTextures → {result.Count} texture(s)");return result;}/// <summary> Resolves a texture by logical name, decoding the raw image bytes into a Godot <see cref="ImageTexture"/>. Supports PNG, JPG, and WebP formats. Returns <c>null</c> on decode failure or if the texture is not found in any mod. <see cref="Collect"/> keys textures by basename only, so an ID with a directory prefix (e.g. <c>"prolog/cloak_ground"</c>) would miss on the exact lookup. As a fallback the ID is also matched against the full relative path without extension and against the basename, letting callers address textures the same way the editor's <c>BackgroundFactory</c> does. </summary>
public Texture2D Resolve(string textureName){var cached=TryGetCached(textureName);if(cached!=null)return cached;var textures=Collect();if(!textures.TryGetValue(textureName,out var entry)){entry=FindByPathOrBasename(textures,textureName);if(entry.ModId==null){GameLogger.Log("MODS",$"ResolveTexture: '{textureName}' not found in any mod",LogLevel.Error);return null;}}var data=Context.ReadFileBinary(entry.ModId,entry.FullPath);if(data==null){GameLogger.Log("MODS",$"ResolveTexture: '{textureName}' could not be read from mod '{entry.ModId}'",LogLevel.Error);return null;}var ext=Path.GetExtension(entry.FullPath)?.ToLowerInvariant();var image=new Image();Error loadError;if(ext==".png")loadError=image.LoadPngFromBuffer(data);else if(ext==".jpg"||ext==".jpeg")loadError=image.LoadJpgFromBuffer(data);else if(ext==".webp")loadError=image.LoadWebpFromBuffer(data);else{GameLogger.Log("MODS",$"ResolveTexture: unsupported extension '{ext}' for '{textureName}'",LogLevel.Error);return null;}if(loadError!=Error.Ok){GameLogger.Log("MODS",$"ResolveTexture: image decode failed for '{textureName}' ({loadError})",LogLevel.Error);return null;}var texture=ImageTexture.CreateFromImage(image);image.Dispose();if(texture!=null)_resolvedCache[textureName]=texture;return texture;}/// <summary> Locates a texture entry when the exact basename lookup misses. First tries the full relative path without extension (so <c>"prolog/cloak_ground"</c> matches <c>"prolog/cloak_ground.png"</c>), then falls back to the bare filename (so a flat <c>"lamp"</c> ID keeps working regardless of which texture group directory it lives in). Matching is case-insensitive. Returns a default <see cref="FileEntry"/> when nothing matches. </summary>
private static FileEntry FindByPathOrBasename(Dictionary<string,FileEntry>textures,string textureName){var normalized=textureName.Replace('\\','/');foreach(var kvp in textures){var pathNoExt=Path.ChangeExtension(kvp.Key,null)?.TrimEnd('.');if(string.Equals(pathNoExt,normalized,StringComparison.OrdinalIgnoreCase))return kvp.Value;}foreach(var kvp in textures){var baseName=Path.GetFileNameWithoutExtension(kvp.Key);if(string.Equals(baseName,textureName,StringComparison.OrdinalIgnoreCase))return kvp.Value;}return default;}/// <summary> Returns a previously resolved texture from the in-memory cache, validated with <c>GodotObject.IsInstanceValid</c>. </summary>
public Texture2D TryGetCached(string textureName){if(_resolvedCache.TryGetValue(textureName,out var cached)&&GodotObject.IsInstanceValid(cached))return cached;return null;}/// <inheritdoc />
public override void ClearCache(){_fileCache=null;_resolvedCache.Clear();}}/// <summary> Static convenience accessor for <see cref="TextureResolver"/>, proxying through <see cref="ModManager.Instance.GetResolver{T}"/>. </summary>
public static class Textures{/// <summary>Collects texture file entries from every configured texture group, keyed by basename.</summary>
public static Dictionary<string,FileEntry>Collect()=>ModManager.Instance.GetResolver<TextureResolver>()?.Collect();/// <summary>Decodes and caches the named texture; null on miss or decode failure.</summary>
public static Texture2D Resolve(string name)=>ModManager.Instance.GetResolver<TextureResolver>()?.Resolve(name);/// <summary>Returns the already-decoded texture instance, or null when not cached.</summary>
public static Texture2D TryGetCached(string name)=>ModManager.Instance.GetResolver<TextureResolver>()?.TryGetCached(name);}}

===== FILE Core/Mods/Resolvers/Impls/WrapperTemplateResolver.cs =====
using System.Collections.Generic;using System.IO;namespace Cthangover.Core.Mods.Resolvers{/// <summary> Collects wrapper template files (<c>.wrappertmpl</c>) from mods. Wrappers are text templates that define how entities are rendered or serialised — they are collected from <c>wrappers/</c> directories and resolved by name. Each wrapper definition contains the raw template content and the mod that provided it. </summary>
public class WrapperTemplateResolver:ModAssetResolver{/// <inheritdoc />
public override string Id=>"wrappers";private List<WrapperDefinition>_cache;/// <summary> Collects all wrapper template definitions from every loaded mod. Templates are read from <c>wrappers/</c> directories and identified by filename without extension. Caches the result. </summary>
public List<WrapperDefinition>Collect(){Context.Initialize();if(_cache!=null)return _cache;var result=new List<WrapperDefinition>();foreach(var modId in Context.GetOrderedModIds()){var provider=Context.Mods[modId].FileProvider;CollectWrapperDefsRecursive(provider,modId,"wrappers",result);}_cache=result;return result;}/// <inheritdoc />
public override void ClearCache(){_cache=null;}private static void CollectWrapperDefsRecursive(IModFileProvider provider,string modId,string dir,List<WrapperDefinition>result){foreach(var entry in provider.ListFiles(dir)){if(entry.EndsWith("/")){var subDir=entry.TrimEnd('/');CollectWrapperDefsRecursive(provider,modId,subDir,result);continue;}if(!entry.EndsWith(".wrappertmpl",System.StringComparison.OrdinalIgnoreCase))continue;var text=provider.ReadFileText(entry);if(text==null)continue;var name=Path.GetFileNameWithoutExtension(entry.Replace('\\','/').Split('/')[^1]);result.Add(new WrapperDefinition{ModId=modId,Name=name,Content=text});}}}/// <summary> Static convenience accessor for <see cref="WrapperTemplateResolver"/>, proxying through <see cref="ModManager.Instance.GetResolver{T}"/>. </summary>
public static class WrapperTemplates{/// <summary>Collects every .wrappertmpl template from all mods, keyed by template name.</summary>
public static List<WrapperDefinition>Collect()=>ModManager.Instance.GetResolver<WrapperTemplateResolver>()?.Collect();}}

===== FILE Core/Mods/Resolvers/ModAssetResolver.cs =====
namespace Cthangover.Core.Mods.Resolvers{/// <summary> Convenience base class for <see cref="IModAssetResolver"/> implementations. Stores the <see cref="ModResolutionContext"/> reference provided by <see cref="Initialize"/> so that derived classes can access the mod registry and cross-resolver lookup without manual plumbing. </summary>
public abstract class ModAssetResolver:IModAssetResolver{/// <summary> Unique identifier for this resolver. Must be overridden by derived classes (e.g. "textures", "shaders", "scenarios"). </summary>
public abstract string Id{get;}/// <summary> The <see cref="ModResolutionContext"/> provided during <see cref="Initialize"/>. Gives access to the mod registry, file I/O helpers, and cross-resolver lookup via <see cref="ModResolutionContext.GetResolver{T}"/>. </summary>
protected ModResolutionContext Context{get;private set;}/// <summary> Stores the context reference for use by derived classes. Override to add custom initialisation logic — always call <c>base.Initialize(context)</c> first. </summary>
public virtual void Initialize(ModResolutionContext context){Context=context;}/// <summary> Clears all cached data. Must be overridden by derived classes to reset their collection caches so the next collection picks up changes from newly loaded or unloaded mods. </summary>
public abstract void ClearCache();}}

===== FILE Core/Mods/Resolvers/ModResolutionContext.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text.Json;using System.Text.RegularExpressions;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods.Resolvers{/// <summary> Provides resolvers with a unified API for accessing the mod registry, reading files from mod providers, resolving JSON with <c>${include}</c> directives, and looking up other resolvers. Created internally by <see cref="ModManager"/> and injected into each <see cref="IModAssetResolver"/> via <see cref="IModAssetResolver.Initialize"/>. All file I/O methods route through the mod's <see cref="IModFileProvider"/>, which means they work identically for folder mods and zip mods — the resolver never branches on storage format. </summary>
public class ModResolutionContext{private readonly ModRegistry _registry;private Dictionary<string,IModAssetResolver>_resolvers;internal ModResolutionContext(ModRegistry registry){_registry=registry;}internal void SetResolvers(Dictionary<string,IModAssetResolver>resolvers){_resolvers=resolvers;}/// <summary>Read-only snapshot of currently loaded mods.</summary>
public IReadOnlyDictionary<string,IModInfo>Mods=>_registry.Mods;/// <summary>Whether the registry has completed initialisation.</summary>
public bool IsInitialized=>_registry.IsInitialized;/// <summary>Idempotent bootstrap of the mod registry.</summary>
public void Initialize(){_registry.Initialize();}/// <summary>Discards all loaded mods and re-runs discovery.</summary>
public void Reload(){_registry.Reload();}/// <summary>Returns mod IDs in dependency-respecting order.</summary>
public IReadOnlyList<string>GetOrderedModIds(bool filterDisabled=true)=>_registry.GetOrderedModIds(filterDisabled);/// <summary>Looks up a mod by ID from the registry.</summary>
public IModInfo GetMod(string id){return _registry.GetMod(id);}/// <summary> Checks whether the given mod-relative path exists inside the specified mod's provider. </summary>
public bool FileExists(string modId,string path){var mod=GetMod(modId);return mod?.FileProvider?.FileExists(path)??false;}/// <summary> Lists files and directories at the given mod-relative path inside the specified mod. Directories are suffixed with <c>/</c>. </summary>
public IEnumerable<string>ListFiles(string modId,string directory=""){var mod=GetMod(modId);if(mod==null)return Enumerable.Empty<string>();return mod.FileProvider.ListFiles(directory);}/// <summary>Reads a text file from the specified mod's provider.</summary>
public string ReadFileText(string modId,string path){var mod=GetMod(modId);if(mod==null)return null;return mod.FileProvider.ReadFileText(path);}/// <summary>Reads a binary file from the specified mod's provider.</summary>
public byte[]ReadFileBinary(string modId,string path){var mod=GetMod(modId);if(mod==null)return null;return mod.FileProvider.ReadFileBinary(path);}/// <summary> Opens a read-only stream to a file inside the specified mod's provider. The caller must dispose the stream. </summary>
public Stream OpenStream(string modId,string path){var mod=GetMod(modId);if(mod==null)return null;return mod.FileProvider.OpenStream(path);}/// <summary> Returns the absolute filesystem path for a mod-relative path, or <c>null</c> for zip mods (which cannot provide filesystem access). </summary>
public string GetFileSystemPath(string modId,string path){var mod=GetMod(modId);if(mod==null)return null;return mod.FileProvider.GetFileSystemPath(path);}/// <summary> Reads a text file and recursively resolves all <c>${ path/to/include }</c> directives within it, returning the fully-expanded text. Circular includes are detected and reported as errors. </summary>
public string ReadResolvedText(string modId,string path){var mod=GetMod(modId);if(mod==null)return null;var content=mod.FileProvider.ReadFileText(path);if(content==null)return null;try{var visited=new HashSet<string>(StringComparer.OrdinalIgnoreCase){path};return ExpandModIncludes(mod.FileProvider,content,path,visited);}catch(Exception ex){GameLogger.Log("MODS",$"Include resolution failed for '{modId}/{path}': {ex.Message}",LogLevel.Error);return null;}}/// <summary> Overload of <see cref="ReadResolvedText(string, string)"/> that uses an explicit provider instead of the mod's built-in one. Useful for resolving includes from unpacked or temporary files. </summary>
public string ReadResolvedText(string modId,string path,IModFileProvider providerOverride){var content=providerOverride.ReadFileText(path);if(content==null)return null;try{var visited=new HashSet<string>(StringComparer.OrdinalIgnoreCase){path};return ExpandModIncludes(providerOverride,content,path,visited);}catch(Exception ex){GameLogger.Log("MODS",$"Include resolution failed for '{modId}/{path}': {ex.Message}",LogLevel.Error);return null;}}/// <summary> Reads a JSON file with include resolution and deserialises it to the requested type. Returns <c>null</c> on any error. </summary>
public T ReadJson<T>(string modId,string path)where T:class{var text=ReadResolvedText(modId,path);if(text==null)return null;try{return JsonSerializer.Deserialize<T>(text,JsonOptionsProvider.ReadOptions);}catch(JsonException ex){GameLogger.Log("MODS",$"JSON deserialize failed for '{modId}/{path}': {ex.Message}",LogLevel.Error);return null;}}/// <summary> Returns a registered resolver of the specified type, or <c>null</c> if not found. Used for cross-resolver dependencies — e.g. <see cref="ShaderResolver"/> looks up <see cref="FileListResolver"/> to get the raw file list. </summary>
internal T GetResolver<T>()where T:class,IModAssetResolver{if(_resolvers==null)return null;return _resolvers.Values.OfType<T>().FirstOrDefault();}private static readonly Regex _includePattern=new Regex(@"\$\s*\{\s*([^{}]+)\s*\}",RegexOptions.Compiled);/// <summary> Recursively processes <c>${ path }</c> include directives in a text file, replacing each directive with the content of the referenced file. Detects circular includes and missing files. Shared statically so that it can be used in tests without a full <see cref="ModResolutionContext"/>. </summary>
public static string ExpandModIncludes(IModFileProvider provider,string content,string currentPath,HashSet<string>visited){return _includePattern.Replace(content,match=>{var includePath=match.Groups[1].Value.Trim();if(string.IsNullOrEmpty(includePath))throw new InvalidOperationException("Empty include path in ${}.");var resolvedPath=provider.ResolveIncludePath(includePath,currentPath);var loaded=provider.ReadFileText(resolvedPath);if(loaded==null)loaded=provider.ReadFileText(resolvedPath+".json");if(loaded==null)throw new InvalidOperationException($"Include not found: '{resolvedPath}' (from '${{{includePath}}}').");if(visited.Contains(resolvedPath))throw new InvalidOperationException($"Circular include detected: {resolvedPath}");visited.Add(resolvedPath);try{var expanded=ExpandModIncludes(provider,loaded,resolvedPath,visited);return expanded.Trim();}finally{visited.Remove(resolvedPath);}});}}}

===== FILE Core/Mods/ScriptTypeRegistry.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Reflection;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods{/// <summary> Maps script file paths and class names to compiled C# <see cref="System.Type"/> objects across all loaded mods, respecting mod load-order priority. <para/> Populated by <see cref="ModCompiler"/> after each mod's C# source is compiled. Each mod contributes a {path → Type} and {name → Type} mapping. <para/> Resolution order: 1. <see cref="ResolveByPath"/> — iterates mods in priority order, returns first match 2. <see cref="ResolveByName"/> — same priority-ordered search by class name / full name 3. <see cref="Resolve"/> — tries path first, falls back to file-name → class-name lookup <para/> This is the bridge between a .tscn's <c>path="scripts/MyScript.cs"</c> reference and the actual <c>Type</c> in the compiled mod assembly. Paths are normalized (stripped of <c>res://</c> prefix, backslashes → forward slashes) before lookup. </summary>
public static class ScriptTypeRegistry{private static readonly Dictionary<string,Dictionary<string,Type>>_modPathToType=new();private static readonly Dictionary<string,Dictionary<string,Type>>_modNameToType=new();private static readonly List<Type>_allTypes=new();private static List<(string ModId,int Priority)>_orderedModIds;static ScriptTypeRegistry(){RegisterCoreAssembly(typeof(ScriptTypeRegistry).Assembly);}/// <summary> Registers the main game assembly's Node types as the lowest-priority fallback. Mod-provided scenes reference core behaviours by their <c>res://Core/...cs</c> path; <see cref="Resolve"/> has no path entry for them, so it falls through to <see cref="ResolveByName"/>, which needs these types. Mod assemblies registered later still win because their name maps are consulted before the fallback list. </summary>
public static void RegisterCoreAssembly(Assembly assembly){if(assembly==null)return;try{foreach(var type in assembly.GetTypes()){if(!type.IsClass||type.IsAbstract|| !typeof(Godot.Node).IsAssignableFrom(type))continue;if(!_allTypes.Contains(type))_allTypes.Add(type);}}catch(Exception ex){GameLogger.Log("MODS",$"ScriptTypeRegistry: failed to scan core assembly: {ex.Message}",LogLevel.Error);}}/// <summary>Sets the mod priority order (id + load position) used by all lookups; supplied by <see cref="ModRegistry"/> after load order is computed.</summary>
public static void SetModOrder(List<(string ModId,int Priority)>orderedMods){_orderedModIds=orderedMods;}/// <summary> Indexes a freshly compiled mod assembly: adds its non-abstract <see cref="Godot.Node"/> subclasses to the name maps and, for every source file whose file name matches a class, records the path-to-type mapping used by .tscn script resolution. </summary>
public static void RegisterAssembly(Assembly assembly,string modId,IEnumerable<(string FilePath,string Source)>sourceFiles){if(!_modPathToType.ContainsKey(modId)){_modPathToType[modId]=new Dictionary<string,Type>(StringComparer.OrdinalIgnoreCase);_modNameToType[modId]=new Dictionary<string,Type>(StringComparer.OrdinalIgnoreCase);}var pathDict=_modPathToType[modId];var nameDict=_modNameToType[modId];var types=assembly.GetTypes();foreach(var type in types){if(!type.IsClass||type.IsAbstract)continue;if(!typeof(Godot.Node).IsAssignableFrom(type))continue;_allTypes.Add(type);var typeName=type.Name;if(!nameDict.ContainsKey(typeName))nameDict[typeName]=type;var fullName=type.FullName;if(!nameDict.ContainsKey(fullName))nameDict[fullName]=type;}foreach(var(filePath,_)in sourceFiles){var relative=NormalizePath(filePath);var fileName=System.IO.Path.GetFileNameWithoutExtension(relative);if(nameDict.TryGetValue(fileName,out var t)){if(!pathDict.ContainsKey(relative))pathDict[relative]=t;}else{foreach(var type in types){if(!type.IsClass||type.IsAbstract|| !typeof(Godot.Node).IsAssignableFrom(type))continue;if(type.Name.Equals(fileName,StringComparison.OrdinalIgnoreCase)){if(!pathDict.ContainsKey(relative))pathDict[relative]=type;break;}}}}}/// <summary>Priority-ordered lookup by normalized script path; first matching mod wins, then registration order.</summary>
public static Type ResolveByPath(string tscnPath){var normalized=NormalizePath(tscnPath);if(_orderedModIds!=null){foreach(var(modId,_)in _orderedModIds){if(_modPathToType.TryGetValue(modId,out var pathDict)&&pathDict.TryGetValue(normalized,out var type))return type;}}foreach(var kvp in _modPathToType){if(kvp.Value.TryGetValue(normalized,out var type))return type;}return null;}/// <summary>Priority-ordered lookup by class name or full name, with a case-insensitive fallback scan over all indexed types.</summary>
public static Type ResolveByName(string className){if(_orderedModIds!=null){foreach(var(modId,_)in _orderedModIds){if(_modNameToType.TryGetValue(modId,out var nameDict)&&nameDict.TryGetValue(className,out var type))return type;}}foreach(var kvp in _modNameToType){if(kvp.Value.TryGetValue(className,out var type))return type;}return _allTypes.FirstOrDefault(t=>t.Name.Equals(className,StringComparison.OrdinalIgnoreCase)||t.FullName.Equals(className,StringComparison.OrdinalIgnoreCase));}/// <summary> Combined resolver for a .tscn script reference: input without separators or a dot is treated as a class name; otherwise the path is tried first and the bare file name second (as a class name). </summary>
public static Type Resolve(string tscnPath){if(string.IsNullOrEmpty(tscnPath))return null;if(!tscnPath.Contains("/")&& !tscnPath.Contains("\\")&& !tscnPath.Contains("."))return ResolveByName(tscnPath);var result=ResolveByPath(tscnPath);if(result!=null)return result;var fileName=System.IO.Path.GetFileNameWithoutExtension(tscnPath);if(!string.IsNullOrEmpty(fileName))return ResolveByName(fileName);return null;}/// <summary> Forgets one mod's node types (a mod unload or re-registration during an in-process reload); the other mods and the kernel types stay. </summary>
public static void RemoveMod(string modId){if(string.IsNullOrEmpty(modId))return;if(_modNameToType.TryGetValue(modId,out var nameDict)){var types=new HashSet<Type>(nameDict.Values);_allTypes.RemoveAll(types.Contains);}_modPathToType.Remove(modId);_modNameToType.Remove(modId);}/// <summary>Wipes every mapping and re-registers the core assembly fallback; called on mod reload.</summary>
public static void Clear(){_modPathToType.Clear();_modNameToType.Clear();_allTypes.Clear();_orderedModIds=null;RegisterCoreAssembly(typeof(ScriptTypeRegistry).Assembly);}private static string NormalizePath(string path){if(string.IsNullOrEmpty(path))return path;if(path.StartsWith("res://",StringComparison.OrdinalIgnoreCase))path=path.Substring(6);return ModPath.Normalize(path);}}}

===== FILE Core/Mods/Testing/ModAssert.cs =====
using System;using System.Globalization;namespace Cthangover.Core.Mods.Testing{/// <summary> Assertion failure of a mod unit test. The runner reports PASS/FAIL purely by catching this exception (plus any other exception, which is reported as Failed with the original message), so no external test framework is pulled into the game runtime. </summary>
public class ModAssertException:Exception{/// <summary>Creates a failure with a human-readable description.</summary>
public ModAssertException(string message):base(message){}}/// <summary> Minimal assertion API for mod unit tests. Every failure throws <see cref="ModAssertException"/> with a message that already names the expectation, so a failing report line reads without opening the source (the "understandable errors" step of the debug ladder, §8.2). </summary>
public static class ModAssert{/// <summary>Asserts two strings are equal.</summary>
public static void Equal(string expected,string actual,string what=null){if(!string.Equals(expected,actual,StringComparison.Ordinal))throw new ModAssertException(Msg(what,$"expected '{expected}', actual '{actual}'"));}/// <summary>Asserts two ints are equal.</summary>
public static void Equal(long expected,long actual,string what=null){if(expected!=actual)throw new ModAssertException(Msg(what,$"expected {expected}, actual {actual}"));}/// <summary>Asserts two floats are equal within <paramref name="epsilon"/>.</summary>
public static void Equal(float expected,float actual,float epsilon=1e-4f,string what=null){if(Math.Abs(expected-actual)>epsilon)throw new ModAssertException(Msg(what,$"expected {expected.ToString(CultureInfo.InvariantCulture)}, actual {actual.ToString(CultureInfo.InvariantCulture)} (±{epsilon})"));}/// <summary>Asserts two doubles are equal within <paramref name="epsilon"/> (CloseTo).</summary>
public static void CloseTo(double expected,double actual,double epsilon=1e-4,string what=null){if(double.IsNaN(expected)||double.IsNaN(actual)||Math.Abs(expected-actual)>epsilon)throw new ModAssertException(Msg(what,$"expected {expected.ToString(CultureInfo.InvariantCulture)} ±{epsilon}, actual {actual.ToString(CultureInfo.InvariantCulture)}"));}/// <summary>Asserts the condition is true.</summary>
public static void True(bool condition,string what=null){if(!condition)throw new ModAssertException(Msg(what,"expected true"));}/// <summary>Asserts the condition is false.</summary>
public static void False(bool condition,string what=null){if(condition)throw new ModAssertException(Msg(what,"expected false"));}/// <summary>Asserts the value is null.</summary>
public static void Null(object value,string what=null){if(value!=null)throw new ModAssertException(Msg(what,$"expected null, actual '{value}'"));}/// <summary>Asserts the value is not null.</summary>
public static void NotNull(object value,string what=null){if(value==null)throw new ModAssertException(Msg(what??"value","is null"));}/// <summary> Asserts the action throws <see cref="ModAssertException"/> (or any exception when <paramref name="anyException"/>). Fails when nothing is thrown. </summary>
public static void Throws(Action action,string what=null,bool anyException=false){try{action();}catch(ModAssertException){return;}catch(Exception ex)when(anyException){_=ex;return;}throw new ModAssertException(Msg(what,"expected an exception, but none was thrown"));}private static string Msg(string what,string detail){return string.IsNullOrEmpty(what)?$"ModAssert failed: {detail}":$"ModAssert failed [{what}]: {detail}";}}}

===== FILE Core/Mods/Testing/ModTestAttribute.cs =====
using System;namespace Cthangover.Core.Mods.Testing{/// <summary> Marks a static method inside a mod's <c>test_sources</c> as a mod unit test. Tests are discovered by <see cref="ModTestDiscovery"/> through reflection in the separately compiled <c>{modId}__tests</c> assembly — the production pipeline never compiles or loads them (see the "tests are off the player's path" contract in plan-mod-testing.md §3.1). The method must be static, take a single <see cref="ModTestContext"/> parameter and return either <c>void</c> or <c>Task</c>. The test name comes from the attribute; failures are reported by throwing — typically via <see cref="ModAssert"/>. </summary>
[AttributeUsage(AttributeTargets.Method,AllowMultiple=false)]public sealed class ModTestAttribute:Attribute{/// <summary>Test name used in reports; conventionally "group/case".</summary>
public string Name{get;}/// <summary> Per-test timeout in seconds for async tests. A synchronous test cannot be aborted in-process, so the timeout applies to the returned <c>Task</c> only; hangs in sync tests are contained by the per-process isolation of the CI fan-out. </summary>
public int TimeoutSeconds{get;set;}=30;/// <summary>Optional skip reason; when non-empty the test is reported as Skipped without running.</summary>
public string Skip{get;set;}/// <summary>Declares a test with the given report name.</summary>
public ModTestAttribute(string name){Name=name;}}}

===== FILE Core/Mods/Testing/ModTestContext.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Text.Json;using System.Threading;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods.Testing{/// <summary> Everything a mod unit test may touch, passed to every <c>[ModTest]</c> method. Provides mod-file access through the same <see cref="ModManager"/> pipeline production code uses, a log sink that lands both in GameLogger and in the report entry, a cancellation token and two sandboxed scratch areas: <para><see cref="SandboxDir"/> — a filesystem directory under <c>user://mod_test_sandbox/{modId}</c>, wiped before the suite starts (not after — a failing test keeps its artifacts for debugging) and listed as the report's artifacts.</para> <para><see cref="SandboxRoot"/> — a detached Godot <see cref="Node"/> for tests that exercise Node-dependent logic; the runner frees it after each test so a leaked child cannot outlive the test.</para> The context makes the correct path the most convenient one: the engine cannot intercept arbitrary file IO (honest residual zone §6.5), so tests are encouraged to write only here. </summary>
public sealed class ModTestContext{private readonly List<string>logBuffer;private readonly string sandboxDir;/// <summary>Creates a context for one test execution. Internal: tests receive it, never construct it.</summary>
internal ModTestContext(string modId,List<string>logBuffer,string sandboxDir,Node sandboxRoot,CancellationToken cancellationToken){ModId=modId;this.logBuffer=logBuffer;this.sandboxDir=sandboxDir;SandboxRoot=sandboxRoot;CancellationToken=cancellationToken;}/// <summary>Id of the mod under test; also the assembly the test code was compiled from.</summary>
public string ModId{get;}/// <summary>Cooperative cancellation — set when the per-test timeout elapses or the whole run is aborted.</summary>
public CancellationToken CancellationToken{get;}/// <summary>Detached Godot node for Node-dependent test logic; freed by the engine after the test.</summary>
public Node SandboxRoot{get;}/// <summary> Absolute path of the per-mod sandbox directory, created on first access. Wiped before the suite, kept after the run for artifacts. </summary>
public string SandboxDir{get{Directory.CreateDirectory(sandboxDir);return sandboxDir;}}/// <summary>Reads a UTF-8 text file from the mod, or null when missing.</summary>
public string ReadFileText(string path)=>ModManager.Instance.ReadFileText(ModId,path);/// <summary> Reads a mod JSON file and deserializes it. Throws on parse failure rather than returning null — a mod-data error is a test failure, not a silent pass. </summary>
public T ReadJson<T>(string path)where T:class{var text=ReadFileText(path);ModAssert.NotNull(text,$"mod file '{path}'");return JsonSerializer.Deserialize<T>(text,JsonOptionsProvider.ReadOptions)??throw new ModAssertException($"mod file '{path}' deserialized to null");}/// <summary>Lists the mod's files in <paramref name="directory"/> (entries ending with '/' are subdirectories).</summary>
public IEnumerable<string>ListFiles(string directory="")=>ModManager.Instance.ListFiles(ModId,directory);/// <summary> Logs a line into the report entry of the current test and into GameLogger (category MODTEST) — visible in stdout and the log file. </summary>
public void Log(string message){lock(logBuffer)logBuffer.Add(message);GameLogger.Log("MODTEST",$"[{ModId}] {message}");}}}

===== FILE Core/Mods/Testing/ModTestDiscovery.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Reflection;namespace Cthangover.Core.Mods.Testing{/// <summary>A discovered [ModTest] method bound to its declaring class for instantiation-free invocation.</summary>
public sealed class DiscoveredModTest{/// <summary>Report name from the attribute (or "Class.Method" fallback — discovery rejects null names).</summary>
public string Name{get;init;}/// <summary>Reflection handle of the static test method.</summary>
public MethodInfo Method{get;init;}/// <summary>Per-test timeout in seconds.</summary>
public int TimeoutSeconds{get;init;}/// <summary>Skip reason; non-empty means the test is reported Skipped without running.</summary>
public string Skip{get;init;}}/// <summary> Reflection scan of a compiled test assembly: finds static methods marked with <see cref="ModTestAttribute"/> in public types. Static test methods may be private — the plan's authoring convention is a bare <c>static void</c> with no access modifier, so discovery deliberately ignores visibility (the assembly is private to the test engine anyway). An unreadable type set is reported as a suite-level <see cref="ModTestStatus.Error"/> rather than silently producing an empty run. </summary>
public static class ModTestDiscovery{private const BindingFlags TestMethodBinding=BindingFlags.Static|BindingFlags.Public|BindingFlags.NonPublic;/// <summary> Returns every [ModTest] method in <paramref name="assembly"/> in stable (type name, method name) order — deterministic reports make history diffs meaningful. </summary>
public static List<DiscoveredModTest>Discover(Assembly assembly){if(assembly==null)throw new ArgumentNullException(nameof(assembly));var result=new List<DiscoveredModTest>();Type[]types;try{types=assembly.GetTypes();}catch(ReflectionTypeLoadException ex){// Surface which types failed and why: a mod test assembly that cannot fully load is a data problem of the mod, not of the engine.
types=ex.Types.Where(t=>t!=null).ToArray();}foreach(var type in types.Where(t=>t.IsClass&& !t.IsAbstract).OrderBy(t=>t.FullName,StringComparer.Ordinal)){foreach(var method in type.GetMethods(TestMethodBinding).OrderBy(m=>m.Name,StringComparer.Ordinal)){var attr=method.GetCustomAttribute<ModTestAttribute>();if(attr==null||string.IsNullOrWhiteSpace(attr.Name))continue;result.Add(new DiscoveredModTest{Name=attr.Name,Method=method,TimeoutSeconds=attr.TimeoutSeconds>0?attr.TimeoutSeconds:30,Skip=attr.Skip,});}}return result;}}}

===== FILE Core/Mods/Testing/ModTestEngine.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Reflection;using System.Threading;using System.Threading.Tasks;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods.Testing{/// <summary> Facade of the mod unit-test system (plan §5). Owns the gate (<c>--run-tests</c> / <c>run_mod_tests</c>), the separate compilation of <c>test_sources</c> into <c>{modId}__tests</c> assemblies and the run orchestration. Gate guarantees (§3.1): when not activated, no member of this class is touched — production compilation never reads <see cref="ModManifest.TestSources"/>, test DLLs cache under a dedicated directory and never land in a player's installation, and no <c>__tests</c> assembly exists in the AppDomain after a normal boot. Orchestration (§5.1): called after the boot pipeline reaches Ready, it compiles each mod's tests (Roslyn diagnostics are reported verbatim on failure — the mod is marked ERROR and the run continues), discovers and runs the tests, writes the report and returns the aggregate exit flag the entry scene turns into <c>GetTree().Quit(0|1)</c>. </summary>
public static class ModTestEngine{private static readonly ModTestLedger ledger=new();/// <summary>Roslyn errors of the last failed test compilation per mod, carried into the report's &lt;compile&gt; entry.</summary>
private static readonly Dictionary<string,string>lastCompileErrors=new(StringComparer.OrdinalIgnoreCase);/// <summary>Results accumulated by the last (or current) run; kept for Report Viewer / diagnostics.</summary>
public static ModTestLedger Ledger=>ledger;/// <summary>True when <c>--run-tests</c> is present in the user command-line arguments.</summary>
public static bool IsRequestedByArgs(){if(!GameEnvironment.IsRuntime)return false;return OS.GetCmdlineUserArgs().Any(a=>a=="--run-tests"||a.StartsWith("--run-tests=",StringComparison.Ordinal));}/// <summary>Mod filter from <c>--mod=&lt;id&gt;</c> user args, or null when absent.</summary>
public static string RequestedModFilter(){if(!GameEnvironment.IsRuntime)return null;return OS.GetCmdlineUserArgs().Where(a=>a.StartsWith("--mod=",StringComparison.Ordinal)).Select(a=>a.Substring("--mod=".Length).Trim()).FirstOrDefault(id=> !string.IsNullOrEmpty(id));}/// <summary>True when the test mode is active: explicit CLI flag, or run_mod_tests in mod_config.json.</summary>
public static bool IsTestMode(){if(IsRequestedByArgs())return true;try{return ModConfig.Instance.RunModTests;}catch{return false;}}/// <summary> Compiles the mod's <c>test_sources</c> into the separate <c>{modId}__tests</c> assembly. Returns null on compilation failure (diagnostics already logged); never called by the production pipeline. </summary>
public static Assembly CompileTestAssembly(ModInfo mod,CancellationToken cancellationToken){var patterns=mod?.Manifest?.TestSources;if(patterns==null||patterns.Count==0)return null;var sources=new List<(string FilePath,string Source)>();// Absolute on-disk path per source (same rule as production mods): debuggers map breakpoints and stacks show the real file.
var treePaths=new List<string>();foreach(var pattern in patterns){var prefix=pattern;var suffix="*.cs";var sepIndex=pattern.LastIndexOf('/');if(sepIndex>=0){prefix=pattern.Substring(0,sepIndex);suffix=pattern.Substring(sepIndex+1);}foreach(var file in mod.FileProvider.ListFiles(prefix)){if(!file.EndsWith(".cs",StringComparison.OrdinalIgnoreCase))continue;var name=file.Substring(file.LastIndexOf('/')+1);var match=suffix=="*"||suffix=="*.cs"||(suffix.StartsWith("*.")&&name.EndsWith(suffix.Substring(1),StringComparison.OrdinalIgnoreCase));if(match){// Deduplicate the pattern prefix: file paths already carry "tests/", and the report path keeps a single canonical "{modId}/" prefix.
var reportPath=file.StartsWith("tests/")?$"{mod.Id}/{file}":$"{mod.Id}/tests/{file}";sources.Add((reportPath,mod.FileProvider.ReadFileText(file)));treePaths.Add(Compilation.ModReferenceProvider.ResolveSourcePath(mod.Id,file,mod.FileProvider?.RootPath));}}}if(sources.Count==0)return null;// Separate cache root: a player's assembly cache never contains test DLLs, and editing tests does not invalidate the production cache.
var productionRoot=ModCompiler.CacheRoot;var testRoot=Path.Combine(Path.GetDirectoryName(productionRoot)??productionRoot,"test_assemblies");Directory.CreateDirectory(testRoot);var coreSignature=typeof(ModTestEngine).Assembly.ManifestModule.ModuleVersionId.ToString("N");// The production assemblies the tests link against are part of the key: a changed mod API must recompile the tests (a compile error with file:line) instead of reusing a stale DLL that fails at run time with MissingMethodException.
var hash=ModHasher.ComputeHash(sources,"tests;pdb=named;paths=abs;core="+coreSignature+";prod="+ProductionSignature(mod));var cachedPath=Path.Combine(testRoot,$"{mod.Id}__tests_{hash}.dll");// Missing PDB = pre-§9.2 cache entry: recompile once to gain symbols.
if(File.Exists(cachedPath)&&File.Exists(Path.ChangeExtension(cachedPath,".pdb")))return ModAssemblyLoader.LoadTestAssembly(cachedPath,mod.Id);// Encoding is mandatory for PDB emission (see ModCompiler.Compile).
var syntaxTrees=sources.Select((s,i)=>Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText(s.Source,path:treePaths[i],encoding:System.Text.Encoding.UTF8,cancellationToken:cancellationToken)).ToList();var references=new List<Microsoft.CodeAnalysis.MetadataReference>();// Known mod ids make the reference scan pick at most one (newest) DLL per mod from the production cache: stale cooking_<oldhash>.dll copies would otherwise collide with the live one (CS0433).
var knownModIds=ModManager.Instance.Mods.Keys.ToList();var refMethod=typeof(ModCompiler).GetMethod("AddCoreReferences",BindingFlags.NonPublic|BindingFlags.Static);// currentModId must NOT be the mod under test here: the self-cache skip would exclude the mod's own production assembly from the references — exactly the assembly the tests must link against. No other mod may reference its own stale DLL either, so pass a sentinel id that matches no cache artifact.
refMethod?.Invoke(null,new object[]{references,"$__tests__",knownModIds});var compilation=Microsoft.CodeAnalysis.CSharp.CSharpCompilation.Create(assemblyName:$"{mod.Id}__tests",syntaxTrees:syntaxTrees,references:references,options:new Microsoft.CodeAnalysis.CSharp.CSharpCompilationOptions(Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary).WithOptimizationLevel(Microsoft.CodeAnalysis.OptimizationLevel.Debug));using var ms=new MemoryStream();// Portable PDB next to the test DLL: file-backed load + adjacent symbols give the debugger line-level breakpoints inside mod test code (plan-mod-testing.md §9.2).
using var pdb=new MemoryStream();// Name the cache-file PDB in the debug directory (see ModCompiler.Compile).
var result=compilation.Emit(ms,pdb,options:new Microsoft.CodeAnalysis.Emit.EmitOptions(debugInformationFormat:Microsoft.CodeAnalysis.Emit.DebugInformationFormat.PortablePdb,pdbFilePath:Path.ChangeExtension(cachedPath,".pdb")));if(!result.Success){var errors=result.Diagnostics.Where(d=>d.Severity==Microsoft.CodeAnalysis.DiagnosticSeverity.Error).Take(20).Select(ModCompiler.FormatDiagnostic).ToList();GameLogger.CompilationErrors.Add($"Test assembly '{mod.Id}__tests' failed with {errors.Count} error(s):\n  "+string.Join("\n  ",errors));GameLogger.Log("MODTEST",$"ModTestEngine: test compilation of '{mod.Id}' FAILED with {errors.Count} error(s)",LogLevel.Error);foreach(var e in errors)GameLogger.Log("MODTEST",$"  {e}",LogLevel.Warning);lastCompileErrors[mod.Id]=string.Join("\n",errors);return null;}File.WriteAllBytes(cachedPath,ms.ToArray());var cachedPdbPath=Path.ChangeExtension(cachedPath,".pdb");pdb.Seek(0,SeekOrigin.Begin);File.WriteAllBytes(cachedPdbPath,pdb.ToArray());GameLogger.Log("MODTEST",$"ModTestEngine: compiled test assembly '{mod.Id}__tests' ({sources.Count} files) -> {cachedPath}");return ModAssemblyLoader.LoadTestAssembly(cachedPath,mod.Id);}/// <summary> Identity of the production assemblies a mod's tests compile against: module version ids of the mod's own assembly and of its transitive dependencies, as currently loaded. A recompiled mod gets a new MVID, so its tests recompile too; a cache hit keeps the MVID. Mods without code contribute nothing. </summary>
private static string ProductionSignature(ModInfo mod){var ids=new SortedSet<string>(StringComparer.OrdinalIgnoreCase);var pending=new Stack<string>();pending.Push(mod.Id);while(pending.Count>0){var id=pending.Pop();if(!ids.Add(id))continue;if(ModManager.Instance.GetMod(id)is ModInfo info&&info.Manifest?.DependencyIds!=null)foreach(var dep in info.Manifest.DependencyIds)pending.Push(dep);}var loaded=AppDomain.CurrentDomain.GetAssemblies().Where(a=> !a.IsDynamic).GroupBy(a=>a.GetName().Name,StringComparer.OrdinalIgnoreCase).ToDictionary(g=>g.Key,g=>g.Last(),StringComparer.OrdinalIgnoreCase);return string.Join(";",ids.Where(loaded.ContainsKey).Select(id=>id+"="+loaded[id].ManifestModule.ModuleVersionId.ToString("N")));}/// <summary> Runs the whole test mode: compiles and runs tests for every eligible mod (optionally filtered by <paramref name="modFilter"/>), writes the report through <see cref="ModTestReportWriter"/> and returns the aggregate pass flag. Compile failures and <c>mod_test_exclude</c> entries do not abort the run (§5.1). </summary>
public static async Task<bool>RunAllAsync(string modFilter,CancellationToken cancellationToken){ledger.Clear();var excluded=new HashSet<string>(StringComparer.OrdinalIgnoreCase);try{foreach(var id in ModConfig.Instance.ModTestExclude??new List<string>())excluded.Add(id);}catch{/* config not loaded — no exclusions */}var eligible=ModManager.Instance.Mods.Values.OfType<ModInfo>().Where(m=>m.Manifest?.TestSources!=null&&m.Manifest.TestSources.Count>0).Where(m=>modFilter==null||string.Equals(m.Id,modFilter,StringComparison.OrdinalIgnoreCase)).Where(m=> !excluded.Contains(m.Id)).OrderBy(m=>m.Id,StringComparer.OrdinalIgnoreCase).ToList();if(eligible.Count==0){// An explicit --mod= that matches nothing is a mistake (typo, mod disabled, test_sources missing), not a green run.
if(modFilter!=null){GameLogger.Log("MODTEST",$"ModTestEngine: --mod={modFilter} matched no enabled mod with test_sources (excluded: {(excluded.Count == 0 ? "none" : string.Join(", ", excluded))})",LogLevel.Error);GameLogger.Log("MODTEST",$"[TEST_RESULT] name=mod_units passed=0 failed=1");return false;}GameLogger.Log("MODTEST","ModTestEngine: no mods with test_sources found");return true;}GameLogger.Log("MODTEST",$"ModTestEngine: running mod tests, mode=author, mods: {string.Join(", ", eligible.Select(m => m.Id))}");await ModTestRunner.CaptureRootBaselineAsync();var summaries=new List<ModTestSuiteSummary>();var aborted=false;try{foreach(var mod in eligible){cancellationToken.ThrowIfCancellationRequested();Assembly testAssembly=null;lastCompileErrors.Remove(mod.Id);try{testAssembly=CompileTestAssembly(mod,cancellationToken);}catch(Exception ex){GameLogger.Log("MODTEST",$"compile of '{mod.Id}__tests' threw: {ex.Message}",LogLevel.Error);}lastCompileErrors.TryGetValue(mod.Id,out var compileErrors);var summary=await ModTestRunner.RunSuiteAsync(mod.Id,testAssembly,ledger,cancellationToken,compileErrors);summaries.Add(summary);}}catch(OperationCanceledException)when(cancellationToken.IsCancellationRequested){// Keep what already ran: a partial report is the debugging material for why the run was aborted.
aborted=true;GameLogger.Log("MODTEST",$"ModTestEngine: run aborted after {summaries.Count}/{eligible.Count} suite(s); writing partial report",LogLevel.Error);}var success=ModTestReportWriter.Write(summaries,"author");return success&& !aborted;}}}

===== FILE Core/Mods/Testing/ModTestLedger.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Mods.Testing{/// <summary> Thread-safe accumulator of every test outcome of a run (plan §7.1). Tests write nothing themselves: the runner records each outcome here and <see cref="ModTestReportWriter"/> flushes the ledger to the output channels. Suite summaries are flushed after each mod (§7.4), so a later mod crashing the process does not lose earlier statistics. </summary>
public sealed class ModTestLedger{private readonly object gate=new();private readonly List<ModTestEntry>entries=new();private readonly List<ModTestSuiteSummary>suites=new();/// <summary>Appends one test outcome.</summary>
public void Add(ModTestEntry entry){lock(gate)entries.Add(entry);}/// <summary>Appends a completed per-mod summary (snapshot taken by the runner).</summary>
public void AddSuite(ModTestSuiteSummary summary){lock(gate)suites.Add(summary);}/// <summary>Snapshot of every recorded test outcome in arrival order.</summary>
public List<ModTestEntry>SnapshotEntries(){lock(gate)return new List<ModTestEntry>(entries);}/// <summary>Snapshot of every completed per-mod summary.</summary>
public List<ModTestSuiteSummary>SnapshotSuites(){lock(gate)return new List<ModTestSuiteSummary>(suites);}/// <summary>Removes all recorded data (used between engine runs).</summary>
public void Clear(){lock(gate){entries.Clear();suites.Clear();}}}}

===== FILE Core/Mods/Testing/ModTestReport.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Mods.Testing{/// <summary>Outcome of a single mod unit test (plan-mod-testing.md §7.2).</summary>
public enum ModTestStatus{/// <summary>Test ran to completion without a failure.</summary>
Passed,/// <summary>Test threw (assert or unexpected exception).</summary>
Failed,/// <summary>Test declared skipped via <see cref="ModTestAttribute.Skip"/>.</summary>
Skipped,/// <summary>Async test exceeded its per-test timeout.</summary>
Timeout,/// <summary>Infrastructure error: compilation failure of the test assembly or recovery self-check violation.</summary>
Error,}/// <summary> One recorded test outcome in the ledger. The test itself knows nothing about reporting: the engine records every outcome (including compile errors at mod granularity) and <see cref="ModTestReportWriter"/> is the only writer. </summary>
public class ModTestEntry{/// <summary>Mod the test belongs to.</summary>
public string ModId{get;set;}/// <summary>Report name of the test ("group/case").</summary>
public string TestName{get;set;}/// <summary>Outcome.</summary>
public ModTestStatus Status{get;set;}/// <summary>Wall-clock duration in milliseconds.</summary>
public double DurationMs{get;set;}/// <summary>Assert/exception text; for Skipped — the skip reason.</summary>
public string Message{get;set;}/// <summary>Stack trace for Failed/Error.</summary>
public string StackTrace{get;set;}/// <summary>Lines written through <see cref="ModTestContext.Log"/> during this test.</summary>
public IReadOnlyList<string>Log{get;set;}=new List<string>();/// <summary>Artifact paths inside the mod's SandboxDir recorded by the runner.</summary>
public IReadOnlyList<string>Artifacts{get;set;}=new List<string>();}/// <summary> Per-mod summary line of a run: used by the console итоги block and the JSON report. A mod whose test assembly failed to compile gets <see cref="ModTestStatus.Error"/> with the Roslyn diagnostics. </summary>
public class ModTestSuiteSummary{/// <summary>Mod id.</summary>
public string ModId{get;set;}/// <summary>Aggregate status: the worst of the individual outcomes (or Error for compile failure).</summary>
public ModTestStatus Status{get;set;}/// <summary>Passed test count.</summary>
public int Passed{get;set;}/// <summary>Failed test count (includes Timeout).</summary>
public int Failed{get;set;}/// <summary>Skipped test count.</summary>
public int Skipped{get;set;}/// <summary>Error count (compile failure, recovery self-check violations).</summary>
public int Errors{get;set;}/// <summary>Total suite duration in milliseconds.</summary>
public double DurationMs{get;set;}/// <summary>Per-test entries in discovery order.</summary>
public List<ModTestEntry>Tests{get;set;}=new();/// <summary>Mod version from the manifest, for the report header.</summary>
public string Version{get;set;}/// <summary>Compilation cache hash of the test sources, to bind the report to the exact mod content.</summary>
public string Hash{get;set;}}}

===== FILE Core/Mods/Testing/ModTestReportWriter.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text.Json;using System.Text.Json.Serialization;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods.Testing{/// <summary> The only writer of mod test results (plan §7.3). Produces three channels: <para>1. Console block — always, through GameLogger (category MODTEST), so it lands in stdout and <c>user://logs/cthangover.log</c>.</para> <para>2. JSON — <c>user://mod_tests/last-report.json</c> (overwritten) plus a timestamped copy in <c>user://mod_tests/runs/</c>.</para> <para>3. History — one JSONL line per suite in <c>user://mod_tests/history.jsonl</c> (rotation: last 50 runs).</para> Additionally prints the unified CI contract <c>[TEST_RESULT] name=mod_units:&lt;id&gt; passed=N failed=M</c> per mod and a <c>mod_units</c> aggregate — consumed by Cthangover.TestRunner like every integration test. </summary>
public static class ModTestReportWriter{private const int HistoryMaxLines=50;private static string ReportRoot{get{if(GameEnvironment.IsRuntime){try{return ProjectSettings.GlobalizePath("user://mod_tests/");}catch{/* fall through */}}return Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),"Godot","app_userdata","Cthangover","mod_tests");}}/// <summary>Writes every channel and prints the console report; returns the aggregate pass/fail of the run.</summary>
public static bool Write(IReadOnlyList<ModTestSuiteSummary>suites,string mode){var runId=DateTime.UtcNow.ToString("yyyyMMdd_HHmmss",System.Globalization.CultureInfo.InvariantCulture);var root=ReportRoot;Directory.CreateDirectory(root);// Console: per-suite block + итоги
foreach(var suite in suites){GameLogger.Log("MODTEST",$"=== MOD TESTS: {suite.ModId} v{suite.Version ?? "?"} (hash {ShortHash(suite.Hash)}) ===");foreach(var test in suite.Tests){var tag=test.Status switch{ModTestStatus.Passed=>"PASS",ModTestStatus.Failed=>"FAIL",ModTestStatus.Skipped=>"SKIP",ModTestStatus.Timeout=>"TIMEOUT",_=>"ERROR",};GameLogger.Log("MODTEST",$"[{tag}] {test.ModId}/{test.TestName} ({test.DurationMs:0} ms){Suffix(test)}");if(!string.IsNullOrEmpty(test.Message))GameLogger.Log("MODTEST",$"       {test.Message.Replace("\n", "\n       ")}");foreach(var line in test.Log)GameLogger.Log("MODTEST",$"       log: {line}");}GameLogger.Log("MODTEST",$"=== {suite.ModId}: {suite.Passed}/{suite.Tests.Count} passed, {suite.Failed} failed, {suite.Skipped} skipped, {suite.Errors} errors ({suite.DurationMs / 1000.0:0.0#} s) ===");}var totals=ComputeTotals(suites);var totalTests=totals.Passed+totals.Failed+totals.Skipped;var totalPassed=totals.Passed;var greenMods=suites.Count(s=>s.Status==ModTestStatus.Passed||s.Status==ModTestStatus.Skipped);GameLogger.Log("MODTEST","=== ИТОГИ MOD TESTS ===");foreach(var s in suites)GameLogger.Log("MODTEST",$"  {s.ModId,-20} {StatusTag(s.Status),-7} {s.Passed}/{s.Passed + s.Failed + s.Skipped + s.Errors,-4} {s.DurationMs / 1000.0:0.0#}s");GameLogger.Log("MODTEST",$"Модов: {greenMods}/{suites.Count} зелёных; тестов: {totalPassed}/{totalTests} прошедших, {totals.Failed} упавших, {totals.Skipped} пропущенных");GameLogger.Log("MODTEST",$"Отчёт: {root}last-report.json");// Unified CI contract lines
foreach(var s in suites)GameLogger.Log("MODTEST",$"[TEST_RESULT] name=mod_units:{s.ModId} passed={s.Passed} failed={s.Failed + s.Errors} skipped={s.Skipped}");GameLogger.Log("MODTEST",$"[TEST_RESULT] name=mod_units passed={totals.Passed} failed={totals.Failed} skipped={totals.Skipped}");// JSON report
var report=new ReportFile{RunId=runId,Mode=mode,WrittenUtc=DateTime.UtcNow,GodotVersion=Engine.GetVersionInfo()["string"].AsString(),Suites=suites,};var json=JsonSerializer.Serialize(report,new JsonSerializerOptions{WriteIndented=true,DefaultIgnoreCondition=JsonIgnoreCondition.WhenWritingNull,Converters={new JsonStringEnumConverter()},});File.WriteAllText(Path.Combine(root,"last-report.json"),json);Directory.CreateDirectory(Path.Combine(root,"runs"));File.WriteAllText(Path.Combine(root,"runs",runId+".json"),json);// History (JSONL, rotated)
var historyPath=Path.Combine(root,"history.jsonl");var lines=File.Exists(historyPath)?File.ReadAllLines(historyPath).Where(l=> !string.IsNullOrWhiteSpace(l)).ToList():new List<string>();foreach(var s in suites){lines.Add(JsonSerializer.Serialize(new HistoryLine{RunId=runId,ModId=s.ModId,Status=s.Status.ToString(),Passed=s.Passed,Failed=s.Failed,Skipped=s.Skipped,Errors=s.Errors,DurationMs=s.DurationMs,Hash=s.Hash,}));}if(lines.Count>HistoryMaxLines)lines=lines.Skip(lines.Count-HistoryMaxLines).ToList();File.WriteAllLines(historyPath,lines);return totals.Success;}/// <summary>Aggregate counts of a run; <see cref="Failed"/> includes errors and timeouts, skips never fail a run.</summary>
public readonly record struct ModTestTotals(int Passed,int Failed,int Skipped,bool Success);/// <summary> Aggregates suite summaries for the CI contract line and the exit code: a run succeeds when no test failed, timed out or errored. Skipped tests are reported separately and never count as failures. </summary>
public static ModTestTotals ComputeTotals(IReadOnlyList<ModTestSuiteSummary>suites){var passed=suites.Sum(s=>s.Passed);var failed=suites.Sum(s=>s.Failed+s.Errors);var skipped=suites.Sum(s=>s.Skipped);return new ModTestTotals(passed,failed,skipped,failed==0);}/// <summary>Copies per-mod JSON reports into the CI artifact directory (runner collects them); best-effort.</summary>
public static void CopyToArtifacts(IReadOnlyList<ModTestSuiteSummary>suites,string artifactsDir){try{Directory.CreateDirectory(artifactsDir);var root=ReportRoot;foreach(var s in suites){var src=Path.Combine(root,"last-report.json");if(File.Exists(src))File.Copy(src,Path.Combine(artifactsDir,s.ModId+".json"),overwrite:true);}}catch(Exception ex){GameLogger.Log("MODTEST",$"CopyToArtifacts failed: {ex.Message}",LogLevel.Warning);}}private static string Suffix(ModTestEntry e){return e.Status==ModTestStatus.Skipped&& !string.IsNullOrEmpty(e.Message)?"":"";}private static string StatusTag(ModTestStatus status){return status switch{ModTestStatus.Passed=>"PASS",ModTestStatus.Failed=>"FAIL",ModTestStatus.Skipped=>"SKIP",ModTestStatus.Timeout=>"FAIL",_=>"ERROR",};}private static string ShortHash(string hash){if(string.IsNullOrEmpty(hash))return"—";return hash.Length<=4?hash:hash.Substring(0,4)+"…";}private sealed class ReportFile{public string RunId{get;set;}public string Mode{get;set;}public DateTime WrittenUtc{get;set;}public string GodotVersion{get;set;}public IReadOnlyList<ModTestSuiteSummary>Suites{get;set;}}private sealed class HistoryLine{public string RunId{get;set;}public string ModId{get;set;}public string Status{get;set;}public int Passed{get;set;}public int Failed{get;set;}public int Skipped{get;set;}public int Errors{get;set;}public double DurationMs{get;set;}public string Hash{get;set;}}}}

===== FILE Core/Mods/Testing/ModTestRunner.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Reflection;using System.Runtime.ExceptionServices;using System.Threading;using System.Threading.Tasks;using Cthangover.Core.Bootstrap;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Mods.Testing{/// <summary> Executes the discovered tests of one mod and applies the automatic recovery cycle after every test (plan §6.3). The author never cleans up: registries reset via <see cref="IResettable"/>, derived registry state rebuilds through <see cref="AppServices"/>, the event-bus style global state is restored (<c>Engine.TimeScale</c>, root nodes), and the per-mod file sandbox is wiped before the suite. After recovery the runner self-checks the invariants (§6.4) and reports ERROR instead of letting silent contamination corrupt subsequent tests. </summary>
public static class ModTestRunner{/// <summary> Children of <c>/root</c> that existed before the first test (core autoloads, mod-added services, the launcher scene), by instance id. Recovery frees only nodes outside this set and reports a baseline node a test destroyed. Null until <see cref="CaptureRootBaselineAsync"/> ran. </summary>
private static Dictionary<ulong,string>rootBaseline;/// <summary> Records the <c>/root</c> children as the recovery baseline. Waits one frame first so nodes the boot added through <c>call_deferred("add_child")</c> are already in the tree. Must run once before the first suite. </summary>
public static async Task CaptureRootBaselineAsync(){if(Engine.GetMainLoop()is not SceneTree tree||tree.Root==null)return;await tree.ToSignal(tree,SceneTree.SignalName.ProcessFrame);rootBaseline=tree.Root.GetChildren().ToDictionary(n=>n.GetInstanceId(),n=>n.Name.ToString());GameLogger.Log("MODTEST",$"recovery baseline: /root children = {string.Join(", ", rootBaseline.Values)}",LogLevel.Debug);}/// <summary> Runs one mod's tests in-process. Returns the suite summary; also records every entry into <paramref name="ledger"/>. </summary>
public static async Task<ModTestSuiteSummary>RunSuiteAsync(string modId,Assembly testAssembly,ModTestLedger ledger,CancellationToken cancellationToken,string compileErrors=null){var summary=new ModTestSuiteSummary{ModId=modId};var mod=ModManager.Instance.GetMod(modId);summary.Version=mod?.Version;summary.Hash=mod is ModInfo info&&info.Manifest?.TestSources!=null?TestSourceHash(info):null;if(testAssembly==null){summary.Status=ModTestStatus.Error;summary.Errors=1;summary.Tests.Add(new ModTestEntry{ModId=modId,TestName="<compile>",Status=ModTestStatus.Error,Message=string.IsNullOrEmpty(compileErrors)?"test assembly could not be compiled/loaded":"test assembly failed to compile:\n"+compileErrors,});ledger.AddSuite(summary);return summary;}List<DiscoveredModTest>tests;try{tests=ModTestDiscovery.Discover(testAssembly);}catch(Exception ex){summary.Status=ModTestStatus.Error;summary.Errors=1;summary.Tests.Add(new ModTestEntry{ModId=modId,TestName="<discovery>",Status=ModTestStatus.Error,Message=ex.Message,StackTrace=ex.StackTrace,});ledger.AddSuite(summary);return summary;}var sandboxDir=GetSandboxDir(modId);PrepareSandbox(sandboxDir);var swTotal=System.Diagnostics.Stopwatch.StartNew();foreach(var test in tests){cancellationToken.ThrowIfCancellationRequested();var entry=await RunOneAsync(modId,test,sandboxDir,cancellationToken);summary.Tests.Add(entry);ledger.Add(entry);var contamination=RecoveryCycle(modId);if(contamination!=null){var err=new ModTestEntry{ModId=modId,TestName=test.Name+" [recovery]",Status=ModTestStatus.Error,Message=contamination,};summary.Tests.Add(err);ledger.Add(err);}}swTotal.Stop();summary.DurationMs=swTotal.Elapsed.TotalMilliseconds;summary.Passed=summary.Tests.Count(t=>t.Status==ModTestStatus.Passed);summary.Failed=summary.Tests.Count(t=>t.Status==ModTestStatus.Failed||t.Status==ModTestStatus.Timeout);summary.Skipped=summary.Tests.Count(t=>t.Status==ModTestStatus.Skipped);// Recovery contamination and aborted tests are both Error entries.
summary.Errors=summary.Tests.Count(t=>t.Status==ModTestStatus.Error);summary.Status=summary.Errors>0?ModTestStatus.Error:summary.Failed>0?ModTestStatus.Failed:summary.Passed==0&&summary.Skipped>0?ModTestStatus.Skipped:ModTestStatus.Passed;ledger.AddSuite(summary);return summary;}private static async Task<ModTestEntry>RunOneAsync(string modId,DiscoveredModTest test,string sandboxDir,CancellationToken cancellationToken){var entry=new ModTestEntry{ModId=modId,TestName=test.Name};if(!string.IsNullOrEmpty(test.Skip)){entry.Status=ModTestStatus.Skipped;entry.Message=test.Skip;return entry;}var logBuffer=new List<string>();var cts=CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);cts.CancelAfter(TimeSpan.FromSeconds(test.TimeoutSeconds));var sandboxRoot=new Node{Name=$"ModTestSandbox_{modId}"};var sw=System.Diagnostics.Stopwatch.StartNew();try{var ctx=new ModTestContext(modId,logBuffer,sandboxDir,sandboxRoot,cts.Token);var result=Invoke(test.Method,ctx);if(result is Task task){var completed=await Task.WhenAny(task,Task.Delay(Timeout.Infinite,cts.Token));if(completed!=task){// The linked token fires for both the per-test timeout and an aborted run; only the former is a TIMEOUT.
cancellationToken.ThrowIfCancellationRequested();throw new TimeoutException($"test exceeded {test.TimeoutSeconds}s timeout");}await task;// surface aggregate exceptions
if(task.IsFaulted)throw task.Exception?.GetBaseException()??new Exception("task faulted");}entry.Status=ModTestStatus.Passed;}catch(ModAssertException ex){entry.Status=ModTestStatus.Failed;entry.Message=ex.Message;entry.StackTrace=ex.StackTrace;}catch(TimeoutException ex){entry.Status=ModTestStatus.Timeout;entry.Message=ex.Message;}catch(OperationCanceledException)when(cts.Token.IsCancellationRequested&& !cancellationToken.IsCancellationRequested){entry.Status=ModTestStatus.Timeout;entry.Message=$"test exceeded {test.TimeoutSeconds}s timeout";}catch(OperationCanceledException)when(cancellationToken.IsCancellationRequested){entry.Status=ModTestStatus.Error;entry.Message="test run aborted (external cancellation) while this test was running";}catch(Exception ex){var baseEx=ex is AggregateException agg?agg.GetBaseException():ex;entry.Status=ModTestStatus.Failed;entry.Message=baseEx.Message;entry.StackTrace=baseEx.StackTrace;}finally{cts.Dispose();entry.DurationMs=sw.Elapsed.TotalMilliseconds;lock(logBuffer)entry.Log=new List<string>(logBuffer);CollectArtifacts(entry,sandboxDir);FreeSandboxRoot(sandboxRoot);}return entry;}/// <summary> Calls the static test method and rethrows what the test itself threw: reflection wraps a synchronous throw in <see cref="TargetInvocationException"/>, which would replace the assertion message and the test's stack with reflection frames. </summary>
public static object Invoke(MethodInfo method,ModTestContext ctx){try{return method.GetParameters().Length==0?method.Invoke(null,null):method.Invoke(null,new object[]{ctx});}catch(TargetInvocationException ex)when(ex.InnerException!=null){ExceptionDispatchInfo.Capture(ex.InnerException).Throw();throw;// unreachable
}}/// <summary> The automatic recovery cycle (plan §6.3): resettables, registry rebuild, Engine.TimeScale, stray root nodes, engine globals. Returns a human-readable contamination description when a self-check invariant (§6.4) fails after recovery, or null when the environment is clean. </summary>
private static string RecoveryCycle(string modId){try{Engine.TimeScale=1.0;foreach(var resettable in AppServices.Current.Resettables){try{resettable.ResetForNewGame();}catch(Exception ex){GameLogger.Log("MODTEST",$"resettable '{resettable.Id}' failed: {ex.Message}",LogLevel.Warning);}}// Rebuild derived registry state the same way hot-reload does, so re-collected registries are consistent again.
AppServices.Current.RebuildRegistries();FreeStrayRootNodes();return SelfCheck();}catch(Exception ex){return$"recovery cycle itself failed: {ex.Message}";}}/// <summary> Frees the <c>/root</c> children a test added — everything outside the baseline. Without a baseline nothing is freed: guessing by name would destroy core autoloads and the launcher scene. </summary>
private static void FreeStrayRootNodes(){var tree=Engine.GetMainLoop()as SceneTree;if(tree?.Root==null||rootBaseline==null)return;foreach(var child in tree.Root.GetChildren().ToList()){if(!rootBaseline.ContainsKey(child.GetInstanceId())){GameLogger.Log("MODTEST",$"recovery: freeing stray root node '{child.Name}'",LogLevel.Debug);child.QueueFree();}}}/// <summary>Self-check of the recovery invariants (plan §6.4): TimeScale, baseline root nodes alive, key registries non-empty.</summary>
private static string SelfCheck(){if(Math.Abs(Engine.TimeScale-1.0)>1e-9)return"Engine.TimeScale is not 1 after recovery";if(rootBaseline!=null){var lost=rootBaseline.Where(kv=>GodotObject.InstanceFromId(kv.Key)is not Node node|| !GodotObject.IsInstanceValid(node)||node.IsQueuedForDeletion()).ToList();if(lost.Count>0){// Reported once, against the test that caused it.
foreach(var kv in lost)rootBaseline.Remove(kv.Key);return$"test freed baseline root node(s): {string.Join(", ", lost.Select(kv => kv.Value))}";}}var services=AppServices.Current;if(services.Ui==null||services.Menus==null||services.SceneRoutes==null)return"key AppServices registries are missing after recovery";return null;}private static string GetSandboxDir(string modId){if(GameEnvironment.IsRuntime){try{return ProjectSettings.GlobalizePath($"user://mod_test_sandbox/{modId}");}catch{/* fall through */}}return Path.Combine(Path.GetTempPath(),"cthangover_mod_test_sandbox",modId);}private static void PrepareSandbox(string sandboxDir){try{if(Directory.Exists(sandboxDir))Directory.Delete(sandboxDir,recursive:true);}catch(Exception ex){GameLogger.Log("MODTEST",$"sandbox cleanup failed: {ex.Message}",LogLevel.Warning);}Directory.CreateDirectory(sandboxDir);}private static void CollectArtifacts(ModTestEntry entry,string sandboxDir){try{if(!Directory.Exists(sandboxDir))return;entry.Artifacts=Directory.GetFiles(sandboxDir,"*",SearchOption.AllDirectories).ToList();}catch{entry.Artifacts=new List<string>();}}private static void FreeSandboxRoot(Node sandboxRoot){if(sandboxRoot==null)return;foreach(var child in sandboxRoot.GetChildren().ToList())child.QueueFree();sandboxRoot.QueueFree();}private static string TestSourceHash(ModInfo info){try{var sources=new List<(string,string)>();foreach(var pattern in info.Manifest.TestSources){foreach(var file in info.FileProvider.ListFiles(ParentDir(pattern))){if(!file.EndsWith(".cs",StringComparison.OrdinalIgnoreCase))continue;sources.Add((file,info.FileProvider.ReadFileText(file)));}}return sources.Count>0?ModHasher.ComputeHash(sources):null;}catch{return null;}}private static string ParentDir(string pattern){var sep=pattern.LastIndexOf('/');return sep<0?"":pattern.Substring(0,sep);}}}

===== FILE Core/Mods/UiButtonEntry.cs =====
using System.Text.Json.Serialization;namespace Cthangover.Core.Mods{/// <summary> JSON DTO for the <c>button</c> object of a <c>kind="panel"</c> entry in a mod manifest's <c>ui</c> array. The toolbar button is owned by the UI registry, not by the mod: the registry resolves <see cref="Icon"/> through UIIconFactory (mod-aware, unlike Godot's ResourceLoader), labels it with the translated <see cref="Locale"/> key, and orders it by <see cref="Index"/>. Several buttons may share an index; ties are broken by mod load order. </summary>
public class UiButtonEntry{/// <summary>Logical icon id resolved through UIIconFactory. Required for panels.</summary>
[JsonPropertyName("icon")]public string Icon{get;set;}/// <summary>Translation key for the button tooltip. Required for panels.</summary>
[JsonPropertyName("locale")]public string Locale{get;set;}/// <summary> Toolbar order index. Lower values go first. Defaults to 10 so mods that do not care keep a stable, load-order-based position. </summary>
[JsonPropertyName("index")]public int Index{get;set;}=10;}}

===== FILE Core/Mods/UiEntry.cs =====
using System.Text.Json.Serialization;namespace Cthangover.Core.Mods{/// <summary> JSON DTO for one entry of a mod manifest's <c>ui</c> array — the single, manifest-driven way a mod declares a GUI element (no C# registration API). <para/> Two kinds exist. <see cref="KindPanel"/> is a button-toggled panel: the UI registry owns a toolbar button for it and is the only one allowed to change its visibility. <see cref="KindOverlay"/> has no button; the element's own code decides when it is visible (optionally auto-shown by <see cref="ShowOnStart"/>). <para/> Entries are validated at startup by <c>UiRegistry.Initialize</c>; invalid entries are logged and skipped. <see cref="ModId"/> and <see cref="ModOrderIndex"/> are filled in during collection and never come from JSON — they carry the owning mod and its load-order position, which are needed to break ordering ties deterministically. </summary>
public class UiEntry{/// <summary>Panel kind: has a toolbar button, visibility owned by the registry.</summary>
public const string KindPanel="panel";/// <summary>Overlay kind: no button, visibility owned by the element's own code.</summary>
public const string KindOverlay="overlay";/// <summary>Unique within the owning mod; doubles as the runtime cache key and Toggle target.</summary>
[JsonPropertyName("id")]public string Id{get;set;}/// <summary>Either <see cref="KindPanel"/> or <see cref="KindOverlay"/>.</summary>
[JsonPropertyName("kind")]public string Kind{get;set;}/// <summary> Optional logical scene this GUI belongs to. When set, the GUI is only available in that scene and is hidden when leaving it. When absent the GUI is scene-independent and lives for the whole session. </summary>
[JsonPropertyName("scene")]public string Scene{get;set;}/// <summary>Name of the anchor slot node (e.g. "ModBeforeUIPanel", "Lastground").</summary>
[JsonPropertyName("slot")]public string Slot{get;set;}/// <summary>Mod-relative path to the .tscn scene, resolved through TscnScenes.</summary>
[JsonPropertyName("scene_path")]public string ScenePath{get;set;}/// <summary> When true the instance lives for the whole application; when false it is created on scene entry and freed on scene exit. Panels are always treated as <c>true</c> during collection. </summary>
[JsonPropertyName("once")]public bool Once{get;set;}/// <summary> Overlays only: when true the element is shown as soon as its scene is entered; when false (default) it waits for a registry Toggle or its own code. Ignored for panels. </summary>
[JsonPropertyName("show_on_start")]public bool ShowOnStart{get;set;}/// <summary>Layer/order inside the anchor slot. Defaults to 10; optional.</summary>
[JsonPropertyName("priority")]public int Priority{get;set;}=10;/// <summary>Present only for <see cref="KindPanel"/>; forbidden for overlays.</summary>
[JsonPropertyName("button")]public UiButtonEntry Button{get;set;}/// <summary>Owning mod id, filled during collection.</summary>
[JsonIgnore]public string ModId{get;set;}/// <summary>Position of the owning mod in load order, filled during collection.</summary>
[JsonIgnore]public int ModOrderIndex{get;set;}}}

===== FILE Core/Mods/Unload/AssemblyOwnership.cs =====
namespace Cthangover.Core.Mods.Unload{using System;using System.Collections.Generic;using System.Reflection;/// <summary> "Does this reference belong to the code being unloaded?" — the single rule every unload sweeper applies. A value is owned when its runtime type (or any generic argument / element type of it) is declared in one of the dying assemblies; a delegate is owned when any entry of its invocation list points into them, by method or by target (lambdas close over compiler-generated classes of the mod assembly, and kernel wrappers such as the event bus close over the mod's delegate). Answers for types are cached: a sweep asks about the same few hundred types thousands of times. </summary>
public sealed class AssemblyOwnership{private readonly HashSet<Assembly>assemblies;private readonly Dictionary<Type,bool>typeCache=new();/// <summary>Creates the rule for <paramref name="assemblies"/> (the assemblies of every mod released in one unload pass).</summary>
public AssemblyOwnership(IEnumerable<Assembly>assemblies){this.assemblies=new HashSet<Assembly>(assemblies??Array.Empty<Assembly>());}/// <summary>The dying assemblies.</summary>
public IReadOnlyCollection<Assembly>Assemblies=>assemblies;/// <summary>True when nothing is being unloaded; every query then answers false.</summary>
public bool IsEmpty=>assemblies.Count==0;/// <summary>True when <paramref name="assembly"/> is one of the dying assemblies.</summary>
public bool Owns(Assembly assembly)=>assembly!=null&&assemblies.Contains(assembly);/// <summary>True when <paramref name="type"/> or a type it is built from is declared in a dying assembly.</summary>
public bool Owns(Type type){if(type==null||IsEmpty)return false;if(typeCache.TryGetValue(type,out var cached))return cached;// Placeholder against recursive generic shapes (class A : IEquatable<A>).
typeCache[type]=false;var owned=assemblies.Contains(type.Assembly);if(!owned&&type.HasElementType)owned=Owns(type.GetElementType());if(!owned&&type.IsGenericType&& !type.IsGenericTypeDefinition){foreach(var argument in type.GetGenericArguments()){if(Owns(argument)){owned=true;break;}}}typeCache[type]=owned;return owned;}/// <summary>True when one invocation-list entry of <paramref name="handler"/> belongs to a dying assembly.</summary>
public bool Owns(Delegate handler){if(handler==null||IsEmpty)return false;foreach(var entry in handler.GetInvocationList()){if(OwnsSingle(entry))return true;}return false;}/// <summary>Owned value: an owned delegate or an instance of an owned type (a Type/Assembly/member object counts by itself).</summary>
public bool OwnsValue(object value){switch(value){case null:return false;case Delegate d:return Owns(d);case Type t:return Owns(t);case Assembly a:return Owns(a);case MemberInfo m:return Owns(m.DeclaringType)||Owns(m.Module.Assembly);default:return Owns(value.GetType());}}/// <summary> Removes the owned entries from a (multicast) delegate. Returns the survivor (null when nothing survives); <paramref name="changed"/> tells whether anything was removed. </summary>
public Delegate Strip(Delegate handler,out bool changed){changed=false;if(handler==null)return null;Delegate result=null;foreach(var entry in handler.GetInvocationList()){if(OwnsSingle(entry)){changed=true;continue;}result=Delegate.Combine(result,entry);}return changed?result:handler;}private bool OwnsSingle(Delegate entry){var method=entry.Method;if(method!=null&&(Owns(method.DeclaringType)||Owns(method.Module.Assembly)))return true;var target=entry.Target;if(target==null)return false;if(Owns(target.GetType()))return true;if(target is Delegate inner)return Owns(inner);// A kernel wrapper delegate (a closure capturing a mod delegate) is owned through its closure: look one level into its delegate fields.
foreach(var field in target.GetType().GetFields(BindingFlags.Instance|BindingFlags.Public|BindingFlags.NonPublic)){if(!typeof(Delegate).IsAssignableFrom(field.FieldType)&&field.FieldType!=typeof(object))continue;if(field.GetValue(target)is Delegate captured&& !ReferenceEquals(captured,entry)&&Owns(captured))return true;}return false;}}}

===== FILE Core/Mods/Unload/GodotObjectSweeper.cs =====
namespace Cthangover.Core.Mods.Unload{using System;using System.Collections;using System.Collections.Generic;using System.Linq;using System.Reflection;using Cthangover.Core.Utils;using Godot;/// <summary> Releases every engine-side hold on a dying mod assembly before its load context unloads. A live Godot object whose script is a mod type keeps a strong GC handle to its managed instance (and so to the mod assembly) for as long as the native object lives; a signal connected to a mod delegate keeps the delegate alive inside the native connection. Neither is visible from managed statics, so this pass walks the engine side: <list type="number"> <item>live objects: every managed wrapper GodotSharp tracks (<c>DisposablesTracker</c> — this includes nodes outside the tree, cached UI and timers) plus the whole scene tree;</item> <item>connections to owned delegates are disconnected on every surviving object;</item> <item>owned objects are freed: nodes leave the tree and are freed immediately (callers run this outside signal emission, from a deferred step), plain objects are freed, reference-counted ones lose their script so the native object lives on as its engine base class while the mod instance dies.</item> </list> </summary>
public static class GodotObjectSweeper{/// <summary>Runs the pass; returns one report line per freed group / disconnected signal.</summary>
public static List<string>Release(SceneTree tree,AssemblyOwnership ownership){var report=new List<string>();if(ownership==null||ownership.IsEmpty|| !GameEnvironment.IsRuntime)return report;var objects=new List<GodotObject>();var seen=new HashSet<ulong>();foreach(var tracked in TrackedObjects())AddLive(tracked,objects,seen);if(tree?.Root!=null)CollectTree(tree.Root,objects,seen);var disconnected=0;var owned=new List<GodotObject>();foreach(var obj in objects){if(ownership.Owns(obj.GetType())){owned.Add(obj);continue;}disconnected+=DisconnectOwned(obj,ownership);}if(disconnected>0)report.Add($"disconnected {disconnected} signal connection(s) to mod code");var freedNodes=0;var freedObjects=0;var detached=0;// Ancestors first: freeing a parent frees its subtree, so the depth-sorted walk sees each surviving node exactly once.
foreach(var node in owned.OfType<Node>().OrderBy(Depth).ToList()){if(!GodotObject.IsInstanceValid(node))continue;node.GetParent()?.RemoveChild(node);node.Free();freedNodes++;}foreach(var obj in owned){if(obj is Node|| !GodotObject.IsInstanceValid(obj))continue;if(obj is RefCounted){obj.SetScript(default(Variant));obj.Dispose();detached++;}else{obj.Free();freedObjects++;}}if(freedNodes+freedObjects+detached>0)report.Add($"freed {freedNodes} node(s) and {freedObjects} object(s) of mod types, detached {detached} reference-counted instance(s)");return report;}private static int DisconnectOwned(GodotObject obj,AssemblyOwnership ownership){var count=0;try{foreach(var signal in obj.GetSignalList()){var name=signal["name"].AsStringName();foreach(var connection in obj.GetSignalConnectionList(name)){var callable=connection["callable"].AsCallable();var target=callable.Target;var owned=(callable.Delegate!=null&&ownership.Owns(callable.Delegate))||(target!=null&&GodotObject.IsInstanceValid(target)&&ownership.Owns(target.GetType()));if(!owned)continue;obj.Disconnect(name,callable);count++;}}}catch(Exception ex){GameLogger.Log("MODS_ASSEMBLY",$"GodotObjectSweeper: signal scan of '{obj.GetType().Name}' failed: {ex.Message}",LogLevel.Debug);}return count;}private static void CollectTree(Node node,List<GodotObject>objects,HashSet<ulong>seen){AddLive(node,objects,seen);foreach(var child in node.GetChildren(includeInternal:true))CollectTree(child,objects,seen);}private static void AddLive(GodotObject obj,List<GodotObject>objects,HashSet<ulong>seen){if(obj==null|| !GodotObject.IsInstanceValid(obj))return;if(seen.Add(obj.GetInstanceId()))objects.Add(obj);}private static int Depth(Node node){var depth=0;for(var parent=node.GetParent();parent!=null;parent=parent.GetParent())depth++;return depth;}/// <summary>Managed wrappers GodotSharp tracks for disposal at shutdown (internal <c>DisposablesTracker</c>).</summary>
private static IEnumerable<GodotObject>TrackedObjects(){var tracker=typeof(GodotObject).Assembly.GetType("Godot.DisposablesTracker");var property=tracker?.GetProperty("GodotObjectInstances",BindingFlags.NonPublic|BindingFlags.Static);if(property?.GetValue(null)is not IDictionary instances){GameLogger.Log("MODS_ASSEMBLY","GodotObjectSweeper: DisposablesTracker not found; only scene-tree nodes are released",LogLevel.Warning);return Array.Empty<GodotObject>();}var result=new List<GodotObject>();foreach(DictionaryEntry entry in instances){if(entry.Key is WeakReference<GodotObject>weak&&weak.TryGetTarget(out var target))result.Add(target);}return result;}}}

===== FILE Core/Mods/Unload/GodotScriptBridgeUnload.cs =====
namespace Cthangover.Core.Mods.Unload{using System;using System.Collections;using System.Collections.Generic;using System.Linq;using System.Reflection;using System.Runtime.Loader;using Cthangover.Core.Utils;/// <summary> Lets GodotSharp forget the script types of an unloaded mod. <c>ScriptManagerBridge</c> keeps two static maps (script pointer ↔ type, <c>res://</c> path ↔ type); a type in them pins its assembly's load context. GodotSharp already knows how to drop them — on <see cref="AssemblyLoadContext.Unloading"/> it removes every type it recorded for that context — but records types only while <c>AlcReloadCfg.IsAlcReloadingEnabled</c> is set, and GodotPlugins configures that flag to "editor only" at startup. A running game (from the editor or exported) therefore never released mod script types. <see cref="EnableTracking"/> turns the flag on before the first mod assembly loads, so GodotSharp tracks mod types itself; <see cref="PurgeOwnedTypes"/> is the fallback for types registered before that point (and for a GodotSharp that renamed its internals): it removes owned types from both maps directly. Both touch private GodotSharp state through reflection; any mismatch is logged and the unload verification reports the resulting leak. </summary>
public static class GodotScriptBridgeUnload{private const BindingFlags Private=BindingFlags.NonPublic|BindingFlags.Static;private const BindingFlags PrivateInstance=BindingFlags.NonPublic|BindingFlags.Instance;private static bool trackingEnabled;/// <summary> Enables GodotSharp's per-context script type tracking. Idempotent; only meaningful inside the Godot runtime. </summary>
public static void EnableTracking(){if(trackingEnabled|| !GameEnvironment.IsRuntime)return;trackingEnabled=true;var flag=typeof(Godot.Bridge.AlcReloadCfg).GetField("IsAlcReloadingEnabled",Private);if(flag==null){GameLogger.Log("MODS_ASSEMBLY","GodotScriptBridgeUnload: AlcReloadCfg.IsAlcReloadingEnabled not found; mod script types are purged manually on unload",LogLevel.Warning);return;}flag.SetValue(null,true);}/// <summary>Registers a mod context with GodotSharp so its script types are dropped when it unloads.</summary>
public static void Track(AssemblyLoadContext context){if(context==null|| !GameEnvironment.IsRuntime)return;EnableTracking();Godot.Bridge.ScriptManagerBridge.TrackAlcForUnloading(context);}/// <summary> Removes owned types still present in the script maps (after the context's <c>Unloading</c> already ran). Returns how many entries were removed; zero is the healthy case. </summary>
public static int PurgeOwnedTypes(AssemblyOwnership ownership){if(ownership==null||ownership.IsEmpty|| !GameEnvironment.IsRuntime)return 0;try{var bridge=typeof(Godot.Bridge.ScriptManagerBridge);var removed=0;var scriptMap=bridge.GetField("_scriptTypeBiMap",Private)?.GetValue(null);if(scriptMap!=null){var rwLock=scriptMap.GetType().GetField("ReadWriteLock")?.GetValue(scriptMap)as System.Threading.ReaderWriterLockSlim;rwLock?.EnterWriteLock();try{removed+=RemoveOwned(scriptMap,"_scriptTypeMap",ownership,byValue:true);removed+=RemoveOwned(scriptMap,"_typeScriptMap",ownership,byValue:false);}finally{rwLock?.ExitWriteLock();}}var pathMap=bridge.GetField("_pathTypeBiMap",Private)?.GetValue(null);if(pathMap!=null){removed+=RemoveOwned(pathMap,"_pathTypeMap",ownership,byValue:true);removed+=RemoveOwned(pathMap,"_typePathMap",ownership,byValue:false);}return removed;}catch(Exception ex){GameLogger.Log("MODS_ASSEMBLY",$"GodotScriptBridgeUnload.PurgeOwnedTypes failed: {ex.Message}",LogLevel.Warning);return 0;}}private static int RemoveOwned(object map,string fieldName,AssemblyOwnership ownership,bool byValue){if(map.GetType().GetField(fieldName,PrivateInstance)?.GetValue(map)is not IDictionary dictionary)return 0;var doomed=new List<object>();foreach(DictionaryEntry entry in dictionary){var type=(byValue?entry.Value:entry.Key)as Type;if(ownership.Owns(type))doomed.Add(entry.Key);}foreach(var key in doomed)dictionary.Remove(key);return doomed.Count;}}}

===== FILE Core/Mods/Unload/JsonCacheReset.cs =====
namespace Cthangover.Core.Mods.Unload{using System;using System.Reflection;using System.Text.Json;/// <summary> Flushes System.Text.Json's reflection caches before a mod context is released. Serializing a mod type leaves its <c>JsonTypeInfo</c> in the caching context of every options instance that touched it and, worse, emitted property accessors in the process-wide <c>ReflectionEmitCachingMemberAccessor</c> — both pin the mod's <c>AssemblyLoadContext</c> (found with <c>dotnet-dump gcroot</c> on the first in-process game switch). The runtime ships the exact operation for .NET hot reload: <c>JsonSerializerOptionsUpdateHandler.ClearCache</c> (registered as a <c>MetadataUpdateHandler</c>) clears every tracked options instance and the emitted-accessor cache. It is internal, so it is invoked through reflection; if a future runtime renames it, the reset logs and the unload verification reports the leak instead of crashing. </summary>
public static class JsonCacheReset{private static readonly MethodInfo ClearCacheMethod=typeof(JsonSerializerOptions).Assembly.GetType("System.Text.Json.JsonSerializerOptionsUpdateHandler")?.GetMethod("ClearCache",BindingFlags.Public|BindingFlags.NonPublic|BindingFlags.Static);/// <summary>True when the runtime exposes the cache reset this class relies on.</summary>
public static bool IsAvailable=>ClearCacheMethod!=null;/// <summary>Clears all System.Text.Json reflection caches. Returns false when the reset is unavailable or failed.</summary>
public static bool Clear(){if(ClearCacheMethod==null)return false;try{ClearCacheMethod.Invoke(null,new object[]{null});return true;}catch(Exception){return false;}}}}

===== FILE Core/Mods/Unload/ModUnloadTicket.cs =====
namespace Cthangover.Core.Mods.Unload{using System;using System.Collections.Generic;using System.Threading;using System.Threading.Tasks;using Godot;/// <summary> What an unload leaves behind: one weak reference per released mod context. Holding nothing strong is the whole point — the ticket must not be the reference that keeps a context alive. Collection is asynchronous in .NET (the runtime finishes an unload on a later GC once no frame, handle or type refers into the context), so verification is a separate, repeatable step: <see cref="Collect"/> right away (unit tests, synchronous reloads) or <see cref="CollectAsync"/> across a few engine frames (Godot releases the GC handles of freed objects at frame boundaries). </summary>
public sealed class ModUnloadTicket{private readonly List<(string ModId,WeakReference Context)>contexts;/// <summary>Wraps the weak references taken by <see cref="ModUnloader"/> (null = nothing was released).</summary>
public ModUnloadTicket(IEnumerable<(string ModId,WeakReference Context)>contexts){this.contexts=contexts!=null?new List<(string,WeakReference)>(contexts):new List<(string,WeakReference)>();}/// <summary>Ids of the mods whose contexts were released, in unload order.</summary>
public IReadOnlyList<string>ModIds{get{var ids=new List<string>();foreach(var(modId,_)in contexts)ids.Add(modId);return ids;}}/// <summary>Ids of the mods whose context is still alive right now.</summary>
public IReadOnlyList<string>Alive{get{var alive=new List<string>();foreach(var(modId,context)in contexts){if(context.IsAlive)alive.Add(modId);}return alive;}}/// <summary>Forces collections until every context died or <paramref name="attempts"/> ran out; returns the survivors.</summary>
public IReadOnlyList<string>Collect(int attempts=10){for(var i=0;i<attempts&&Alive.Count>0;i++){GC.Collect(2,GCCollectionMode.Forced,blocking:true,compacting:true);GC.WaitForPendingFinalizers();}return Alive;}/// <summary> Like <see cref="Collect"/> but yields an engine frame between the attempts, so objects freed this frame release their handles first. Returns the survivors (empty = every context unloaded). </summary>
public async Task<IReadOnlyList<string>>CollectAsync(SceneTree tree,int frames=30,CancellationToken cancellationToken=default){for(var i=0;i<frames;i++){if(Collect(attempts:2).Count==0)return Array.Empty<string>();cancellationToken.ThrowIfCancellationRequested();await tree.ToSignal(tree,SceneTree.SignalName.ProcessFrame);}return Alive;}}}

===== FILE Core/Mods/Unload/ModUnloader.cs =====
namespace Cthangover.Core.Mods.Unload{using System;using System.Collections.Generic;using System.Linq;using System.Reflection;using System.Runtime.CompilerServices;using Cthangover.Core.Events;using Cthangover.Core.Mods.Consumers;using Cthangover.Core.Utils;using Godot;/// <summary> The unload protocol for a set of mods, run as one pass so references between the dying mods never block each other. Order matters: <list type="number"> <item><b>Mod-side teardown, types still alive</b> — the reverse consumer fan-out per assembly (initializers get <c>OnModUnloading</c>, registries drop what they took) and the script-type registry entry.</item> <item><b>Engine side</b> — Godot objects of mod types are freed and signal connections into mod code disconnected (<see cref="GodotObjectSweeper"/>).</item> <item><b>Managed leftovers</b> — event-bus subscriptions by code ownership, mod-contributed assembly consumers, then the generic <see cref="StaticReferenceSweeper"/> over the surviving assemblies and autoload nodes, then the System.Text.Json caches.</item> <item><b>Release</b> — each context unloads; GodotSharp drops the mod script types on the context's <c>Unloading</c> event, the manual purge covers anything registered before tracking was enabled.</item> </list> Collection itself happens later; the returned <see cref="ModUnloadTicket"/> verifies it. Everything is logged under <c>MODS_UNLOAD</c>: a non-empty sweep report names the subsystem that should unregister itself properly. </summary>
public static class ModUnloader{/// <summary> Unloads <paramref name="modIdsInUnloadOrder"/> (dependents first). <paramref name="tree"/> is the live scene tree (null outside Godot). <paramref name="hostAssemblies"/> overrides the assemblies whose statics are swept (default: every surviving mod, plus the kernel inside the engine). Unknown ids are ignored. </summary>
[MethodImpl(MethodImplOptions.NoInlining)]public static ModUnloadTicket Unload(IReadOnlyList<string>modIdsInUnloadOrder,SceneTree tree,IEnumerable<Assembly>hostAssemblies=null){var records=ModAssemblyLoader.Detach(modIdsInUnloadOrder);if(records.Count==0)return new ModUnloadTicket(null);var ownership=new AssemblyOwnership(records.SelectMany(OwnedAssemblies));foreach(var record in records)Unregister(record);var report=new List<string>();report.AddRange(GodotObjectSweeper.Release(tree,ownership));var subscriptions=ModEventBus.RemoveOwned(ownership);foreach(var record in records)ModEventBus.Instance?.ClearForMod(record.ModId);if(subscriptions>0)report.Add($"removed {subscriptions} event bus subscription(s) of mod code");var consumers=AssemblyConsumerRegistry.RemoveOwned(ownership);if(consumers>0)report.Add($"removed {consumers} mod-contributed assembly consumer(s)");var hosts=hostAssemblies??DefaultHosts();report.AddRange(StaticReferenceSweeper.Sweep(ownership,hosts,AutoloadNodes(tree,ownership)));if(!JsonCacheReset.Clear())report.Add("System.Text.Json cache reset unavailable: serialized mod types may stay pinned");var tickets=new List<(string,WeakReference)>();foreach(var record in records){if(record.Context==null){report.Add($"'{record.ModId}' was loaded in the default context and stays in memory");continue;}tickets.Add((record.ModId,new WeakReference(record.Context)));record.Context.TryUnload();}var purged=GodotScriptBridgeUnload.PurgeOwnedTypes(ownership);if(purged>0)report.Add($"purged {purged} script type entr(ies) GodotSharp did not release itself");var ids=new List<string>();foreach(var record in records)ids.Add(record.ModId);GameLogger.Log("MODS_UNLOAD",$"Unloaded {records.Count} mod(s): {string.Join(", ", ids)}");foreach(var line in report)GameLogger.Log("MODS_UNLOAD","  "+line);return new ModUnloadTicket(tickets);}/// <summary> Reverse fan-out for mods that stay loaded but re-register (a reload rebuilds every registry; the assembly and its static state survive). </summary>
public static void UnregisterKept(IReadOnlyList<string>modIdsInUnloadOrder){foreach(var record in ModAssemblyLoader.Records(modIdsInUnloadOrder))Unregister(record);}private static void Unregister(LoadedModAssembly record){for(var i=record.Assemblies.Count-1;i>=0;i--)AssemblyConsumerRegistry.FanOutUnloading(record.Assemblies[i],record.ModId);ScriptTypeRegistry.RemoveMod(record.ModId);}private static IEnumerable<Assembly>OwnedAssemblies(LoadedModAssembly record){// Lazily resolved assemblies of the context (mod-shipped libraries) die with it too, even if they never went through the fan-out.
return record.Context!=null?record.Assemblies.Concat(record.Context.Assemblies).Distinct():record.Assemblies;}private static IEnumerable<Assembly>DefaultHosts(){// The kernel's statics are read only inside the engine: outside it (unit tests) reading one may run a static constructor that calls into Godot, which kills the process instead of throwing.
if(GameEnvironment.IsRuntime)yield return typeof(ModUnloader).Assembly;foreach(var record in ModAssemblyLoader.Records(ModAssemblyLoader.LoadedModIds)){foreach(var assembly in record.Assemblies)yield return assembly;}}private static List<object>AutoloadNodes(SceneTree tree,AssemblyOwnership ownership){var nodes=new List<object>();if(tree?.Root==null)return nodes;foreach(var node in tree.Root.GetChildren()){if(GodotObject.IsInstanceValid(node)&& !ownership.Owns(node.GetType()))nodes.Add(node);}return nodes;}}}

===== FILE Core/Mods/Unload/StaticReferenceSweeper.cs =====
namespace Cthangover.Core.Mods.Unload{using System;using System.Collections;using System.Collections.Generic;using System.Reflection;/// <summary> Drops references into dying mod assemblies from the long-lived parts of the process: the static fields of the surviving assemblies (the kernel and the mods that stay) and the objects reachable from them, plus a few explicit roots (autoload nodes). This is the generic answer to the registries and "first registration wins" bridges a mod fills from its initializer and nobody ever clears — <c>LightingBridge</c> slots in the kernel, <c>SceneEventController</c> timer lists, a kept mod's own registry singleton holding another mod's types (<c>BattleCoreRegistry.Instance._cores</c>): each pins the dying mod's <see cref="System.Runtime.Loader.AssemblyLoadContext"/> forever. Rules, applied to every field value on the way: <list type="bullet"> <item>a delegate loses its owned invocation-list entries;</item> <item>an owned object (instance of a mod type, a mod <see cref="Type"/>) is replaced by null;</item> <item>a list/set loses owned items; a dictionary loses entries with an owned key or value;</item> <item>objects whose type comes from a surviving host assembly, and the items of collections, are walked further (up to <see cref="MaxDepth"/>, each object once). Objects of framework or engine types are never walked — only their collection contents are — so the sweep stays bounded.</item> </list> Only fields whose declared type can hold a reference into mod code are read (<see cref="CanReferenceModCode"/>): reading a static field runs the type's static constructor, which must not happen as a side effect of an unload. Static <c>readonly</c> fields cannot be reassigned, so an owned value there is reported, not cleared; collections and objects behind them are still cleaned. Every change is reported, so the log shows which subsystem forgot to unregister — the sweep is the safety net, not the contract. </summary>
public sealed class StaticReferenceSweeper{/// <summary>How far the walk goes from a static field: field → object → collection → object → …</summary>
public const int MaxDepth=6;private const BindingFlags StaticFields=BindingFlags.Static|BindingFlags.Public|BindingFlags.NonPublic|BindingFlags.DeclaredOnly;private const BindingFlags InstanceFields=BindingFlags.Instance|BindingFlags.Public|BindingFlags.NonPublic|BindingFlags.DeclaredOnly;private readonly AssemblyOwnership ownership;private readonly HashSet<Assembly>hosts;private readonly HashSet<object>visited=new(ReferenceEqualityComparer.Instance);private readonly List<string>report=new();private StaticReferenceSweeper(AssemblyOwnership ownership,HashSet<Assembly>hosts){this.ownership=ownership;this.hosts=hosts;}/// <summary> Sweeps the static fields of every type in <paramref name="hostAssemblies"/> (owned assemblies are skipped: their statics die with them), the objects of host types reachable from them and the objects in <paramref name="instances"/> (walked even when their type is not a host type, up to the first GodotSharp base class). Returns one line per change or uncleanable reference. </summary>
public static List<string>Sweep(AssemblyOwnership ownership,IEnumerable<Assembly>hostAssemblies,IEnumerable<object>instances=null){if(ownership==null||ownership.IsEmpty)return new List<string>();var hosts=new HashSet<Assembly>();foreach(var assembly in hostAssemblies??Array.Empty<Assembly>()){if(assembly!=null&& !assembly.IsDynamic&& !ownership.Owns(assembly))hosts.Add(assembly);}var sweeper=new StaticReferenceSweeper(ownership,hosts);foreach(var assembly in hosts){foreach(var type in LoadableTypes(assembly)){if(type.ContainsGenericParameters)continue;foreach(var field in ReferenceFields(type,StaticFields))sweeper.TrySweepField(field,null,MaxDepth);}}foreach(var instance in instances??Array.Empty<object>()){if(instance!=null&& !ownership.Owns(instance.GetType()))sweeper.SweepObject(instance,MaxDepth);}return sweeper.report;}private void TrySweepField(FieldInfo field,object owner,int depth){try{SweepField(field,owner,depth);}catch(Exception ex){report.Add($"{field.DeclaringType?.FullName}.{field.Name}: sweep failed: {ex.GetType().Name}: {ex.Message}");}}private void SweepField(FieldInfo field,object owner,int depth){if(field.IsLiteral||ownership.Owns(field.DeclaringType))return;var value=field.GetValue(owner);if(value==null)return;var where=$"{field.DeclaringType?.FullName}.{field.Name}";if(value is Delegate handler){var stripped=ownership.Strip(handler,out var changed);if(changed)Assign(field,owner,stripped,where,"delegate entries");return;}if(ownership.OwnsValue(value)){Assign(field,owner,null,where,"reference");return;}var removed=CleanValue(value,depth-1);if(removed>0)report.Add($"{where}: removed {removed} item(s)");}private void Assign(FieldInfo field,object owner,object value,string where,string what){if(field.IsInitOnly&&field.IsStatic){report.Add($"{where}: readonly static holds a mod {what}, cannot clear (pins the mod)");return;}field.SetValue(owner,value);report.Add($"{where}: cleared mod {what}");}/// <summary>Cleans a surviving value: a collection's owned items, or the fields of a host object. Returns removed collection items.</summary>
private int CleanValue(object value,int depth){if(depth<=0||value is string||value is Array||value is Type||value is Assembly)return 0;if(value is IEnumerable enumerable)return CleanCollection(enumerable,depth);if(hosts.Contains(value.GetType().Assembly))SweepObject(value,depth);return 0;}/// <summary>Walks the instance fields of <paramref name="instance"/> once.</summary>
private void SweepObject(object instance,int depth){if(depth<=0|| !visited.Add(instance))return;for(var type=instance.GetType();type!=null&&type!=typeof(object);type=type.BaseType){if(type.Assembly==typeof(Godot.GodotObject).Assembly)break;foreach(var field in ReferenceFields(type,InstanceFields))TrySweepField(field,instance,depth);}}private int CleanCollection(IEnumerable collection,int depth){if(!visited.Add(collection))return 0;try{switch(collection){case IDictionary dictionary when!dictionary.IsReadOnly:return CleanDictionary(dictionary,depth);case IList list when!list.IsReadOnly&& !list.IsFixedSize:return CleanList(list,depth);default:return CleanGenericCollection(collection,depth);}}catch(InvalidOperationException){// A collection mutated concurrently: skip, the next unload retries.
return 0;}}private int CleanDictionary(IDictionary dictionary,int depth){// IDictionary's own enumerator yields DictionaryEntry for every implementation (ConcurrentDictionary included); the generic one would yield KeyValuePair.
var entries=new List<DictionaryEntry>();var enumerator=dictionary.GetEnumerator();while(enumerator.MoveNext())entries.Add(enumerator.Entry);var doomed=new List<object>();var removed=0;foreach(var entry in entries){if(ownership.OwnsValue(entry.Key)||ownership.OwnsValue(entry.Value)){doomed.Add(entry.Key);continue;}if(entry.Value is Delegate handler){var stripped=ownership.Strip(handler,out var changed);if(!changed)continue;if(stripped==null)doomed.Add(entry.Key);else dictionary[entry.Key]=stripped;removed++;continue;}if(entry.Value!=null)removed+=CleanValue(entry.Value,depth-1);}foreach(var key in doomed)dictionary.Remove(key);return removed+doomed.Count;}private int CleanList(IList list,int depth){var removed=0;for(var i=list.Count-1;i>=0;i--){var item=list[i];if(ownership.OwnsValue(item)){list.RemoveAt(i);removed++;continue;}if(item!=null)removed+=CleanValue(item,depth-1);}return removed;}/// <summary>Sets and other generic collections exposing <c>ICollection&lt;T&gt;.Remove</c> (HashSet, LinkedList, SortedSet).</summary>
private int CleanGenericCollection(IEnumerable collection,int depth){var collectionInterface=GenericCollectionInterface(collection.GetType());if(collectionInterface==null||(bool)collectionInterface.GetProperty("IsReadOnly").GetValue(collection))return 0;var remove=collectionInterface.GetMethod("Remove");var elementIsValueType=collectionInterface.GetGenericArguments()[0].IsValueType;var items=new List<object>();foreach(var item in collection)items.Add(item);var removed=0;foreach(var item in items){if(ownership.OwnsValue(item)){if((bool)remove.Invoke(collection,new[]{item}))removed++;}else if(item!=null&& !elementIsValueType){removed+=CleanValue(item,depth-1);}}return removed;}private static Type GenericCollectionInterface(Type type){foreach(var candidate in type.GetInterfaces()){if(candidate.IsGenericType&&candidate.GetGenericTypeDefinition()==typeof(ICollection<>))return candidate;}return null;}private static IEnumerable<Type>LoadableTypes(Assembly assembly){try{return assembly.GetTypes();}catch(ReflectionTypeLoadException ex){var loaded=new List<Type>();foreach(var type in ex.Types){if(type!=null)loaded.Add(type);}return loaded;}}private static List<FieldInfo>ReferenceFields(Type type,BindingFlags flags){var result=new List<FieldInfo>();FieldInfo[]fields;try{fields=type.GetFields(flags);}catch(Exception){return result;}foreach(var field in fields){if(CanReferenceModCode(field.FieldType))result.Add(field);}return result;}/// <summary> Field types that can hold a reference into mod code. Strings, primitives, enums and arrays cannot; sealed non-generic classes of the framework and the engine (such as <c>StringName</c> in the Godot-generated <c>MethodName</c>/<c>SignalName</c> holders) cannot either. Sealed classes of the game itself can (a registry singleton holding mod types), so they are read. </summary>
private static bool CanReferenceModCode(Type fieldType){if(fieldType.IsPrimitive||fieldType.IsEnum||fieldType.IsPointer||fieldType.IsByRefLike||fieldType==typeof(string))return false;if(fieldType.IsArray)return false;if(fieldType.IsValueType)return fieldType.IsGenericType;if(fieldType.IsSealed&& !fieldType.IsGenericType&& !typeof(Delegate).IsAssignableFrom(fieldType)&&fieldType!=typeof(Type))return!IsPlatformAssembly(fieldType.Assembly);return true;}private static bool IsPlatformAssembly(Assembly assembly){var name=assembly.GetName().Name??string.Empty;return assembly==typeof(object).Assembly||assembly==typeof(Godot.GodotObject).Assembly||name.StartsWith("System.",StringComparison.Ordinal)||name.StartsWith("Microsoft.",StringComparison.Ordinal);}}}

===== FILE Core/Mods/WrapperDefinition.cs =====
namespace Cthangover.Core.Mods{/// <summary> Loaded wrapper template — a named UI tree definition stored as raw text content. Collected from <c>.wrappertmpl</c> files across mods by <c>ModManager.CollectWrapperTemplates</c> and exposed to consumers through <c>WrapperTemplates.Collect()</c>. The engine currently only collects these templates — nothing instantiates them automatically. The content is kept as a raw string because it is meant to be parsed at instantiation time with the game's custom template format, not JSON. </summary>
public class WrapperDefinition{/// <summary>The mod that owns this template.</summary>
public string ModId{get;set;}/// <summary>Template name (filename minus .wrappertmpl extension).</summary>
public string Name{get;set;}/// <summary>Raw template text — parsed at instantiation time.</summary>
public string Content{get;set;}}}

===== FILE Core/Quests/IQuest.cs =====
namespace Cthangover.Core.Quests{/// <summary> Read-only interface for quest state queries. Exposes the minimal set of properties that dialogue scripts, condition checkers, and UI bindings need to inspect a quest without being able to mutate it. Any concrete quest class that participates in the <see cref="QuestFactory"/> lookup system must implement this contract, and extension methods in <see cref="IQuestExtensions"/> provide convenience helpers layered on top of <c>ContainsTag</c> for common condition patterns. </summary>
public interface IQuest{/// <summary> Unique string key that matches the quest's entry in the mod definition file and serves as the lookup key inside <see cref="QuestFactory"/>'s internal <c>Dictionary</c>. </summary>
string ID{get;}/// <summary> Human-readable display name localised via <c>TranslationServer.Translate</c> before being shown in the journal window or notification banners. </summary>
string Name{get;}/// <summary> Current progression state of the quest, represented as one of the <see cref="QuestStatus"/> enum values. Dialogue condition nodes and journal formatting code use this to decide which description string to show and whether prerequisite gating should pass. </summary>
QuestStatus Status{get;}/// <summary> Returns <c>true</c> if the quest's runtime tag set contains <paramref name="tag"/>. Tags are the primary mechanism for tracking sub-objectives during quest branches; a single quest accumulates tags as the player reaches milestones, and other systems (dialogue, scene transitions) query tags to gate content. </summary>
bool ContainsTag(string tag);}}

===== FILE Core/Quests/QuestServiceRegistry.cs =====
using Cthangover.Core.Actions;namespace Cthangover.Core.Quests{/// <summary> Kernel access point for quest operations, decoupling the core from the engine mod that owns quest state (<c>mods/quests_base</c>). The mod's initializer assigns <see cref="Provider"/> during <c>IModInitializer.OnModLoaded</c>; core-side consumers — <c>ScenarioActionContext</c>, <c>GDQuestService</c> and the scenario condition evaluation context — resolve quests through it. Before the mod registers (tools, very early boot) <see cref="Provider"/> is <c>null</c>: action contexts are created only after the boot pipeline has loaded mods, and unit tests inject their own <see cref="IQuestService"/> implementations. </summary>
public static class QuestServiceRegistry{/// <summary>Provider published by the quests engine mod; <c>null</c> until the mod loads.</summary>
public static IQuestService Provider{get;set;}}}

===== FILE Core/Quests/QuestStatus.cs =====
namespace Cthangover.Core.Quests{/// <summary> Discrete progression states for the quest lifecycle. Each value maps to a 1‑byte integer that doubles as an array index into <see cref="QuestBase.StatusToDescription"/> and as the key for journal‑window filtering. The three‑state model avoids the complexity of arbitrary stage counts while still supporting branch‑like progression via the tag system in <see cref="QuestData"/>. </summary>
public enum QuestStatus:byte{/// <summary> The quest has never been accepted or activated. The journal window hides quests in this state unless the player has explicitly toggled "show all" mode. </summary>
NotStarted=0x00,/// <summary> The quest is in progress; the player has accepted the task and may have completed some sub‑objectives (tracked via tags) but has not yet reached the final stage. </summary>
Progress=0x01,/// <summary> Terminal state indicating the quest has been fully completed. Quests in this state are usually moved to a "completed" section of the journal and are excluded from most progression‑gating condition checks. </summary>
End=0x02,};}

===== FILE Core/Scenarios/ScenarioMetadata.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Core.Scenarios{/// <summary> Extracts YAML-like front-matter metadata from scenario script text. Lines before a <c>---</c> separator or the first non-key:value line are parsed as <c>key: value</c> pairs. The command parsing half of the former ScenarioParser moved to the dialog_base engine mod; this metadata-only parser stays in the core because mod asset resolvers need it without a dialog dependency. </summary>
public static class ScenarioMetadata{/// <summary> Returns both the metadata dictionary and the remaining script content after the metadata block. </summary>
public static(Dictionary<string,string>metadata,string remainingText)ParseMetadata(string text){var metadata=new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);var lines=text.Split('\n');var contentStart=0;var inMetadata=true;for(var i=0;i<lines.Length;i++){var line=lines[i].Trim();if(!inMetadata){continue;}if(line=="---"){contentStart=i+1;break;}if(string.IsNullOrEmpty(line)){contentStart=i;break;}var colonIdx=line.IndexOf(':');if(colonIdx>0){var key=line.Substring(0,colonIdx).Trim();var val=line.Substring(colonIdx+1).Trim();metadata[key]=val;}else{contentStart=i;break;}}var remaining=string.Join("\n",lines,contentStart,lines.Length-contentStart);return(metadata,remaining);}}}

===== FILE Core/Scenes/ConditionCompare.cs =====
namespace Cthangover.Core.Scenes{/// <summary> Shared integer comparison utility consumed by <see cref="FlagCondition"/> and <see cref="QuestPropCondition"/> during evaluation. Both node types support the same six operators (<c>== != &gt;= &lt;= &gt; &lt;</c>) on integer values, so the comparison logic lives here to avoid duplication. Extracted from the former <c>ScenarioCondition.Compare</c> private method when evaluation moved into polymorphic <see cref="ConditionNode.Evaluate"/> methods. </summary>
public static class ConditionCompare{/// <summary> Compares two integers using the given operator string. Unknown operators return <c>false</c>. </summary>
public static bool Compare(string op,int a,int b){return op switch{"=="=>a==b,"!="=>a!=b,">="=>a>=b,"<="=>a<=b,">"=>a>b,"<"=>a<b,_=>false};}}}

===== FILE Core/Scenes/ConditionNode.cs =====
namespace Cthangover.Core.Scenes{/// <summary> Abstract base for all nodes in the condition expression AST produced by <see cref="ConditionParser"/>. Every node implements <see cref="Evaluate(EvaluationContext)"/> — the tree is walked polymorphically rather than via a central switch statement. This design means that a mod assembly can define a custom <see cref="ConditionNode"/> subclass, and any code that constructs an AST containing it (including the core parser once extended) automatically evaluates it — no registration, no dispatch table, no <c>_ => true</c> fallthrough. Built-in node types: <list type="bullet"> <item><see cref="AndNode"/> / <see cref="OrNode"/> — binary boolean combinators with short-circuit</item> <item><see cref="NotNode"/> — unary negation</item> <item><see cref="BoolLiteral"/> — constant <c>true</c> / <c>false</c></item> <item><see cref="FlagCondition"/> — runtime flag comparison</item> <item><see cref="QuestMethodCondition"/> — quest tag membership</item> <item><see cref="QuestPropCondition"/> — quest int-property comparison</item> <item><see cref="QuestBoolPropCondition"/> — quest boolean property</item> </list> </summary>
public abstract class ConditionNode:ICondition{/// <summary> Evaluates this node against the given runtime context and returns the boolean result. Called recursively — for combinator nodes like <see cref="AndNode"/> this triggers a depth-first walk. </summary> <param name="ctx">Runtime state: flag dictionary and quest factory.</param>
public abstract bool Evaluate(EvaluationContext ctx);}}

===== FILE Core/Scenes/ConditionParser.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Core.Scenes{/// <summary> Tokenizes condition expression strings and delegates tree construction to the pluggable <see cref="Parsing.ExpressionParser"/>. Operators are resolved through <see cref="Parsing.OperatorRegistry"/>; leaf values through <see cref="Parsing.LeafParserRegistry"/>. Mods can register custom parsers in either registry to extend the condition DSL without touching core code. </summary>
public static class ConditionParser{/// <summary>Lexical tokens of the condition grammar: literals, punctuation, logical operators, and comparison operators.</summary>
public enum TokenType{Ident,Number,String,Dot,LParen,RParen,Comma,And,Or,Not,Eq,Neq,Ge,Le,Gt,Lt,EOF}/// <summary>A single lexical token with its <see cref="TokenType"/> and source text.</summary>
public class Token{/// <summary>Token category produced by the tokenizer.</summary>
public TokenType Type;/// <summary>Raw source text of the token (operators keep their spelling, e.g. "&amp;&amp;").</summary>
public string Value;}private class Tokenizer{private readonly string input;private int pos;private readonly List<Token>tokens=new();public Tokenizer(string input){this.input=input;}/// <summary>Runs the tokenizer over the whole input and returns the token list ending with an EOF token.</summary>
public List<Token>Tokenize(){tokens.Clear();pos=0;while(pos<input.Length){SkipWhitespace();if(pos>=input.Length)break;var c=input[pos];if(c=='('){Add(TokenType.LParen,"(");pos++;}else if(c==')'){Add(TokenType.RParen,")");pos++;}else if(c==','){Add(TokenType.Comma,",");pos++;}else if(c=='.'){Add(TokenType.Dot,".");pos++;}else if(c=='!'){if(pos+1<input.Length&&input[pos+1]=='='){Add(TokenType.Neq,"!=");pos+=2;}else{Add(TokenType.Not,"!");pos++;}}else if(c=='='){if(pos+1<input.Length&&input[pos+1]=='='){Add(TokenType.Eq,"==");pos+=2;}else throw Error("expected '='");}else if(c=='>'){if(pos+1<input.Length&&input[pos+1]=='='){Add(TokenType.Ge,">=");pos+=2;}else{Add(TokenType.Gt,">");pos++;}}else if(c=='<'){if(pos+1<input.Length&&input[pos+1]=='='){Add(TokenType.Le,"<=");pos+=2;}else{Add(TokenType.Lt,"<");pos++;}}else if(c=='&'){if(pos+1<input.Length&&input[pos+1]=='&'){Add(TokenType.And,"&&");pos+=2;}else throw Error("expected '&'");}else if(c=='|'){if(pos+1<input.Length&&input[pos+1]=='|'){Add(TokenType.Or,"||");pos+=2;}else throw Error("expected '|'");}else if(c=='"'||c=='\''){var quote=c;pos++;var start=pos;while(pos<input.Length&&input[pos]!=quote)pos++;if(pos>=input.Length)throw Error("unterminated string");var val=input.Substring(start,pos-start);Add(TokenType.String,val);pos++;}else if(char.IsDigit(c)){var start=pos;while(pos<input.Length&&char.IsDigit(input[pos]))pos++;Add(TokenType.Number,input.Substring(start,pos-start));}else if(char.IsLetter(c)||c=='_'){var start=pos;while(pos<input.Length&&(char.IsLetterOrDigit(input[pos])||input[pos]=='_'))pos++;Add(TokenType.Ident,input.Substring(start,pos-start));}else{throw Error($"unexpected character '{c}'");}}Add(TokenType.EOF,"");return tokens;}private void SkipWhitespace(){while(pos<input.Length&&char.IsWhiteSpace(input[pos]))pos++;}private void Add(TokenType type,string value){tokens.Add(new Token{Type=type,Value=value});}private FormatException Error(string msg){return new FormatException($"Condition parse error at pos {pos}: {msg}\n{input}\n{new string(' ', pos)}^");}}/// <summary> Parses a condition expression string into a <see cref="ConditionNode"/> AST. Delegates tokenisation to the built-in tokenizer and tree construction to the pluggable <see cref="Parsing.ExpressionParser"/> which resolves operators through <see cref="Parsing.OperatorRegistry"/> and leaf values through <see cref="Parsing.LeafParserRegistry"/>. Returns <c>null</c> for null, empty, or whitespace-only input. Throws <see cref="FormatException"/> on syntax errors with position information. </summary> <param name="input">The condition expression string to parse.</param>
public static ConditionNode Parse(string input){if(string.IsNullOrWhiteSpace(input))return null;var tokenizer=new Tokenizer(input);var tokens=tokenizer.Tokenize();return(ConditionNode)new Parsing.ExpressionParser().Parse(tokens);}}}

===== FILE Core/Scenes/ConditionStateMonitor.cs =====
using System.Threading;using Cthangover.Core.Events;namespace Cthangover.Core.Scenes{/// <summary> Единый «флаг актуальности» результатов вычисления сценарных условий. Подписывается на все события изменения состояния, влияющие на кондишины (теги/статусы квестов, флаги, восстановление сейва, интерактивы), и инкрементирует монотонный Version. Потребители (ScenarioEvent, цепочки) считают закешированный результат актуальным, пока Version не изменился. MarkStateChanged() — публичный ручной инвалидатор для модов с кастомным состоянием вне квестов. Notify&lt;T&gt; — публичен для юнит-тестов без Godot. Внимание: ModEventBus.ClearAll() сбрасывает подписки; в игре он не вызывается, но при вызове нужно заново дёрнуть EnsureSubscribed(). </summary>
public static class ConditionStateMonitor{private static long _version;private static readonly object Lock=new();private static bool _subscribed;/// <summary> Монотонно растущий счётчик изменений состояния. Любое событие, подписанное монитором, увеличивает его на 1; равенство значений означает «состояние не менялось с прошлого чтения». </summary>
public static long Version=>Interlocked.Read(ref _version);/// <summary> Ручной инвалидатор: форсирует рост Version вне штатных событий (для модов с кастомным состоянием, влияющим на кондишины). </summary>
public static void MarkStateChanged(){Interlocked.Increment(ref _version);}/// <summary>Обработчик любого события монитора — бампает версию.</summary>
public static void Notify<T>(T evt)where T:ModEvent{MarkStateChanged();}/// <summary> Гарантирует подписку монитора на все события изменения состояния. Идемпотентна — повторные вызовы ничего не делают. Вызывается из статического конструктора; повторный вызов нужен только если <c>ModEventBus.ClearAll()</c> сбросил статические подписки. </summary>
public static void EnsureSubscribed(){lock(Lock){if(_subscribed)return;_subscribed=true;ModEventBus.Subscribe<QuestTagChangedEvent>(Notify);ModEventBus.Subscribe<QuestStatusChangedEvent>(Notify);ModEventBus.Subscribe<QuestDataStatusChangedEvent>(Notify);ModEventBus.Subscribe<FlagChangedEvent>(Notify);ModEventBus.Subscribe<QuestStateRestoredEvent>(Notify);ModEventBus.Subscribe<InteractiveStateChangedEvent>(Notify);ModEventBus.Subscribe<MapZoneChangedEvent>(Notify);}}static ConditionStateMonitor(){EnsureSubscribed();}}}

===== FILE Core/Scenes/EvaluationContext.cs =====
using System.Collections.Generic;using Cthangover.Core.Actions;namespace Cthangover.Core.Scenes{/// <summary> Carries the runtime state that condition nodes need during evaluation. A single instance is reused across the entire AST walk — created once in <see cref="ScenarioCondition.Evaluate"/> and passed through every <see cref="ConditionNode.Evaluate"/> call. <c>Flags</c> is a reference to the static flag dictionary managed by <see cref="ScenarioCondition.SetFlag"/>, allowing <see cref="FlagCondition"/> to resolve flag values without static access. <c>QuestFactory</c> is a reference to the quest service published by the quests engine mod through <see cref="Cthangover.Core.Quests.QuestServiceRegistry"/>, injected so that quest-based leaf nodes shipped with the mod can resolve quests without a hard reference to the mod assembly. The context is deliberately a plain POCO — not a Godot object — so that it can be instantiated in unit tests without the Godot runtime. </summary>
public class EvaluationContext{/// <summary>Reference to the static flag dictionary from <see cref="ScenarioCondition"/>.</summary>
public Dictionary<string,string>Flags;/// <summary>Quest service of the quests engine mod (core contract <see cref="IQuestService"/>) for quest resolution.</summary>
public IQuestService QuestFactory;}}

===== FILE Core/Scenes/FlagCondition.cs =====
namespace Cthangover.Core.Scenes{/// <summary> An AST leaf node representing a comparison between a named runtime flag and a literal value. Looks up the flag key in the <see cref="EvaluationContext.Flags"/> dictionary at evaluation time. Supports both numeric and string comparisons; if both the stored and expected values parse as integers, a numeric comparison is performed via <see cref="ConditionCompare.Compare"/>. When the flag is absent from the dictionary, the condition returns <c>true</c> for <c>!=</c> and <c>false</c> for all other operators — this "missing means not-equal" semantics allows scenario scripts to gate on flags that may not yet have been set. </summary>
public class FlagCondition:ConditionNode{/// <summary>The flag key to look up in the runtime flag dictionary.</summary>
public string Key{get;}/// <summary>The comparison operator (<c>==</c>, <c>!=</c>, etc.).</summary>
public string Op{get;}/// <summary>The literal value to compare the flag against.</summary>
public string Value{get;}/// <summary> Creates a flag comparison condition node. </summary> <param name="key">The flag key.</param> <param name="op">The comparison operator.</param> <param name="value">The literal value to compare against.</param>
public FlagCondition(string key,string op,string value){Key=key;Op=op;Value=value;}/// <summary>Reads the live flag value and compares it; a missing flag counts as "not equal".</summary>
public override bool Evaluate(EvaluationContext ctx){if(!ctx.Flags.TryGetValue(Key,out var current))return Op=="!=";if(int.TryParse(current,out var ci)&&int.TryParse(Value,out var fi))return ConditionCompare.Compare(Op,ci,fi);return Op switch{"=="=>current==Value,"!="=>current!=Value,_=>false};}}}

===== FILE Core/Scenes/GodotSceneService.cs =====
using System;using Cthangover.Core.Bootstrap;using Cthangover.Core.Events;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Scenes{/// <summary> Manages native Godot scene file (.tscn) transitions with a shader-based fade overlay. Handles scene routing for menus, mod scene routes and the main base scene. Orchestrates the full transition lifecycle: fade-out, synchronous scene change via <see cref="SceneTree.ChangeSceneToFile"/>, deferred shader patching, subscription execution, and fade-in. The static <see cref="IsTransitioning"/> flag is consumed by <see cref="SceneManager"/> to decide between instant and animated background swaps during scene entry. </summary>
public partial class GodotSceneService:Node{private SceneManager sceneManager;private CanvasLayer transitionLayer;private TextureRect transitionOverlay;private ShaderMaterial transitionMaterial;private string pendingScenePath;private string pendingSubscriptionSceneName;private string currentGodotSceneName;private Tween fadeTween;/// <summary> Indicates whether a scene transition fade is currently in progress. When <c>true</c>, <see cref="SceneManager"/> applies background changes instantly rather than animating them, avoiding visual conflicts with the fade effect. </summary>
public static bool IsTransitioning{get;private set;}/// <summary> Logical name of the Godot scene last switched to (a mod route such as <c>novel_editor</c>, or <c>mainmenu</c>). Lets a tool that temporarily leaves the screen (the scenario editor's play-test) come back to it. </summary>
public string CurrentGodotSceneName=>currentGodotSceneName;/// <summary>Enables always-on processing (transitions must run while the game is paused) and caches the <see cref="SceneManager"/> autoload.</summary>
public override void _Ready(){base._Ready();ProcessMode=ProcessModeEnum.Always;sceneManager=GetNode<SceneManager>("/root/SceneManager");}/// <summary> Initiates loading a .tscn scene file. Notifies all music players in the "music_player" group when the target is a registered mod route, then delegates to <see cref="BeginSceneTransition"/> for the actual transition. Routes registered with fade enabled receive the shader transition; everything else switches immediately. </summary> <param name="nextScene">The resource path to the .tscn file to load.</param>
public void LoadScene(string nextScene){GameLogger.Log("SCENE",$"load scene '{nextScene}'...");var route=AppServices.Current.SceneRoutes.FindByScenePath(nextScene);if(route!=null&& !string.IsNullOrEmpty(route.MusicScene)){GameLogger.Log("AUDIO",$"GodotSceneService publishing route activation for '{route.LogicalName}'");ModEventBus.Publish(new SceneRouteActivatingEvent{LogicalName=route.LogicalName,ScenePath=nextScene,MusicScene=route.MusicScene,});}BeginSceneTransition(nextScene);}private void BeginSceneTransition(string nextScene){pendingScenePath=nextScene;if(!ShouldFade(pendingScenePath)){PerformSceneSwitch();return;}IsTransitioning=true;var tree=GetTree();if(tree==null)return;var root=tree.Root;if(root==null)return;EnsureOverlay(root);transitionLayer.Visible=true;if(transitionMaterial!=null){transitionMaterial.SetShaderParameter("progress",1f);KillFadeTween();fadeTween=tree.CreateTween();fadeTween.SetProcessMode(Tween.TweenProcessMode.Physics);fadeTween.TweenMethod(Callable.From<float>(OnShaderExitProgress),1f,0f,0.25f);fadeTween.Finished+=OnFadeOutComplete;}}private static bool ShouldFade(string scenePath){var route=AppServices.Current.SceneRoutes.FindByScenePath(scenePath);return route!=null&&route.Fade;}private void EnsureOverlay(Node root){if(transitionLayer!=null&&GodotObject.IsInstanceValid(transitionLayer))return;transitionLayer=new CanvasLayer{Name="SceneTransitionLayer",Layer=128};root.AddChild(transitionLayer);transitionOverlay=new TextureRect{Name="SceneTransitionOverlay",MouseFilter=Control.MouseFilterEnum.Ignore,ExpandMode=TextureRect.ExpandModeEnum.IgnoreSize,StretchMode=TextureRect.StretchModeEnum.Scale};transitionOverlay.AnchorRight=1f;transitionOverlay.AnchorBottom=1f;transitionOverlay.GrowHorizontal=Control.GrowDirection.Both;transitionOverlay.GrowVertical=Control.GrowDirection.Both;transitionLayer.AddChild(transitionOverlay);var blackImage=Image.CreateEmpty(1,1,false,Image.Format.Rgba8);blackImage.Fill(new Color(0,0,0,1));transitionOverlay.Texture=ImageTexture.CreateFromImage(blackImage);var shader=Shaders.Resolve("scene_transition");if(shader!=null){transitionMaterial=new ShaderMaterial();transitionMaterial.Shader=shader;transitionOverlay.Material=transitionMaterial;}}private void KillFadeTween(){if(fadeTween!=null){fadeTween.Kill();fadeTween=null;}}private void OnShaderExitProgress(float t){if(transitionMaterial!=null)transitionMaterial.SetShaderParameter("progress",t);}private void OnFadeOutComplete(){KillFadeTween();CallDeferred(nameof(PerformSceneSwitch));}private void PerformSceneSwitch(){var tree=GetTree();if(tree==null)return;GameLogger.Log("SCENE",$"PerformSceneSwitch: changing scene to '{pendingScenePath}'...");if(IsModScene(pendingScenePath)||TscnScenes.HasPatches(pendingScenePath)){var node=TscnScenes.LoadAndBuild(pendingScenePath);if(node==null){GameLogger.Log("SCENE",$"PerformSceneSwitch: failed to build mod scene '{pendingScenePath}'",LogLevel.Error);return;}tree.ChangeSceneToNode(node);}else{tree.ChangeSceneToFile(pendingScenePath);}GameLogger.Log("SCENE",$"PerformSceneSwitch: scene changed to '{pendingScenePath}' OK");CallDeferred(nameof(PatchShaderMaterialsAfterLoad));CallDeferred(nameof(RunSubscriptionAfterLoad));if(ShouldFade(pendingScenePath))CallDeferred(nameof(StartFadeInAfterSwitch));}private void RunSubscriptionAfterLoad(){GameLogger.Log("SCENE",$"RunSubscriptionAfterLoad: scene='{pendingSubscriptionSceneName}'");if(string.IsNullOrEmpty(pendingSubscriptionSceneName))return;var tree=GetTree();if(tree==null)return;ModEventBus.Publish(new SceneEnteredEvent{SceneName=pendingSubscriptionSceneName,Root=tree.CurrentScene,});GameLogger.Log("SCENE","RunSubscriptionAfterLoad: DONE");}private void StartFadeInAfterSwitch(){var tree=GetTree();if(tree==null)return;var root=tree.Root;if(root==null)return;EnsureOverlay(root);if(transitionMaterial==null)return;transitionLayer.Visible=true;transitionMaterial.SetShaderParameter("progress",0f);KillFadeTween();fadeTween=tree.CreateTween();fadeTween.SetProcessMode(Tween.TweenProcessMode.Physics);fadeTween.TweenMethod(Callable.From<float>(OnShaderEnterProgress),0f,1f,0.25f);fadeTween.Finished+=OnFadeInComplete;}private void OnShaderEnterProgress(float t){if(transitionMaterial!=null)transitionMaterial.SetShaderParameter("progress",t);}private void OnFadeInComplete(){KillFadeTween();if(transitionLayer!=null&&GodotObject.IsInstanceValid(transitionLayer))transitionLayer.Visible=false;IsTransitioning=false;}/// <summary> True when the path points at a mod-provided scene (mod-relative, no <c>res://</c> prefix). Native project scenes keep the engine loader; mod scenes are built by <see cref="TscnScenes"/> and swapped in with <see cref="SceneTree.ChangeSceneToNode"/>. </summary>
private static bool IsModScene(string scenePath)=> !string.IsNullOrEmpty(scenePath)&& !scenePath.StartsWith("res://",StringComparison.OrdinalIgnoreCase);private void PatchShaderMaterialsAfterLoad(){GameLogger.Log("SCENE","PatchShaderMaterialsAfterLoad: START");ShaderModReplacer.PatchScene(GetTree().Root);GameLogger.Log("SCENE","PatchShaderMaterialsAfterLoad: DONE");}/// <summary> Resolves a <see cref="GodotSceneType"/> to its .tscn file path via <see cref="GetScenePath"/>, publishes <see cref="SceneExitedEvent"/> for the currently loaded Godot scene and remembers its logical name for the matching <see cref="SceneEnteredEvent"/>, then delegates to <see cref="LoadScene"/>. If no file path is resolved, falls back to <see cref="SceneManager.SwitchScene"/> for scenario-based routing. </summary> <param name="sceneType">The type of Godot scene to switch to.</param>
public void SwitchScene(GodotSceneType sceneType){if(!string.IsNullOrEmpty(currentGodotSceneName)){ModEventBus.Publish(new SceneExitedEvent{SceneName=currentGodotSceneName});}pendingSubscriptionSceneName=sceneType.ToString().ToLowerInvariant();currentGodotSceneName=pendingSubscriptionSceneName;var path=GetScenePath(sceneType);if(!string.IsNullOrEmpty(path)){LoadScene(path);return;}GameLogger.Log("SCENE",$"SwitchScene: no scene path registered for '{sceneType}'",LogLevel.Error);}/// <summary> Switches to a scene registered by a mod through <see cref="SceneRouteRegistry"/>. Publishes the exit event for the current scene and remembers the route's logical name so the entered event carries it, then loads the mod-relative tscn. This is the route counterpart of <see cref="SwitchScene(GodotSceneType)"/>. </summary>
public void SwitchRoute(SceneRoute route){if(route==null||string.IsNullOrEmpty(route.ScenePath))return;if(!string.IsNullOrEmpty(currentGodotSceneName))ModEventBus.Publish(new SceneExitedEvent{SceneName=currentGodotSceneName});// Keep the logical name exactly as the mod registered it: playlist scene keys and mod sound groups are matched case-sensitively.
pendingSubscriptionSceneName=route.LogicalName;currentGodotSceneName=route.LogicalName;LoadScene(route.ScenePath);}/// <summary> Convenience method that switches to the main menu scene. Equivalent to <c>SwitchScene(GodotSceneType.MainMenu)</c>. </summary>
public void SwitchToMenu(){SwitchScene(GodotSceneType.MainMenu);}/// <summary> Loads the base shell scene. Encapsulates the shell path so that menus, return flows and mods never hardcode <c>scenes/ui/base_scene.tscn</c>. Unlike <see cref="SwitchScene(GodotSceneType)"/> this keeps the current pending-logical-scene flow: the shell's event chain picks up <see cref="SceneManager.PendingSceneName"/> after the load and switches to the requested logical scene. </summary>
public void LoadShell(){LoadScene(GetScenePath(GodotSceneType.BaseScene));}/// <summary> Single source of truth for scene paths by <see cref="GodotSceneType"/>. Only the main menu and the shell remain here; every gameplay scene is a mod route resolved through <see cref="SceneRouteRegistry"/>. Callers should ask for a scene type instead of hardcoding paths themselves. </summary>
public static string GetScenePath(GodotSceneType type){if(type==GodotSceneType.MainMenu)return"scenes/menu/main_menu.tscn";if(type==GodotSceneType.BaseScene)return"scenes/ui/base_scene.tscn";return null;}}}

===== FILE Core/Scenes/GodotSceneType.cs =====
namespace Cthangover.Core.Scenes{/// <summary> Enumerates the built-in Godot .tscn scenes that can be directly loaded by <see cref="GodotSceneService"/>. Each value maps to a specific scene file path (e.g. <c>res://scenes/menu/main_menu.tscn</c>). Also used by music player behaviors to determine context-appropriate audio. Gameplay scenes such as arenas or minigames are not listed here — mods register them through <see cref="SceneRouteRegistry"/> instead. </summary>
public enum GodotSceneType{/// <summary>The main menu scene.</summary>
MainMenu,/// <summary>The base visual novel scene used for all scenario-driven content.</summary>
BaseScene,};}

===== FILE Core/Scenes/ICondition.cs =====
namespace Cthangover.Core.Scenes;/// <summary> A node of the quest-condition expression tree parsed from DSL condition strings (comparison, logical, and grouping nodes). Implementations evaluate themselves against the runtime context. </summary>
public interface ICondition{/// <summary> Evaluates this node against the given runtime context and returns the boolean result. Called recursively — for combinator nodes like <see cref="AndNode"/> this triggers a depth-first walk. </summary> <param name="ctx">Runtime state: flag dictionary and quest factory.</param>
bool Evaluate(EvaluationContext ctx);}

===== FILE Core/Scenes/ITscnSceneEntryPoint.cs =====
using Godot;namespace Cthangover.Core.Scenes{/// <summary> Entry-point contract for scenes loaded via the TSCN runtime parser. Implementations are resolved by name (from the <c>__entry_point</c> property on the root node) and called after the entire node tree is built, properties applied, and signals connected. <para/> Resolution order: <see cref="Mods.ScriptTypeRegistry"/> first (mod-compiled types), then <c>Type.GetType()</c>, then all <c>AppDomain</c> assemblies as fallback. </summary>
public interface ITscnSceneEntryPoint{void OnSceneBuilt(Node sceneRoot);}}

===== FILE Core/Scenes/ModScenePrefab.cs =====
using Godot;namespace Cthangover.Core.Scenes{/// <summary> Mod-aware replacement for <see cref="PackedScene"/> references held in code. Godot's <c>GD.Load&lt;PackedScene&gt;</c> cannot see files inside <c>mods/</c> (the folder is <c>.gdignore</c>d), so code that instantiates mod-provided scenes stores a mod-relative path and defers to <see cref="TscnScenes"/>. Each <see cref="Instantiate{T}"/> returns a fresh node built by the runtime tscn pipeline, mirroring PackedScene's instantiate-per-call contract. </summary>
public sealed class ModScenePrefab{private readonly string path;/// <summary>Creates a prefab handle for a mod-relative scene path (e.g. "scenes/ui/foo.tscn").</summary>
public ModScenePrefab(string modRelativePath){path=modRelativePath;}/// <summary>Mod-relative path this prefab resolves, exposed for logging and diagnostics.</summary>
public string Path=>path;/// <summary>Builds a new instance and casts it to <typeparamref name="T"/>; null when unavailable.</summary>
public T Instantiate<T>()where T:Node{return TscnScenes.LoadAndBuild(path)as T;}/// <summary>Builds a new untyped instance; null when the scene cannot be resolved.</summary>
public Node Instantiate(){return TscnScenes.LoadAndBuild(path);}}}

===== FILE Core/Scenes/Nodes/Impls/AndNode.cs =====
namespace Cthangover.Core.Scenes.Nodes.Impls{/// <summary> AST node for boolean AND — evaluates <see cref="Left"/> first, then <see cref="Right"/> only if <see cref="Left"/> is <c>true</c>. This short-circuit behaviour is inherited from the C# <c>&amp;&amp;</c> operator and requires no explicit control flow. Produced by <see cref="ConditionParser"/> from the <c>&amp;&amp;</c> token in condition expressions. When a mod constructs an AST manually, it wraps <see cref="Left"/> and <see cref="Right"/> directly without involving the parser. </summary>
public class AndNode:ConditionNode{/// <summary>Evaluated first; if <c>false</c>, <see cref="Right"/> is skipped.</summary>
public ICondition Left{get;}/// <summary>Evaluated only when <see cref="Left"/> returns <c>true</c>.</summary>
public ICondition Right{get;}/// <summary>Creates an AND node over two operand sub-expressions.</summary> <param name="left">Operand evaluated first.</param> <param name="right">Operand evaluated only when <paramref name="left"/> is true.</param>
public AndNode(ICondition left,ICondition right){Left=left;Right=right;}/// <summary>Short-circuits: <see cref="Right"/> runs only when <see cref="Left"/> is true.</summary>
public override bool Evaluate(EvaluationContext ctx)=>Left.Evaluate(ctx)&&Right.Evaluate(ctx);}}

===== FILE Core/Scenes/Nodes/Impls/BoolLiteral.cs =====
namespace Cthangover.Core.Scenes.Nodes.Impls{/// <summary> AST leaf node representing a constant boolean value. Produced by <see cref="ConditionParser"/> from the <c>true</c> and <c>false</c> keywords in condition expressions. When an identifier <c>true</c> or <c>false</c> is immediately followed by a dot (e.g. <c>true.IsActive</c>), the parser treats it as a quest ID rather than a boolean literal, so quests named "true" or "false" are still accessible. </summary>
public class BoolLiteral:ConditionNode{/// <summary>The constant boolean value: <c>true</c> or <c>false</c>.</summary>
public bool Value;/// <summary>Creates a constant node from the parsed keyword.</summary> <param name="value">Constant result of the condition.</param>
public BoolLiteral(bool value)=>Value=value;/// <summary>Returns the constant regardless of context.</summary>
public override bool Evaluate(EvaluationContext ctx)=>Value;}}

===== FILE Core/Scenes/Nodes/Impls/NotNode.cs =====
namespace Cthangover.Core.Scenes.Nodes.Impls{/// <summary> AST node for boolean NOT — negates the result of <see cref="Operand"/>. Produced by <see cref="ConditionParser"/> from the <c>!</c> token. A double <c>!!</c> produces a <see cref="NotNode"/> wrapping another <see cref="NotNode"/> — the parser does not fold constants. </summary>
public class NotNode:ConditionNode{/// <summary>The expression whose boolean result is negated.</summary>
public ICondition Operand{get;}/// <summary>Creates a NOT node around the negated sub-expression.</summary> <param name="operand">Expression whose result is inverted.</param>
public NotNode(ICondition operand){Operand=operand;}/// <summary>Returns the inverted result of <see cref="Operand"/>.</summary>
public override bool Evaluate(EvaluationContext ctx)=> !Operand.Evaluate(ctx);}}

===== FILE Core/Scenes/Nodes/Impls/OrNode.cs =====
namespace Cthangover.Core.Scenes.Nodes.Impls{/// <summary> AST node for boolean OR — evaluates <see cref="Left"/> first, then <see cref="Right"/> only if <see cref="Left"/> is <c>false</c>. This short-circuit behaviour is inherited from the C# <c>||</c> operator and requires no explicit control flow. Produced by <see cref="ConditionParser"/> from the <c>||</c> token in condition expressions. </summary>
public class OrNode:ConditionNode{/// <summary>Evaluated first; if <c>true</c>, <see cref="Right"/> is skipped.</summary>
public ICondition Left{get;}/// <summary>Evaluated only when <see cref="Left"/> returns <c>false</c>.</summary>
public ICondition Right{get;}/// <summary>Creates an OR node over two operand sub-expressions.</summary> <param name="left">Operand evaluated first.</param> <param name="right">Operand evaluated only when <paramref name="left"/> is false.</param>
public OrNode(ICondition left,ICondition right){Left=left;Right=right;}/// <summary>Short-circuits: <see cref="Right"/> runs only when <see cref="Left"/> is false.</summary>
public override bool Evaluate(EvaluationContext ctx)=>Left.Evaluate(ctx)||Right.Evaluate(ctx);}}

===== FILE Core/Scenes/Parsing/ExpressionParser.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Scenes.Parsing.Impls;namespace Cthangover.Core.Scenes.Parsing{/// <summary> Pratt precedence-climbing parser that converts a token list into an <see cref="ICondition"/> tree. Operators are resolved through <see cref="OperatorRegistry"/>; leaf values through <see cref="LeafParserRegistry"/>. Precedence values: higher = tighter binding. Infix operators use <c>op.Precedence</c> for the right operand to enforce left- associativity. The loop condition is strict greater-than (<c>></c>) so that operators at the current precedence level are consumed from left to right. The parser makes a single left-to-right pass over the token list, consuming each token exactly once. No backtracking, no regex. </summary>
public class ExpressionParser{private TokenPeeker _peeker;static ExpressionParser(){OperatorRegistry.Register(new AndOpParser());OperatorRegistry.Register(new OrOpParser());OperatorRegistry.Register(new NotOpParser());}/// <summary> Parses a complete token list into an <see cref="ICondition"/> tree. </summary>
public ICondition Parse(List<ConditionParser.Token>tokens){_peeker=new TokenPeeker(tokens,null);var parseSubExpr=new Func<ICondition>(()=>ParseExpr(0));_peeker=new TokenPeeker(tokens,parseSubExpr);var result=ParseExpr(0);if(!_peeker.AtEnd&&_peeker.Peek.Type!=ConditionParser.TokenType.EOF)throw new FormatException($"Condition parse error: unexpected token '{_peeker.Peek.Value}' at end of expression");return result;}private ICondition ParseExpr(int minPrecedence){var left=ParsePrefix();while(!_peeker.AtEnd){var op=OperatorRegistry.Get(_peeker.Peek.Type);if(op==null|| !op.IsInfix||op.Precedence<=minPrecedence)break;_peeker.Advance();var right=ParseExpr(op.Precedence);left=op.BuildInfix(left,right);}return left;}private ICondition ParsePrefix(){if(_peeker.Check(ConditionParser.TokenType.LParen)){_peeker.Advance();var expr=ParseExpr(0);_peeker.Expect(ConditionParser.TokenType.RParen,"expected ')' after expression");return expr;}var op=_peeker.AtEnd?null:OperatorRegistry.Get(_peeker.Peek.Type);if(op!=null&&op.IsPrefix){_peeker.Advance();var operand=ParseExpr(op.Precedence);return op.BuildPrefix(operand);}var leafParser=LeafParserRegistry.FindParser(_peeker);if(leafParser==null)throw new FormatException($"Condition parse error: unexpected token '{(_peeker.AtEnd ? "EOF" : _peeker.Peek.Value)}'");return leafParser.Parse(_peeker,()=>ParseExpr(0));}}}

===== FILE Core/Scenes/Parsing/GDCondition.cs =====
using System.Collections.Generic;using Godot;namespace Cthangover.Core.Scenes.Parsing{/// <summary> Condition leaf node backed by a GDScript custom condition (registered by <c>ModGDScriptLoader</c> through <see cref="GDLeafParser"/>). Evaluation converts the flag dictionary to a Godot Dictionary and invokes the script's <c>evaluate(flags, args)</c> method, so the whole custom-condition contract stays in GDScript without C# glue per mod. </summary>
public class GDCondition:ConditionNode{private readonly GodotObject _instance;private readonly Godot.Collections.Dictionary _args;/// <summary>Wraps a GDScript instance and the positional arguments parsed after the condition name.</summary> <param name="instance">Live GodotObject exposing <c>evaluate(flags, args)</c>.</param> <param name="args">Positional call arguments keyed by their string index ("0", "1", …).</param>
public GDCondition(GodotObject instance,Godot.Collections.Dictionary args){_instance=instance;_args=args;}/// <summary>Copies flags into a Godot Dictionary and calls the script's <c>evaluate</c> method.</summary>
public override bool Evaluate(EvaluationContext ctx){var gdFlags=new Godot.Collections.Dictionary();foreach(var kv in ctx.Flags)gdFlags[kv.Key]=kv.Value;return _instance.Call("evaluate",gdFlags,_args).AsBool();}}}

===== FILE Core/Scenes/Parsing/GDLeafParser.cs =====
using System;using Godot;namespace Cthangover.Core.Scenes.Parsing{/// <summary> Leaf parser for GDScript custom conditions such as <c>gds.is_rain</c> or <c>gds.flag_equals("victory")</c>. Created by <c>ModGDScriptLoader</c> and inserted at the front of the <see cref="LeafParserRegistry"/> list, so a GDScript condition wins over the built-in quest/flag parsers. Matches when the next two identifiers joined by a dot equal the script's <c>get_condition_name()</c> value. </summary>
public class GDLeafParser:ILeafParser{private readonly string _conditionName;private readonly GodotObject _instance;/// <summary>Creates a parser bound to one GDScript condition name.</summary> <param name="conditionName">Value returned by the script's <c>get_condition_name()</c>, e.g. "gds.is_rain".</param> <param name="instance">Live GodotObject whose <c>evaluate(flags, args)</c> computes the result.</param>
public GDLeafParser(string conditionName,GodotObject instance){_conditionName=conditionName;_instance=instance;}/// <summary>True when the next <c>ident.ident</c> pair matches this parser's condition name (checked on a positional snapshot).</summary>
public bool CanParse(TokenPeeker peeker){if(peeker.AtEnd||peeker.Peek.Type!=ConditionParser.TokenType.Ident)return false;var snapshot=peeker.Snapshot();var ident=snapshot.Advance().Value;if(snapshot.AtEnd||snapshot.Peek.Type!=ConditionParser.TokenType.Dot)return false;snapshot.Advance();if(snapshot.AtEnd||snapshot.Peek.Type!=ConditionParser.TokenType.Ident)return false;var method=snapshot.Advance().Value;return$"{ident}.{method}"==_conditionName;}/// <summary>Consumes the condition name and an optional parenthesised argument list, producing a <see cref="GDCondition"/>.</summary>
public ICondition Parse(TokenPeeker peeker,Func<ICondition>parseSubExpr){peeker.Advance();// ident
peeker.Expect(ConditionParser.TokenType.Dot,"expected '.' after mod prefix");peeker.Advance();// method name
var args=new Godot.Collections.Dictionary();if(!peeker.AtEnd&&peeker.Peek.Type==ConditionParser.TokenType.LParen){peeker.Advance();// '('
if(!peeker.AtEnd&&peeker.Peek.Type!=ConditionParser.TokenType.RParen){var argIndex=0;while(true){if(peeker.AtEnd)throw new FormatException("Condition parse error: unexpected end of arguments");args[argIndex.ToString()]=peeker.Advance().Value;argIndex++;if(peeker.Check(ConditionParser.TokenType.Comma)){peeker.Advance();continue;}break;}}peeker.Expect(ConditionParser.TokenType.RParen,"expected ')'");}return new GDCondition(_instance,args);}}}

===== FILE Core/Scenes/Parsing/ILeafParser.cs =====
using System;namespace Cthangover.Core.Scenes.Parsing{/// <summary> Parses a leaf value from the token stream — a literal, a flag comparison, a quest method call, or any other terminal expression that produces an <see cref="ICondition"/>. Registered in <see cref="LeafParserRegistry"/>. Mods add custom leaf types (e.g. weather conditions, skill checks) by implementing this interface and calling <see cref="LeafParserRegistry.Register"/>. <see cref="CanParse"/> receives a <see cref="TokenPeeker"/> — a read-only view of upcoming tokens — and must return true only when this parser can handle the input. No tokens are consumed during the check. <see cref="Parse"/> receives a mutable stream and the <paramref name="parseSubExpr"/> callback for recursive sub-expression parsing (used when a leaf contains parentheses, e.g. flag arguments that are themselves expressions). </summary>
public interface ILeafParser{/// <summary> Checks whether this parser can handle the tokens at the current position. Peek only — do not advance the stream. </summary>
bool CanParse(TokenPeeker peeker);/// <summary> Consumes tokens from the stream and returns the parsed <see cref="ICondition"/>. </summary> <param name="peeker">Mutable token stream — advance past consumed tokens.</param> <param name="parseSubExpr">Callback for nested sub-expression parsing, e.g. content inside parentheses.</param>
ICondition Parse(TokenPeeker peeker,Func<ICondition>parseSubExpr);}}

===== FILE Core/Scenes/Parsing/IOperatorParser.cs =====
using System;namespace Cthangover.Core.Scenes.Parsing{/// <summary> Parses a binary or unary operator token into an <see cref="ICondition"/> subtree. Registered in <see cref="OperatorRegistry"/> — mods can add custom operators (e.g. XOR, NAND) by implementing this interface and calling <see cref="OperatorRegistry.Register"/> during mod initialisation. <see cref="Precedence"/> controls binding strength: higher values mean looser binding. In <c>A && B || C</c>, <c>||</c> has higher precedence (binds looser) than <c>&&</c>, so the tree is <c>||(&&(A, B), C)</c>. For infix operators (<c>&&</c>, <c>||</c>), <see cref="ExpressionParser"/> calls <see cref="BuildInfix"/> with left and right operands. For prefix operators (<c>!</c>), it calls <see cref="BuildPrefix"/> with the single operand. </summary>
public interface IOperatorParser{/// <summary>The token type this parser handles.</summary>
ConditionParser.TokenType Token{get;}/// <summary>Binding precedence — higher = looser binding (||=40, &&=30, !=50).</summary>
int Precedence{get;}/// <summary>True for prefix unary operators (!A).</summary>
bool IsPrefix{get;}/// <summary>True for infix binary operators (A && B).</summary>
bool IsInfix{get;}/// <summary>Constructs an infix node from left and right operands. Returns null when <see cref="IsInfix"/> is false.</summary>
ICondition BuildInfix(ICondition left,ICondition right);/// <summary>Constructs a prefix node from its operand. Returns null when <see cref="IsPrefix"/> is false.</summary>
ICondition BuildPrefix(ICondition operand);}}

===== FILE Core/Scenes/Parsing/Impls/AndOpParser.cs =====
using Cthangover.Core.Scenes.Nodes.Impls;namespace Cthangover.Core.Scenes.Parsing.Impls{/// <summary> Pratt parser binding for the <c>&amp;&amp;</c> token. Binds tighter than <c>||</c> (precedence 30 vs 40), so <c>A || B && C</c> parses as <c>||(A, &&(B, C))</c>. </summary>
public class AndOpParser:IOperatorParser{/// <summary>Binds the <c>&amp;&amp;</c> token type.</summary>
public ConditionParser.TokenType Token=>ConditionParser.TokenType.And;/// <summary>Precedence 20 — tighter than <c>||</c> (10), looser than prefix <c>!</c> (50).</summary>
public int Precedence=>20;/// <summary><c>&amp;&amp;</c> is strictly an infix operator.</summary>
public bool IsPrefix=>false;/// <summary><c>&amp;&amp;</c> is strictly an infix operator.</summary>
public bool IsInfix=>true;/// <summary>Builds an <see cref="AndNode"/> with left-associative operands.</summary>
public ICondition BuildInfix(ICondition left,ICondition right)=>new AndNode((ConditionNode)left,(ConditionNode)right);/// <summary>Never called — <see cref="IsPrefix"/> is false.</summary>
public ICondition BuildPrefix(ICondition operand)=>null;}}

===== FILE Core/Scenes/Parsing/Impls/BoolLiteralParser.cs =====
using System;using Cthangover.Core.Scenes.Nodes.Impls;namespace Cthangover.Core.Scenes.Parsing.Impls{/// <summary> Parses <c>true</c> and <c>false</c> keyword tokens into <see cref="BoolLiteral"/> nodes. Only matches when the keyword is not immediately followed by a dot — <c>true.IsActive</c> is NOT a boolean literal; it is a quest reference (handled by <see cref="QuestParser"/>). </summary>
public class BoolLiteralParser:ILeafParser{/// <summary>True for the bare keywords <c>true</c>/<c>false</c> not followed by a dot (quest IDs win over literals).</summary>
public bool CanParse(TokenPeeker peeker){if(peeker.AtEnd||peeker.Peek.Type!=ConditionParser.TokenType.Ident)return false;var value=peeker.Peek.Value;if(value!="true"&&value!="false")return false;var next=peeker.PeekAhead(1);return next.Type!=ConditionParser.TokenType.Dot;}/// <summary>Consumes the keyword and wraps it into a constant <see cref="BoolLiteral"/>.</summary>
public ICondition Parse(TokenPeeker peeker,Func<ICondition>parseSubExpr){var token=peeker.Advance();return new BoolLiteral(token.Value=="true");}}}

===== FILE Core/Scenes/Parsing/Impls/FlagParser.cs =====
using System;namespace Cthangover.Core.Scenes.Parsing.Impls{/// <summary> Parses flag comparison expressions: <c>flag_name OP literal</c>. Matches when the stream contains an identifier followed by a comparison operator (== != >= <= > <). Must be tried AFTER <see cref="QuestParser"/> and <see cref="BoolLiteralParser"/> in the registry — those parsers also start with an identifier but consume the dot or check for true/false respectively. By the time <see cref="CanParse"/> is called here, any dot-followed or keyword identifiers have already been claimed. </summary>
public class FlagParser:ILeafParser{/// <summary>True for identifiers immediately followed by a comparison operator (dot-forms are claimed by earlier parsers).</summary>
public bool CanParse(TokenPeeker peeker){if(peeker.AtEnd||peeker.Peek.Type!=ConditionParser.TokenType.Ident)return false;var next=peeker.PeekAhead(1);return next.Type==ConditionParser.TokenType.Eq||next.Type==ConditionParser.TokenType.Neq||next.Type==ConditionParser.TokenType.Ge||next.Type==ConditionParser.TokenType.Le||next.Type==ConditionParser.TokenType.Gt||next.Type==ConditionParser.TokenType.Lt;}/// <summary>Consumes <c>key OP literal</c> (string or number literal) and builds a <see cref="FlagCondition"/>.</summary>
public ICondition Parse(TokenPeeker peeker,Func<ICondition>parseSubExpr){var key=peeker.Advance().Value;var op=ParseCompOp(peeker);ConditionParser.Token valToken;if(peeker.Check(ConditionParser.TokenType.String)||peeker.Check(ConditionParser.TokenType.Number))valToken=peeker.Advance();else throw new FormatException($"Condition parse error: expected string or number after operator, got '{peeker.Peek.Value}'");return new FlagCondition(key,op,valToken.Value);}private static string ParseCompOp(TokenPeeker peeker){if(peeker.Check(ConditionParser.TokenType.Eq)){peeker.Advance();return"==";}if(peeker.Check(ConditionParser.TokenType.Neq)){peeker.Advance();return"!=";}if(peeker.Check(ConditionParser.TokenType.Ge)){peeker.Advance();return">=";}if(peeker.Check(ConditionParser.TokenType.Le)){peeker.Advance();return"<=";}if(peeker.Check(ConditionParser.TokenType.Gt)){peeker.Advance();return">";}if(peeker.Check(ConditionParser.TokenType.Lt)){peeker.Advance();return"<";}throw new FormatException($"Condition parse error: expected comparison operator, got '{peeker.Peek.Value}'");}}}

===== FILE Core/Scenes/Parsing/Impls/GroupParser.cs =====
using System;namespace Cthangover.Core.Scenes.Parsing.Impls{/// <summary> Parses parenthesised sub-expressions: <c>( expr )</c>. This parser is registered last in <see cref="LeafParserRegistry"/> because <c>(</c> is unambiguous — no other leaf parser starts with that token. Still placed at the end so that all identifier-based parsers get a chance first. </summary>
public class GroupParser:ILeafParser{/// <summary>True at any opening parenthesis — unambiguous, so this parser is registered last.</summary>
public bool CanParse(TokenPeeker peeker){return!peeker.AtEnd&&peeker.Peek.Type==ConditionParser.TokenType.LParen;}/// <summary>Parses the parenthesised sub-expression recursively and returns its subtree unchanged.</summary>
public ICondition Parse(TokenPeeker peeker,Func<ICondition>parseSubExpr){peeker.Advance();var expr=parseSubExpr();peeker.Expect(ConditionParser.TokenType.RParen,"expected ')' after expression");return expr;}}}

===== FILE Core/Scenes/Parsing/Impls/NotOpParser.cs =====
using Cthangover.Core.Scenes.Nodes.Impls;namespace Cthangover.Core.Scenes.Parsing.Impls{/// <summary> Pratt parser binding for the <c>!</c> token — unary negation. Highest precedence (50) to bind tightly to the next expression. Right-associative: <c>!!A</c> parses as <c>!( !(A) )</c>. </summary>
public class NotOpParser:IOperatorParser{/// <summary>Binds the <c>!</c> token type.</summary>
public ConditionParser.TokenType Token=>ConditionParser.TokenType.Not;/// <summary>Precedence 50 — highest, so <c>!</c> binds to the immediately following expression.</summary>
public int Precedence=>50;/// <summary><c>!</c> is strictly a prefix operator.</summary>
public bool IsPrefix=>true;/// <summary><c>!</c> is strictly a prefix operator.</summary>
public bool IsInfix=>false;/// <summary>Never called — <see cref="IsInfix"/> is false.</summary>
public ICondition BuildInfix(ICondition left,ICondition right)=>null;/// <summary>Builds a <see cref="NotNode"/> around the operand.</summary>
public ICondition BuildPrefix(ICondition operand)=>new NotNode((ConditionNode)operand);}}

===== FILE Core/Scenes/Parsing/Impls/OrOpParser.cs =====
using Cthangover.Core.Scenes.Nodes.Impls;namespace Cthangover.Core.Scenes.Parsing.Impls{/// <summary> Pratt parser binding for the <c>||</c> token. Binds looser than <c>&amp;&amp;</c> (precedence 40 vs 30). </summary>
public class OrOpParser:IOperatorParser{/// <summary>Binds the <c>||</c> token type.</summary>
public ConditionParser.TokenType Token=>ConditionParser.TokenType.Or;/// <summary>Precedence 10 — loosest, so <c>&amp;&amp;</c> groups tighter inside <c>||</c> operands.</summary>
public int Precedence=>10;/// <summary><c>||</c> is strictly an infix operator.</summary>
public bool IsPrefix=>false;/// <summary><c>||</c> is strictly an infix operator.</summary>
public bool IsInfix=>true;/// <summary>Builds an <see cref="OrNode"/> with left-associative operands.</summary>
public ICondition BuildInfix(ICondition left,ICondition right)=>new OrNode((ConditionNode)left,(ConditionNode)right);/// <summary>Never called — <see cref="IsPrefix"/> is false.</summary>
public ICondition BuildPrefix(ICondition operand)=>null;}}

===== FILE Core/Scenes/Parsing/LeafParserRegistry.cs =====
using System.Collections.Generic;using Cthangover.Core.Scenes.Parsing.Impls;namespace Cthangover.Core.Scenes.Parsing{/// <summary> Ordered registry of <see cref="ILeafParser"/> instances, queried by <see cref="ExpressionParser"/> when the next token does not match any registered operator. Parsers are tried in registration order — the first whose <see cref="ILeafParser.CanParse"/> returns true wins. Mods register custom leaf parsers via <see cref="Register"/> (or <see cref="RegisterFirst"/> to take priority). Core parsers (booleans, flags, groups) are registered at static init; the quest parser registers itself from the quests engine mod at <c>IModInitializer.OnModLoaded</c> — the parser set is fully extensible and the core hardcodes no gameplay parsers. </summary>
public static class LeafParserRegistry{private static readonly List<ILeafParser>_parsers=new();static LeafParserRegistry(){Register(new BoolLiteralParser());Register(new FlagParser());Register(new GroupParser());}/// <summary>Appends a parser to the end of the list.</summary>
public static void Register(ILeafParser parser){_parsers.Add(parser);}/// <summary>Inserts a parser at the front of the list so it takes priority.</summary>
public static void RegisterFirst(ILeafParser parser){_parsers.Insert(0,parser);}/// <summary> Returns the first registered parser whose <see cref="ILeafParser.CanParse"/> returns true for the current token position, or null. </summary>
public static ILeafParser FindParser(TokenPeeker peeker){foreach(var parser in _parsers){if(parser.CanParse(peeker))return parser;}return null;}}}

===== FILE Core/Scenes/Parsing/OperatorRegistry.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Scenes.Parsing{/// <summary> Registry of <see cref="IOperatorParser"/> instances, keyed by <see cref="ConditionParser.TokenType"/>. Looked up by <see cref="ExpressionParser"/> during Pratt precedence-climbing to resolve infix and prefix operators at runtime rather than through hardcoded switch statements. Mods register custom operators via <see cref="Register"/> during <see cref="Cthangover.Core.Mods.IModInitializer.OnModResourcesReady"/>. Registrations are global — any scenario in any mod can use the new operator once it is registered. </summary>
public static class OperatorRegistry{private static readonly Dictionary<ConditionParser.TokenType,IOperatorParser>_parsers=new();/// <summary> Registers an operator parser. If an operator with the same <see cref="IOperatorParser.Token"/> is already registered, the new one is ignored. </summary>
public static void Register(IOperatorParser parser){if(!_parsers.ContainsKey(parser.Token))_parsers[parser.Token]=parser;}/// <summary>Looks up an operator parser by token type, or null.</summary>
public static IOperatorParser Get(ConditionParser.TokenType token){_parsers.TryGetValue(token,out var parser);return parser;}}}

===== FILE Core/Scenes/Parsing/TokenPeeker.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Core.Scenes.Parsing{/// <summary> Read-only view of upcoming tokens used by <see cref="ILeafParser.CanParse"/> and <see cref="ILeafParser.Parse"/>. Wraps a <see cref="ConditionParser.Token"/> list and position index. Peek methods do not advance — only <see cref="Advance"/> consumes. Also carries the <paramref name="parseSubExpr"/> callback injected by <see cref="ExpressionParser"/> so that leaf parsers can recursively parse sub-expressions (e.g. inside parentheses). </summary>
public class TokenPeeker{private readonly List<ConditionParser.Token>_tokens;private int _pos;private readonly Func<ICondition>_parseSubExpr;/// <summary>Creates a peeker over a token list.</summary> <param name="tokens">Full token list produced by <see cref="ConditionParser"/>.</param> <param name="parseSubExpr">Callback for recursively parsing parenthesised sub-expressions; may be null for CanParse-only use.</param>
public TokenPeeker(List<ConditionParser.Token>tokens,Func<ICondition>parseSubExpr){_tokens=tokens;_pos=0;_parseSubExpr=parseSubExpr;}/// <summary>The token at the current position (EOF token when the list is exhausted).</summary>
public ConditionParser.Token Peek=>_tokens[_pos];/// <summary>True when the position moved past the last token.</summary>
public bool AtEnd=>_pos>=_tokens.Count;/// <summary>Returns the token <paramref name="offset"/> positions ahead without consuming; clamps to the EOF token past the end.</summary>
public ConditionParser.Token PeekAhead(int offset){var idx=_pos+offset;return idx<_tokens.Count?_tokens[idx]:_tokens[_tokens.Count-1];}/// <summary>True when the current token has the expected type (never matches at end of input).</summary>
public bool Check(ConditionParser.TokenType type)=> !AtEnd&&Peek.Type==type;/// <summary>Consumes and returns the current token, moving the position forward.</summary>
public ConditionParser.Token Advance(){var token=Peek;_pos++;return token;}/// <summary>Consumes the current token when it matches, otherwise throws with a parser error message.</summary>
public ConditionParser.Token Expect(ConditionParser.TokenType type,string errorMsg){if(AtEnd||Peek.Type!=type)throw new FormatException($"Condition parse error: {errorMsg}, got '{(AtEnd ? "EOF" : Peek.Value)}'");return Advance();}/// <summary>Clones the position for speculative CanParse checks.</summary>
public TokenPeeker Snapshot(){var clone=new TokenPeeker(_tokens,_parseSubExpr);clone._pos=_pos;return clone;}/// <summary>Parses a sub-expression using the injected callback.</summary>
public ICondition ParseSubExpr()=>_parseSubExpr();}}

===== FILE Core/Scenes/Patching/ConnectionSelector.cs =====
using System;using Cthangover.Core.Scenes.TscnParsing;namespace Cthangover.Core.Scenes.Patching{/// <summary> One entry of the <c>_patch_remove_connections</c> list. A selector is a set of <c>key=value</c> pairs joined with <c>;</c> — <c>from</c>, <c>to</c>, <c>signal</c>, <c>method</c> and <c>flags</c> are recognised. Omitted fields are wildcards, which lets a patch remove a connection by target (<c>to=</c>), by signal alone, or by any combination. At least <c>signal</c> and one of <c>from</c>/<c>to</c> are required: with neither, the selector could silently remove every connection of the scene. </summary>
public class ConnectionSelector{/// <summary>Source node path (empty = wildcard).</summary>
public string From{get;set;}/// <summary>Target node path (empty = wildcard).</summary>
public string To{get;set;}/// <summary>Signal name (required by <see cref="TryParse"/>).</summary>
public string Signal{get;set;}/// <summary>Method name (empty = wildcard).</summary>
public string Method{get;set;}/// <summary>Connection flags (null = wildcard).</summary>
public int?Flags{get;set;}/// <summary> Parses one selector string. Returns false with a human-readable <paramref name="error"/> for unknown keys, malformed flags, or a missing signal/source pair so the applier can log and skip instead of removing too much. </summary>
public static bool TryParse(string text,out ConnectionSelector selector,out string error){selector=null;error=null;if(string.IsNullOrWhiteSpace(text)){error="selector is empty";return false;}var result=new ConnectionSelector();foreach(var rawPart in text.Split(';')){var part=rawPart.Trim();if(part.Length==0)continue;var eq=part.IndexOf('=');if(eq<=0){error=$"selector part '{part}' is not key=value";return false;}var key=part.Substring(0,eq).Trim().ToLowerInvariant();var value=part.Substring(eq+1).Trim();if(value.Length==0){error=$"selector key '{key}' has an empty value";return false;}switch(key){case"from":result.From=value;break;case"to":result.To=value;break;case"signal":result.Signal=value;break;case"method":result.Method=value;break;case"flags":if(!int.TryParse(value,out var flags)){error=$"selector flags '{value}' is not an integer";return false;}result.Flags=flags;break;default:error=$"unknown selector key '{key}'";return false;}}if(string.IsNullOrEmpty(result.Signal)){error="selector requires 'signal'";return false;}if(string.IsNullOrEmpty(result.From)&&string.IsNullOrEmpty(result.To)){error="selector requires 'from' or 'to'";return false;}selector=result;return true;}/// <summary>True when every specified field equals the connection's field.</summary>
public bool Matches(ParsedConnection connection){if(connection==null)return false;if(From!=null&& !string.Equals(From,connection.From,StringComparison.Ordinal))return false;if(To!=null&& !string.Equals(To,connection.To,StringComparison.Ordinal))return false;if(Signal!=null&& !string.Equals(Signal,connection.Signal,StringComparison.Ordinal))return false;if(Method!=null&& !string.Equals(Method,connection.Method,StringComparison.Ordinal))return false;if(Flags.HasValue&&Flags.Value!=connection.Flags)return false;return true;}}}

===== FILE Core/Scenes/Patching/MaskMatcher.cs =====
namespace Cthangover.Core.Scenes.Patching{/// <summary> Case-sensitive glob matcher used by scene-patch masks (<c>_patch_delete_nodes</c>, <c>_patch_remove</c>). <c>*</c> matches any run of characters including <c>/</c> (so a node mask can span hierarchy levels), <c>?</c> matches exactly one character. The engine has no general-purpose wildcard helper, and this is deliberately tiny: an iterative two-pointer matcher with backtracking avoids regex compilation per key on the scene-load hot path. </summary>
public static class MaskMatcher{/// <summary> True when <paramref name="value"/> matches the glob <paramref name="pattern"/>. Null/empty patterns never match; a pattern without wildcards is an exact comparison. </summary>
public static bool IsMatch(string pattern,string value){if(string.IsNullOrEmpty(pattern)||value==null)return false;var p=0;var v=0;var star= -1;var mark=0;while(v<value.Length){if(p<pattern.Length&&(pattern[p]=='?'||pattern[p]==value[v])){p++;v++;continue;}if(p<pattern.Length&&pattern[p]=='*'){star=p;mark=v;p++;continue;}if(star>=0){p=star+1;v= ++mark;continue;}return false;}while(p<pattern.Length&&pattern[p]=='*')p++;return p==pattern.Length;}/// <summary>True when the text contains at least one wildcard character.</summary>
public static bool HasMask(string text){if(string.IsNullOrEmpty(text))return false;for(var i=0;i<text.Length;i++){if(text[i]=='*'||text[i]=='?')return true;}return false;}}}

===== FILE Core/Scenes/Patching/NodePatchOperations.cs =====
namespace Cthangover.Core.Scenes.Patching{/// <summary> Reserved operations extracted from one layer node before it is merged. <see cref="ScenePatchApplier"/> keeps them in a dictionary keyed by the layer node instead of adding patch-only fields to the parser's <see cref="TscnParsing.ParsedNode"/>, so the parsing model stays a faithful representation of the file. </summary>
internal sealed class NodePatchOperations{/// <summary><c>metadata/_patch_delete</c>: remove the target node (cascades).</summary>
public bool Delete;/// <summary><c>metadata/_patch_remove</c>: property key masks removed before values merge.</summary>
public System.Collections.Generic.List<string>Remove;/// <summary><c>metadata/_patch_add_groups</c>.</summary>
public System.Collections.Generic.List<string>AddGroups;/// <summary><c>metadata/_patch_remove_groups</c>.</summary>
public System.Collections.Generic.List<string>RemoveGroups;/// <summary><c>metadata/_patch_delete_nodes</c> (root only): node path masks.</summary>
public System.Collections.Generic.List<string>DeleteNodes;/// <summary><c>metadata/_patch_remove_connections</c> (root only): connection selectors.</summary>
public System.Collections.Generic.List<string>RemoveConnections;/// <summary><c>metadata/_patch_remove_ext_resources</c> (root only): ext_resource ids.</summary>
public System.Collections.Generic.List<string>RemoveExtResources;}}

===== FILE Core/Scenes/Patching/PatchMetadata.cs =====
namespace Cthangover.Core.Scenes.Patching{/// <summary> Reserved <c>metadata/_patch_*</c> property keys consumed by <see cref="ScenePatchApplier"/>. A scene patch is a normal Godot inherited scene, so every operation Godot can express (property overrides, added nodes, sibling order, groups, resources, connections) is written natively; only operations inheritance cannot express — deleting nodes, deleting inherited properties, mask-based bulk edits, resource GC — are encoded as these metadata markers. <para/> The keys are stripped from the parsed scene before it reaches <see cref="TscnBuild.SceneBuilder"/>, so they never become Godot node metadata. The whole <c>metadata/_patch_*</c> namespace is reserved: a regular mod using such keys for its own metadata will conflict. </summary>
public static class PatchMetadata{/// <summary>Prefix of every reserved key.</summary>
public const string Prefix="metadata/_patch_";/// <summary>Bool on an override node or sub_resource body: remove the target node/sub_resource (nodes cascade to descendants, connections and node-path bindings).</summary>
public const string Delete=Prefix+"delete";/// <summary>String or string list on an override node: delete matching target properties (glob masks, <c>"*"</c> = all).</summary>
public const string Remove=Prefix+"remove";/// <summary>String list on an override node: add groups to the target node.</summary>
public const string AddGroups=Prefix+"add_groups";/// <summary>String list on an override node: remove groups from the target node.</summary>
public const string RemoveGroups=Prefix+"remove_groups";/// <summary>String list on the patch root: delete target nodes by path masks (paths are relative to the root, e.g. <c>MenuContainer/*Btn</c>; the root itself is never matched).</summary>
public const string DeleteNodes=Prefix+"delete_nodes";/// <summary>String list on the patch root: connection selectors (<c>from=...;signal=...;method=...</c>) removing every matching connection.</summary>
public const string RemoveConnections=Prefix+"remove_connections";/// <summary>String list on the patch root: ids of ext_resources to remove when nothing references them anymore.</summary>
public const string RemoveExtResources=Prefix+"remove_ext_resources";/// <summary>Bool on the patch root: run resource garbage collection once after all layers have been applied.</summary>
public const string Gc=Prefix+"gc";}}

===== FILE Core/Scenes/Patching/ScenePatch.cs =====
using Cthangover.Core.Scenes.TscnParsing;namespace Cthangover.Core.Scenes.Patching{/// <summary> One parsed scene patch — an inherited scene whose root instances the target. Produced by <see cref="ScenePatchParser"/> and cached by <see cref="ScenePatchResolver"/>; applied to a target <see cref="ParsedScene"/> by <see cref="ScenePatchApplier"/>. The parsed <see cref="Scene"/> is treated as immutable: the applier deep-clones the layer before touching it, so the cached parse can be reused across scene loads. </summary>
public class ScenePatch{/// <summary>Id of the mod that shipped the patch file (layer order and diagnostics).</summary>
public string ModId{get;set;}/// <summary>Mod-relative path of the patch file itself.</summary>
public string SourcePath{get;set;}/// <summary>Canonical target scene key (<c>scenes/menu/main_menu.tscn</c>).</summary>
public string TargetPath{get;set;}/// <summary> Local id of the patch's own <c>PackedScene</c> ext_resource — the header reference to the target. The applier skips it when folding patch resources into the target so the base scene does not gain a self-referencing resource. </summary>
public string TargetExtResourceId{get;set;}/// <summary>Raw parsed patch content.</summary>
public ParsedScene Scene{get;set;}}}

===== FILE Core/Scenes/Patching/ScenePatchApplier.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Scenes.TscnParsing;using Cthangover.Core.Utils;namespace Cthangover.Core.Scenes.Patching{/// <summary> Applies scene patches to a parsed target scene. A patch is an inherited scene (see <see cref="ScenePatch"/>): nodes without <c>type</c>/<c>instance</c> override existing nodes by full path, nodes with a type are additions, and the reserved <c>metadata/_patch_*</c> keys (see <see cref="PatchMetadata"/>) express the deletions and bulk edits Godot's inheritance format cannot. <para/> <b>Purity and reuse:</b> the applier never mutates the cached patch parse — each layer is deep-cloned first, so the same patch can be applied to many scene loads and to many different base scenes. The target <see cref="ParsedScene"/> is mutated in place and is expected to already contain the results of previous layers, which is what makes the mod load order a cascade. <para/> <b>Layer order:</b> reserved-key extraction → resource merge/remap → deletions with cascade → overrides → additions → index re-sort → connections → resource removals. Resource garbage collection runs once after every layer, only when some layer requested it, so a resource that a later layer re-references is not collected. </summary>
public class ScenePatchApplier{/// <summary>Category used for every diagnostic this class emits.</summary>
private const string LogCategory="TSCN";/// <summary> Applies all layers to <paramref name="target"/> in order and runs final resource GC when at least one layer requested it. Returns false only for hard failures (invalid patch root, attempt to delete the root); soft authoring errors are logged and skipped. </summary>
public bool ApplyAll(ParsedScene target,IReadOnlyList<ScenePatch>patches){if(target==null)return false;if(patches==null||patches.Count==0)return true;var ok=true;var gcRequested=false;foreach(var patch in patches){var layer=CloneScene(patch.Scene);if(!ApplyLayer(target,layer,patch,out var layerGc))ok=false;gcRequested|=layerGc;}if(gcRequested)CollectGarbage(target);return ok;}private bool ApplyLayer(ParsedScene target,ParsedScene layer,ScenePatch patch,out bool gcRequested){gcRequested=false;var root=layer.Nodes.FirstOrDefault(n=>n.Parent==null);if(root==null){GameLogger.Log(LogCategory,$"ScenePatchApplier: patch '{patch.SourcePath}' has no root node",LogLevel.Error);return false;}var nodeOps=new Dictionary<ParsedNode,NodePatchOperations>();foreach(var node in layer.Nodes)ExtractOperations(node,nodeOps,ref gcRequested);var subDeletes=new List<SubResourceDef>();foreach(var sub in layer.SubResources){if(TakeBool(sub,PatchMetadata.Delete))subDeletes.Add(sub);}if(nodeOps.TryGetValue(root,out var rootOps)&&rootOps.Delete){GameLogger.Log(LogCategory,$"ScenePatchApplier: patch '{patch.SourcePath}' tries to delete the scene root — layer rejected",LogLevel.Error);return false;}var extMap=MergeExtResources(target,layer,patch.TargetExtResourceId);var subMap=MergeSubResources(target,layer,subDeletes);RemapLayerReferences(layer,extMap,subMap);var exactDeletes=nodeOps.Where(kv=>kv.Value.Delete).Select(kv=>NodePathOf(kv.Key)).ToList();var maskDeletes=rootOps?.DeleteNodes??new List<string>();var deletedPaths=CollectDeletions(target,exactDeletes,maskDeletes,patch.SourcePath);RemoveDeletedNodes(target,deletedPaths);CleanupNodePathReferences(target,deletedPaths);var targetMap=BuildPathMap(target);foreach(var node in layer.Nodes){if(ReferenceEquals(node,root))continue;nodeOps.TryGetValue(node,out var ops);ops??=new NodePatchOperations();if(ops.Delete)continue;var path=NodePathOf(node);var isAddition= !string.IsNullOrEmpty(node.Type)|| !string.IsNullOrEmpty(node.InstanceRef);if(isAddition){if(string.IsNullOrEmpty(node.Parent)){GameLogger.Log(LogCategory,$"ScenePatchApplier: '{patch.SourcePath}' adds second root '{path}' — skipped",LogLevel.Warning);continue;}if(targetMap.ContainsKey(path)){GameLogger.Log(LogCategory,$"ScenePatchApplier: '{patch.SourcePath}' adds '{path}' but it already exists — skipped",LogLevel.Warning);continue;}target.Nodes.Add(node);targetMap[path]=node;continue;}if(MaskMatcher.HasMask(path)){GameLogger.Log(LogCategory,$"ScenePatchApplier: node selector '{path}' uses a mask — unsupported for overrides, skipped",LogLevel.Warning);continue;}if(!targetMap.TryGetValue(path,out var targetNode)){GameLogger.Log(LogCategory,$"ScenePatchApplier: '{patch.SourcePath}' overrides unknown node '{path}' — skipped",LogLevel.Warning);continue;}ApplyOverride(targetNode,node,ops);}var targetRoot=target.Nodes.FirstOrDefault(n=>n.Parent==null);if(targetRoot!=null)ApplyOverride(targetRoot,root,rootOps??new NodePatchOperations());ReorderByIndex(target);ApplyConnections(target,rootOps,layer,patch.SourcePath);RemoveResources(target,rootOps,subDeletes);if(!string.IsNullOrEmpty(layer.EntryPointClass))target.EntryPointClass=layer.EntryPointClass;return true;}private static void ExtractOperations(ParsedNode node,Dictionary<ParsedNode,NodePatchOperations>nodeOps,ref bool gcRequested){var ops=new NodePatchOperations{Delete=TakeBool(node,PatchMetadata.Delete),Remove=NormalizeStringList(TakeProperty(node,PatchMetadata.Remove)),AddGroups=NormalizeStringList(TakeProperty(node,PatchMetadata.AddGroups)),RemoveGroups=NormalizeStringList(TakeProperty(node,PatchMetadata.RemoveGroups)),DeleteNodes=NormalizeStringList(TakeProperty(node,PatchMetadata.DeleteNodes)),RemoveConnections=NormalizeStringList(TakeProperty(node,PatchMetadata.RemoveConnections)),RemoveExtResources=NormalizeStringList(TakeProperty(node,PatchMetadata.RemoveExtResources)),};if(TakeBool(node,PatchMetadata.Gc))gcRequested=true;nodeOps[node]=ops;}private static void ApplyOverride(ParsedNode target,ParsedNode source,NodePatchOperations ops){RemoveProperties(target,ops.Remove);MergeProperties(target,source);if(source.Groups!=null)target.Groups=(string[])source.Groups.Clone();ApplyGroupOperations(target,ops.AddGroups,ops.RemoveGroups);if(!string.IsNullOrEmpty(source.ScriptRef))target.ScriptRef=source.ScriptRef;if(source.Index.HasValue)target.Index=source.Index;MergeNodePathVars(target,source);}private static void MergeProperties(ParsedNode target,ParsedNode source){foreach(var kvp in source.OrderedProperties){if(!target.Properties.ContainsKey(kvp.Key))target.PropertyOrder.Add(kvp.Key);target.Properties[kvp.Key]=kvp.Value;}}private static void MergeNodePathVars(ParsedNode target,ParsedNode source){if(source.NodePathVars==null||source.NodePathVars.Count==0)return;target.NodePathVars??=new Dictionary<string,string>();foreach(var kvp in source.NodePathVars)target.NodePathVars[kvp.Key]=kvp.Value;}private static void RemoveProperties(ParsedNode node,List<string>masks){if(masks==null||masks.Count==0)return;foreach(var key in new List<string>(node.PropertyOrder)){if(!MatchesAny(masks,key))continue;node.Properties.Remove(key);node.PropertyOrder.Remove(key);}}private static void ApplyGroupOperations(ParsedNode target,List<string>add,List<string>remove){if(add!=null){var groups=new List<string>(target.Groups??Array.Empty<string>());foreach(var group in add){if(!groups.Contains(group))groups.Add(group);}target.Groups=groups.ToArray();}if(remove!=null&&target.Groups!=null)target.Groups=target.Groups.Where(g=> !remove.Contains(g)).ToArray();}/// <summary> Resolves exact and masked node deletes against the target and expands them into the full set of removed paths (descendants cascade). Masks are relative to the root and never match the root itself; exact paths that do not exist are authoring warnings. The root is skipped by construction because its path is empty. </summary>
private static HashSet<string>CollectDeletions(ParsedScene target,List<string>exactPaths,List<string>masks,string sourcePath){var deleted=new HashSet<string>(StringComparer.Ordinal);var selected=new HashSet<string>(StringComparer.Ordinal);var map=BuildPathMap(target);foreach(var path in exactPaths){if(string.IsNullOrEmpty(path))continue;if(MaskMatcher.HasMask(path))SelectMask(map.Keys,path,selected);else if(map.ContainsKey(path))selected.Add(path);else GameLogger.Log(LogCategory,$"ScenePatchApplier: '{sourcePath}' deletes unknown node '{path}'",LogLevel.Warning);}foreach(var mask in masks){if(string.IsNullOrEmpty(mask))continue;SelectMask(map.Keys,mask,selected);}foreach(var key in map.Keys){foreach(var selectedPath in selected){if(key.Equals(selectedPath,StringComparison.Ordinal)||key.StartsWith(selectedPath+"/",StringComparison.Ordinal)){deleted.Add(key);break;}}}return deleted;}private static void SelectMask(IEnumerable<string>keys,string mask,HashSet<string>selected){foreach(var key in keys){if(!MaskMatcher.IsMatch(mask,key))continue;selected.Add(key);GameLogger.Log(LogCategory,$"ScenePatchApplier: mask '{mask}' matched '{key}'",LogLevel.Debug);}}private static void RemoveDeletedNodes(ParsedScene target,HashSet<string>deletedPaths){if(deletedPaths.Count==0)return;target.Nodes.RemoveAll(n=>deletedPaths.Contains(NodePathOf(n)));target.Connections.RemoveAll(c=>deletedPaths.Contains(c.From??"")||deletedPaths.Contains(c.To??""));}/// <summary> Clears NodePath-valued properties and <c>node_paths</c> bindings on surviving nodes when they resolve into the deleted subtree, otherwise the builder would log dangling references for paths the patch intentionally removed. Absolute paths are left alone because they may point outside the scene. </summary>
private static void CleanupNodePathReferences(ParsedScene target,HashSet<string>deletedPaths){if(deletedPaths.Count==0)return;foreach(var node in target.Nodes){var ownerPath=NodePathOf(node);if(node.NodePathVars!=null&&node.NodePathVars.Count>0){foreach(var key in new List<string>(node.NodePathVars.Keys)){var resolved=ResolveNodePath(ownerPath,ExtractNodePathValue(node.NodePathVars[key],true));if(resolved!=null&&deletedPaths.Contains(resolved))node.NodePathVars.Remove(key);}}foreach(var key in new List<string>(node.PropertyOrder)){if(!node.Properties.TryGetValue(key,out var value))continue;var resolved=ResolveNodePath(ownerPath,ExtractNodePathValue(value,false));if(resolved==null|| !deletedPaths.Contains(resolved))continue;node.Properties.Remove(key);node.PropertyOrder.Remove(key);}}}/// <summary> Extracts the path text from a NodePath-typed value. Only <c>NodePath(...)</c> wrappers are recognised in properties (<paramref name="allowRawString"/> false) so ordinary strings that happen to look like paths are never treated as bindings; <c>node_paths</c> values are stored raw, so there a plain string or quoted string is accepted too. </summary>
private static string ExtractNodePathValue(object value,bool allowRawString){if(value is ParsedGodotValue pgv&&pgv.TypeName=="NodePath")return pgv.Arg(0)as string;if(!allowRawString|| !(value is string raw)||raw.Length==0)return null;var parsed=TscnPropertyParser.Parse(raw);switch(parsed){case ParsedGodotValue parsedPgv when parsedPgv.TypeName=="NodePath":return parsedPgv.Arg(0)as string;case string text:return text;default:return null;}}/// <summary> Resolves a NodePath written relative to <paramref name="ownerPath"/> into a canonical scene path. Returns null for absolute paths (<c>/root/...</c>, <c>res://...</c>). </summary>
private static string ResolveNodePath(string ownerPath,string nodePath){if(string.IsNullOrEmpty(nodePath))return null;if(nodePath.StartsWith("/",StringComparison.Ordinal)||nodePath.StartsWith("res://",StringComparison.OrdinalIgnoreCase))return null;var parts=ownerPath.Length==0?new List<string>():new List<string>(ownerPath.Split('/'));foreach(var segment in nodePath.Split('/')){if(segment.Length==0||segment==".")continue;if(segment==".."){if(parts.Count>0)parts.RemoveAt(parts.Count-1);continue;}parts.Add(segment);}return string.Join("/",parts);}/// <summary> Applies Godot's <c>index</c> ordering to the flat node list. Only runs when at least one node carries an index. Within each sibling group an indexed node is inserted at its position (shifting the rest), nodes without an index keep their relative order, and groups are emitted parents-before-children so the builder can resolve them. </summary>
private static void ReorderByIndex(ParsedScene target){if(!target.Nodes.Any(n=>n.Index.HasValue))return;var root=target.Nodes.FirstOrDefault(n=>n.Parent==null);var groups=new Dictionary<string,List<ParsedNode>>(StringComparer.Ordinal);var groupOrder=new Dictionary<string,int>(StringComparer.Ordinal);var nextGroup=0;foreach(var node in target.Nodes){if(ReferenceEquals(node,root))continue;var parent=ParentPathOf(node);if(!groups.TryGetValue(parent,out var list)){list=new List<ParsedNode>();groups[parent]=list;groupOrder[parent]=nextGroup++;}list.Add(node);}var result=new List<ParsedNode>(target.Nodes.Count);if(root!=null)result.Add(root);foreach(var key in groups.Keys.OrderBy(DepthOf).ThenBy(k=>groupOrder[k])){result.AddRange(ApplyIndexOrder(groups[key]));}target.Nodes=result;}private static List<ParsedNode>ApplyIndexOrder(List<ParsedNode>nodes){var provisional=new List<ParsedNode>(nodes.Count);foreach(var node in nodes){if(!node.Index.HasValue)provisional.Add(node);}foreach(var node in nodes.Where(n=>n.Index.HasValue).OrderBy(n=>n.Index.Value))provisional.Insert(Math.Clamp(node.Index.Value,0,provisional.Count),node);return provisional;}private static int DepthOf(string parentPath){return parentPath.Length==0?0:parentPath.Split('/').Length;}private static void ApplyConnections(ParsedScene target,NodePatchOperations rootOps,ParsedScene layer,string sourcePath){if(rootOps?.RemoveConnections!=null){foreach(var raw in rootOps.RemoveConnections){if(!ConnectionSelector.TryParse(raw,out var selector,out var error)){GameLogger.Log(LogCategory,$"ScenePatchApplier: '{sourcePath}' invalid connection selector '{raw}': {error}",LogLevel.Error);continue;}var removed=target.Connections.RemoveAll(c=>selector.Matches(c));GameLogger.Log(LogCategory,$"ScenePatchApplier: '{sourcePath}' selector '{raw}' removed {removed} connection(s)",LogLevel.Debug);}}foreach(var connection in layer.Connections){var duplicate=target.Connections.Any(c=>string.Equals(c.Signal,connection.Signal,StringComparison.Ordinal)&&string.Equals(c.From,connection.From,StringComparison.Ordinal)&&string.Equals(c.To,connection.To,StringComparison.Ordinal)&&string.Equals(c.Method,connection.Method,StringComparison.Ordinal)&&c.Flags==connection.Flags);if(!duplicate)target.Connections.Add(connection);}}private static void RemoveResources(ParsedScene target,NodePatchOperations rootOps,List<SubResourceDef>subDeletes){var references=CollectReferences(target);if(rootOps?.RemoveExtResources!=null){foreach(var id in rootOps.RemoveExtResources){if(string.IsNullOrEmpty(id)||references.Ext.Contains(id))continue;var removed=target.ExtResources.RemoveAll(e=>e?.Id==id);if(removed>0)GameLogger.Log(LogCategory,$"ScenePatchApplier: removed unreferenced ext_resource '{id}'");}}foreach(var sub in subDeletes){if(sub?.Id==null||references.Sub.Contains(sub.Id))continue;var removed=target.SubResources.RemoveAll(s=>s.Id==sub.Id);if(removed>0)GameLogger.Log(LogCategory,$"ScenePatchApplier: removed unreferenced sub_resource '{sub.Id}'");}}/// <summary> Final-pass garbage collection: removes every ext/sub_resource that no surviving node references. Runs once after all layers so a resource that only a later layer re-references survives; each removal is logged for patch authors. </summary>
private static void CollectGarbage(ParsedScene target){var references=CollectReferences(target);var removedExt=target.ExtResources.RemoveAll(e=>e?.Id==null|| !references.Ext.Contains(e.Id));var removedSub=target.SubResources.RemoveAll(s=>s.Id==null|| !references.Sub.Contains(s.Id));if(removedExt>0||removedSub>0)GameLogger.Log(LogCategory,$"ScenePatchApplier: GC removed {removedExt} ext_resource(s), {removedSub} sub_resource(s)");}private static(HashSet<string>Ext,HashSet<string>Sub)CollectReferences(ParsedScene target){var ext=new HashSet<string>(StringComparer.Ordinal);var sub=new HashSet<string>(StringComparer.Ordinal);foreach(var node in target.Nodes){CollectRefString(node.ScriptRef,ext);CollectRefString(node.InstanceRef,ext);foreach(var value in node.Properties.Values)CollectRefValue(value,ext,sub);}foreach(var def in target.SubResources){foreach(var value in def.Properties.Values)CollectRefValue(value,ext,sub);}return(ext,sub);}private static void CollectRefValue(object value,HashSet<string>ext,HashSet<string>sub){switch(value){case TscnPropertyValue pv when pv.IsExtResourceRef:if(!string.IsNullOrEmpty(pv.RefId))ext.Add(pv.RefId);return;case TscnPropertyValue pv when pv.IsSubResourceRef:if(!string.IsNullOrEmpty(pv.RefId))sub.Add(pv.RefId);return;case string s:CollectRefString(s,ext);return;case string[]strings:foreach(var item in strings)CollectRefString(item,ext);return;case ParsedGodotValue[]godotValues:foreach(var pgv in godotValues)foreach(var arg in pgv.Args)CollectRefString(arg as string,ext);return;}}private static void CollectRefString(string raw,HashSet<string>ext){if(string.IsNullOrEmpty(raw))return;const string prefix="ExtResource(";var index=0;while(true){var start=raw.IndexOf(prefix,index,StringComparison.Ordinal);if(start<0)return;start+=prefix.Length;var end=raw.IndexOf(')',start);if(end<0)return;var id=raw.Substring(start,end-start).Trim().Trim('"');if(id.Length>0)ext.Add(id);index=end;}}private static Dictionary<string,string>MergeExtResources(ParsedScene target,ParsedScene layer,string targetExtResourceId){var map=new Dictionary<string,string>(StringComparer.Ordinal);foreach(var ext in layer.ExtResources){if(ext?.Id==null||ext.Id==targetExtResourceId)continue;var same=target.ExtResources.FirstOrDefault(e=>e!=null&&string.Equals(e.Type,ext.Type,StringComparison.OrdinalIgnoreCase)&&string.Equals(ScenePatchTargetResolver.Normalize(e.Path),ScenePatchTargetResolver.Normalize(ext.Path),StringComparison.OrdinalIgnoreCase));if(same!=null){map[ext.Id]=same.Id;continue;}if(target.ExtResources.All(e=>e?.Id!=ext.Id)){target.ExtResources.Add(CloneExtResource(ext,ext.Id));map[ext.Id]=ext.Id;continue;}var newId=MakeUniqueId(ext.Id,id=>target.ExtResources.Any(e=>e?.Id==id));target.ExtResources.Add(CloneExtResource(ext,newId));map[ext.Id]=newId;}return map;}private static Dictionary<string,string>MergeSubResources(ParsedScene target,ParsedScene layer,List<SubResourceDef>subDeletes){var map=new Dictionary<string,string>(StringComparer.Ordinal);foreach(var sub in layer.SubResources){if(sub?.Id==null||subDeletes.Contains(sub))continue;if(target.SubResources.All(s=>s.Id!=sub.Id)){target.SubResources.Add(CloneSubResource(sub,sub.Id));map[sub.Id]=sub.Id;continue;}var newId=MakeUniqueId(sub.Id,id=>target.SubResources.Any(s=>s.Id==id));target.SubResources.Add(CloneSubResource(sub,newId));map[sub.Id]=newId;}return map;}private static string MakeUniqueId(string baseId,Func<string,bool>taken){for(var i=1;;i++){var candidate=$"{baseId}_p{i}";if(!taken(candidate))return candidate;}}private static void RemapLayerReferences(ParsedScene layer,Dictionary<string,string>extMap,Dictionary<string,string>subMap){foreach(var node in layer.Nodes){node.ScriptRef=RemapRefString(node.ScriptRef,"ExtResource",extMap);node.InstanceRef=RemapRefString(node.InstanceRef,"ExtResource",extMap);foreach(var key in new List<string>(node.PropertyOrder)){if(node.Properties.TryGetValue(key,out var value))node.Properties[key]=RemapValue(value,extMap,subMap);}}foreach(var sub in layer.SubResources){foreach(var key in new List<string>(sub.PropertyOrder)){if(sub.Properties.TryGetValue(key,out var value))sub.Properties[key]=RemapValue(value,extMap,subMap);}}}private static object RemapValue(object value,Dictionary<string,string>extMap,Dictionary<string,string>subMap){switch(value){case TscnPropertyValue pv when pv.IsExtResourceRef:return extMap.TryGetValue(pv.RefId,out var extId)?TscnPropertyValue.FromExtResource(extId):pv;case TscnPropertyValue pv when pv.IsSubResourceRef:return subMap.TryGetValue(pv.RefId,out var subId)?TscnPropertyValue.FromSubResource(subId):pv;case string s:var remapped=RemapRefString(s,"ExtResource",extMap);return RemapRefString(remapped,"SubResource",subMap);default:return value;}}private static string RemapRefString(string raw,string kind,Dictionary<string,string>map){if(string.IsNullOrEmpty(raw)||map.Count==0)return raw;var prefix=kind+"(";var start=raw.IndexOf(prefix,StringComparison.Ordinal);if(start<0)return raw;start+=prefix.Length;var end=raw.IndexOf(')',start);if(end<0)return raw;var id=raw.Substring(start,end-start).Trim().Trim('"');if(!map.TryGetValue(id,out var mapped)||string.Equals(mapped,id,StringComparison.Ordinal))return raw;return raw.Substring(0,start)+"\""+mapped+"\""+raw.Substring(end);}private static ParsedScene CloneScene(ParsedScene scene){var clone=new ParsedScene{Format=scene.Format,EntryPointClass=scene.EntryPointClass,Nodes=new List<ParsedNode>(scene.Nodes.Count),ExtResources=new List<ExtResourceRef>(scene.ExtResources.Count),SubResources=new List<SubResourceDef>(scene.SubResources.Count),Connections=new List<ParsedConnection>(scene.Connections.Count),};foreach(var node in scene.Nodes){clone.Nodes.Add(new ParsedNode{Name=node.Name,Type=node.Type,Parent=node.Parent,InstanceRef=node.InstanceRef,ScriptRef=node.ScriptRef,Index=node.Index,Groups=node.Groups==null?null:(string[])node.Groups.Clone(),NodePathVars=node.NodePathVars==null?new Dictionary<string,string>():new Dictionary<string,string>(node.NodePathVars),Properties=CloneProperties(node.Properties),PropertyOrder=new List<string>(node.PropertyOrder),});}foreach(var ext in scene.ExtResources)clone.ExtResources.Add(new ExtResourceRef{Type=ext.Type,Path=ext.Path,Id=ext.Id});foreach(var sub in scene.SubResources){clone.SubResources.Add(new SubResourceDef{Type=sub.Type,Id=sub.Id,Properties=CloneProperties(sub.Properties),PropertyOrder=new List<string>(sub.PropertyOrder),});}foreach(var connection in scene.Connections){clone.Connections.Add(new ParsedConnection{Signal=connection.Signal,From=connection.From,To=connection.To,Method=connection.Method,Flags=connection.Flags,});}return clone;}private static ExtResourceRef CloneExtResource(ExtResourceRef ext,string id){return new ExtResourceRef{Type=ext.Type,Path=ext.Path,Id=id};}private static SubResourceDef CloneSubResource(SubResourceDef sub,string id){return new SubResourceDef{Type=sub.Type,Id=id,Properties=CloneProperties(sub.Properties),PropertyOrder=new List<string>(sub.PropertyOrder),};}private static Dictionary<string,object>CloneProperties(Dictionary<string,object>properties){var clone=new Dictionary<string,object>(properties.Count);foreach(var kvp in properties)clone[kvp.Key]=CloneValue(kvp.Value);return clone;}private static object CloneValue(object value){switch(value){case TscnPropertyValue pv:return new TscnPropertyValue{Type=pv.Type,Variant=pv.Variant,RefId=pv.RefId};case ParsedGodotValue pgv:return new ParsedGodotValue{TypeName=pgv.TypeName,Args=new List<object>(pgv.Args)};case string[]strings:return(string[])strings.Clone();case int[]ints:return(int[])ints.Clone();case long[]longs:return(long[])longs.Clone();case float[]floats:return(float[])floats.Clone();case double[]doubles:return(double[])doubles.Clone();case byte[]bytes:return(byte[])bytes.Clone();case ParsedGodotValue[]godotValues:var copy=new ParsedGodotValue[godotValues.Length];for(var i=0;i<godotValues.Length;i++){var pgv=godotValues[i];copy[i]=new ParsedGodotValue{TypeName=pgv.TypeName,Args=new List<object>(pgv.Args)};}return copy;default:return value;}}private static Dictionary<string,ParsedNode>BuildPathMap(ParsedScene scene){var map=new Dictionary<string,ParsedNode>(StringComparer.Ordinal);foreach(var node in scene.Nodes){var path=NodePathOf(node);if(path.Length==0)continue;if(!map.ContainsKey(path))map[path]=node;}return map;}/// <summary>Full path of a node relative to the scene root; the root itself yields an empty string.</summary>
private static string NodePathOf(ParsedNode node){if(node==null)return"";if(string.IsNullOrEmpty(node.Parent))return"";if(node.Parent=="."||node.Parent=="..")return node.Name??"";return node.Parent.TrimEnd('/')+"/"+node.Name;}private static string ParentPathOf(ParsedNode node){var parent=node.Parent;if(string.IsNullOrEmpty(parent)||parent=="."||parent=="..")return"";return parent.TrimEnd('/');}private static bool MatchesAny(List<string>masks,string value){foreach(var mask in masks){if(MaskMatcher.IsMatch(mask,value))return true;}return false;}private static bool TakeBool(ParsedNode node,string key){return TakeProperty(node,key)is bool value&&value;}private static List<string>NormalizeStringList(object value){switch(value){case string[]strings:return strings.Where(s=> !string.IsNullOrEmpty(s)).ToList();case string text:var trimmed=text.Trim();if(trimmed.Length==0)return null;if(trimmed.StartsWith("[",StringComparison.Ordinal)&&trimmed.EndsWith("]",StringComparison.Ordinal)){var result=new List<string>();foreach(var part in TscnPropertyParser.SplitArgs(trimmed.Substring(1,trimmed.Length-2))){var item=part.Trim();if(item.Length>=2&&item.StartsWith("\"",StringComparison.Ordinal)&&item.EndsWith("\"",StringComparison.Ordinal))item=item.Substring(1,item.Length-2);if(item.Length>0)result.Add(item);}return result;}return new List<string>{text};default:return null;}}private static object TakeProperty(ParsedNode node,string key){if(node?.Properties==null|| !node.Properties.TryGetValue(key,out var value))return null;node.Properties.Remove(key);node.PropertyOrder.Remove(key);return value;}private static bool TakeBool(SubResourceDef sub,string key){if(sub?.Properties==null|| !sub.Properties.TryGetValue(key,out var value))return false;sub.Properties.Remove(key);sub.PropertyOrder.Remove(key);return value is bool flag&&flag;}}}

===== FILE Core/Scenes/Patching/ScenePatchDebug.cs =====
using System.Text;using Cthangover.Core.Scenes.TscnParsing;using Cthangover.Core.Utils;namespace Cthangover.Core.Scenes.Patching{/// <summary> Minimal scene-patch debugging aid: renders the effective scene — the result after all patch layers were applied — as text and writes it through <see cref="GameLogger"/>. Patch layers are invisible in the Godot editor (each patch sees only the base scene), so when a mash-up of several mods behaves unexpectedly this dump is the ground truth for what the builder will actually receive. <see cref="Format"/> is pure and reused by tests. </summary>
public static class ScenePatchDebug{private const string LogCategory="TSCN";/// <summary>Formats the effective scene as an indented node tree plus resources and connections.</summary>
public static string Format(ParsedScene scene){if(scene==null)return"<null scene>";var sb=new StringBuilder();sb.AppendLine($"scene: format={scene.Format ?? "?"}, entry_point={scene.EntryPointClass ?? "none"}");sb.AppendLine($"nodes ({scene.Nodes.Count}):");foreach(var node in scene.Nodes){var path=NodePathOf(node);var kind= !string.IsNullOrEmpty(node.InstanceRef)?$"instance={node.InstanceRef}":$"type={node.Type ?? "?"}";sb.Append("  ").Append(path.Length==0?"<root>":path).Append(" [").Append(kind);if(!string.IsNullOrEmpty(node.ScriptRef))sb.Append(", script=").Append(node.ScriptRef);if(node.Index.HasValue)sb.Append(", index=").Append(node.Index.Value);sb.AppendLine("]");foreach(var key in node.PropertyOrder)sb.Append("      ").Append(key).Append(" = ").AppendLine(Describe(node.Properties.TryGetValue(key,out var v)?v:null));if(node.Groups is{Length:>0})sb.Append("      groups = [").Append(string.Join(", ",node.Groups)).AppendLine("]");}sb.AppendLine($"ext_resources ({scene.ExtResources.Count}):");foreach(var ext in scene.ExtResources)sb.Append("  ").Append(ext.Id).Append(" -> ").Append(ext.Type).Append(' ').AppendLine(ext.Path);sb.AppendLine($"sub_resources ({scene.SubResources.Count}):");foreach(var sub in scene.SubResources)sb.Append("  ").Append(sub.Id).Append(" : ").AppendLine(sub.Type);sb.AppendLine($"connections ({scene.Connections.Count}):");foreach(var conn in scene.Connections)sb.Append("  ").Append(conn.From).Append('.').Append(conn.Signal).Append(" -> ").Append(conn.To).Append('.').AppendLine(conn.Method);return sb.ToString().TrimEnd();}/// <summary>Logs the effective scene under a label; no-op-safe outside the runtime.</summary>
public static void DumpEffective(ParsedScene scene,string label){GameLogger.Log(LogCategory,$"ScenePatchDebug: effective scene '{label}'\n{Format(scene)}");}private static string Describe(object value){switch(value){case null:return"null";case TscnPropertyValue property:return$"{property.Type}({property.RefId})";case ParsedGodotValue godotValue:return$"{godotValue.TypeName}({string.Join(", ", godotValue.Args)})";case string[]strings:return"["+string.Join(", ",strings)+"]";default:return value.ToString();}}private static string NodePathOf(ParsedNode node){if(string.IsNullOrEmpty(node.Parent))return"";if(node.Parent=="."||node.Parent=="..")return node.Name??"";return node.Parent.TrimEnd('/')+"/"+node.Name;}}}

===== FILE Core/Scenes/Patching/ScenePatchParser.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Scenes.TscnParsing;namespace Cthangover.Core.Scenes.Patching{/// <summary> Parses patch text into a <see cref="ScenePatch"/>. A patch is a regular .tscn file parsed by the same <see cref="TscnParser"/> as any scene; the only extra contract is the root node: it must carry an <c>instance=ExtResource(...)</c> reference to a <c>PackedScene</c> ext_resource, which is simultaneously the patch header, the editor's inherited-scene marker and the machine-readable target. Files without that shape are not patches (they may be ordinary scenes that happen to live under patches/scenes). </summary>
public static class ScenePatchParser{/// <summary> Parses <paramref name="text"/>. Returns false with a diagnostic <paramref name="error"/> when the file is not a valid patch. </summary>
public static bool TryParse(string text,string modId,string sourcePath,out ScenePatch patch,out string error){patch=null;error=null;if(string.IsNullOrWhiteSpace(text)){error="patch file is empty";return false;}ParsedScene scene;try{scene=new TscnParser().Parse(text);}catch(Exception ex){error=$"parse failed: {ex.Message}";return false;}var root=scene.Nodes.FirstOrDefault(n=>n.Parent==null);if(root==null){error="patch has no root node";return false;}if(string.IsNullOrEmpty(root.InstanceRef)){error="patch root must instance the target scene (instance=ExtResource(...))";return false;}var refId=ExtractResourceId(root.InstanceRef);var ext=scene.ExtResources.FirstOrDefault(e=>string.Equals(e.Id,refId,StringComparison.Ordinal));if(ext==null){error=$"root instance references unknown ext_resource '{refId}'";return false;}if(!string.Equals(ext.Type,"PackedScene",StringComparison.OrdinalIgnoreCase)){error=$"root instance must reference a PackedScene, got '{ext.Type}'";return false;}var target=ScenePatchTargetResolver.Normalize(ext.Path);if(target.Length==0){error="target scene path is empty";return false;}patch=new ScenePatch{ModId=modId,SourcePath=sourcePath,TargetPath=target,TargetExtResourceId=ext.Id,Scene=scene};return true;}/// <summary> Extracts the local id from an <c>ExtResource("id")</c> value. Returns the raw trimmed string when the value has no wrapper so malformed input still produces a readable lookup error. </summary>
private static string ExtractResourceId(string refString){if(string.IsNullOrEmpty(refString))return null;const string prefix="ExtResource(";var start=refString.IndexOf(prefix,StringComparison.Ordinal);if(start<0)return refString.Trim('"');start+=prefix.Length;var end=refString.IndexOf(')',start);if(end<0)return refString.Trim('"');return refString.Substring(start,end-start).Trim().Trim('"');}}}

===== FILE Core/Scenes/Patching/ScenePatchResolver.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;namespace Cthangover.Core.Scenes.Patching{/// <summary> Discovers and caches scene patches from every loaded mod. Patch files live under <c>patches/scenes/</c> (recursively) and are grouped by the canonical target key they declare in their inherited-scene header; within one mod files apply in path order, across mods in load order, so the returned layer list is exactly the cascade <see cref="ScenePatchApplier"/> expects. <para/> Registered as the <c>"scene_patches"</c> resolver. Unlike JSON patches the key is not an entity kind but the target scene path, because a patch already names its base scene. </summary>
public class ScenePatchResolver:ModAssetResolver{/// <inheritdoc />
public override string Id=>"scene_patches";/// <summary>Directory scanned inside every mod, recursively.</summary>
public const string PatchDirectory="patches/scenes";private Dictionary<string,List<ScenePatch>>_cache;/// <summary> Collects every patch from every loaded mod, keyed by canonical target scene path. Cached until <see cref="ClearCache"/>; later mods append to a key's layer list. </summary>
public Dictionary<string,List<ScenePatch>>Collect(){Context.Initialize();if(_cache!=null)return _cache;var sources=new List<(string ModId,IModFileProvider Provider)>();foreach(var modId in Context.GetOrderedModIds()){var provider=Context.GetMod(modId)?.FileProvider;if(provider!=null)sources.Add((modId,provider));}_cache=CollectFromProviders(sources);if(_cache.Count>0)GameLogger.Log("TSCN",$"ScenePatchResolver: collected {_cache.Sum(kv => kv.Value.Count)} patch(es) for {_cache.Count} scene(s)");return _cache;}/// <summary> All layers targeting <paramref name="sceneKey"/>, in application order. Returns an empty list for a scene without patches so callers can treat "no patches" uniformly. </summary>
public List<ScenePatch>GetPatches(string sceneKey){if(string.IsNullOrEmpty(sceneKey))return new List<ScenePatch>();var result=new List<ScenePatch>();foreach(var kvp in Collect()){if(ScenePatchTargetResolver.MatchesSceneKey(sceneKey,kvp.Key))result.AddRange(kvp.Value);}return result;}/// <summary>True when at least one mod patches the scene.</summary>
public bool HasPatches(string sceneKey)=>GetPatches(sceneKey).Count>0;/// <inheritdoc />
public override void ClearCache(){_cache=null;}/// <summary> Testable collection core: walks each provider's <c>patches/scenes</c> tree in the given order (mod load order), sorts files inside one mod for determinism, and parses every <c>.tscn</c>. Invalid files are logged and skipped so one bad patch cannot break scene loading. </summary>
public static Dictionary<string,List<ScenePatch>>CollectFromProviders(IEnumerable<(string ModId,IModFileProvider Provider)>sources){var result=new Dictionary<string,List<ScenePatch>>(StringComparer.Ordinal);foreach(var(modId,provider)in sources){var files=new List<string>();CollectPatchFiles(provider,PatchDirectory,files);files.Sort(StringComparer.OrdinalIgnoreCase);foreach(var file in files){if(!file.EndsWith(".tscn",StringComparison.OrdinalIgnoreCase))continue;var text=provider.ReadFileText(file);if(!ScenePatchParser.TryParse(text,modId,file,out var patch,out var error)){GameLogger.Log("TSCN",$"ScenePatchResolver: '{modId}/{file}' is not a valid scene patch: {error}",LogLevel.Error);continue;}if(!result.TryGetValue(patch.TargetPath,out var list)){list=new List<ScenePatch>();result[patch.TargetPath]=list;}list.Add(patch);}}return result;}private static void CollectPatchFiles(IModFileProvider provider,string directory,List<string>result){foreach(var entry in provider.ListFiles(directory)){if(entry.EndsWith("/")){CollectPatchFiles(provider,entry.TrimEnd('/'),result);continue;}var prefix=ModPath.NormalizeDirectory(PatchDirectory);if(entry.StartsWith(prefix,StringComparison.OrdinalIgnoreCase))result.Add(entry);}}}}

===== FILE Core/Scenes/Patching/ScenePatchTargetResolver.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Mods;namespace Cthangover.Core.Scenes.Patching{/// <summary> Translates the target path a patch declares in its <c>[ext_resource type="PackedScene"]</c> header into the canonical scene key used by <see cref="TscnSceneResolver"/>. <para/> Three spellings denote the same scene: the resolver key (<c>scenes/menu/main_menu.tscn</c>), the native form (<c>res://scenes/menu/main_menu.tscn</c>) and the on-disk form of a mod scene (<c>res://mods/&lt;mod&gt;/scenes/menu/main_menu.tscn</c>, which is what the Godot editor writes when the mod lives inside the project). Normalisation strips the scheme and the <c>mods/&lt;mod&gt;/</c> prefix; the resolver then matches by suffix against the collected scene index so a patch works regardless of which mod won the override. </summary>
public static class ScenePatchTargetResolver{/// <summary> Converts a declared target path into canonical mod-relative form: forward slashes, no <c>res://</c> scheme, no leading slash, and no <c>mods/&lt;mod&gt;/</c> prefix. Returns an empty string for null/empty input. </summary>
public static string Normalize(string declaredPath){if(string.IsNullOrEmpty(declaredPath))return"";var path=declaredPath.Replace('\\','/');if(path.StartsWith("res://",StringComparison.OrdinalIgnoreCase))path=path.Substring("res://".Length);path=path.TrimStart('/');var segments=path.Split('/');if(segments.Length>=3&&segments[0].Equals("mods",StringComparison.OrdinalIgnoreCase))path=string.Join("/",segments,2,segments.Length-2);return ModPath.Normalize(path);}/// <summary> True when a patch targeting <paramref name="declaredPath"/> applies to the scene registered under <paramref name="sceneKey"/>. Exact match, or the declared path ends with the scene key (covers extra prefixes the normaliser did not strip). </summary>
public static bool MatchesSceneKey(string sceneKey,string declaredPath){if(string.IsNullOrEmpty(sceneKey)||string.IsNullOrEmpty(declaredPath))return false;var normalized=Normalize(declaredPath);if(normalized.Length==0)return false;if(string.Equals(normalized,sceneKey,StringComparison.OrdinalIgnoreCase))return true;return normalized.EndsWith("/"+sceneKey,StringComparison.OrdinalIgnoreCase);}/// <summary> Picks the winning scene key for a declared target from the collected scene index. Exact matches win over suffix matches, mirroring how <c>TscnSceneResolver</c> resolves override conflicts (the index itself already holds one entry per key). </summary>
public static string Resolve(string declaredPath,IEnumerable<string>availableKeys){var normalized=Normalize(declaredPath);if(normalized.Length==0||availableKeys==null)return null;string suffixMatch=null;foreach(var key in availableKeys){if(key==null)continue;if(string.Equals(key,normalized,StringComparison.OrdinalIgnoreCase))return key;if(suffixMatch==null&&normalized.EndsWith("/"+key,StringComparison.OrdinalIgnoreCase))suffixMatch=key;}return suffixMatch;}}}

===== FILE Core/Scenes/ScenarioCondition.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Events;using Cthangover.Core.Quests;using Cthangover.Core.Utils;namespace Cthangover.Core.Scenes{/// <summary> Evaluates condition expression strings by parsing them into an AST via <see cref="ConditionParser"/> and walking the tree polymorphically — each <see cref="ConditionNode"/> subclass implements its own <see cref="ConditionNode.Evaluate"/> method. Maintains a static dictionary of named flags settable via <see cref="SetFlag"/> and injects them (along with <see cref="QuestFactory"/>) into an <see cref="EvaluationContext"/> that is carried through the entire AST walk. The polymorphic design means that a mod assembly can define a custom <see cref="ConditionNode"/> and it will be evaluated automatically when present in the AST — no dispatch table, no switch, no registration. </summary>
public static class ScenarioCondition{private static readonly Dictionary<string,string>flags=new();private static readonly Dictionary<string,ConditionNode>AstCache=new();private static readonly object AstLock=new();private static readonly EvaluationContext evalCtx=new();/// <summary> Sets a named flag value in the static flag dictionary. Used by scenario scripts to set conditions that can be checked in subsequent <see cref="Evaluate"/> calls. Publishes a <see cref="FlagChangedEvent"/> only when the value actually changed (setting the same value is a no-op), so condition caches invalidate on real change. </summary> <param name="key">The flag name.</param> <param name="value">The flag value, which may be parsed as integer for numeric comparisons.</param>
public static void SetFlag(string key,string value){if(flags.TryGetValue(key,out var current)&&current==value)return;flags[key]=value;ModEventBus.Publish(new FlagChangedEvent{Key=key,Value=value});}/// <summary> Parses and evaluates a condition expression string. Returns <c>true</c> for null or whitespace-only input (vacuous truth). Returns <c>false</c> on parse errors. Supports boolean combinators (<c>&amp;&amp;</c>, <c>||</c>, <c>!</c>), numeric comparisons on flags and quest properties, quest tag membership checks (<c>hasTag</c>, <c>notHasTag</c>), and boolean literals (<c>true</c>, <c>false</c>). The parsed AST is cached per unique condition string (the tree and its static inputs are immutable), while execution still reads live flag and quest state at evaluation time. Parse errors are not cached and re-parse (and fail) on every call. </summary> <param name="condition">The condition expression string to evaluate.</param>
public static bool Evaluate(string condition){if(string.IsNullOrWhiteSpace(condition))return true;ConditionNode ast;lock(AstLock){if(!AstCache.TryGetValue(condition,out ast)){try{ast=ConditionParser.Parse(condition);}catch(FormatException ex){GameLogger.Log("COND",$"parse error: {ex.Message}",LogLevel.Error);return false;}if(ast==null)return true;AstCache[condition]=ast;}}evalCtx.Flags=flags;evalCtx.QuestFactory=QuestServiceRegistry.Provider;return ast.Evaluate(evalCtx);}}}

===== FILE Core/Scenes/ScenarioDefinition.cs =====
namespace Cthangover.Core.Scenes{/// <summary> Represents a parsed .scenario file entry from a mod manifest. Maps a visual novel scenario script to a target scene with scheduling metadata, condition expressions, and save/permanence flags. Collections of these definitions are built by <see cref="ModManager.CollectScenarioDefinitions"/> and consumed in <see cref="SceneManager.ComposeEvents"/> to produce <see cref="SceneEventInfo"/> entries for the event queue. </summary>
public class ScenarioDefinition{/// <summary> The display name of the scenario, also used as the unique event identifier within its scene. </summary>
public string Name{get;set;}/// <summary> The scene identifier that this scenario is associated with. </summary>
public string Scene{get;set;}/// <summary> Execution priority; lower values execute earlier. Ties are broken by identifier ordering. </summary>
public int Priority{get;set;}/// <summary> The identifier of another event that must execute before this one, enabling topological ordering within a scene's event list. </summary>
public string After{get;set;}/// <summary> A condition expression string parsed by <see cref="ConditionParser"/> and evaluated by <see cref="ScenarioCondition.Evaluate"/>. The event is skipped if the condition evaluates to <c>false</c>. </summary>
public string Condition{get;set;}/// <summary> When set, overrides whether the in-game light clock (time-of-day) progresses during this scenario. <c>null</c> means default behavior. </summary>
public bool?LightUseTime{get;set;}/// <summary> When <c>true</c>, the player is allowed to save during this scenario. </summary>
public bool SaveAllowed{get;set;}/// <summary> When <c>true</c>, this scenario is marked as completed after its first execution and will be filtered out on subsequent scene entries. </summary>
public bool IsOneRun{get;set;}/// <summary> The identifier of the mod that provides this scenario definition. </summary>
public string ModId{get;set;}/// <summary> The file system path to the .scenario file containing the scenario script text. </summary>
public string FilePath{get;set;}}}

===== FILE Core/Scenes/SceneCandidateOutcome.cs =====
namespace Cthangover.Core.Scenes{/// <summary>What the scene composition did with one candidate event, see <see cref="SceneCandidateReport"/>.</summary>
public enum SceneCandidateOutcome{/// <summary>Instantiated and queued on the scene's event chain.</summary>
Queued,/// <summary>Its header condition evaluated to false.</summary>
ConditionFalse,/// <summary>A one-run scenario already completed in this game.</summary>
Completed,/// <summary>Another event with the same id was taken first (an earlier mod or an overlay won).</summary>
Shadowed,/// <summary>Instantiation failed (unreadable file, missing type…); see the SCENE log.</summary>
Failed,}}

===== FILE Core/Scenes/SceneCandidateReport.cs =====
namespace Cthangover.Core.Scenes{/// <summary> One candidate event of a scene composition as reported by <see cref="Events.SceneComposedEvent"/>: the event id (scenario name or C# type), the owning mod or overlay and file for scenario events, the ordering inputs and what happened to it. </summary>
public class SceneCandidateReport{public string Id{get;init;}public string ModId{get;init;}public string ScenarioPath{get;init;}public int Priority{get;init;}public string Condition{get;init;}public bool IsOneRun{get;init;}public SceneCandidateOutcome Outcome{get;set;}}}

===== FILE Core/Scenes/SceneContextNode.cs =====
using System.Collections.Generic;using Cthangover.Core.Events;using Cthangover.Core.Interactive;using Cthangover.Core.UI.Executable;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Scenes{/// <summary> Persistent autoload-style singleton node that caches scene-tree node lookups and manages interactive event objects. Automatically detects Godot scene changes (via <see cref="_Process"/>) and clears all cached references, ensuring stale nodes are never returned. Provides static helpers like <see cref="FindNode{T}"/> for convenience access throughout the codebase. The singleton instance is set during <see cref="_Ready"/> and cleared on <see cref="_ExitTree"/>. </summary>
public partial class SceneContextNode:Node{/// <summary> The singleton instance of <see cref="SceneContextNode"/>. Only one instance is allowed; duplicates log an error and self-disable. </summary>
public static SceneContextNode Instance{get;set;}/// <summary> Tracks the most recently applied background texture ID, used by save/restore flows and lazy reload logic in <see cref="SceneManager"/>. </summary>
public static string LastBackgroundID{get;set;}/// <summary> Returns the root node of the currently active Godot scene, or <c>null</c> if no scene tree is available. </summary>
public static Node CurrentScene=>Instance?.GetTree()?.CurrentScene;private Dictionary<string,object>data=new();private Dictionary<string,IEventObject>eventObjects=new();private HashSet<string>notFoundCache=new();/// <summary>Claims the singleton (duplicates log an error and stay inert), enables always-on processing, and subscribes cache clearing to <see cref="SceneExitedEvent"/>. The interactive subsystem lifecycle lives in the interactive_base engine mod (InteractiveModInitializer).</summary>
public override void _Ready(){if(Instance!=null&&GodotObject.IsInstanceValid(Instance)){var scene=GetTree()?.CurrentScene?.Name??"?";var existingPath=Instance.GetPath().ToString();var myPath=GetPath().ToString();GameLogger.Log("DUPLICATE",$"SceneContextNode._Ready: Instance ALREADY SET by '{existingPath}', ignoring duplicate at '{myPath}' on scene '{scene}'",LogLevel.Error);return;}Instance=this;ProcessMode=ProcessModeEnum.Always;ModEventBus.Subscribe<SceneExitedEvent>(_=>ClearData());}/// <summary>Releases the singleton reference when the node leaves the tree.</summary>
public override void _ExitTree(){if(Instance==this)Instance=null;}/// <summary> Destroys all tracked interactive event objects via <see cref="IEventObject.Destruct"/>, and clears the node-lookup cache and the not-found cache. The interactive subsystem's own scene-exit teardown is wired by the interactive_base engine mod (InteractiveModInitializer). Called automatically when the active Godot scene changes. </summary>
public void ClearData(){GameLogger.Log("SCENE_CTX",$"ClearData: data={data.Count}, notFoundCache={notFoundCache.Count}, eventObjects={eventObjects.Count}");data.Clear();if(Lists.IsNotEmpty(eventObjects)){foreach(var entry in eventObjects)entry.Value.Destruct();eventObjects.Clear();}notFoundCache.Clear();}/// <summary> Statically searches the entire scene tree for a node by name and type, using the singleton instance's <see cref="GetSafeNode{T}"/>. Returns <c>null</c> if the instance is unavailable or the node is not found. </summary> <typeparam name="T">The expected Godot <see cref="Node"/> type.</typeparam> <param name="nodeName">The name of the node to find.</param>
public static T FindNode<T>(string nodeName)where T:Node{if(Instance==null|| !GodotObject.IsInstanceValid(Instance))return null;return Instance.GetSafeNode<T>(nodeName);}/// <summary> Resolves the first node in a Godot group, caching the result in the internal data dictionary. Re-checks on scene change and validates the cached Godot object reference to avoid returning disposed nodes. </summary> <typeparam name="T">The expected Godot <see cref="Node"/> type.</typeparam> <param name="groupName">The Godot group name to query.</param>
public T GetSceneRoot<T>(string groupName)where T:Node{if(data.TryGetValue(groupName,out var value)&&value!=null){if(value is GodotObject go&&GodotObject.IsInstanceValid(go))return(T)value;data.Remove(groupName);}var nodes=GetTree().GetNodesInGroup(groupName);if(nodes.Count>0){var v=nodes[0]as T;data[groupName]=v;return v;}GameLogger.Log("SCENE",$"object with group '{groupName}' not found",LogLevel.Error);return null;}/// <summary> Performs a cached recursive node lookup through the scene tree root. On first call for a given name, searches the tree via <see cref="Node.FindChild"/> and caches the result. Subsequent calls return the cached reference after validating it is still a valid Godot object. Failed lookups are stored in a not-found cache to avoid repeated searches within the same scene lifecycle. </summary> <typeparam name="T">The expected Godot <see cref="Node"/> type.</typeparam> <param name="nodeName">The name of the node to find.</param>
public T GetSafeNode<T>(string nodeName)where T:Node{if(data.TryGetValue(nodeName,out var value)&&value!=null){if(value is GodotObject go&&GodotObject.IsInstanceValid(go))return(T)value;data.Remove(nodeName);}if(notFoundCache.Contains(nodeName))return null;var tree=GetTree();var root=tree?.Root;if(root==null){GameLogger.Log("SCENE_CTX",$"GetSafeNode<{typeof(T).Name}>('{nodeName}') -> root is NULL (tree={(tree != null ? "exists" : "NULL")})",LogLevel.Error);return null;}GameLogger.Log("SCENE_CTX",$"GetSafeNode<{typeof(T).Name}>('{nodeName}') -> searching tree (root children={root.GetChildCount()}, CurrentScene={tree.CurrentScene?.Name ?? "NULL"})",LogLevel.Debug);T v=root.FindChild(nodeName,true,false)as T;if(v==null){notFoundCache.Add(nodeName);GameLogger.Log("SCENE",$"node '{nodeName}' of type {typeof(T).Name} not found",LogLevel.Error);return null;}GameLogger.Log("SCENE_CTX",$"GetSafeNode<{typeof(T).Name}>('{nodeName}') -> FOUND and cached (parent={v.GetParent()?.Name ?? "NULL"})",LogLevel.Debug);data[nodeName]=v;return v;}/// <summary> Destroys and removes a tracked interactive event object by its identifier. Calls <see cref="IEventObject.Destruct"/> before removal. </summary> <param name="id">The unique identifier of the event object to remove.</param>
public void RemoveEventObject(string id){if(eventObjects.TryGetValue(id,out var obj))obj.Destruct();eventObjects.Remove(id);}/// <summary> Removes any entries from the event object dictionary whose values have become <c>null</c>, preventing accumulation of garbage entries. </summary>
public void OptimizeEventObjects(){var keysToRemove=new List<string>();foreach(var pair in eventObjects)if(pair.Value==null)keysToRemove.Add(pair.Key);foreach(var key in keysToRemove)eventObjects.Remove(key);}}}

===== FILE Core/Scenes/SceneDefinition.cs =====
using System.Collections.Generic;using System.Text.Json.Serialization;using Cthangover.Core.Utils;namespace Cthangover.Core.Scenes{/// <summary> Defines a visual novel scene's default configuration as loaded from mod JSON manifests. Specifies fallback visuals (background images with optional randomization), ambient audio, and a default scenario script that executes when no other events are registered for the scene. Instantiated by <see cref="ModManager"/> during scene collection and consumed by <see cref="SceneManager.ApplySceneDefaults"/>. </summary>
public class SceneDefinition{/// <summary> The unique scene identifier, matching scene names passed to <see cref="SceneManager.SwitchScene"/>. </summary>
public string Name{get;set;}/// <summary> The identifier of the mod that defines this scene configuration. </summary>
public string ModId{get;set;}/// <summary> One or more background texture IDs. When the scene is entered, a random entry is selected and loaded via <see cref="BackgroundFactory"/>. Supports both a single string value and a JSON array via <see cref="StringOrArrayConverter"/>. </summary>
[JsonConverter(typeof(StringOrArrayConverter))]public List<string>DefaultBackground{get;set;}/// <summary> The ambient audio asset ID played when entering this scene. A null or empty value stops any currently playing ambient. </summary>
public string DefaultAmbient{get;set;}/// <summary> The file path to a .scenario file that executes when no registered events exist for the scene. Acts as the visual novel fallback script. </summary>
public string DefaultScenario{get;set;}}}

===== FILE Core/Scenes/SceneEventAttribute.cs =====
using System;namespace Cthangover.Core.Scenes{/// <summary> Custom attribute applied to <see cref="ExecutableEvent"/> subclasses to declare their scene association and execution ordering constraints. Discovered via reflection by <see cref="SceneEventRegistry.RegisterFromAttributes"/> during initialization. Cannot be inherited or applied multiple times to the same class. </summary>
[AttributeUsage(AttributeTargets.Class,AllowMultiple=false,Inherited=false)]public class SceneEventAttribute:Attribute{/// <summary> The scene identifier that this event class belongs to. </summary>
public string SceneName{get;}/// <summary> Execution priority; lower values execute earlier. Default is 0. </summary>
public int Priority{get;set;}/// <summary> The identifier of another event that must execute before this one, used for topological ordering within a scene's event queue. </summary>
public string After{get;set;}/// <summary> A condition expression string parsed by <see cref="ConditionParser"/> that must evaluate to <c>true</c> for this event to be included in the queue. </summary>
public string Condition{get;set;}/// <summary> Creates a new <see cref="SceneEventAttribute"/> bound to the specified scene. </summary> <param name="sceneName">The scene identifier this event class belongs to.</param>
public SceneEventAttribute(string sceneName){SceneName=sceneName;}}}

===== FILE Core/Scenes/SceneEventInfo.cs =====
namespace Cthangover.Core.Scenes{/// <summary> Runtime descriptor for a scene event, holding the metadata needed to instantiate, schedule, and conditionally filter it. Can reference either a C# class (via <see cref="ClassName"/>) registered through <see cref="SceneEventRegistry"/>, or a .scenario file (via <see cref="ScenarioPath"/>) discovered from mod manifests. Used by <see cref="SceneManager.ComposeEvents"/> and <see cref="SceneManager.CreateEventInstance"/>. </summary>
public class SceneEventInfo{/// <summary> Unique identifier for this event within its scene, used for deduplication and topological ordering. </summary>
public string Id{get;set;}/// <summary> The fully-qualified type name of the C# class implementing <see cref="ExecutableEvent"/>. Mutually exclusive with <see cref="ScenarioPath"/>. </summary>
public string ClassName{get;set;}/// <summary> The file path to a .scenario script file. Mutually exclusive with <see cref="ClassName"/>. </summary>
public string ScenarioPath{get;set;}/// <summary> Mod (or content overlay) that owns <see cref="ScenarioPath"/>. The path is mod-relative, so it is only unambiguous together with the owner: two mods may ship the same <c>scenarios/…</c> path. </summary>
public string ModId{get;set;}/// <summary> Execution priority; lower values execute earlier. Ties broken by <see cref="Id"/> ordering. </summary>
public int Priority{get;set;}/// <summary> The identifier of an event that must precede this one in the execution order. </summary>
public string After{get;set;}/// <summary> A condition expression string evaluated by <see cref="ScenarioCondition.Evaluate"/>. The event is skipped if the condition resolves to <c>false</c>. </summary>
public string Condition{get;set;}/// <summary> When set, overrides whether the in-game light clock progresses during this event. </summary>
public bool?LightUseTime{get;set;}/// <summary> When <c>true</c>, this event is marked as completed after execution and excluded from future scene entries. </summary>
public bool IsOneRun{get;set;}}}

===== FILE Core/Scenes/SceneEventRegistry.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Reflection;using Cthangover.Core.Bootstrap;using Cthangover.Core.UI.Executable;using Cthangover.Core.Utils;namespace Cthangover.Core.Scenes{/// <summary> Mod-derived registry of executable scene events: <see cref="ExecutableEvent"/> types discovered through <see cref="SceneEventAttribute"/> plus events registered imperatively by mod initializers after the build. The registry instance lives in <see cref="AppServices.Current"/>; the static members below are a facade delegating to it, so existing callers keep working unchanged both before and after a rebuild. </summary>
public class SceneEventRegistry:IGameRegistry{private readonly Dictionary<string,List<SceneEventInfo>>events=new();private bool built;/// <summary> Factory for scenario-backed executable events, published by the dialog_base engine mod on load. SceneManager builds inline scenario events through it; null until the dialog mod registers its factory. </summary>
public IScenarioEventFactory ScenarioEventFactory{get;set;}/// <summary>Shared instance the static facade delegates to.</summary>
private static SceneEventRegistry Owned=>AppServices.Current.SceneEvents;/// <summary> Creates an empty registry: cheap and side-effect free per the IGameRegistry contract. <see cref="Rebuild"/> fills the data. </summary>
internal SceneEventRegistry(){}/// <inheritdoc />
public string Id=>"scene_events";/// <summary> Re-scans all loaded assemblies for <see cref="ExecutableEvent"/> types decorated with <see cref="SceneEventAttribute"/>, replacing the previous table. Forced: safe to call repeatedly. Events registered imperatively (by mod initializers) must re-register afterwards. </summary>
public void Rebuild(){lock(events){events.Clear();RegisterFromAttributes();built=true;}}/// <summary> Drops all registered events; the next build re-scans the assemblies. Called on mod reload through AppServices.ClearRegistries. </summary>
public void Clear(){lock(events){events.Clear();built=false;}}/// <summary> Scans all loaded assemblies for <see cref="ExecutableEvent"/> types decorated with <see cref="SceneEventAttribute"/> and registers them in the event table. Uses double-checked locking for thread safety; keeps the old Initialize idempotence — subsequent calls are no-ops. </summary>
public void EnsureBuilt(){if(built)return;lock(events){if(built)return;RegisterFromAttributes();built=true;}}// ---- Static facade (legacy API, unchanged for callers) ------------ <summary>Legacy entry point; delegates to the shared instance.</summary>
public static void Initialize()=>Owned.EnsureBuilt();/// <summary> Registers a specific <see cref="ExecutableEvent"/> subclass for a scene with optional priority, dependency, and condition parameters. </summary> <typeparam name="T">The <see cref="ExecutableEvent"/> subclass to register.</typeparam> <param name="sceneName">The scene identifier to associate the event with.</param> <param name="priority">Execution priority (lower = earlier). Default 0.</param> <param name="after">Identifier of an event that must precede this one.</param> <param name="condition">Condition expression string evaluated at runtime.</param>
public static void Register<T>(string sceneName,int priority=0,string after=null,string condition=null)where T:class=>Owned.AddEvent(typeof(T),sceneName,priority,after,condition);/// <summary> Non-generic variant of <see cref="Register{T}"/> that accepts a <see cref="Type"/> directly. Works before and after the build: a registration made after Initialize simply joins the table. </summary> <param name="type">The <see cref="ExecutableEvent"/> type to register.</param> <param name="sceneName">The scene identifier to associate the event with.</param> <param name="priority">Execution priority (lower = earlier). Default 0.</param> <param name="after">Identifier of an event that must precede this one.</param> <param name="condition">Condition expression string evaluated at runtime.</param>
public static void Register(Type type,string sceneName,int priority=0,string after=null,string condition=null)=>Owned.AddEvent(type,sceneName,priority,after,condition);/// <summary> Returns all registered <see cref="SceneEventInfo"/> entries for the given scene name, sorted topologically by priority and <c>After</c> dependencies. Ensures the registry has been built first. </summary> <param name="sceneName">The scene identifier to retrieve events for.</param>
public static List<SceneEventInfo>GetEvents(string sceneName)=>Owned.GetEventsSorted(sceneName);/// <summary> Scans a specific assembly for <see cref="SceneEventAttribute"/>-decorated <see cref="ExecutableEvent"/> types and registers them. Thread-safe. Useful for registering events from dynamically loaded mod assemblies after initial startup. </summary> <param name="assembly">The assembly to scan for event types.</param>
public static void RegisterAssembly(Assembly assembly)=>Owned.AddFromAssembly(assembly);/// <summary>Unload pass: drops events contributed by the given assembly. Delegates to the shared instance.</summary>
public static void UnregisterAssembly(Assembly assembly,string modId)=>Owned.RemoveFromAssembly(assembly,modId);// ---- Instance implementation -------------------------------------- <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public void AddEvent(Type type,string sceneName,int priority,string after,string condition){lock(events){if(!events.TryGetValue(sceneName,out var list)){list=new List<SceneEventInfo>();events[sceneName]=list;}list.Add(new SceneEventInfo{Id=type.FullName,ClassName=type.FullName,Priority=priority,After=after,Condition=condition,});}}/// <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public List<SceneEventInfo>GetEventsSorted(string sceneName){EnsureBuilt();lock(events){if(!events.TryGetValue(sceneName,out var list))return new List<SceneEventInfo>();return SortEvents(list).ToList();}}/// <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public void AddFromAssembly(Assembly assembly){lock(events){RegisterFromAttributes(assembly);}}/// <summary> Removes events whose implementation class comes from the given assembly — the unload pass of the assembly consumer pipeline. The event table stores type names, so the reverse pass resolves each entry back to its type and drops the ones the unloading assembly contributed; scenario-file events are never touched here. </summary>
public void RemoveFromAssembly(Assembly assembly,string modId){var assemblyName=assembly.GetName().Name;lock(events){foreach(var key in events.Keys.ToList()){events[key].RemoveAll(info=>{if(string.IsNullOrEmpty(info.ClassName))return false;var type=Type.GetType(info.ClassName,false)??FindType(info.ClassName);return type!=null&&type.Assembly.GetName().Name==assemblyName;});if(events[key].Count==0)events.Remove(key);}}}private static Type FindType(string fullName){foreach(var assembly in Reflections.LoadedAssemblies()){try{var type=assembly.GetType(fullName,false);if(type!=null)return type;}catch{// unload window: assembly partially collected, skip
}}return null;}private void RegisterFromAttributes(){foreach(var assembly in Reflections.LoadedAssemblies())RegisterFromAttributes(assembly);}private void RegisterFromAttributes(Assembly assembly){Type[]types;try{types=assembly.GetTypes();}catch(ReflectionTypeLoadException ex){// A collectible mod assembly can be mid-unload while a rebuild scans the AppDomain; skip it the same way Reflections does.
types=ex.Types.Where(t=>t!=null).ToArray();}catch(Exception ex){GameLogger.Log("SCENE",$"SceneEventRegistry: cannot enumerate types of '{assembly.GetName().Name}': {ex.Message}",LogLevel.Warning);return;}foreach(var type in types){var attr=type.GetCustomAttribute<SceneEventAttribute>();if(attr==null)continue;if(!typeof(IExecutableEvent).IsAssignableFrom(type))continue;AddEvent(type,attr.SceneName,attr.Priority,attr.After,attr.Condition);}}private static IEnumerable<SceneEventInfo>SortEvents(List<SceneEventInfo>list){var visited=new HashSet<string>();var result=new List<SceneEventInfo>();var pending=new HashSet<string>();void Visit(SceneEventInfo info){if(info.Id!=null&&visited.Contains(info.Id))return;if(info.Id!=null&&pending.Contains(info.Id)){GameLogger.Log("SCENE",$"Circular dependency detected: {info.Id}",LogLevel.Warning);return;}if(!string.IsNullOrEmpty(info.After)){var after=list.FirstOrDefault(e=>e.Id==info.After);if(after!=null){pending.Add(info.Id);Visit(after);pending.Remove(info.Id);}}if(info.Id!=null&&visited.Add(info.Id))result.Add(info);else if(info.Id==null&& !result.Contains(info))result.Add(info);}foreach(var info in list.OrderBy(e=>e.Priority).ThenBy(e=>e.Id))Visit(info);return result;}}}

===== FILE Core/Scenes/SceneLaunchArgs.cs =====
using System;using System.Linq;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Scenes{/// <summary> Parses the first-class <c>--scene=&lt;logical&gt;</c> launch flag from Godot user arguments (everything after <c>--</c> on the command line). The flag is a core navigation feature, not a test hook: when present, the launcher boots the mod pipeline as usual and then jumps straight into the requested mod-defined scene instead of showing the launcher UI or the main menu. Only logical scene names (resolved through <see cref="SceneManager"/>) are accepted — a raw <c>.tscn</c> path can already be passed to Godot natively as the boot scene. Kept separate from <see cref="SceneManager"/> so argument parsing stays independent of the autoload lifecycle and is trivially reusable by any future entry point. </summary>
public static class SceneLaunchArgs{/// <summary> Returns the logical scene name requested via <c>--scene=&lt;id&gt;</c>, or null when the flag is absent or empty. Only the first occurrence wins; repeated flags are ignored. Guarded by <see cref="GameEnvironment.IsRuntime"/> because <c>OS.GetCmdlineUserArgs()</c> is a Godot API that must not be touched from pure test environments. </summary>
public static string RequestedSceneName(){if(!GameEnvironment.IsRuntime)return null;return OS.GetCmdlineUserArgs().Where(a=>a.StartsWith("--scene=",StringComparison.Ordinal)).Select(a=>a.Substring("--scene=".Length).Trim()).FirstOrDefault(id=> !string.IsNullOrEmpty(id));}}}

===== FILE Core/Scenes/SceneManager.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Actions;using Cthangover.Core.Audio;using Cthangover.Core.Bootstrap;using Cthangover.Core.Events;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Settings;using Cthangover.Core.UI;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Scenes{/// <summary> Central orchestrator for visual novel scene progression. Composes and schedules executable events from C# attributes and .scenario files, applies scene defaults (random background, ambient audio), and routes to native Godot scenes for menus and to mod-registered scene routes (see <see cref="SceneRouteRegistry"/>). Manages the <see cref="ExecutableMainEventChain"/> queue and drives scene enter/exit lifecycle events consumed by mod GUI and gameplay hooks. </summary>
public partial class SceneManager:Node{private Dictionary<string,List<SceneDefinition>>allScenes;private string currentSceneName;private bool isInitialized;private IExecutableEventChain eventChain;private SceneEventController eventController;private IDialogBox dialogBox;/// <summary> Gets the name of the currently active scene as tracked by this manager. </summary>
public string CurrentSceneName=>currentSceneName;/// <summary> Statically retrieves the current scene name by locating the <see cref="SceneManager"/> singleton via <see cref="SceneContextNode"/>. Returns <c>null</c> if the manager has not been instantiated. </summary>
public static string GetCurrentSceneName(){var instance=SceneContextNode.FindNode<SceneManager>("SceneManager");return instance?.CurrentSceneName;}/// <summary> Checks whether saving is permitted in the currently active scene by inspecting all <see cref="ScenarioDefinition"/> entries associated with it. Returns <c>true</c> if any scenario for the scene has <c>SaveAllowed</c> enabled. </summary>
public static bool IsSaveAllowedForCurrentScene(){var instance=SceneContextNode.FindNode<SceneManager>("SceneManager");if(instance==null)return false;var sceneName=instance.CurrentSceneName;if(string.IsNullOrEmpty(sceneName))return false;var allScenarios=ScenarioDefs.CollectDefinitions();if(allScenarios==null|| !allScenarios.TryGetValue(sceneName,out var scenarios))return false;foreach(var def in scenarios){if(def.SaveAllowed)return true;}return false;}private void UpdateSaveIcon(){var toolBox=SceneContextNode.FindNode<Node>("TopView")as ISaveIconHost;toolBox?.UpdateSaveIconVisibility();}private void FilterCompletedScenarios(List<SceneEventInfo>composedEvents,Dictionary<SceneEventInfo,SceneCandidateReport>reports){var runtime=GameData.Instance?.Runtime;if(runtime==null)return;var beforeCount=composedEvents.Count;for(var i=composedEvents.Count-1;i>=0;i--){if(!IsScenarioCompleted(composedEvents[i],runtime))continue;reports[composedEvents[i]].Outcome=SceneCandidateOutcome.Completed;composedEvents.RemoveAt(i);}if(beforeCount!=composedEvents.Count)GameLogger.Log("SCENE",$"SwitchScene: filtered {beforeCount - composedEvents.Count} completed scenario(s), {composedEvents.Count} remaining");}/// <summary> Checks whether a one-run scenario has already been completed in the current game. Consults both completion sources: <see cref="RuntimeData.CompletedScenarioIds"/> (persistent per-game history, updated as dialogs finish) and <see cref="RuntimeData.LoadedCompletedScenarioIds"/> (the raw block list restored from the save, valid until <see cref="RuntimeData.ClearLoadState"/>). </summary>
private static bool IsScenarioCompleted(SceneEventInfo info,RuntimeData runtime){if(info.Id!=null&&runtime.CompletedScenarioIds.Contains(info.Id))return true;return runtime.LoadedCompletedScenarioIds!=null&&info.Id!=null&&runtime.LoadedCompletedScenarioIds.Contains(info.Id);}/// <summary> Gets or sets the name of a scene queued for deferred transition, used when a scene switch must be delayed (e.g. awaiting dialog completion). </summary>
public string PendingSceneName{get;set;}/// <summary> Gets or sets whether test-play mode is active. When enabled, <see cref="SwitchScene"/> redirects non-menu targets to the main menu unless <see cref="TestScenarioText"/> is populated. </summary>
public static bool IsTestPlayActive{get;set;}/// <summary> Gets or sets the scenario text to execute in test-play mode. Once consumed by <see cref="ComposeEvents"/>, it is cleared. </summary>
public static string TestScenarioText{get;set;}/// <summary>Runs the synchronous boot pipeline on non-launcher entries; launcher boots invoke the same phases through <see cref="Boot"/>.</summary>
public override void _Ready(){if(!BootController.IsLauncherBoot)Boot.RunSync(this);}/// <summary> Implements the "Scenes" boot phase: initializes the <see cref="ModManager"/>, <see cref="SceneEventRegistry"/>, collects scene definitions from all loaded mods, fills the UI and main menu registries, notifies mod initializers and loads mod settings. The only normal caller is <see cref="Boot"/> (both pipeline branches invoke it from the same phase); the method is idempotent (<c>isInitialized</c>), so calls from outside <see cref="Boot"/> once boot has finished are no-ops. </summary>
public void Initialize(){if(isInitialized)return;ModManager.Instance.Initialize();AppServices.Current.SceneEvents.EnsureBuilt();allScenes=AppServices.Current.SceneDefs.CollectAll();AppServices.Current.Ui.EnsureBuilt();AppServices.Current.Menus.EnsureBuilt();ModInitializerRegistry.NotifyResourcesReady();ModSettingsRegistry.LoadAll();isInitialized=true;ModEventBus.Subscribe<SceneEnteredEvent>(e=>ApplySceneDefaults(),priority:0);ModEventBus.Subscribe<SceneEnteredEvent>(e=>PlaySceneAmbient(),priority:5);ModEventBus.Subscribe<DialogEndedEvent>(_=>{if(IsTestPlayActive){GameLogger.Log("SCENE","TEST-PLAY OnDialogEnd: dialog finished");IsTestPlayActive=false;}});GameLogger.Log("SCENE",$"SceneManager initialized with {allScenes?.Count ?? 0} scene(s)");}/// <summary> Re-collects the logical scene table from the (re)loaded mod set without re-running the one-shot boot subscriptions. Called by <see cref="Mods.ModManager.Reload"/>; a no-op when the scene context is not available (pure test environments). </summary>
public static void RescanScenes(){SceneContextNode.FindNode<SceneManager>("SceneManager")?.RescanSceneTable();}private void RescanSceneTable(){allScenes=AppServices.Current.SceneDefs.CollectAll();GameLogger.Log("SCENE",$"SceneManager rescanned: {allScenes?.Count ?? 0} scene(s)");}/// <summary> Core scene-switching pipeline. Clears static lights, resolves node references, freezes UI activity, then composes and sorts executable events for the target scene. Events come from C# classes registered via <see cref="SceneEventRegistry"/> and from .scenario files in mods. Conditions are evaluated via <see cref="ScenarioCondition.Evaluate"/>, valid events are instantiated and enqueued in the <see cref="ExecutableMainEventChain"/>. If no events exist, falls back to the scene's <see cref="SceneDefinition.DefaultScenario"/>. Godot-native scenes (mainmenu) and mod scene routes are routed through <see cref="GodotSceneService"/>. Publishes enter/exit lifecycle events consumed by mod hooks, applies a random default background, and updates music/ambient. </summary> <param name="sceneName">The identifier of the target scene.</param>
public void SwitchScene(string sceneName){GameLogger.Log("SCENE",$"SwitchScene: ENTER sceneName='{sceneName}', currentSceneName='{currentSceneName}'");if(!isInitialized)Initialize();LightingBridge.ClearSceneLights();if(string.IsNullOrEmpty(sceneName)){GameLogger.Log("SCENE","SwitchScene: sceneName is null or empty",LogLevel.Error);return;}if(IsTestPlayActive){var lowerTest=sceneName.ToLowerInvariant();if(lowerTest=="mainmenu"||lowerTest=="menu"){IsTestPlayActive=false;}else if(string.IsNullOrEmpty(TestScenarioText)){GameLogger.Log("SCENE",$"SwitchScene: test play redirect '{sceneName}' -> 'mainmenu'");sceneName="mainmenu";}}if(AppServices.Current.SceneRoutes.TryGetRoute(sceneName,out var route)){var routeService=GetNode<GodotSceneService>("/root/GodotSceneService");routeService?.SwitchRoute(route);return;}if(IsGodotScene(sceneName)){var sceneService=GetNode<GodotSceneService>("/root/GodotSceneService");sceneService?.SwitchScene(GodotSceneType.MainMenu);return;}if(!string.IsNullOrEmpty(currentSceneName)){ModEventBus.Publish(new SceneExitedEvent{SceneName=currentSceneName});}GameLogger.Log("SCENE",$"SwitchScene: '{currentSceneName}' -> '{sceneName}'");ResolveReferences();FreezeActivity();ClearCurrentEvents();var reportList=new List<SceneCandidateReport>();var shadowed=new List<SceneCandidateReport>();try{var composedEvents=ComposeEvents(sceneName,shadowed);var reports=new Dictionary<SceneEventInfo,SceneCandidateReport>();foreach(var info in composedEvents??new List<SceneEventInfo>()){var report=NewReport(info,SceneCandidateOutcome.Queued);reports[info]=report;reportList.Add(report);}reportList.AddRange(shadowed);if(composedEvents==null||composedEvents.Count==0){currentSceneName=sceneName;ModEventBus.Publish(new SceneEnteredEvent{SceneName=sceneName,Root=GetTree().CurrentScene,});if(!IsTestPlayActive)TryLoadDefaultScenario();return;}currentSceneName=sceneName;ModEventBus.Publish(new SceneEnteredEvent{SceneName=sceneName,Root=GetTree().CurrentScene,});FilterCompletedScenarios(composedEvents,reports);var pendingEvents=new List<IExecutableEvent>();foreach(var eventInfo in composedEvents){try{if(!ScenarioCondition.Evaluate(eventInfo.Condition)){reports[eventInfo].Outcome=SceneCandidateOutcome.ConditionFalse;GameLogger.Log("SCENE",$"SwitchScene: skipped '{eventInfo.Id}' (condition not met: {eventInfo.Condition})",LogLevel.Warning);continue;}var instance=CreateEventInstance(eventInfo);if(instance!=null){pendingEvents.Add(instance);GameLogger.Log("SCENE",$"SwitchScene: created event '{eventInfo.Id}'");}else{reports[eventInfo].Outcome=SceneCandidateOutcome.Failed;}}catch(Exception ex){reports[eventInfo].Outcome=SceneCandidateOutcome.Failed;GameLogger.Log("SCENE",$"SwitchScene: failed to create event '{eventInfo.Id}': {ex.Message}",LogLevel.Error);}}GameLogger.Log("SCENE",$"SwitchScene: adding {pendingEvents.Count} pending event(s) to chain");foreach(var instance in pendingEvents){if(eventChain==null){GameLogger.Log("SCENE",$"  ERROR: eventChain is null, skipping event '{instance.GetType().Name}'",LogLevel.Error);continue;}eventChain.AddEvent(instance);}UpdateSaveIcon();GameData.Instance?.Runtime.ClearLoadState();}finally{UnfreezeActivity();if(!IsTestPlayActive)ModEventBus.Publish(new SceneComposedEvent{SceneName=sceneName,Candidates=reportList});}}private static SceneCandidateReport NewReport(SceneEventInfo info,SceneCandidateOutcome outcome){return new SceneCandidateReport{Id=info.Id,ModId=info.ModId,ScenarioPath=info.ScenarioPath,Priority=info.Priority,Condition=info.Condition,IsOneRun=info.IsOneRun,Outcome=outcome,};}private List<SceneEventInfo>ComposeEvents(string sceneName,List<SceneCandidateReport>shadowed){if(IsTestPlayActive&& !string.IsNullOrEmpty(TestScenarioText)){var factory=AppServices.Current.SceneEvents.ScenarioEventFactory;if(factory==null){GameLogger.Log("SCENE","ComposeEvents: no ScenarioEventFactory registered (dialog_base mod not loaded)",LogLevel.Error);}else{var evt=factory.CreateFromText(TestScenarioText);evt.IsOneRun=true;eventChain?.AddEvent(evt);}TestScenarioText=null;return new List<SceneEventInfo>();}var allEvents=new List<SceneEventInfo>();var seenIds=new HashSet<string>();// 1. Collect C# events from registry
var registered=AppServices.Current.SceneEvents.GetEventsSorted(sceneName);foreach(var evt in registered){if(seenIds.Add(evt.Id))allEvents.Add(evt);else shadowed.Add(NewReport(evt,SceneCandidateOutcome.Shadowed));}// 2. Collect .scenario file events from mods
var scenarioDefs=ScenarioDefs.CollectDefinitions();if(scenarioDefs!=null&&scenarioDefs.TryGetValue(sceneName,out var scenarios)){foreach(var scenario in scenarios){var id=scenario.Name;var info=new SceneEventInfo{Id=id,ScenarioPath=scenario.FilePath,ModId=scenario.ModId,Priority=scenario.Priority,After=scenario.After,Condition=scenario.Condition,LightUseTime=scenario.LightUseTime,IsOneRun=scenario.IsOneRun,};if(seenIds.Add(id))allEvents.Add(info);else shadowed.Add(NewReport(info,SceneCandidateOutcome.Shadowed));}}// 3. Sort by priority + topological sort for 'after' dependencies
allEvents=SortEvents(allEvents);GameLogger.Log("SCENE",$"ComposeEvents '{sceneName}': {allEvents.Count} event(s)");return allEvents;}private static List<SceneEventInfo>SortEvents(List<SceneEventInfo>events){var visited=new HashSet<string>();var result=new List<SceneEventInfo>();var visiting=new HashSet<string>();void Visit(SceneEventInfo info){if(info.Id==null){if(!result.Contains(info))result.Add(info);return;}if(visited.Contains(info.Id))return;if(visiting.Contains(info.Id)){GameLogger.Log("SCENE",$"Circular dependency detected: {info.Id}",LogLevel.Warning);return;}if(!string.IsNullOrEmpty(info.After)){var after=events.FirstOrDefault(e=>e.Id==info.After);if(after!=null){visiting.Add(info.Id);Visit(after);visiting.Remove(info.Id);}}visited.Add(info.Id);result.Add(info);}foreach(var info in events.OrderBy(e=>e.Priority).ThenBy(e=>e.Id))Visit(info);return result;}/// <summary> True when the name is a built-in Godot scene route (<c>mainmenu</c>/<c>menu</c>) rather than a mod-provided route or a mod-defined logical scene. Exposed so tooling — the validator mod — can classify scenario scene references with the same rules as the runtime. </summary>
public static bool IsNativeSceneName(string sceneName){if(string.IsNullOrEmpty(sceneName))return false;var lower=sceneName.ToLowerInvariant();return lower=="mainmenu"||lower=="menu";}private static bool IsGodotScene(string sceneName)=>IsNativeSceneName(sceneName);/// <summary> Checks whether a logical scene name can be switched to: a Godot-native route (mainmenu/menu), a scene route registered by a mod through <see cref="SceneRouteRegistry"/>, or a scenario scene collected from the mods. Tools such as <c>SceneSwitcher</c> use it to reject typos with a non-zero exit code instead of silently running an empty scene and reporting success. </summary>
public bool HasScene(string sceneName){if(string.IsNullOrEmpty(sceneName))return false;if(!isInitialized)Initialize();if(IsGodotScene(sceneName))return true;if(AppServices.Current.SceneRoutes.TryGetRoute(sceneName,out _))return true;return allScenes!=null&&allScenes.ContainsKey(sceneName);}private static readonly Random _bgRandom=new();private void ApplySceneDefaults(){var defs=allScenes?.GetValueOrDefault(currentSceneName);if(defs==null||defs.Count==0)return;var def=defs[0];if(def.DefaultBackground!=null&&def.DefaultBackground.Count>0){var index=def.DefaultBackground.Count>1?_bgRandom.Next(def.DefaultBackground.Count):0;var bg=def.DefaultBackground[index];if(!string.IsNullOrEmpty(bg)){SceneContextNode.LastBackgroundID=bg;LoadDefaultBackground(bg);}}}private void LoadDefaultBackground(string backgroundId){var texture=BackgroundFactory.Instance.Get(backgroundId);if(texture==null){GameLogger.Log("SCENE",$"LoadDefaultBackground: failed to load '{backgroundId}'",LogLevel.Error);return;}var viewBox=GetViewBox();if(viewBox!=null){if(GodotSceneService.IsTransitioning)viewBox.SetBackgroundTexture(texture);else viewBox.TransitionBackground(texture);}var depthTex=BackgroundFactory.Instance.Get(backgroundId+"_depth");var albedoTex=BackgroundFactory.Instance.Get(backgroundId+"_albedo");LightingBridge.SetupBackgroundMaps(depthTex,albedoTex);}private IBackgroundView GetViewBox(){var sceneRoot=GetTree()?.CurrentScene;if(sceneRoot==null)return null;var scene=sceneRoot.FindChild("Scene",false,false);if(scene==null)return null;return scene.GetNodeOrNull<Node>("ViewBox")as IBackgroundView;}private void TryLoadDefaultScenario(){var defs=allScenes?.GetValueOrDefault(currentSceneName);if(defs==null||defs.Count==0)return;var path=defs[0].DefaultScenario;if(string.IsNullOrEmpty(path)){GameLogger.Log("SCENE",$"SwitchScene: no events and no defaultScenario for '{currentSceneName}'",LogLevel.Error);return;}var text=ReadScenarioText(path);if(text==null){GameLogger.Log("SCENE",$"SwitchScene: defaultScenario '{path}' not found for '{currentSceneName}'",LogLevel.Error);return;}var factory=AppServices.Current.SceneEvents.ScenarioEventFactory;if(factory==null){GameLogger.Log("SCENE","SwitchScene: no ScenarioEventFactory registered (dialog_base mod not loaded)",LogLevel.Error);return;}eventChain?.AddEvent(factory.CreateFromText(text));GameLogger.Log("SCENE",$"SwitchScene: loaded defaultScenario '{path}' for '{currentSceneName}'");}private void PlaySceneAmbient(){var defs=allScenes?.GetValueOrDefault(currentSceneName);if(defs==null||defs.Count==0)return;var def=defs[0];var ambientId=def.DefaultAmbient;if(string.IsNullOrEmpty(ambientId)){var audioService=GetNodeOrNull<Node>("/root/AudioService")as IAudioService;audioService?.StopAmbient();return;}var audio=GetNodeOrNull<Node>("/root/AudioService")as IAudioService;audio?.PlayAmbient(ambientId);}private IExecutableEvent CreateEventInstance(SceneEventInfo info){if(!string.IsNullOrEmpty(info.ClassName)){var type=Type.GetType(info.ClassName);if(type==null){type=Reflections.LoadedAssemblies().SelectMany(a=>a.GetTypes()).FirstOrDefault(t=>t.FullName==info.ClassName);}if(type==null){GameLogger.Log("SCENE",$"CreateEventInstance: type not found '{info.ClassName}'",LogLevel.Error);return null;}if(!typeof(IExecutableEvent).IsAssignableFrom(type)){GameLogger.Log("SCENE",$"CreateEventInstance: '{info.ClassName}' is not an IExecutableEvent");return null;}var instance=(IExecutableEvent)Activator.CreateInstance(type);if(instance is IConditionedEvent ce&& !string.IsNullOrEmpty(info.Condition))ce.Condition=info.Condition;return instance;}if(!string.IsNullOrEmpty(info.ScenarioPath)){var scenarioText=string.IsNullOrEmpty(info.ModId)?ReadScenarioText(info.ScenarioPath):ScenarioDefs.ReadText(info.ModId,info.ScenarioPath);if(scenarioText==null){GameLogger.Log("SCENE",$"CreateEventInstance: cannot read scenario '{info.ScenarioPath}'",LogLevel.Error);return null;}var factory=AppServices.Current.SceneEvents.ScenarioEventFactory;if(factory==null){GameLogger.Log("SCENE",$"CreateEventInstance: no ScenarioEventFactory registered (dialog_base mod not loaded), event '{info.Id}'",LogLevel.Error);return null;}return factory.CreateFromDefinition(scenarioText,info.Id,info.Condition,info.LightUseTime,info.IsOneRun,info.ModId,info.ScenarioPath);}GameLogger.Log("SCENE",$"CreateEventInstance: no ClassName or ScenarioPath for '{info.Id}'");return null;}private static string ReadScenarioText(string filePath){// Try reading through a mod provider first
foreach(var kvp in ModRegistry.Instance.Mods){var provider=kvp.Value.FileProvider;var text=provider.ReadFileText(filePath);if(text!=null)return text;}// Fallback to Godot's FileAccess
var file=Godot.FileAccess.Open(filePath,Godot.FileAccess.ModeFlags.Read);if(file!=null){var text=file.GetAsText();file.Close();return text;}return null;}private void ClearCurrentEvents(){eventChain?.ClearEvents();}private void FreezeActivity(){if(eventController!=null)eventController.IsActive=false;if(dialogBox!=null)dialogBox.IsActive=false;}private void UnfreezeActivity(){if(eventController!=null)eventController.IsActive=true;if(dialogBox!=null)dialogBox.IsActive=true;}private void ResolveReferences(){if(eventController==null|| !IsInstanceValid(eventController))eventController=GetNodeOrNull<SceneEventController>("/root/EventController");if(eventChain==null||eventChain is not GodotObject chainNode|| !IsInstanceValid(chainNode))eventChain=GetTree()?.GetFirstNodeInGroup("main_event_chain")as IExecutableEventChain;if(dialogBox==null||dialogBox is not GodotObject dialogNode|| !IsInstanceValid(dialogNode)){var root=GetTree()?.Root;if(root!=null)dialogBox=SceneContextNode.FindNode<Node>("DialogBox")as IDialogBox;}}}}

===== FILE Core/Scenes/SceneRoute.cs =====
using Cthangover.Core.Audio;namespace Cthangover.Core.Scenes{/// <summary> Declaration of a mod-provided scene route. The core knows nothing about gameplay-specific scene names (battle, shop, minigame); a mod registers an arbitrary logical name and the core routes <c>switch_scene</c> to the mod-relative tscn built by the runtime pipeline. The fade flag lets the mod request the shader transition that used to be hardcoded for battle, and the music pair tells the audio layer which playlist scene to select and which <see cref="MusicType"/> bucket to play (e.g. Combat). </summary>
public class SceneRoute{/// <summary>Logical scene name used by scenarios, e.g. <c>"Arena"</c>.</summary>
public string LogicalName{get;set;}/// <summary>Mod-relative path of the tscn, e.g. <c>scenes/arena/arena.tscn</c>.</summary>
public string ScenePath{get;set;}/// <summary>When true, the scene is entered through the fade transition overlay.</summary>
public bool Fade{get;set;}/// <summary>Scene name passed to playlist resolution; <c>null</c> keeps the current playlist.</summary>
public string MusicScene{get;set;}/// <summary>Music bucket to switch to when the route is entered.</summary>
public MusicType Music{get;set;}=MusicType.Ambient;}}

===== FILE Core/Scenes/SceneRouteRegistry.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Bootstrap;using Cthangover.Core.Utils;namespace Cthangover.Core.Scenes{/// <summary> Self-registration point for mod-provided scene routes. The core keeps no knowledge of gameplay-specific scene names or paths: a mod's initializer calls <see cref="Register"/> with a logical name (the value scenarios use in <c>switch_scene</c>) and a mod-relative tscn path. <see cref="SceneManager"/> consults the registry before falling back to scenario composition, and <see cref="GodotSceneService"/> asks it for the transition style and music bucket when the path is loaded. A later registration with the same logical name overrides the earlier one (mod load order). Cleared on mod reload together with the other UI registries. <para/> Routes are registered by mod initializers (<c>IModInitializer.OnModResourcesReady</c>), not collected from manifests, so <see cref="Rebuild"/> only resets the table and lets the initializers re-register. The registry instance lives in <see cref="AppServices.Current"/>; the static members below are a facade delegating to it, so existing callers keep working unchanged. </summary>
public class SceneRouteRegistry:IGameRegistry{private readonly Dictionary<string,SceneRoute>routes=new(StringComparer.OrdinalIgnoreCase);/// <summary>Shared instance the static facade delegates to.</summary>
private static SceneRouteRegistry Owned=>AppServices.Current.SceneRoutes;/// <summary> Creates an empty registry: cheap and side-effect free per the IGameRegistry contract. </summary>
internal SceneRouteRegistry(){}/// <inheritdoc />
public string Id=>"scene_routes";/// <summary> Routes are re-populated by IModInitializer.OnModResourcesReady, not collected from manifests — rebuilding just resets the table so the initializers re-register from scratch. </summary>
public void Rebuild(){Reset();}/// <summary> Drops all registrations; next route lookup fails until a mod re-registers. Called on mod reload through AppServices.ClearRegistries. </summary>
void IGameRegistry.Clear(){Reset();}// ---- Static facade (legacy API, unchanged for callers) ------------ <summary> Registers (or replaces) a route. The logical name is matched case-insensitively, so <c>switch_scene Battle</c> and <c>scene=battle</c> resolve to the same entry. </summary>
public static void Register(string logicalName,string modRelativeScenePath,bool fade,string musicScene=null,Audio.MusicType music=Audio.MusicType.Ambient)=>Owned.RegisterRoute(logicalName,modRelativeScenePath,fade,musicScene,music);/// <summary>Looks up a route by logical name; false when no mod registered it.</summary>
public static bool TryGet(string logicalName,out SceneRoute route)=>Owned.TryGetRoute(logicalName,out route);/// <summary> Finds the route whose scene path matches the pending scene path, so the transition and music layers do not need to know logical names. </summary>
public static SceneRoute FindByPath(string scenePath)=>Owned.FindByScenePath(scenePath);/// <summary>Drops all registrations; next route lookup fails until a mod re-registers.</summary>
public static void Clear()=>Owned.Reset();// ---- Instance implementation -------------------------------------- <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public void Reset(){routes.Clear();}/// <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public void RegisterRoute(string logicalName,string modRelativeScenePath,bool fade,string musicScene,Audio.MusicType music){if(string.IsNullOrWhiteSpace(logicalName)||string.IsNullOrWhiteSpace(modRelativeScenePath)){GameLogger.Log("SCENE","SceneRouteRegistry.Register: empty name or path ignored",LogLevel.Error);return;}routes[logicalName]=new SceneRoute{LogicalName=logicalName,ScenePath=modRelativeScenePath,Fade=fade,MusicScene=musicScene,Music=music,};GameLogger.Log("SCENE",$"SceneRouteRegistry: '{logicalName}' -> '{modRelativeScenePath}' (fade={fade})");}/// <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public bool TryGetRoute(string logicalName,out SceneRoute route){route=null;return!string.IsNullOrEmpty(logicalName)&&routes.TryGetValue(logicalName,out route);}/// <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public SceneRoute FindByScenePath(string scenePath){if(string.IsNullOrEmpty(scenePath))return null;foreach(var route in routes.Values){if(string.Equals(route.ScenePath,scenePath,StringComparison.OrdinalIgnoreCase))return route;}return null;}}}

===== FILE Core/Scenes/TscnBuild/ExtResourceBuilderRegistry.cs =====
using System.Collections.Generic;using System.Linq;using System.Reflection;using Cthangover.Core.Bootstrap;using Cthangover.Core.Utils;namespace Cthangover.Core.Scenes.TscnBuild{/// <summary> Auto-discovering registry of <see cref="IExtResourceBuilder"/> implementations, indexed by ResourceType. <para/> Initial scan on first access picks up all already-loaded assemblies (including core). <see cref="RegisterAssembly"/> is called by <see cref="Mods.ModAssemblyLoader.RegisterAssembly"/> when a mod DLL is loaded, adding the mod's builders without re-scanning everything. First-registered-wins — core builders always take priority over mod-added ones. <para/> The registry instance lives in <see cref="AppServices.Current"/>; the static members below are a facade delegating to it, so existing callers keep working unchanged. </summary>
public class ExtResourceBuilderRegistry:IGameRegistry{private Dictionary<string,IExtResourceBuilder>_builders;private bool built;/// <summary>Shared instance the static facade delegates to.</summary>
private static ExtResourceBuilderRegistry Owned=>AppServices.Current.ExtResourceBuilders;/// <summary> Creates an empty registry: cheap and side-effect free per the IGameRegistry contract. <see cref="Rebuild"/> fills the data. </summary>
internal ExtResourceBuilderRegistry(){}/// <inheritdoc />
public string Id=>"tscn_ext_builders";/// <summary> Scans all loaded assemblies for <see cref="IExtResourceBuilder"/> implementations unless already built. Keeps the old Initialize idempotence: later calls are no-ops until the registry is cleared. </summary>
public void EnsureBuilt(){if(!built)Rebuild();}/// <summary> Forced full re-scan of all loaded assemblies (assembly order equals load order, so first-registered-wins keeps core builders ahead of mod-added ones), replacing the previous table. Safe to call repeatedly; picks up assemblies loaded after the previous build. </summary>
public void Rebuild(){var scanned=new Dictionary<string,IExtResourceBuilder>();foreach(var b in Reflections.FindAndCreate<IExtResourceBuilder>())if(!scanned.ContainsKey(b.ResourceType))scanned[b.ResourceType]=b;_builders=scanned;built=true;}/// <summary> Drops all registered builders; the next build re-scans the assemblies. Called on mod reload through AppServices.ClearRegistries. </summary>
public void Clear(){_builders=null;built=false;}// ---- Static facade (legacy API, unchanged for callers) ------------ <summary>Legacy entry point; delegates to the shared instance.</summary>
public static void Initialize()=>Owned.EnsureBuilt();/// <summary>Scans a newly-loaded mod assembly for <see cref="IExtResourceBuilder"/> implementations and registers them. Called from <see cref="Mods.ModAssemblyLoader.RegisterAssembly"/>.</summary>
public static void RegisterAssembly(Assembly assembly)=>Owned.AddFromAssembly(assembly);/// <summary>Returns the builder registered for the given resource type, or null when none is.</summary>
public static IExtResourceBuilder Get(string type)=>Owned.GetBuilder(type);/// <summary>Unload pass: drops builders contributed by the given assembly. Delegates to the shared instance.</summary>
public static void UnregisterAssembly(Assembly assembly)=>Owned.RemoveFromAssembly(assembly);// ---- Instance implementation --------------------------------------
private void AddFromAssembly(Assembly assembly){EnsureBuilt();foreach(var b in Reflections.FindAndCreate<IExtResourceBuilder>(assembly))if(!_builders.ContainsKey(b.ResourceType))_builders[b.ResourceType]=b;}/// <summary> Removes builders whose concrete type comes from the given assembly — the unload pass of the assembly consumer pipeline. Built-in builders survive; a slot left empty falls back to a full rebuild on next use. </summary>
public void RemoveFromAssembly(Assembly assembly){if(_builders==null)return;foreach(var key in _builders.Where(kvp=>kvp.Value.GetType().Assembly==assembly).Select(kvp=>kvp.Key).ToList())_builders.Remove(key);}private IExtResourceBuilder GetBuilder(string type){EnsureBuilt();_builders.TryGetValue(type,out var b);return b;}}}

===== FILE Core/Scenes/TscnBuild/ExtResourceBuilders/TextureExtResourceBuilder.cs =====
using System.IO;using Godot;namespace Cthangover.Core.Scenes.TscnBuild.ExtResourceBuilders{/// <summary>Builds <see cref="Texture2D"/> from raw image bytes (PNG, JPG, WebP).</summary>
public class TextureExtResourceBuilder:IExtResourceBuilder{/// <summary>Handles <c>[ext_resource type="Texture2D"]</c> entries (the registry's default for unhandled types).</summary>
public string ResourceType=>"Texture2D";/// <summary>Decodes PNG/JPG/WebP bytes into an <see cref="ImageTexture"/>; other formats return null.</summary>
public Resource Build(byte[]data,string path){var img=new Image();var ext=Path.GetExtension(path).ToLowerInvariant();if(ext==".png")img.LoadPngFromBuffer(data);else if(ext==".jpg"||ext==".jpeg")img.LoadJpgFromBuffer(data);else if(ext==".webp")img.LoadWebpFromBuffer(data);else return null;return ImageTexture.CreateFromImage(img);}}}

===== FILE Core/Scenes/TscnBuild/IExtResourceBuilder.cs =====
using Godot;namespace Cthangover.Core.Scenes.TscnBuild{/// <summary> Builds a Godot <see cref="Resource"/> from raw bytes read through <see cref="Mods.IModFileProvider"/>. Each implementation handles exactly one ext_resource type (e.g. "Texture2D"). <para/> Unlike <see cref="ISubResourceBuilder"/> which receives a parsed <c>[sub_resource]</c> definition, ext-resource builders receive raw file bytes — the resource is loaded from a mod file, not defined inline. <para/> Auto-discovered via <c>Reflections.FindAndCreate&lt;IExtResourceBuilder&gt;()</c>. To add support for a new resource type, implement this interface in any loaded assembly — no registration code needed. </summary>
public interface IExtResourceBuilder{/// <summary>Resource type name as it appears in the [ext_resource] header (e.g. "Texture2D").</summary>
string ResourceType{get;}/// <summary>Creates the Godot resource from raw file bytes. The <paramref name="path"/> is the original mod-relative path (may be used for extension detection).</summary>
Resource Build(byte[]data,string path);}}

===== FILE Core/Scenes/TscnBuild/IPropertyResolver.cs =====
using Godot;namespace Cthangover.Core.Scenes.TscnBuild{/// <summary> Resolves a single node property during scene building. Each implementation handles one category of property value — Script refs, ExtResource refs, SubResource refs, or plain values. <para/> The <see cref="SceneBuilder"/> iterates all properties and delegates to <see cref="PropertyResolverRegistry.Find"/> which performs first-match-wins lookup. The <see cref="DefaultPropertyResolver"/> is always last (catch-all via <c>CanResolve → true</c>). <para/> Auto-discovered via <c>Reflections.FindAndCreate&lt;IPropertyResolver&gt;()</c>. Order in the registry list determines resolution priority — <see cref="ScriptPropertyResolver"/> is registered before <see cref="ExtResourcePropertyResolver"/>, etc. </summary>
public interface IPropertyResolver{/// <summary>Non-destructive check: does this resolver handle the given property?</summary>
bool CanResolve(string key,object value);/// <summary>Apply the property to the node. Called only if <see cref="CanResolve"/> returned true.</summary>
void Apply(Node node,string key,object value,PropertyContext ctx);}}

===== FILE Core/Scenes/TscnBuild/ISubResourceBuilder.cs =====
using Cthangover.Core.Scenes.TscnParsing;using Godot;namespace Cthangover.Core.Scenes.TscnBuild{/// <summary> Builds a Godot <see cref="Resource"/> from a <see cref="SubResourceDef"/> parsed from a [sub_resource] section. Each implementation handles exactly one resource type (e.g. "StyleBoxFlat", "Gradient"). <para/> Godot native objects (Color, Vector2, etc.) are created here — this is build-time code that only runs inside Godot runtime. Unlike <see cref="TscnPropertyParser"/> which deliberately avoids creating Godot objects to remain testable outside the engine. <para/> Auto-discovered via <c>Reflections.FindAndCreate&lt;ISubResourceBuilder&gt;()</c>. </summary>
public interface ISubResourceBuilder{/// <summary>Resource type name as it appears in the [sub_resource] header.</summary>
string ResourceType{get;}/// <summary>Creates and configures the Godot resource. The <paramref name="ctx"/> provides cross-resource resolution (e.g. resolving a SubResource ref to a Gradient within GradientTexture1D).</summary>
Resource Build(TscnParsing.SubResourceDef def,SubResourceBuildContext ctx);}/// <summary> Context passed to <see cref="ISubResourceBuilder.Build"/>, providing a callback to resolve resource references by ID. Used for cross-sub-resource dependencies (e.g. a GradientTexture1D referencing a Gradient sub-resource). </summary>
public class SubResourceBuildContext{public System.Func<string,Resource>ResourceResolver{get;set;}}}

===== FILE Core/Scenes/TscnBuild/PropertyContext.cs =====
using System;using Cthangover.Core.Scenes.TscnParsing;using Godot;namespace Cthangover.Core.Scenes.TscnBuild{/// <summary>Context for <see cref="IPropertyResolver.Apply"/> — bundles the parsed scene, recursive instance loader, and fallback resource resolver.</summary>
public class PropertyContext{/// <summary>The parsed scene being built; used to look up ext_resource entries by id.</summary>
public ParsedScene Scene{get;set;}/// <summary>Callback that recursively builds a PackedScene reference into a Node tree.</summary>
public Func<string,Node>InstanceLoader{get;set;}/// <summary>Fallback resolver for non-Script/non-PackedScene ext_resource types (textures, fonts, …).</summary>
public Func<string,Resource>ExtResourceResolver{get;set;}/// <summary>Creates a context bundling the scene model and the two build callbacks.</summary>
public PropertyContext(ParsedScene scene,Func<string,Node>instanceLoader,Func<string,Resource>extResourceResolver){Scene=scene;InstanceLoader=instanceLoader;ExtResourceResolver=extResourceResolver;}}}

===== FILE Core/Scenes/TscnBuild/PropertyResolverRegistry.cs =====
using System.Collections.Generic;using System.Reflection;using Cthangover.Core.Bootstrap;using Cthangover.Core.Scenes.TscnBuild.PropertyResolvers;using Cthangover.Core.Utils;namespace Cthangover.Core.Scenes.TscnBuild{/// <summary> Auto-discovering ordered list of <see cref="IPropertyResolver"/> implementations. First-match-wins via <see cref="Find"/>. DefaultPropertyResolver is always moved to the end so catch-all doesn't shadow specific resolvers. <para/> The registry instance lives in <see cref="AppServices.Current"/>; the static members below are a facade delegating to it, so existing callers keep working unchanged. </summary>
public class PropertyResolverRegistry:IGameRegistry{private List<IPropertyResolver>_resolvers;private bool built;/// <summary>Shared instance the static facade delegates to.</summary>
private static PropertyResolverRegistry Owned=>AppServices.Current.PropertyResolvers;/// <summary> Creates an empty registry: cheap and side-effect free per the IGameRegistry contract. <see cref="Rebuild"/> fills the data. </summary>
internal PropertyResolverRegistry(){}/// <inheritdoc />
public string Id=>"tscn_property_resolvers";/// <summary> Scans all loaded assemblies for <see cref="IPropertyResolver"/> implementations unless already built. Keeps the old Initialize idempotence: later calls are no-ops until the registry is cleared. </summary>
public void EnsureBuilt(){if(!built)Rebuild();}/// <summary> Forced full re-scan of all loaded assemblies (assembly order equals load order), replacing the previous list; DefaultPropertyResolver is bumped to the end again so the catch-all resolver stays last. Safe to call repeatedly; picks up assemblies loaded after the previous build. </summary>
public void Rebuild(){var scanned=new List<IPropertyResolver>();foreach(var r in Reflections.FindAndCreate<IPropertyResolver>())scanned.Add(r);_resolvers=scanned;built=true;BumpDefaultToEnd();}/// <summary> Drops all discovered resolvers; the next build re-scans the assemblies. Called on mod reload through AppServices.ClearRegistries. </summary>
public void Clear(){_resolvers=null;built=false;}// ---- Static facade (legacy API, unchanged for callers) ------------ <summary>Legacy entry point; delegates to the shared instance.</summary>
public static void Initialize()=>Owned.EnsureBuilt();/// <summary>Scans a newly-loaded mod assembly for <see cref="IPropertyResolver"/> implementations and appends them, keeping DefaultPropertyResolver last.</summary>
public static void RegisterAssembly(Assembly assembly)=>Owned.AddFromAssembly(assembly);/// <summary>Returns the first resolver that can handle the given key/value pair, or null when none can.</summary>
public static IPropertyResolver Find(string key,object value)=>Owned.FindResolver(key,value);/// <summary>Unload pass: drops resolvers contributed by the given assembly. Delegates to the shared instance.</summary>
public static void UnregisterAssembly(Assembly assembly)=>Owned.RemoveFromAssembly(assembly);// ---- Instance implementation --------------------------------------
private void AddFromAssembly(Assembly assembly){EnsureBuilt();foreach(var r in Reflections.FindAndCreate<IPropertyResolver>(assembly))_resolvers.Add(r);BumpDefaultToEnd();}/// <summary> Removes resolvers whose concrete type comes from the given assembly — the unload pass of the assembly consumer pipeline. Core resolvers survive and the catch-all DefaultPropertyResolver stays last. </summary>
public void RemoveFromAssembly(Assembly assembly){if(_resolvers==null)return;_resolvers.RemoveAll(r=>r.GetType().Assembly==assembly);}private void BumpDefaultToEnd(){var idx=_resolvers.FindIndex(r=>r is DefaultPropertyResolver);if(idx>=0){var d=_resolvers[idx];_resolvers.RemoveAt(idx);_resolvers.Add(d);}}private IPropertyResolver FindResolver(string key,object value){EnsureBuilt();foreach(var r in _resolvers)if(r.CanResolve(key,value))return r;return null;}}}

===== FILE Core/Scenes/TscnBuild/PropertyResolvers/DefaultPropertyResolver.cs =====
using Cthangover.Core.Scenes.TscnParsing;using Godot;namespace Cthangover.Core.Scenes.TscnBuild.PropertyResolvers{/// <summary>Catch-all: converts value via <see cref="SubResourceFactory.ToVariant"/> and sets it on the node. Always last in the resolver chain.</summary>
public class DefaultPropertyResolver:IPropertyResolver{/// <summary>Catch-all: claims every property value that is not a resource reference. Must stay last in the resolver list.</summary>
public bool CanResolve(string k,object v)=>v is not TscnPropertyValue;/// <summary>Converts the parsed value to a Godot Variant and assigns it via <c>Node.Set</c>.</summary>
public void Apply(Node n,string k,object v,PropertyContext ctx)=>n.Set(k,SubResourceFactory.ToVariant(v));}}

===== FILE Core/Scenes/TscnBuild/PropertyResolvers/ExtResourcePropertyResolver.cs =====
using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Scenes.TscnParsing;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Scenes.TscnBuild.PropertyResolvers{/// <summary> Resolves <c>ExtResource("id")</c> properties. Script → Type resolution (skipped, node already created). PackedScene → recursive build + Pack(). Other types → fallback extResourceResolver callback. </summary>
public class ExtResourcePropertyResolver:IPropertyResolver{/// <summary>Claims every property whose value is an <c>ExtResource(...)</c> reference.</summary>
public bool CanResolve(string k,object v)=>v is TscnPropertyValue pv&&pv.IsExtResourceRef;/// <summary>PackedScene refs are built recursively and packed; other types go through the fallback resource resolver. Script refs on already-created nodes are ignored.</summary>
public void Apply(Node node,string key,object value,PropertyContext ctx){var pv=(TscnPropertyValue)value;var extRes=ctx.Scene.ExtResources.FirstOrDefault(r=>r.Id==pv.RefId);if(extRes==null)return;if(extRes.Type=="Script"){var st=ScriptTypeRegistry.Resolve(extRes.Path);if(st!=null&&typeof(Node).IsAssignableFrom(st))return;}if(extRes.Type=="PackedScene"&&ctx.InstanceLoader!=null){var sub=ctx.InstanceLoader(extRes.Path);if(sub!=null){AssignSceneOwners(sub,sub);var packed=new PackedScene();if(packed.Pack(sub)==Error.Ok)node.Set(key,packed);else GameLogger.Log("TSCN_BUILD",$"ExtResourcePropertyResolver: failed to pack '{extRes.Path}' for '{key}'",LogLevel.Warning);}return;}if(ctx.ExtResourceResolver!=null){var r=ctx.ExtResourceResolver(pv.RefId);if(r!=null)node.Set(key,Variant.From(r));}}/// <summary> <see cref="PackedScene.Pack"/> serializes only nodes whose <c>Owner</c> is the packed root, while the runtime builder produces owner-less trees (Godot assigns owners only when loading native scenes). Without this, PackedScene-typed exports (e.g. <c>DialogBox.answerBoxScene</c>) instantiate as a bare root with no children. Owners are assigned on the throwaway instance built for packing, not on the cached resolver tree. </summary>
private static void AssignSceneOwners(Node root,Node current){foreach(var child in current.GetChildren()){child.Owner=root;AssignSceneOwners(root,child);}}}}

===== FILE Core/Scenes/TscnBuild/PropertyResolvers/ScriptPropertyResolver.cs =====
using Godot;namespace Cthangover.Core.Scenes.TscnBuild.PropertyResolvers{/// <summary>Handles <c>script</c> property. No-op — script is already applied at node creation via <c>Activator.CreateInstance</c>.</summary>
public class ScriptPropertyResolver:IPropertyResolver{/// <summary>Claims the node-header <c>script</c> property so the default setter never applies it — the node's C# type was already chosen by <see cref="SceneBuilder.CreateNode"/> via <c>ScriptTypeRegistry</c>.</summary>
public bool CanResolve(string k,object v)=>k=="script";/// <summary>Intentionally empty: script assignment happens at node creation.</summary>
public void Apply(Node n,string k,object v,PropertyContext ctx){}}}

===== FILE Core/Scenes/TscnBuild/PropertyResolvers/SubResourcePropertyResolver.cs =====
using Cthangover.Core.Scenes.TscnParsing;using Godot;namespace Cthangover.Core.Scenes.TscnBuild.PropertyResolvers{/// <summary>Handles <c>SubResource("id")</c>. No-op — sub-resources are resolved during BuildAll before nodes.</summary>
public class SubResourcePropertyResolver:IPropertyResolver{/// <summary>Claims <c>SubResource(...)</c> property values so they are not written as raw refs by the default resolver.</summary>
public bool CanResolve(string k,object v)=>v is TscnPropertyValue pv&&pv.IsSubResourceRef;/// <summary>Intentionally empty — sub_resources are attached to properties by the sub-resource builders before nodes are built.</summary>
public void Apply(Node n,string k,object v,PropertyContext ctx){}}}

===== FILE Core/Scenes/TscnBuild/PropertyResolvers/ThemeOverridePropertyResolver.cs =====
using System;using Cthangover.Core.Scenes.TscnParsing;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Scenes.TscnBuild.PropertyResolvers{/// <summary> Applies editor-written theme overrides (<c>theme_override_constants/separation</c>, <c>theme_override_font_sizes/font_size</c>, <c>theme_override_colors/...</c>, and the stylebox/font/icon variants) through Control's <c>AddTheme*Override</c> API. <para/> The runtime parser used to declare these nested keys unsupported, which is invisible for mod scenes authored with scripts but not for native scenes: <c>scenes/menu/main_menu.tscn</c> and <c>settings_menu.tscn</c> rely on them, and patched native scenes must build through the runtime pipeline, so dropping them would visibly change the menu. Handling them here closes that gap without changing how mod scenes are authored. <para/> Values that are <see cref="TscnPropertyValue"/> references (an override pointing at an ext/sub resource) are deliberately left to the existing resource resolvers. </summary>
public class ThemeOverridePropertyResolver:IPropertyResolver{private const string Prefix="theme_override_";/// <summary>Claims plain-valued <c>theme_override_*/</c> keys; reference-valued overrides are left to the resource resolvers.</summary>
public bool CanResolve(string key,object value){return!string.IsNullOrEmpty(key)&&key.StartsWith(Prefix,StringComparison.Ordinal)&&key.IndexOf('/')>0&&value is not TscnPropertyValue;}/// <summary>Routes the override to the matching AddTheme*Override by category (constants, font_sizes, colors, fonts, icons, styles).</summary>
public void Apply(Node node,string key,object value,PropertyContext ctx){if(node is not Control control){GameLogger.Log("TSCN_BUILD",$"ThemeOverridePropertyResolver: '{key}' on non-Control '{node.Name}' ignored",LogLevel.Warning);return;}var slash=key.IndexOf('/');var category=key.Substring(Prefix.Length,slash-Prefix.Length);var name=key.Substring(slash+1);var variant=SubResourceFactory.ToVariant(value);try{switch(category){case"constants":control.AddThemeConstantOverride(name,variant.AsInt32());break;case"font_sizes":control.AddThemeFontSizeOverride(name,variant.AsInt32());break;case"colors":control.AddThemeColorOverride(name,variant.AsColor());break;case"fonts":control.AddThemeFontOverride(name,variant.As<Font>());break;case"icons":control.AddThemeIconOverride(name,variant.As<Texture2D>());break;case"styles":control.AddThemeStyleboxOverride(name,variant.As<StyleBox>());break;default:GameLogger.Log("TSCN_BUILD",$"ThemeOverridePropertyResolver: unknown category '{category}' for '{key}'",LogLevel.Warning);break;}}catch(Exception ex){GameLogger.Log("TSCN_BUILD",$"ThemeOverridePropertyResolver: failed to apply '{key}': {ex.Message}",LogLevel.Warning);}}}}

===== FILE Core/Scenes/TscnBuild/SceneBuilder.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Scenes.TscnParsing;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Scenes.TscnBuild{/// <summary> Converts a <see cref="ParsedScene"/> into a live Godot Node tree. Build order: sub-resources → nodes (recursive PackedScene instantiation) → properties (via <see cref="PropertyResolverRegistry"/>) → node-path vars → signals → entry-point. Entry-point type is resolved across ScriptTypeRegistry, Type.GetType(), and all AppDomain assemblies as fallback. </summary>
public class SceneBuilder{private readonly SubResourceFactory _subResourceFactory=new();private readonly Dictionary<string,Node>_builtNodes=new();private readonly List<(Node Node,string Key,NodePath Path)>_nodePathAssignments=new();private Node _rootNode;/// <summary> Builds a live Node tree from a parsed scene. <paramref name="instanceLoader"/> is called for PackedScene references (both node instances and exported properties). <paramref name="extResourceResolver"/> is a fallback for non-Script/non-PackedScene resource types. Returns the root Node, or null on failure. </summary>
public Node Build(ParsedScene scene,Func<string,Node>instanceLoader,Func<string,Resource>extResourceResolver){_builtNodes.Clear();_nodePathAssignments.Clear();_rootNode=null;_subResourceFactory.BuildAll(scene.SubResources,id=>_subResourceFactory.Get(id));foreach(var parsedNode in scene.Nodes){Node node=CreateNode(parsedNode,scene,instanceLoader,extResourceResolver);if(node==null)continue;node.Name=parsedNode.Name;if(parsedNode.Groups!=null)foreach(var group in parsedNode.Groups)node.AddToGroup(group,false);ApplyProperties(node,parsedNode,scene,extResourceResolver,instanceLoader);ApplyNodePathVars(node,parsedNode.NodePathVars);var parent=ResolveParent(parsedNode);if(parent!=null)parent.AddChild(node,false);else if(_rootNode==null)_rootNode=node;_builtNodes[parsedNode.Name]=node;}ResolveNodePathAssignments();foreach(var conn in scene.Connections)ApplyConnection(conn);if(!string.IsNullOrEmpty(scene.EntryPointClass)){var entryType=ScriptTypeRegistry.ResolveByName(scene.EntryPointClass)??Type.GetType(scene.EntryPointClass)??Reflections.LoadedAssemblies().SelectMany(a=>a.GetTypes()).FirstOrDefault(t=>t.FullName==scene.EntryPointClass||t.Name==scene.EntryPointClass);if(entryType!=null&&typeof(ITscnSceneEntryPoint).IsAssignableFrom(entryType))(Activator.CreateInstance(entryType)as ITscnSceneEntryPoint)?.OnSceneBuilt(_rootNode);}return _rootNode;}private Node CreateNode(ParsedNode parsedNode,ParsedScene scene,Func<string,Node>instanceLoader,Func<string,Resource>extResourceResolver){if(!string.IsNullOrEmpty(parsedNode.InstanceRef)){var refId=ExtractResourceId(parsedNode.InstanceRef);var extRes=scene.ExtResources.FirstOrDefault(r=>r.Id==refId);if(extRes!=null&&instanceLoader!=null){var instanceNode=instanceLoader(extRes.Path);if(instanceNode!=null){instanceNode.Name=parsedNode.Name;return instanceNode;}}var typeName=parsedNode.Type??"Node";return ClassDB.Instantiate(typeName).Obj as Node;}if(!string.IsNullOrEmpty(parsedNode.ScriptRef)){var refId=ExtractResourceId(parsedNode.ScriptRef);var extRes=scene.ExtResources.FirstOrDefault(r=>r.Id==refId);if(extRes!=null){var scriptType=ScriptTypeRegistry.Resolve(extRes.Path);if(scriptType!=null&&typeof(Node).IsAssignableFrom(scriptType))return(Node)Activator.CreateInstance(scriptType);}}var fallbackType=parsedNode.Type??"Node";return ClassDB.Instantiate(fallbackType).Obj as Node;}private Node ResolveParent(ParsedNode parsedNode){var parentRef=parsedNode.Parent;if(string.IsNullOrEmpty(parentRef)||parentRef=="."||parentRef=="..")return _rootNode;var pathParts=parentRef.Split('/');var current=_rootNode;foreach(var part in pathParts){if(string.IsNullOrEmpty(part)||part==".")continue;if(current==null){if(!_builtNodes.TryGetValue(part,out current))return null;}else{current=current.GetNodeOrNull(part);if(current==null)return null;}}return current;}private static void ApplyProperties(Node node,ParsedNode parsedNode,ParsedScene scene,Func<string,Resource>extResourceResolver,Func<string,Node>instanceLoader){var ctx=new PropertyContext(scene,instanceLoader,extResourceResolver);foreach(var kvp in parsedNode.OrderedProperties){try{var r=PropertyResolverRegistry.Find(kvp.Key,kvp.Value);r?.Apply(node,kvp.Key,kvp.Value,ctx);}catch(Exception ex){GameLogger.Log("TSCN_BUILD",$"SceneBuilder: failed to apply '{kvp.Key}' on '{node.Name}': {ex.Message}",LogLevel.Warning);}}}private void ApplyNodePathVars(Node node,Dictionary<string,string>nodePathVars){if(nodePathVars==null||nodePathVars.Count==0)return;foreach(var kvp in nodePathVars){if(string.IsNullOrEmpty(kvp.Value))continue;var parsed=TscnPropertyParser.Parse(kvp.Value);if(parsed is ParsedGodotValue pgv&&pgv.TypeName=="NodePath")_nodePathAssignments.Add((node,kvp.Key,new NodePath((string)pgv.Arg(0))));}}/// <summary> Resolves exported Node-typed properties (declared in a tscn node's <c>node_paths</c> header) once the whole tree exists. Godot's native loader turns these NodePaths into the referenced nodes; the runtime builder must do the same, otherwise <c>[Export] private Control foo</c> fields stay null. Resolution is deferred because a node's children are added only after its own properties have been applied. </summary>
private void ResolveNodePathAssignments(){foreach(var(node,key,path)in _nodePathAssignments){if(node==null|| !GodotObject.IsInstanceValid(node))continue;var target=node.GetNodeOrNull(path);if(target==null){GameLogger.Log("TSCN_BUILD",$"SceneBuilder: node_paths '{key}' -> '{path}' not found on '{node.Name}'",LogLevel.Warning);continue;}node.Set(key,target);}_nodePathAssignments.Clear();}private void ApplyConnection(ParsedConnection conn){Node fromNode=conn.From=="."?_rootNode:(_builtNodes.TryGetValue(conn.From,out var fn)?fn:null);Node toNode=conn.To=="."?_rootNode:(_builtNodes.TryGetValue(conn.To,out var tn)?tn:null);if(fromNode==null||toNode==null|| !GodotObject.IsInstanceValid(fromNode)|| !GodotObject.IsInstanceValid(toNode)){GameLogger.Log("TSCN_BUILD",$"SceneBuilder: cannot apply connection '{conn.Signal}' from '{conn.From}' to '{conn.To}'",LogLevel.Warning);return;}if(!fromNode.HasSignal(conn.Signal)){GameLogger.Log("TSCN_BUILD",$"SceneBuilder: signal '{conn.Signal}' not found on '{fromNode.Name}'",LogLevel.Warning);return;}if(!toNode.HasMethod(conn.Method)){GameLogger.Log("TSCN_BUILD",$"SceneBuilder: method '{conn.Method}' not found on '{toNode.Name}'",LogLevel.Warning);return;}fromNode.Connect(conn.Signal,new Callable(toNode,conn.Method),(uint)conn.Flags);}private static string ExtractResourceId(string refString){if(string.IsNullOrEmpty(refString))return null;const string prefix="ExtResource(";var start=refString.IndexOf(prefix,StringComparison.Ordinal);if(start<0)return refString.Trim('"');start+=prefix.Length;var end=refString.IndexOf(')',start);if(end<0)return refString.Trim('"');return refString.Substring(start,end-start).Trim('"');}}}

===== FILE Core/Scenes/TscnBuild/SubResourceBuilderRegistry.cs =====
using System.Collections.Generic;using System.Linq;using System.Reflection;using Cthangover.Core.Bootstrap;using Cthangover.Core.Utils;namespace Cthangover.Core.Scenes.TscnBuild{/// <summary> Auto-discovering registry of <see cref="ISubResourceBuilder"/> implementations, indexed by ResourceType. <para/> Initial scan on first access picks up all already-loaded assemblies (including core). <see cref="RegisterAssembly"/> is called by <see cref="Mods.ModAssemblyLoader.RegisterAssembly"/> when a mod DLL is loaded, adding the mod's builders without re-scanning everything. First-registered-wins — core builders always take priority over mod-added ones. <para/> The registry instance lives in <see cref="AppServices.Current"/>; the static members below are a facade delegating to it, so existing callers keep working unchanged. </summary>
public class SubResourceBuilderRegistry:IGameRegistry{private Dictionary<string,ISubResourceBuilder>_builders;private bool built;/// <summary>Shared instance the static facade delegates to.</summary>
private static SubResourceBuilderRegistry Owned=>AppServices.Current.SubResourceBuilders;/// <summary> Creates an empty registry: cheap and side-effect free per the IGameRegistry contract. <see cref="Rebuild"/> fills the data. </summary>
internal SubResourceBuilderRegistry(){}/// <inheritdoc />
public string Id=>"tscn_sub_builders";/// <summary> Scans all loaded assemblies for <see cref="ISubResourceBuilder"/> implementations unless already built. Keeps the old Initialize idempotence: later calls are no-ops until the registry is cleared. </summary>
public void EnsureBuilt(){if(!built)Rebuild();}/// <summary> Forced full re-scan of all loaded assemblies (assembly order equals load order, so first-registered-wins keeps core builders ahead of mod-added ones), replacing the previous table. Safe to call repeatedly; picks up assemblies loaded after the previous build. </summary>
public void Rebuild(){var scanned=new Dictionary<string,ISubResourceBuilder>();foreach(var b in Reflections.FindAndCreate<ISubResourceBuilder>())if(!scanned.ContainsKey(b.ResourceType))scanned[b.ResourceType]=b;_builders=scanned;built=true;}/// <summary> Drops all registered builders; the next build re-scans the assemblies. Called on mod reload through AppServices.ClearRegistries. </summary>
public void Clear(){_builders=null;built=false;}// ---- Static facade (legacy API, unchanged for callers) ------------ <summary>Legacy entry point; delegates to the shared instance.</summary>
public static void Initialize()=>Owned.EnsureBuilt();/// <summary>Scans a newly-loaded mod assembly for <see cref="ISubResourceBuilder"/> implementations and registers them. Called from <see cref="Mods.ModAssemblyLoader.RegisterAssembly"/>.</summary>
public static void RegisterAssembly(Assembly assembly)=>Owned.AddFromAssembly(assembly);/// <summary>Looks up a builder by resource type name. Returns null if no builder is registered for the given type.</summary>
public static ISubResourceBuilder Get(string type)=>Owned.GetBuilder(type);/// <summary>Unload pass: drops builders contributed by the given assembly. Delegates to the shared instance.</summary>
public static void UnregisterAssembly(Assembly assembly)=>Owned.RemoveFromAssembly(assembly);// ---- Instance implementation --------------------------------------
private void AddFromAssembly(Assembly assembly){EnsureBuilt();foreach(var b in Reflections.FindAndCreate<ISubResourceBuilder>(assembly))if(!_builders.ContainsKey(b.ResourceType))_builders[b.ResourceType]=b;}/// <summary> Removes builders whose concrete type comes from the given assembly — the unload pass of the assembly consumer pipeline. Built-in builders survive; a slot left empty falls back to a full rebuild on next use. </summary>
public void RemoveFromAssembly(Assembly assembly){if(_builders==null)return;foreach(var key in _builders.Where(kvp=>kvp.Value.GetType().Assembly==assembly).Select(kvp=>kvp.Key).ToList())_builders.Remove(key);}private ISubResourceBuilder GetBuilder(string type){EnsureBuilt();_builders.TryGetValue(type,out var b);return b;}}}

===== FILE Core/Scenes/TscnBuild/SubResourceBuilders/CurveBuilder.cs =====
using Cthangover.Core.Scenes.TscnParsing;using Godot;namespace Cthangover.Core.Scenes.TscnBuild.SubResourceBuilders{/// <summary> Builds <see cref="Curve"/> from sub_resource properties. </summary>
public class CurveBuilder:ISubResourceBuilder{/// <summary>Handles <c>[sub_resource type="Curve"]</c>.</summary>
public string ResourceType=>"Curve";/// <summary>Creates a <see cref="Curve"/> and applies all parsed properties generically.</summary>
public Resource Build(SubResourceDef def,SubResourceBuildContext ctx){var c=new Curve();SubResourceFactory.ApplyProperties(c,def);return c;}}}

===== FILE Core/Scenes/TscnBuild/SubResourceBuilders/FontFileBuilder.cs =====
using Cthangover.Core.Scenes.TscnParsing;using Godot;namespace Cthangover.Core.Scenes.TscnBuild.SubResourceBuilders{/// <summary>Builds <see cref="FontFile"/>. Also serves "Font" alias (mapped by SubResourceFactory).</summary>
public class FontFileBuilder:ISubResourceBuilder{/// <summary>Handles <c>[sub_resource type="FontFile"]</c> (plus the "Font" alias mapped by SubResourceFactory).</summary>
public string ResourceType=>"FontFile";/// <summary>Creates a <see cref="FontFile"/> and applies the parsed font data properties.</summary>
public Resource Build(SubResourceDef def,SubResourceBuildContext ctx){var f=new FontFile();SubResourceFactory.ApplyProperties(f,def);return f;}}}

===== FILE Core/Scenes/TscnBuild/SubResourceBuilders/GradientBuilder.cs =====
using Cthangover.Core.Scenes.TscnParsing;using Godot;namespace Cthangover.Core.Scenes.TscnBuild.SubResourceBuilders{/// <summary>Builds <see cref="Gradient"/>. Handles <c>colors</c> (ParsedGodotValue[]→Color[]) and <c>offsets</c> (float[]) specially.</summary>
public class GradientBuilder:ISubResourceBuilder{/// <summary>Handles <c>[sub_resource type="Gradient"]</c>.</summary>
public string ResourceType=>"Gradient";/// <summary>Builds the gradient, converting the <c>colors</c> ParsedGodotValue array and <c>offsets</c> float array explicitly.</summary>
public Resource Build(SubResourceDef def,SubResourceBuildContext ctx){var g=new Gradient();foreach(var kvp in def.OrderedProperties){if(kvp.Key=="colors"&&kvp.Value is ParsedGodotValue[]ca){var cc=new Color[ca.Length];for(var i=0;i<ca.Length;i++)cc[i]=new Color(AsF(ca[i].Arg(0)),AsF(ca[i].Arg(1)),AsF(ca[i].Arg(2)),AsF(ca[i].Arg(3),1f));g.SetColors(cc);}else if(kvp.Key=="offsets"&&kvp.Value is float[]oa)g.SetOffsets(oa);else SubResourceFactory.SetProperty(g,kvp.Key,kvp.Value);}return g;}private static float AsF(object v,float fb=0f)=>v is float f?f:v is int i?i:v is double d?(float)d:fb;}}

===== FILE Core/Scenes/TscnBuild/SubResourceBuilders/GradientTexture1DBuilder.cs =====
using Cthangover.Core.Scenes.TscnParsing;using Godot;namespace Cthangover.Core.Scenes.TscnBuild.SubResourceBuilders{/// <summary>Builds <see cref="GradientTexture1D"/>. The <c>gradient</c> SubResource ref is resolved from already-built resources.</summary>
public class GradientTexture1DBuilder:ISubResourceBuilder{/// <summary>Handles <c>[sub_resource type="GradientTexture1D"]</c>.</summary>
public string ResourceType=>"GradientTexture1D";/// <summary>Builds the texture, resolving the <c>gradient</c> SubResource ref against already-built resources.</summary>
public Resource Build(SubResourceDef def,SubResourceBuildContext ctx){var t=new GradientTexture1D();foreach(var kvp in def.OrderedProperties){if(kvp.Key=="gradient"&&kvp.Value is TscnPropertyValue pv&&pv.IsSubResourceRef){var r=ctx.ResourceResolver?.Invoke(pv.RefId);if(r is Gradient g)t.Gradient=g;}else SubResourceFactory.SetProperty(t,kvp.Key,kvp.Value);}return t;}}}

===== FILE Core/Scenes/TscnBuild/SubResourceBuilders/ImageTextureBuilder.cs =====
using Cthangover.Core.Scenes.TscnParsing;using Godot;namespace Cthangover.Core.Scenes.TscnBuild.SubResourceBuilders{/// <summary>Builds <see cref="ImageTexture"/> from sub_resource properties.</summary>
public class ImageTextureBuilder:ISubResourceBuilder{/// <summary>Handles <c>[sub_resource type="ImageTexture"]</c> (e.g. editor-embedded images).</summary>
public string ResourceType=>"ImageTexture";/// <summary>Creates an <see cref="ImageTexture"/> and applies the parsed image properties.</summary>
public Resource Build(SubResourceDef def,SubResourceBuildContext ctx){var t=new ImageTexture();SubResourceFactory.ApplyProperties(t,def);return t;}}}

===== FILE Core/Scenes/TscnBuild/SubResourceBuilders/ShaderMaterialBuilder.cs =====
using Cthangover.Core.Scenes.TscnParsing;using Godot;namespace Cthangover.Core.Scenes.TscnBuild.SubResourceBuilders{/// <summary>Builds <see cref="ShaderMaterial"/>. The <c>shader</c> property is resolved via ExtResource ref; uniforms applied generically.</summary>
public class ShaderMaterialBuilder:ISubResourceBuilder{/// <summary>Handles <c>[sub_resource type="ShaderMaterial"]</c>.</summary>
public string ResourceType=>"ShaderMaterial";/// <summary>Builds the material, resolving the <c>shader</c> ExtResource ref via the resource resolver; uniforms apply generically.</summary>
public Resource Build(SubResourceDef def,SubResourceBuildContext ctx){var m=new ShaderMaterial();foreach(var kvp in def.OrderedProperties){if(kvp.Key=="shader"&&kvp.Value is TscnPropertyValue pv&&pv.IsExtResourceRef){var r=ctx.ResourceResolver?.Invoke(pv.RefId);if(r is Shader s)m.Shader=s;}else SubResourceFactory.SetProperty(m,kvp.Key,kvp.Value);}return m;}}}

===== FILE Core/Scenes/TscnBuild/SubResourceBuilders/StyleBoxEmptyBuilder.cs =====
using Cthangover.Core.Scenes.TscnParsing;using Godot;namespace Cthangover.Core.Scenes.TscnBuild.SubResourceBuilders{/// <summary>Builds <see cref="StyleBoxEmpty"/> — a transparent placeholder style box.</summary>
public class StyleBoxEmptyBuilder:ISubResourceBuilder{/// <summary>Handles <c>[sub_resource type="StyleBoxEmpty"]</c> — the transparent placeholder style box.</summary>
public string ResourceType=>"StyleBoxEmpty";/// <summary>Creates a <see cref="StyleBoxEmpty"/>; there are no meaningful properties to apply.</summary>
public Resource Build(SubResourceDef def,SubResourceBuildContext ctx){var b=new StyleBoxEmpty();SubResourceFactory.ApplyProperties(b,def);return b;}}}

===== FILE Core/Scenes/TscnBuild/SubResourceBuilders/StyleBoxFlatBuilder.cs =====
using Cthangover.Core.Scenes.TscnParsing;using Godot;namespace Cthangover.Core.Scenes.TscnBuild.SubResourceBuilders{/// <summary>Builds <see cref="StyleBoxFlat"/> from sub_resource properties.</summary>
public class StyleBoxFlatBuilder:ISubResourceBuilder{/// <summary>Handles <c>[sub_resource type="StyleBoxFlat"]</c> — the most common themed panel box.</summary>
public string ResourceType=>"StyleBoxFlat";/// <summary>Creates a <see cref="StyleBoxFlat"/> and applies bg_color, corner radii, and other parsed properties.</summary>
public Resource Build(SubResourceDef def,SubResourceBuildContext ctx){var b=new StyleBoxFlat();SubResourceFactory.ApplyProperties(b,def);return b;}}}

===== FILE Core/Scenes/TscnBuild/SubResourceFactory.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Scenes.TscnParsing;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Scenes.TscnBuild{/// <summary> Builds Godot Resources from parsed [sub_resource] definitions. Delegates type-specific construction to <see cref="SubResourceBuilderRegistry"/>. <see cref="ToVariant"/> is the single boundary where ParsedGodotValue wrappers become actual Godot native objects — only called inside Godot runtime. </summary>
public class SubResourceFactory{private readonly Dictionary<string,Resource>_built=new();/// <summary>Register a pre-built resource under its sub_resource id. Used so that BuildAll can resolve cross-references between sub-resources.</summary>
public void Register(string id,Resource r){_built[id]=r;}/// <summary>Retrieves a previously-built resource by its sub_resource id. Returns null if not found.</summary>
public Resource Get(string id){_built.TryGetValue(id,out var r);return r;}/// <summary>Builds all sub-resources from the parsed scene. Uses a callback for cross-resource resolution (e.g. GradientTexture1D referencing a Gradient).</summary>
public void BuildAll(List<SubResourceDef>subs,Func<string,Resource>resourceResolver){foreach(var def in subs){if(_built.ContainsKey(def.Id))continue;var r=Build(def,resourceResolver);if(r!=null)_built[def.Id]=r;}}private static Resource Build(SubResourceDef def,Func<string,Resource>resourceResolver){var b=SubResourceBuilderRegistry.Get(def.Type)??SubResourceBuilderRegistry.Get(def.Type=="Font"?"FontFile":null);if(b==null){GameLogger.Log("TSCN_BUILD",$"SubResourceFactory: unsupported type '{def.Type}'",LogLevel.Warning);return null;}return b.Build(def,new SubResourceBuildContext{ResourceResolver=resourceResolver});}internal static void ApplyProperties(GodotObject obj,SubResourceDef def){foreach(var kvp in def.OrderedProperties)SetProperty(obj,kvp.Key,kvp.Value);}internal static void SetProperty(GodotObject obj,string key,object value){if(value is TscnPropertyValue)return;obj.Set(key,ToVariant(value));}/// <summary>Converts a parsed value to Godot Variant. This is the boundary where ParsedGodotValue→Godot struct conversion happens — only runs inside Godot runtime.</summary>
internal static Variant ToVariant(object value){if(value==null)return default;if(value is ParsedGodotValue pgv)return GodotValueToVariant(pgv);if(value is ParsedGodotValue[]pga)return GodotValueArrayToVariant(pga);switch(value){case bool b:return b;case int i:return i;case long l:return l;case float f:return f;case double d:return(float)d;case string s:return s;case string[]sa:return CreateArray(sa);case int[]ia:return CreateArray(ia);case long[]la:return CreateArray(la);case float[]fa:return CreateArray(fa);case double[]da:return CreateArray(Array.ConvertAll(da,x=>(float)x));case byte[]ba:return CreateArray(ba);default:return default;}}private static Variant GodotValueToVariant(ParsedGodotValue pgv){var a=pgv.Args;switch(pgv.TypeName){case"NodePath":return a.Count>0&&a[0]is string np?new NodePath(np):default;case"StringName":return a.Count>0&&a[0]is string sn?new StringName(sn):default;case"Vector2":return new Vector2(AsF(a,0),AsF(a,1));case"Vector2i":return new Vector2I(AsI(a,0),AsI(a,1));case"Vector3":return new Vector3(AsF(a,0),AsF(a,1),AsF(a,2));case"Vector3i":return new Vector3I(AsI(a,0),AsI(a,1),AsI(a,2));case"Vector4":return new Vector4(AsF(a,0),AsF(a,1),AsF(a,2),AsF(a,3));case"Vector4i":return new Vector4I(AsI(a,0),AsI(a,1),AsI(a,2),AsI(a,3));case"Color":return new Color(AsF(a,0),AsF(a,1),AsF(a,2),AsF(a,3,1f));case"Rect2":return new Rect2(AsF(a,0),AsF(a,1),AsF(a,2),AsF(a,3));case"Rect2i":return new Rect2I(AsI(a,0),AsI(a,1),AsI(a,2),AsI(a,3));case"Transform2D":return new Transform2D(AsF(a,0),AsF(a,1),AsF(a,2),AsF(a,3),AsF(a,4),AsF(a,5));case"Plane":return new Plane(AsF(a,0),AsF(a,1),AsF(a,2),AsF(a,3));case"Quaternion":return new Quaternion(AsF(a,0),AsF(a,1),AsF(a,2),AsF(a,3));case"AABB":return new Aabb(new Vector3(AsF(a,0),AsF(a,1),AsF(a,2)),new Vector3(AsF(a,3),AsF(a,4),AsF(a,5)));default:return default;}}private static Godot.Collections.Array<Variant>GodotValueArrayToVariant(ParsedGodotValue[]arr){var r=new Godot.Collections.Array<Variant>();foreach(var pgv in arr)r.Add(GodotValueToVariant(pgv));return r;}private static float AsF(List<object>a,int i,float fb=0f)=>i>=a.Count?fb:a[i]is float f?f:a[i]is int iv?iv:a[i]is double d?(float)d:fb;private static int AsI(List<object>a,int i,int fb=0)=>i>=a.Count?fb:a[i]is int iv?iv:a[i]is float f?(int)f:fb;private static Godot.Collections.Array<string>CreateArray(string[]a){var r=new Godot.Collections.Array<string>();foreach(var x in a)r.Add(x);return r;}private static Godot.Collections.Array<int>CreateArray(int[]a){var r=new Godot.Collections.Array<int>();foreach(var x in a)r.Add(x);return r;}private static Godot.Collections.Array<long>CreateArray(long[]a){var r=new Godot.Collections.Array<long>();foreach(var x in a)r.Add(x);return r;}private static Godot.Collections.Array<float>CreateArray(float[]a){var r=new Godot.Collections.Array<float>();foreach(var x in a)r.Add(x);return r;}private static Godot.Collections.Array<byte>CreateArray(byte[]a){var r=new Godot.Collections.Array<byte>();foreach(var x in a)r.Add(x);return r;}}}

===== FILE Core/Scenes/TscnParsing/ISectionParser.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Scenes.TscnParsing{/// <summary> Parses a single section type in a .tscn file (e.g. [node], [ext_resource]). Each implementation is responsible for exactly one section type. <para/> Lifecycle: Open (section header) → Property* (zero or more property lines) → Close (commit to ParsedScene). The orchestrator (<see cref="TscnParser"/>) calls these methods as it walks token stream, never branching on section type itself — lookup is deferred to <see cref="SectionParserRegistry"/>. <para/> Auto-discovered via <c>Reflections.FindAndCreate&lt;ISectionParser&gt;()</c>. To add a new section type, implement this interface in any loaded assembly — no registration code needed. </summary>
public interface ISectionParser{/// <summary>Section type name as it appears in .tscn (e.g. "node", "ext_resource").</summary>
string SectionType{get;}/// <summary>Called when a section header is encountered. <paramref name="headerAttrs"/> are the parsed key=value attributes from the brackets.</summary>
void Open(Dictionary<string,string>headerAttrs);/// <summary>Called for each key=value property line under the current section.</summary>
void Property(string key,string rawValue);/// <summary>Called when the next section header is encountered (or at end of file). Commits all accumulated state to <paramref name="scene"/>.</summary>
void Close(ParsedScene scene);}}

===== FILE Core/Scenes/TscnParsing/ParsedGodotValue.cs =====
namespace Cthangover.Core.Scenes.TscnParsing{/// <summary>Lightweight wrapper for Godot-specific property values (Vector2, Color, NodePath, etc.) — avoids creating actual Godot objects during parsing to remain testable outside the engine.</summary>
public class ParsedGodotValue{/// <summary>Constructor-style name of the Godot value, e.g. "Vector2", "Color", "NodePath".</summary>
public string TypeName{get;set;}/// <summary>Positional arguments inside the parentheses; numbers are stored as float/int, strings as string.</summary>
public System.Collections.Generic.List<object>Args{get;set;}=new();/// <summary>Convenience factory for parsers and tests.</summary>
public static ParsedGodotValue Create(string typeName,params object[]args)=>new(){TypeName=typeName,Args=new System.Collections.Generic.List<object>(args)};/// <summary>Returns the argument at <paramref name="index"/>, or null when out of range (Godot constructors allow omitted trailing args).</summary>
public object Arg(int index)=>index<Args.Count?Args[index]:null;}}

===== FILE Core/Scenes/TscnParsing/ParsedScene.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Scenes.TscnParsing{/// <summary> Intermediate representation of a fully parsed .tscn scene file. Contains flat lists of resources, nodes, and connections — the node hierarchy is encoded via <see cref="ParsedNode.Parent"/> references, not a tree. </summary>
public class ParsedScene{/// <summary>Value of the <c>format=</c> attribute in [gd_scene]; information only (format 3 expected).</summary>
public string Format{get;set;}/// <summary>All [ext_resource] entries in file order.</summary>
public List<ExtResourceRef>ExtResources{get;set;}=new();/// <summary>All [sub_resource] entries in file order.</summary>
public List<SubResourceDef>SubResources{get;set;}=new();/// <summary>All [node] sections in file order — the order is the build order.</summary>
public List<ParsedNode>Nodes{get;set;}=new();/// <summary>Root-script [connection] sections kept by the parser.</summary>
public List<ParsedConnection>Connections{get;set;}=new();/// <summary>Fully-qualified entry-point class from the root node's <c>__entry_point</c> property; null when absent.</summary>
public string EntryPointClass{get;set;}}/// <summary>Parsed [ext_resource] entry. Stores type ("Script"/"PackedScene"), mod-relative path, and local id.</summary>
public class ExtResourceRef{/// <summary>Declared resource type ("Script", "PackedScene", "Texture2D", …).</summary>
public string Type{get;set;}/// <summary>Mod-relative (or normalized) path of the resource file.</summary>
public string Path{get;set;}/// <summary>Local id referenced by <c>ExtResource("id")</c> within this scene file.</summary>
public string Id{get;set;}}/// <summary>Parsed [sub_resource] section. Inline resources (StyleBoxFlat, Gradient, etc.), built later by <see cref="TscnBuild.ISubResourceBuilder"/>.</summary>
public class SubResourceDef{/// <summary>Resource type name from the section header ("StyleBoxFlat", "Gradient", …).</summary>
public string Type{get;set;}/// <summary>Local id referenced by <c>SubResource("id")</c>.</summary>
public string Id{get;set;}/// <summary>Parsed property values keyed by property name.</summary>
public Dictionary<string,object>Properties{get;set;}=new();/// <summary>Keys of <see cref="Properties"/> in source-file order. Godot applies properties in file order, which matters for layout/anchors and sub-resource cross-references.</summary>
public List<string>PropertyOrder{get;set;}=new();/// <summary>Properties in the order they appeared in the .tscn source.</summary>
public IEnumerable<KeyValuePair<string,object>>OrderedProperties{get{foreach(var key in PropertyOrder)if(Properties.TryGetValue(key,out var value))yield return new KeyValuePair<string,object>(key,value);}}}/// <summary> Parsed [node] section. Nodes are stored flat — parent-child relationships via <see cref="Parent"/>: null = root, "." = direct child of root, "Name" or "Name/Child" = explicit path. <see cref="InstanceRef"/> and <see cref="ScriptRef"/> store raw ExtResource strings, resolved at build time. </summary>
public class ParsedNode{/// <summary>Node name from the section header.</summary>
public string Name{get;set;}/// <summary>Godot class name for new nodes; null on override/instance headings.</summary>
public string Type{get;set;}/// <summary>Parent path: null for the root, "." for a direct child of root, "Name/Child" for deeper placement.</summary>
public string Parent{get;set;}/// <summary>Raw <c>instance=ExtResource("id")</c> value for inherited nodes; resolved at build time.</summary>
public string InstanceRef{get;set;}/// <summary>Raw <c>script=ExtResource("id")</c> value; resolved to a C# type at build time.</summary>
public string ScriptRef{get;set;}/// <summary> Editor-written sibling position (<c>index="N"</c> in the [node] heading). Null for ordinary scenes; inherited scenes use it to order overridden and added nodes, which is why the scene patcher re-sorts siblings when any node sets it. </summary>
public int?Index{get;set;}/// <summary>Group names from the <c>groups=[...]</c> heading attribute; null when absent.</summary>
public string[]Groups{get;set;}/// <summary>Exported NodePath bindings declared by the <c>node_paths=[...]</c> heading, keyed by property name.</summary>
public Dictionary<string,string>NodePathVars{get;set;}=new();/// <summary>Parsed property values keyed by property name (entry-point and patch metadata keys are removed before build).</summary>
public Dictionary<string,object>Properties{get;set;}=new();/// <summary>Keys of <see cref="Properties"/> in source-file order. Godot applies node properties in file order — critical for <c>layout_mode</c> → <c>anchors_preset</c> → <c>anchor_*</c>/<c>offset_*</c> sequencing.</summary>
public List<string>PropertyOrder{get;set;}=new();/// <summary>Properties in the order they appeared in the .tscn source.</summary>
public IEnumerable<KeyValuePair<string,object>>OrderedProperties{get{foreach(var key in PropertyOrder)if(Properties.TryGetValue(key,out var value))yield return new KeyValuePair<string,object>(key,value);}}}/// <summary>Parsed [connection] section. Only script-method connections (to=".") are kept; visual/animation connections are filtered during parsing.</summary>
public class ParsedConnection{/// <summary>Signal name emitted by the source node.</summary>
public string Signal{get;set;}/// <summary>Source node path relative to the root ("." = root itself).</summary>
public string From{get;set;}/// <summary>Target node path ("." = root — the only form the parser keeps).</summary>
public string To{get;set;}/// <summary>Method name invoked on the target node.</summary>
public string Method{get;set;}/// <summary>Godot connect flags from the section header (default 0).</summary>
public int Flags{get;set;}}}

===== FILE Core/Scenes/TscnParsing/SectionParserRegistry.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Reflection;using Cthangover.Core.Bootstrap;using Cthangover.Core.Utils;namespace Cthangover.Core.Scenes.TscnParsing{/// <summary> Auto-discovering registry of <see cref="ISectionParser"/> implementations, indexed by SectionType. First-registered-wins; Roslyn-compiled mod assemblies can add section types via <see cref="RegisterAssembly"/>. <para/> The registry stores parser <b>types</b>, not instances, and <see cref="Get"/> creates a fresh parser per section. Parsers carry per-section state between <c>Open</c>, the property lines and <c>Close</c>; sharing one instance broke as soon as two parses ran concurrently (parallel unit tests, background scene loads), because one parse could null out another's current parser mid-section. Instantiating per call keeps <see cref="TscnParser"/> stateless and therefore safe to use from parallel threads. <para/> The registry instance lives in <see cref="AppServices.Current"/>; the static members below are a facade delegating to it, so existing callers keep working unchanged. </summary>
public class SectionParserRegistry:IGameRegistry{private readonly object Sync=new();private Dictionary<string,Type>_parsers;private bool built;/// <summary>Shared instance the static facade delegates to.</summary>
private static SectionParserRegistry Owned=>AppServices.Current.SectionParsers;/// <summary> Creates an empty registry: cheap and side-effect free per the IGameRegistry contract. <see cref="Rebuild"/> fills the data. </summary>
internal SectionParserRegistry(){}/// <inheritdoc />
public string Id=>"tscn_section_parsers";/// <summary> Scans all loaded assemblies for <see cref="ISectionParser"/> implementations unless already built. Thread-safe; keeps the old Initialize idempotence: later calls are no-ops until cleared. </summary>
public void EnsureBuilt(){lock(Sync){if(built)return;RescanLocked();built=true;}}/// <summary> Forced full re-scan of all loaded assemblies (assembly order equals load order, so first-registered-wins keeps core parsers ahead of mod-added ones), replacing the previous table. Safe to call repeatedly; picks up assemblies loaded after the previous build. </summary>
public void Rebuild(){lock(Sync){RescanLocked();built=true;}}/// <summary> Drops all registered parser types; the next build re-scans the assemblies. Called on mod reload through AppServices.ClearRegistries. </summary>
public void Clear(){lock(Sync){_parsers=null;built=false;}}// ---- Static facade (legacy API, unchanged for callers) ------------ <summary>Legacy entry point; delegates to the shared instance.</summary>
public static void Initialize()=>Owned.EnsureBuilt();/// <summary>Scans a newly-loaded mod assembly for <see cref="ISectionParser"/> implementations and registers them.</summary>
public static void RegisterAssembly(Assembly assembly)=>Owned.AddFromAssembly(assembly);/// <summary>Creates a fresh parser instance for the given section type, or null when none is registered.</summary>
public static ISectionParser Get(string sectionType)=>Owned.GetParser(sectionType);/// <summary>Unload pass: drops parser types contributed by the given assembly. Delegates to the shared instance.</summary>
public static void UnregisterAssembly(Assembly assembly)=>Owned.RemoveFromAssembly(assembly);// ---- Instance implementation -------------------------------------- <summary> Removes section parser types contributed by the given assembly — the unload pass of the assembly consumer pipeline. Core parsers remain: they are re-discovered by the next full build anyway. </summary>
public void RemoveFromAssembly(Assembly assembly){lock(Sync){if(_parsers==null)return;foreach(var key in _parsers.Where(kvp=>kvp.Value.Assembly==assembly).Select(kvp=>kvp.Key).ToList())_parsers.Remove(key);}}/// <summary>Full re-scan; caller must hold <see cref="Sync"/>.</summary>
private void RescanLocked(){var parsers=new Dictionary<string,Type>();foreach(var parser in Reflections.FindAndCreate<ISectionParser>()){if(!parsers.ContainsKey(parser.SectionType))parsers[parser.SectionType]=parser.GetType();}_parsers=parsers;}private void AddFromAssembly(Assembly assembly){EnsureBuilt();lock(Sync){foreach(var parser in Reflections.FindAndCreate<ISectionParser>(assembly)){if(!_parsers.ContainsKey(parser.SectionType))_parsers[parser.SectionType]=parser.GetType();}}}private ISectionParser GetParser(string sectionType){EnsureBuilt();lock(Sync){if(!_parsers.TryGetValue(sectionType,out var type))return null;return(ISectionParser)Activator.CreateInstance(type);}}}}

===== FILE Core/Scenes/TscnParsing/SectionParsers/ConnectionSectionParser.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Scenes.TscnParsing.SectionParsers{/// <summary>Parses <c>[connection]</c>. Only script-method connections (to=".") are kept; animation/visual connections are dropped.</summary>
public class ConnectionSectionParser:ISectionParser{/// <summary>Section type name handled by this parser: <c>connection</c>.</summary>
public string SectionType=>"connection";private ParsedConnection _current;/// <summary>Starts a connection from the header attributes; <c>flags</c> is parsed as int when present.</summary>
public void Open(Dictionary<string,string>h){_current=new ParsedConnection{Signal=h.GetValueOrDefault("signal"),From=h.GetValueOrDefault("from"),To=h.GetValueOrDefault("to"),Method=h.GetValueOrDefault("method")};if(h.TryGetValue("flags",out var f)&&int.TryParse(f,out var flags))_current.Flags=flags;}/// <summary>Connections carry no property lines — no-op.</summary>
public void Property(string key,string rawValue){}/// <summary>Commits only root-method connections (to "." or empty); animation/visual targets are dropped.</summary>
public void Close(ParsedScene s){if(_current!=null&& !string.IsNullOrEmpty(_current.Method)&&(_current.To=="."||_current.To==""))s.Connections.Add(_current);_current=null;}}}

===== FILE Core/Scenes/TscnParsing/SectionParsers/EditableSectionParser.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Scenes.TscnParsing.SectionParsers{/// <summary>Parses <c>[editable]</c> — editor-only placeholders for inherited scene instances. All content discarded.</summary>
public class EditableSectionParser:ISectionParser{/// <summary>Section type name handled by this parser: <c>editable</c> — accepted and discarded (editor-only inherited-scene placeholders).</summary>
public string SectionType=>"editable";/// <summary>No-op — the section carries no data the runtime builder needs.</summary>
public void Open(Dictionary<string,string>h){}/// <summary>No-op.</summary>
public void Property(string key,string rawValue){}/// <summary>No-op.</summary>
public void Close(ParsedScene s){}}}

===== FILE Core/Scenes/TscnParsing/SectionParsers/ExtResourceSectionParser.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Scenes.TscnParsing.SectionParsers{/// <summary>Parses <c>[ext_resource]</c>. Each header is self-contained — extracts type, path, id; uid is stripped by lexer.</summary>
public class ExtResourceSectionParser:ISectionParser{/// <summary>Section type name handled by this parser: <c>ext_resource</c>.</summary>
public string SectionType=>"ext_resource";private ExtResourceRef _current;/// <summary>Starts an ext_resource from the <c>type</c>/<c>path</c>/<c>id</c> header attributes.</summary>
public void Open(Dictionary<string,string>h){_current=new ExtResourceRef{Type=h.GetValueOrDefault("type"),Path=h.GetValueOrDefault("path"),Id=h.GetValueOrDefault("id")};}/// <summary>Ext_resource headers are self-contained — no-op.</summary>
public void Property(string key,string rawValue){}/// <summary>Commits the finished entry to <see cref="ParsedScene.ExtResources"/>.</summary>
public void Close(ParsedScene s){if(_current!=null)s.ExtResources.Add(_current);_current=null;}}}

===== FILE Core/Scenes/TscnParsing/SectionParsers/GdSceneSectionParser.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Scenes.TscnParsing.SectionParsers{/// <summary>Parses <c>[gd_scene]</c>. Extracts format; uid and load_steps are stripped by the lexer.</summary>
public class GdSceneSectionParser:ISectionParser{/// <summary>Section type name handled by this parser: <c>gd_scene</c>.</summary>
public string SectionType=>"gd_scene";private string _format;/// <summary>Captures the <c>format=</c> attribute (information only).</summary>
public void Open(Dictionary<string,string>headerAttrs){if(headerAttrs.TryGetValue("format",out var f))_format=f;}/// <summary>The scene header carries no property lines — no-op.</summary>
public void Property(string key,string rawValue){}/// <summary>Copies the captured format value into <see cref="ParsedScene.Format"/>.</summary>
public void Close(ParsedScene scene){if(_format!=null)scene.Format=_format;}}}

===== FILE Core/Scenes/TscnParsing/SectionParsers/NodeSectionParser.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Scenes.TscnParsing.SectionParsers{/// <summary> Parses <c>[node]</c> — the core section. Extracts name, type, parent, instance (PackedScene ref), script (C# class ref), groups, node_paths. Special property handling: <c>script=</c> stored as ScriptRef, <c>__entry_point=</c> extracted to <see cref="ParsedScene.EntryPointClass"/> on root nodes, node_path vars stored as raw strings for build-time resolution. </summary>
public class NodeSectionParser:ISectionParser{/// <summary>Section type name handled by this parser: <c>node</c>.</summary>
public string SectionType=>"node";private ParsedNode _current;/// <summary>Starts a node from the heading attributes: name, type, parent, instance, script, index, groups, node_paths.</summary>
public void Open(Dictionary<string,string>h){_current=new ParsedNode{Name=h.GetValueOrDefault("name"),Type=h.GetValueOrDefault("type"),Parent=h.GetValueOrDefault("parent"),InstanceRef=h.GetValueOrDefault("instance"),ScriptRef=h.GetValueOrDefault("script")};if(h.TryGetValue("index",out var rawIndex)&&int.TryParse(rawIndex,out var index))_current.Index=index;if(h.TryGetValue("groups",out var gr))_current.Groups=ParseGroupArray(gr);if(h.TryGetValue("node_paths",out var np)){var vars=ParseGroupArray(np);_current.NodePathVars=new Dictionary<string,string>();foreach(var v in vars)_current.NodePathVars[v]=null;}}/// <summary>Routes <c>script=</c> into <see cref="ParsedNode.ScriptRef"/>, node-path variables into their dictionary, and everything else into the ordered property map.</summary>
public void Property(string key,string rawValue){if(_current==null)return;if(key=="script"){_current.ScriptRef=rawValue;return;}var pv=TscnPropertyParser.Parse(rawValue);if(_current.NodePathVars!=null&&_current.NodePathVars.ContainsKey(key)){_current.NodePathVars[key]=rawValue;return;}_current.Properties[key]=pv;_current.PropertyOrder.Add(key);}/// <summary>Extracts <c>__entry_point</c> from root nodes into <see cref="ParsedScene.EntryPointClass"/> and appends the node to the scene.</summary>
public void Close(ParsedScene s){if(_current==null)return;if(string.IsNullOrEmpty(s.EntryPointClass)&&(_current.Parent==null||_current.Parent==".")&&_current.Properties.TryGetValue("__entry_point",out var ep)&&ep is string ec){s.EntryPointClass=ec;_current.Properties.Remove("__entry_point");}s.Nodes.Add(_current);_current=null;}private static string[]ParseGroupArray(string raw){if(string.IsNullOrEmpty(raw))return new string[0];var t=raw.Trim();if(t.StartsWith("[")&&t.EndsWith("]"))t=t.Substring(1,t.Length-2);if(t.StartsWith("PackedStringArray(")&&t.EndsWith(")"))t=t.Substring("PackedStringArray(".Length,t.Length-"PackedStringArray(".Length-1);var items=new List<string>();foreach(var p in TscnPropertyParser.SplitArgs(t)){var c=p.Trim();if(c.StartsWith("\"")&&c.EndsWith("\""))c=c.Substring(1,c.Length-2);if(!string.IsNullOrEmpty(c))items.Add(c);}return items.ToArray();}}}

===== FILE Core/Scenes/TscnParsing/SectionParsers/SubResourceSectionParser.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Scenes.TscnParsing.SectionParsers{/// <summary>Parses <c>[sub_resource]</c>. Accumulates properties into a <see cref="SubResourceDef"/>; no Godot objects created here.</summary>
public class SubResourceSectionParser:ISectionParser{/// <summary>Section type name handled by this parser: <c>sub_resource</c>.</summary>
public string SectionType=>"sub_resource";private SubResourceDef _current;/// <summary>Starts a sub_resource from the <c>type</c>/<c>id</c> header attributes.</summary>
public void Open(Dictionary<string,string>h){_current=new SubResourceDef{Type=h.GetValueOrDefault("type"),Id=h.GetValueOrDefault("id")};}/// <summary>Parses each property line into a parsed value and records its source order.</summary>
public void Property(string key,string rawValue){if(_current!=null){_current.Properties[key]=TscnPropertyParser.Parse(rawValue);_current.PropertyOrder.Add(key);}}/// <summary>Commits the finished definition to <see cref="ParsedScene.SubResources"/>.</summary>
public void Close(ParsedScene s){if(_current!=null)s.SubResources.Add(_current);_current=null;}}}

===== FILE Core/Scenes/TscnParsing/TscnLexer.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Scenes.TscnParsing{/// <summary> Categories of tokens produced by <see cref="TscnLexer"/>. <see cref="SectionHeader"/> — a line like <c>[node name="Root"]</c>; <see cref="Property"/> — a key=value line under a section; <see cref="Empty"/> — a blank line (unused by parser, preserved for diagnostics). </summary>
public enum TscnTokenType{SectionHeader,Property,Empty}/// <summary> Single token produced by <see cref="TscnLexer"/>. For section headers, <see cref="SectionName"/> and <see cref="SectionAttrs"/> are populated; for properties, <see cref="PropertyKey"/> and <see cref="PropertyValue"/>. <see cref="LineNumber"/> tracks the original source line for error reporting. </summary>
public class TscnToken{/// <summary>Token category: section header, property line, or empty line.</summary>
public TscnTokenType Type{get;set;}/// <summary>Section name without brackets for <see cref="TscnTokenType.SectionHeader"/> tokens (e.g. "node").</summary>
public string SectionName{get;set;}/// <summary>Parsed key=value attributes of the section header (editor-only keys already stripped).</summary>
public Dictionary<string,string>SectionAttrs{get;set;}/// <summary>Key (left of <c>=</c>) for <see cref="TscnTokenType.Property"/> tokens.</summary>
public string PropertyKey{get;set;}/// <summary>Raw right-hand side for <see cref="TscnTokenType.Property"/> tokens; parsing is deferred to <see cref="TscnPropertyParser"/>.</summary>
public string PropertyValue{get;set;}/// <summary>1-based source line, for diagnostics.</summary>
public int LineNumber{get;set;}/// <summary>Factory for a [section_header] token with parsed key=value attributes.</summary>
public static TscnToken Section(string name,Dictionary<string,string>attrs,int line)=>new(){Type=TscnTokenType.SectionHeader,SectionName=name,SectionAttrs=attrs,LineNumber=line};/// <summary>Factory for a key = value property token.</summary>
public static TscnToken Prop(string key,string value,int line)=>new(){Type=TscnTokenType.Property,PropertyKey=key,PropertyValue=value,LineNumber=line};/// <summary>Factory for an empty line token (preserved for source position tracking).</summary>
public static TscnToken EmptyLine(int line)=>new(){Type=TscnTokenType.Empty,LineNumber=line};}/// <summary> Line-by-line tokenizer for .tscn files. Splits source text into a flat sequence of <see cref="TscnToken"/> objects — section headers and property lines. Empty lines and comment lines (starting with <c>;</c>) are skipped. <para/> Does not parse property values — that is deferred to <see cref="TscnPropertyParser"/> to keep the lexer simple and avoid creating Godot objects during tokenization. Section header attributes (key=value pairs in brackets) are parsed inline; editor-only attributes (<c>uid</c>, <c>load_steps</c>, <c>unique_id</c>) are stripped. </summary>
public class TscnLexer{/// <summary>Tokenizes raw .tscn source text into a flat sequence of TscnToken objects.</summary>
public List<TscnToken>Tokenize(string source){var tokens=new List<TscnToken>();if(string.IsNullOrEmpty(source))return tokens;var lines=source.Replace("\r\n","\n").Replace('\r','\n').Split('\n');for(var i=0;i<lines.Length;i++){var raw=lines[i];var line=raw.TrimStart();if(line.Length==0||line.StartsWith(";"))continue;if(line.StartsWith("[")){var token=ParseSectionHeader(line,i+1);if(token!=null)tokens.Add(token);continue;}var eqIdx=line.IndexOf('=');if(eqIdx>=0){var key=line.Substring(0,eqIdx).TrimEnd();var value=line.Substring(eqIdx+1).TrimStart();tokens.Add(TscnToken.Prop(key,value,i+1));}}return tokens;}private static TscnToken ParseSectionHeader(string line,int lineNumber){var closeIdx=line.LastIndexOf(']');if(closeIdx<0)return null;var inner=line.Substring(1,closeIdx-1);var attrs=new Dictionary<string,string>();var spaceIdx=inner.IndexOf(' ');string sectionName;string remainder;if(spaceIdx<0){sectionName=inner;remainder="";}else{sectionName=inner.Substring(0,spaceIdx);remainder=inner.Substring(spaceIdx+1).TrimStart();}while(remainder.Length>0){var eqIdx=remainder.IndexOf('=');if(eqIdx<0)break;var key=remainder.Substring(0,eqIdx).TrimEnd();remainder=remainder.Substring(eqIdx+1).TrimStart();if(remainder.Length==0)break;var value=ParseSectionValue(ref remainder);if(key.StartsWith("unique_id")||key=="load_steps"||key=="uid"){key=null;}if(key!=null)attrs[key]=value;}return TscnToken.Section(sectionName,attrs,lineNumber);}private static string ParseSectionValue(ref string input){if(input.StartsWith("\""))return ParseQuotedString(ref input);return ParseUnquotedToken(ref input);}private static string ParseQuotedString(ref string input){var result="";var i=1;while(i<input.Length){var ch=input[i];if(ch=='\\'&&i+1<input.Length){result+=input[i+1];i+=2;continue;}if(ch=='"'){i++;break;}result+=ch;i++;}input=input.Substring(i).TrimStart();return result;}private static string ParseUnquotedToken(ref string input){var parenDepth=0;var bracketDepth=0;var inString=false;var i=0;while(i<input.Length){var ch=input[i];if(inString){if(ch=='\\'&&i+1<input.Length){i+=2;continue;}if(ch=='"')inString=false;i++;continue;}if(ch=='"'){inString=true;i++;continue;}if(ch=='('){parenDepth++;i++;continue;}if(ch==')'){parenDepth--;i++;continue;}if(ch=='['){bracketDepth++;i++;continue;}if(ch==']'){bracketDepth--;i++;continue;}if(parenDepth==0&&bracketDepth==0&&(ch==' '||ch==']'))break;i++;}var token=input.Substring(0,i);input=input.Substring(i).TrimStart();return token;}}}

===== FILE Core/Scenes/TscnParsing/TscnParser.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Scenes.TscnParsing{/// <summary> Orchestrator for .tscn parsing. Delegates section handling to <see cref="SectionParserRegistry"/> — no section-specific logic lives here. Produces a <see cref="ParsedScene"/> without creating Godot objects. </summary>
public class TscnParser{/// <summary>Parses .tscn source text into a ParsedScene. The scene contains flat lists of resources, nodes, and connections ready for <see cref="TscnBuild.SceneBuilder"/>.</summary>
public ParsedScene Parse(string source){var tokens=new TscnLexer().Tokenize(source);var scene=new ParsedScene();ISectionParser current=null;foreach(var token in tokens){if(token.Type==TscnTokenType.SectionHeader){current?.Close(scene);current=SectionParserRegistry.Get(token.SectionName);current?.Open(token.SectionAttrs??new Dictionary<string,string>());}else if(token.Type==TscnTokenType.Property&&current!=null){current.Property(token.PropertyKey,token.PropertyValue);}}current?.Close(scene);return scene;}}}

===== FILE Core/Scenes/TscnParsing/TscnPropertyParser.cs =====
using System;using System.Collections.Generic;using System.Globalization;namespace Cthangover.Core.Scenes.TscnParsing{/// <summary> Parses property value strings from .tscn files. Deliberately avoids creating Godot native objects — returns <see cref="ParsedGodotValue"/> wrappers instead, so the parser can run in unit tests. Godot object construction is deferred to <see cref="TscnBuild.SubResourceFactory.ToVariant"/>. </summary>
public static class TscnPropertyParser{/// <summary>Parses a raw .tscn value string. Returns null, bool, int, float, string, ParsedGodotValue, TscnPropertyValue, or arrays.</summary>
public static object Parse(string raw){if(string.IsNullOrEmpty(raw))return null;var trimmed=raw.Trim();if(trimmed=="null")return null;if(trimmed=="true")return true;if(trimmed=="false")return false;if(trimmed.StartsWith("\"")&&trimmed.EndsWith("\""))return Unescape(trimmed.Substring(1,trimmed.Length-2));if(trimmed.StartsWith("ExtResource(")&&trimmed.EndsWith(")")){var inner=trimmed.Substring("ExtResource(".Length,trimmed.Length-"ExtResource(".Length-1);return TscnPropertyValue.FromExtResource(StripQuotes(inner.Trim()));}if(trimmed.StartsWith("SubResource(")&&trimmed.EndsWith(")")){var inner=trimmed.Substring("SubResource(".Length,trimmed.Length-"SubResource(".Length-1);return TscnPropertyValue.FromSubResource(StripQuotes(inner.Trim()));}if(trimmed.StartsWith("NodePath(")&&trimmed.EndsWith(")")){var inner=trimmed.Substring("NodePath(".Length,trimmed.Length-"NodePath(".Length-1);return ParsedGodotValue.Create("NodePath",StripQuotes(inner.Trim()));}if(trimmed.StartsWith("StringName(")&&trimmed.EndsWith(")")){var inner=trimmed.Substring("StringName(".Length,trimmed.Length-"StringName(".Length-1);return ParsedGodotValue.Create("StringName",StripQuotes(inner.Trim()));}if(TryParseConstructorArgs("Vector2",trimmed,out var a2,2))return ParsedGodotValue.Create("Vector2",a2.ToArray());if(TryParseConstructorArgs("Vector2i",trimmed,out var a2i,2))return ParsedGodotValue.Create("Vector2i",a2i.ToArray());if(TryParseConstructorArgs("Vector3",trimmed,out var a3,3))return ParsedGodotValue.Create("Vector3",a3.ToArray());if(TryParseConstructorArgs("Vector3i",trimmed,out var a3i,3))return ParsedGodotValue.Create("Vector3i",a3i.ToArray());if(TryParseConstructorArgs("Vector4",trimmed,out var a4,4))return ParsedGodotValue.Create("Vector4",a4.ToArray());if(TryParseConstructorArgs("Vector4i",trimmed,out var a4i,4))return ParsedGodotValue.Create("Vector4i",a4i.ToArray());if(TryParseConstructorArgs("Color",trimmed,out var ca,3,4))return ParsedGodotValue.Create("Color",ca.ToArray());if(TryParseConstructorArgs("Rect2",trimmed,out var r2,4))return ParsedGodotValue.Create("Rect2",r2.ToArray());if(TryParseConstructorArgs("Rect2i",trimmed,out var r2i,4))return ParsedGodotValue.Create("Rect2i",r2i.ToArray());if(TryParseConstructorArgs("Transform2D",trimmed,out var t2,6))return ParsedGodotValue.Create("Transform2D",t2.ToArray());if(TryParseConstructorArgs("Plane",trimmed,out var pa,4))return ParsedGodotValue.Create("Plane",pa.ToArray());if(TryParseConstructorArgs("Quaternion",trimmed,out var qa,4))return ParsedGodotValue.Create("Quaternion",qa.ToArray());if(TryParseConstructorArgs("AABB",trimmed,out var aa,6))return ParsedGodotValue.Create("AABB",aa.ToArray());if(trimmed.StartsWith("PackedStringArray("))return ParsePackedArray<string>(trimmed,"PackedStringArray",ParseStringElement);if(trimmed.StartsWith("PackedInt32Array("))return ParsePackedArray<int>(trimmed,"PackedInt32Array",s=>int.Parse(s,CultureInfo.InvariantCulture));if(trimmed.StartsWith("PackedInt64Array("))return ParsePackedArray<long>(trimmed,"PackedInt64Array",s=>long.Parse(s,CultureInfo.InvariantCulture));if(trimmed.StartsWith("PackedFloat32Array("))return ParsePackedArray<float>(trimmed,"PackedFloat32Array",s=>float.Parse(s,CultureInfo.InvariantCulture));if(trimmed.StartsWith("PackedFloat64Array("))return ParsePackedArray<double>(trimmed,"PackedFloat64Array",s=>double.Parse(s,CultureInfo.InvariantCulture));if(trimmed.StartsWith("PackedByteArray("))return ParsePackedArray<byte>(trimmed,"PackedByteArray",s=>byte.Parse(s,CultureInfo.InvariantCulture));if(trimmed.StartsWith("PackedVector2Array("))return ParsePackedGodotArray(trimmed,"PackedVector2Array","Vector2",2);if(trimmed.StartsWith("PackedVector3Array("))return ParsePackedGodotArray(trimmed,"PackedVector3Array","Vector3",3);if(trimmed.StartsWith("PackedColorArray("))return ParsePackedGodotArray(trimmed,"PackedColorArray","Color",4);if(trimmed.Contains("Array[")&&trimmed.Contains("]("))return ParseTypedArray(trimmed);if(TryParseInt(trimmed,out var iv))return iv;if(TryParseFloat(trimmed,out var fv))return fv;return trimmed;}/// <summary>Splits a comma-separated argument list respecting nested parens/brackets/quotes. Used by section parsers for PackedStringArray, groups, and constructors.</summary>
internal static List<string>SplitArgs(string inner){var result=new List<string>();var depth=0;var inString=false;var start=0;for(var i=0;i<inner.Length;i++){var ch=inner[i];if(inString){if(ch=='\\'&&i+1<inner.Length){i++;continue;}if(ch=='"')inString=false;continue;}if(ch=='"'){inString=true;continue;}if(ch=='('||ch=='['||ch=='{'){depth++;continue;}if(ch==')'||ch==']'||ch=='}'){depth--;continue;}if(depth==0&&ch==','){result.Add(inner.Substring(start,i-start));start=i+1;}}if(start<inner.Length)result.Add(inner.Substring(start));return result;}private static string StripQuotes(string s)=>s.Length>=2&&s.StartsWith("\"")&&s.EndsWith("\"")?Unescape(s.Substring(1,s.Length-2)):s;private static string Unescape(string s)=>s.Replace("\\\"","\"").Replace("\\n","\n").Replace("\\t","\t").Replace("\\r","\r").Replace("\\\\","\\");private static bool TryParseInt(string s,out int v)=>int.TryParse(s,NumberStyles.Integer,CultureInfo.InvariantCulture,out v);private static bool TryParseFloat(string s,out float v)=>float.TryParse(s,NumberStyles.Float,CultureInfo.InvariantCulture,out v);private static bool TryParseConstructorArgs(string name,string expr,out List<object>args,int exact,int max= -1){args=null;if(max<0)max=exact;var pfx=name+"(";if(!expr.StartsWith(pfx)|| !expr.EndsWith(")"))return false;var inner=expr.Substring(pfx.Length,expr.Length-pfx.Length-1);var strs=SplitArgs(inner);if(strs.Count<exact||strs.Count>max)return false;args=new List<object>();foreach(var a in strs){if(float.TryParse(a.Trim(),NumberStyles.Float,CultureInfo.InvariantCulture,out var f))args.Add(f);else args.Add(a.Trim());}return true;}private static T[]ParsePackedArray<T>(string expr,string name,Func<string,T>parser){var inner=expr.Substring(name.Length+1,expr.Length-name.Length-2);var strs=SplitArgs(inner);var r=new T[strs.Count];for(var i=0;i<strs.Count;i++)r[i]=parser(StripQuotes(strs[i].Trim()));return r;}private static ParsedGodotValue[]ParsePackedGodotArray(string expr,string arrName,string elemName,int ac){var inner=expr.Substring(arrName.Length+1,expr.Length-arrName.Length-2);var elems=SplitTopLevelArgs(inner,ac);var r=new ParsedGodotValue[elems.Count];for(var i=0;i<elems.Count;i++){var sa=SplitArgs(elems[i]);var fa=new List<object>();for(var j=0;j<Math.Min(ac,sa.Count);j++)fa.Add(float.TryParse(sa[j].Trim(),NumberStyles.Float,CultureInfo.InvariantCulture,out var f)?(object)f:sa[j].Trim());r[i]=ParsedGodotValue.Create(elemName,fa.ToArray());}return r;}private static List<string>SplitTopLevelArgs(string expr,int elemArgCount){var r=new List<string>();var d=0;var ca=0;var inS=false;var s=0;for(var i=0;i<expr.Length;i++){var ch=expr[i];if(inS){if(ch=='\\'&&i+1<expr.Length){i++;continue;}if(ch=='"')inS=false;continue;}if(ch=='"'){inS=true;continue;}if(ch=='('){d++;continue;}if(ch==')'){d--;continue;}if(d==0&&ch==','){ca++;if(ca>=elemArgCount){r.Add(expr.Substring(s,i-s).Trim());s=i+1;ca=0;}}}if(s<expr.Length)r.Add(expr.Substring(s).Trim());return r;}private static string ParseStringElement(string s)=>StripQuotes(s);private static string[]ParseTypedArray(string expr){var be=expr.IndexOf("](");if(be<0)return Array.Empty<string>();var ps=be+1;var inner=expr.Substring(ps+1,expr.Length-ps-2);return SplitArgs(inner).ToArray();}}}

===== FILE Core/Scenes/TscnParsing/TscnPropertyValue.cs =====
namespace Cthangover.Core.Scenes.TscnParsing{/// <summary>Wraps ExtResource("id") and SubResource("id") references. Resolved at build time by <see cref="TscnBuild.SceneBuilder"/>.</summary>
public class TscnPropertyValue{/// <summary>Which kind of reference this value wraps.</summary>
public TscnValueType Type{get;set;}/// <summary>Pre-converted Godot value when <see cref="TscnValueType.Variant"/>; default otherwise.</summary>
public Godot.Variant Variant{get;set;}/// <summary>Local resource id for ExtResource/SubResource references.</summary>
public string RefId{get;set;}/// <summary>True for <c>ExtResource("id")</c> values.</summary>
public bool IsExtResourceRef=>Type==TscnValueType.ExtResourceRef;/// <summary>True for <c>SubResource("id")</c> values.</summary>
public bool IsSubResourceRef=>Type==TscnValueType.SubResourceRef;/// <summary>Wraps an already-converted Variant value.</summary>
public static TscnPropertyValue FromVariant(Godot.Variant v)=>new(){Type=TscnValueType.Variant,Variant=v};/// <summary>Wraps an <c>ExtResource("id")</c> reference for later build-time resolution.</summary>
public static TscnPropertyValue FromExtResource(string id)=>new(){Type=TscnValueType.ExtResourceRef,RefId=id};/// <summary>Wraps a <c>SubResource("id")</c> reference for later build-time resolution.</summary>
public static TscnPropertyValue FromSubResource(string id)=>new(){Type=TscnValueType.SubResourceRef,RefId=id};}}

===== FILE Core/Scenes/TscnParsing/TscnValueType.cs =====
namespace Cthangover.Core.Scenes.TscnParsing{/// <summary> Discriminator for <see cref="TscnPropertyValue"/>: is it a plain Godot Variant, an ExtResource reference (resolved at build time by SceneBuilder), or a SubResource reference (resolved from [sub_resource] definitions). </summary>
public enum TscnValueType{Variant,ExtResourceRef,SubResourceRef}}

===== FILE Core/Scenes/TscnSceneResolver.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Scenes.Patching;using Cthangover.Core.Scenes.TscnBuild;using Cthangover.Core.Scenes.TscnParsing;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Scenes{/// <summary> Mod-integrated resolver for runtime TSCN scene loading. Collects .tscn files from all mods' "scenes/" directories into a deduplicated index (later mods override earlier ones), then resolves scenes by mod-relative path without requiring callers to know which mod provides the file. <para/> Connects <see cref="TscnParser"/> and <see cref="SceneBuilder"/> to the mod system: reads source text through <see cref="IModFileProvider"/>, handles recursive PackedScene instantiation with cycle detection, and caches both parsed scenes and built node trees. <para/> Registered as <c>"tscn_scenes"</c> resolver in <see cref="ModManager"/>. Cleared on mod reload (<see cref="ClearCache"/>). </summary>
public class TscnSceneResolver:ModAssetResolver{/// <inheritdoc />
public override string Id=>"tscn_scenes";private Dictionary<string,FileEntry>_sceneIndex;private readonly Dictionary<string,ParsedScene>_parsedCache=new();private readonly Dictionary<string,Node>_builtCache=new();private readonly TscnParser _parser=new();private readonly HashSet<string>_buildingPaths=new();private ParsedScene _resolvingScene;/// <summary> Collects all .tscn scene files from every mod's "scenes/" directory. Builds a flat index keyed by mod-relative path (e.g. "scenes/main_menu.tscn"). Later mods in load order override earlier ones with the same path. The result is cached until <see cref="ClearCache"/> is called. </summary>
public Dictionary<string,FileEntry>Collect(){Context.Initialize();if(_sceneIndex!=null)return _sceneIndex;var result=new Dictionary<string,FileEntry>();var fileListResolver=Context.GetResolver<FileListResolver>();if(fileListResolver==null){_sceneIndex=result;return result;}var files=fileListResolver.Collect("scenes");foreach(var kvp in files){var ext=Path.GetExtension(kvp.Key);if(ext.Equals(".tscn",StringComparison.OrdinalIgnoreCase))result[kvp.Value.FullPath]=kvp.Value;}_sceneIndex=result;GameLogger.Log("TSCN",$"Collect → {result.Count} scene(s)");return result;}/// <summary> Loads, parses, and builds a .tscn scene from the mod system. The scene is resolved by its mod-relative path (e.g. "scenes/main_menu.tscn") — the resolver determines which mod provides the file, honouring load-order overrides. Built nodes are cached and duplicated on each hit so the caller receives a fresh instance every time. </summary> <param name="scenePath"> Mod-relative path to the .tscn file, may optionally start with "res://" (stripped internally). </param>
public Node LoadAndBuild(string scenePath){scenePath=NormalizeResourcePath(scenePath);var index=Collect();string modId=null;if(index.TryGetValue(scenePath,out var entry))modId=entry.ModId;else{var provider=ResolveProviderForPath(scenePath,out modId);if(provider==null){if(HasScenePatches(scenePath))return BuildNativePatched(scenePath);var native=LoadNative(scenePath);if(native!=null){GameLogger.Log("TSCN",$"TscnSceneResolver.LoadAndBuild: native fallback '{scenePath}' → root='{native.Name}' ({native.GetType().Name})");return native;}GameLogger.Log("TSCN",$"TscnSceneResolver.LoadAndBuild: scene '{scenePath}' not found in any mod",LogLevel.Error);return null;}}var cacheKey=scenePath;if(_builtCache.TryGetValue(cacheKey,out var cached)){GameLogger.Log("TSCN",$"TscnSceneResolver.LoadAndBuild: HIT cached '{cacheKey}'",LogLevel.Debug);return DuplicateBuilt(cached);}GameLogger.Log("TSCN",$"TscnSceneResolver.LoadAndBuild: loading '{cacheKey}' from mod '{modId}'...");if(!_buildingPaths.Add(cacheKey)){GameLogger.Log("TSCN",$"TscnSceneResolver: circular reference detected for '{cacheKey}'",LogLevel.Error);return null;}try{var parsed=LoadAndParse(scenePath,modId);if(parsed==null){GameLogger.Log("TSCN",$"TscnSceneResolver.LoadAndBuild: FAILED parse for '{cacheKey}'",LogLevel.Error);return null;}var previousScene=_resolvingScene;_resolvingScene=parsed;var root=new SceneBuilder().Build(parsed,InstanceLoader,ResolveExtResource);_resolvingScene=previousScene;if(root!=null){_builtCache[cacheKey]=root;GameLogger.Log("TSCN",$"TscnSceneResolver.LoadAndBuild: OK '{cacheKey}' → root='{root.Name}' ({root.GetType().Name})");return DuplicateBuilt(root);}return null;}finally{_buildingPaths.Remove(cacheKey);}}private Node DuplicateBuilt(Node root){// Nodes carry registered Godot scripts now, so a Duplicate keeps the script instance and its lifecycle callbacks working.
return root.Duplicate();}private ParsedScene LoadAndParse(string scenePath,string resolvedModId){var cacheKey=scenePath;if(_parsedCache.TryGetValue(cacheKey,out var cached)){GameLogger.Log("TSCN",$"TscnSceneResolver.LoadAndParse: HIT parsed cache '{cacheKey}'",LogLevel.Debug);return cached;}var mod=Context.GetMod(resolvedModId);var provider=mod?.FileProvider;if(provider==null){GameLogger.Log("TSCN",$"TscnSceneResolver.LoadAndParse: provider NOT FOUND for mod '{resolvedModId}'",LogLevel.Error);return null;}GameLogger.Log("TSCN",$"TscnSceneResolver.LoadAndParse: reading '{scenePath}' from mod='{resolvedModId}', provider={provider.GetType().Name}");var text=provider.ReadFileText(scenePath);if(text==null){GameLogger.Log("TSCN",$"TscnSceneResolver.LoadAndParse: file text is NULL for path='{scenePath}'",LogLevel.Error);return null;}GameLogger.Log("TSCN",$"TscnSceneResolver.LoadAndParse: got {text.Length} chars, parsing...");var parsed=ApplyScenePatches(scenePath,_parser.Parse(text));_parsedCache[cacheKey]=parsed;GameLogger.Log("TSCN",$"TscnSceneResolver.LoadAndParse: parsed OK — {parsed.ExtResources.Count} ext_resources, {parsed.SubResources.Count} sub_resources, {parsed.Nodes.Count} nodes, entry_point='{parsed.EntryPointClass ?? "none"}'");return parsed;}/// <summary> Applies all scene patches targeting <paramref name="scenePath"/> to the freshly parsed scene, before it is cached. Patch layers cascade in mod load order (see <see cref="ScenePatchResolver"/>); a scene without patches passes through untouched. </summary>
private ParsedScene ApplyScenePatches(string scenePath,ParsedScene parsed){var patchResolver=Context?.GetResolver<ScenePatchResolver>();var patches=patchResolver?.GetPatches(scenePath);if(patches==null||patches.Count==0)return parsed;var applier=new ScenePatchApplier();if(applier.ApplyAll(parsed,patches))GameLogger.Log("TSCN",$"TscnSceneResolver: applied {patches.Count} patch layer(s) to '{scenePath}'");else GameLogger.Log("TSCN",$"TscnSceneResolver: patch layers for '{scenePath}' reported errors",LogLevel.Warning);return parsed;}/// <summary> True when at least one mod patches this scene, including native <c>res://</c> scenes. The static facade uses it to route patched native scenes through the runtime builder instead of Godot's native loader. </summary>
public bool HasScenePatches(string scenePath){var patchResolver=Context?.GetResolver<ScenePatchResolver>();return patchResolver?.HasPatches(NormalizeResourcePath(scenePath))==true;}/// <summary> Builds a native <c>res://</c> scene through the runtime pipeline so its patches apply. Native scenes normally bypass <see cref="TscnParser"/> via <see cref="LoadNative"/>, which is exactly why a patched one must be read from disk and patched before build. Parsed and built results are cached like mod scenes. </summary>
private Node BuildNativePatched(string scenePath){if(_builtCache.TryGetValue(scenePath,out var cached))return(Node)cached.Duplicate();if(!_buildingPaths.Add(scenePath)){GameLogger.Log("TSCN",$"TscnSceneResolver: circular reference detected for '{scenePath}'",LogLevel.Error);return null;}try{var text=ReadNativeText(scenePath);if(text==null){GameLogger.Log("TSCN",$"TscnSceneResolver.BuildNativePatched: cannot read 'res://{scenePath}'",LogLevel.Error);return null;}var parsed=ApplyScenePatches(scenePath,_parser.Parse(text));_parsedCache[scenePath]=parsed;var previousScene=_resolvingScene;_resolvingScene=parsed;var root=new SceneBuilder().Build(parsed,InstanceLoader,ResolveExtResource);_resolvingScene=previousScene;if(root!=null){_builtCache[scenePath]=root;GameLogger.Log("TSCN",$"TscnSceneResolver.BuildNativePatched: OK '{scenePath}' → root='{root.Name}' ({root.GetType().Name})");return(Node)root.Duplicate();}return null;}finally{_buildingPaths.Remove(scenePath);}}private static string ReadNativeText(string scenePath){if(!GameEnvironment.IsRuntime)return null;try{return Godot.FileAccess.GetFileAsString("res://"+scenePath);}catch(Exception ex){GameLogger.Log("TSCN",$"TscnSceneResolver: failed to read 'res://{scenePath}': {ex.Message}",LogLevel.Error);return null;}}private Node InstanceLoader(string scenePath){return LoadAndBuild(scenePath);}/// <summary> Resolves ext_resource references for non-Script/non-PackedScene types (Texture2D, etc.). Looks up the ext_resource by refId in the currently building scene, normalises the path (strips res:// prefix), searches all mods in priority order for the file, reads raw bytes via IModFileProvider, and constructs the appropriate Godot Resource. </summary>
private Resource ResolveExtResource(string refId){if(_resolvingScene==null)return null;var extRes=_resolvingScene.ExtResources.FirstOrDefault(r=>r.Id==refId);if(extRes==null)return null;var path=NormalizeResourcePath(extRes.Path);GameLogger.Log("TSCN",$"TscnSceneResolver.ResolveExtResource: refId='{refId}' type='{extRes.Type}' path='{extRes.Path}' → norm='{path}'",LogLevel.Debug);var provider=ResolveProviderForPath(path,out var modId);if(provider==null){GameLogger.Log("TSCN",$"TscnSceneResolver.ResolveExtResource: provider NOT FOUND for '{path}'",LogLevel.Warning);return null;}var bytes=provider.ReadFileBinary(path);if(bytes==null){GameLogger.Log("TSCN",$"TscnSceneResolver.ResolveExtResource: bytes NULL for '{path}'",LogLevel.Warning);return null;}var builder=ExtResourceBuilderRegistry.Get(extRes.Type)??ExtResourceBuilderRegistry.Get("Texture");if(builder==null){GameLogger.Log("TSCN",$"TscnSceneResolver.ResolveExtResource: no builder for type='{extRes.Type}'",LogLevel.Warning);return null;}var resource=builder.Build(bytes,path);GameLogger.Log("TSCN",$"TscnSceneResolver.ResolveExtResource: OK type='{extRes.Type}' → {resource?.GetType().Name ?? "null"}",LogLevel.Debug);return resource;}/// <summary> Canonicalises a resource path: forward slashes, no <c>res://</c> scheme, no leading slash and no <c>mods/&lt;mod&gt;/</c> prefix. The last form is what the Godot editor writes when a file of one mod references a file of another mod, so stripping it lets editor-authored <c>ext_resource</c> paths and patch additions resolve unchanged. </summary>
private static string NormalizeResourcePath(string path){if(string.IsNullOrEmpty(path))return path;var n=path.Replace('\\','/');if(n.StartsWith("res://",StringComparison.OrdinalIgnoreCase))n=n.Substring(6);n=n.TrimStart('/');var segments=n.Split('/');if(segments.Length>=3&&segments[0].Equals("mods",StringComparison.OrdinalIgnoreCase))n=string.Join("/",segments,2,segments.Length-2);return n;}/// <summary> Fallback for scenes that still live in the project (res://) rather than in a mod. Godot's <c>ResourceLoader</c> cannot see files inside <c>mods/</c>, but native PackedScenes referenced from mod scenes (or deliberately left native) must still instantiate, mirroring PackedScene's instantiate-per-call contract for the resolver. </summary>
private static Node LoadNative(string scenePath){var resPath="res://"+scenePath;var ext=Path.GetExtension(resPath);if(!ext.Equals(".tscn",StringComparison.OrdinalIgnoreCase)&& !ext.Equals(".scn",StringComparison.OrdinalIgnoreCase)&& !ext.Equals(".escn",StringComparison.OrdinalIgnoreCase))return null;if(!ResourceLoader.Exists(resPath))return null;var packed=ResourceLoader.Load<PackedScene>(resPath);return packed?.Instantiate();}private IModFileProvider ResolveProviderForPath(string scenePath,out string modId){var orderedIds=Context.GetOrderedModIds();foreach(var id in orderedIds){var mod=Context.GetMod(id);if(mod?.FileProvider==null)continue;if(mod.FileProvider.FileExists(scenePath)){modId=id;return mod.FileProvider;}}foreach(var kvp in Context.Mods){if(kvp.Value.FileProvider?.FileExists(scenePath)==true){modId=kvp.Key;return kvp.Value.FileProvider;}}modId=null;return null;}/// <summary> Drops the cached parse/build results for a single scene so the next <see cref="LoadAndBuild"/> call re-reads the source file from disk and rebuilds the node tree. Used by tooling after the scene text has been edited and saved, without flushing the whole resolver cache. </summary> <param name="scenePath">Mod-relative scene path (e.g. "scenes/foo.tscn").</param>
public void Invalidate(string scenePath){scenePath=NormalizeResourcePath(scenePath);_parsedCache.Remove(scenePath);_builtCache.Remove(scenePath);}/// <inheritdoc />
public override void ClearCache(){_sceneIndex=null;_parsedCache.Clear();_builtCache.Clear();_buildingPaths.Clear();_resolvingScene=null;}}/// <summary> Static convenience accessor for <see cref="TscnSceneResolver"/>, proxying through <see cref="ModManager.Instance.GetResolver{T}"/>. </summary>
public static class TscnScenes{/// <summary> Loads and builds a scene by mod-relative path. No mod id parameter: the resolver locates the owning mod by walking loaded mods in load-order priority (native <c>res://</c> scenes fall back to the engine loader, unless patched). Returns null when nothing resolves. </summary>
public static Node LoadAndBuild(string scenePath)=>ModManager.Instance.GetResolver<TscnSceneResolver>()?.LoadAndBuild(scenePath);/// <summary> True when the scene is patched, including native <c>res://</c> scenes that normally bypass the runtime pipeline. Scene routing uses this to send patched native scenes through <see cref="LoadAndBuild"/>. </summary>
public static bool HasPatches(string scenePath)=>ModManager.Instance.GetResolver<TscnSceneResolver>()?.HasScenePatches(scenePath)??false;}}

===== FILE Core/Settings/ActionPoolData.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Settings{/// <summary> Persistent collection of action IDs available in the action pool. These are actions owned by the player that can be assigned to character slots via drag-and-drop in the character panel. Initially empty — template actions start pre-assigned to character slots. External sources (scenarios, collectible cards) add actions here. Persisted to save data. </summary>
public class ActionPoolData{/// <summary>Action ids owned by the player and available for assignment to character slots.</summary>
public List<string>ActionIds{get;set;}=new();}}

===== FILE Core/Settings/CItem.cs =====
using System;namespace Cthangover.Core.Settings{/// <summary> Lightweight serializable inventory entry representing a stack of a single item type. Stored inside <see cref="SavePackage"/> during save and restored into the full <see cref="Cthangover.Core.Items.IItemContainer"/> world objects on load. The <c>ID</c> is resolved against the items engine mod's <c>ItemFactory</c> to fetch the blueprint. </summary>
[Serializable]public class CItem{/// <summary>Factory key for the item blueprint.</summary>
public string ID{get;set;}/// <summary>Number of items in this stack.</summary>
public int Count{get;set;}}}

===== FILE Core/Settings/CharacterInfoData.cs =====
using System.Collections.Generic;using Cthangover.Core.Characters;namespace Cthangover.Core.Settings{/// <summary> Serialization-safe snapshot of a recruited character's mutable runtime state. Stored inside <see cref="RuntimeData.CharacterData"/> keyed by <see cref="CharacterType"/>, and serialized on save into the <c>characters_base</c> engine mod's save slot (the <c>"Characters"</c> blob written by its <c>CharactersSaveable</c> through the generic <c>IModSaveable</c> contract) rather than the <c>"__core__"</c> slot. Does not hold the full <see cref="Cthangover.Core.Characters.Character"/> blueprint — that is fetched from <see cref="Cthangover.Core.Factories.Impls.CharacterFactory"/> via <see cref="ID"/>. </summary>
public class CharacterInfoData{/// <summary>Factory key identifying the character blueprint.</summary>
public string ID{get;set;}/// <summary>Type discriminator used as dictionary key in <see cref="CharacterData"/>.</summary>
public string CharacterType{get;set;}/// <summary>Current experience level.</summary>
public int Level{get;set;}/// <summary>Accumulated experience points toward next level.</summary>
public int Exp{get;set;}/// <summary>Full set of battle attributes (health, mana, etc.).</summary>
public CharacterAttributes Attributes{get;set;}/// <summary> Up to 3 action IDs assigned to this character's slots. Index 0,1,2 map to slot positions; empty slots hold <c>null</c>. When <c>null</c> (<see cref="System.Text.Json"/> default for missing keys in older saves), the battle system falls back to the factory template. </summary>
public List<string>ActionSlots{get;set;}/// <summary> Per-action last-used Unix timestamps for Active cards in this character's slots. Key = action ID, Value = UTC timestamp of last activation. Populated on load and written back when an Active card is used from the character panel. </summary>
public Dictionary<string,long>ActionCooldowns{get;set;}}}

===== FILE Core/Settings/Configs/AudioSection.cs =====
using System;using System.Text.Json.Serialization;namespace Cthangover.Core.Settings.Configs{/// <summary> Serialisable audio preferences with independent volume (0–100, integer) and enabled toggle for each bus. Defaults: sounds at full volume, music and ambient at 5% — deliberate imbalance so SFX punch through by default while background layers are subtle. </summary>
[Serializable]public class AudioSection{/// <summary>Master toggle for the SFX audio bus.</summary>
[JsonPropertyName("sounds_enabled")]public bool SoundsEnabled{get;set;}=true;/// <summary>Master toggle for the music audio bus.</summary>
[JsonPropertyName("musics_enabled")]public bool MusicsEnabled{get;set;}=true;/// <summary>Master toggle for the ambient/background audio bus.</summary>
[JsonPropertyName("ambient_enabled")]public bool AmbientEnabled{get;set;}=true;/// <summary>SFX volume, integer 0–100 (default 100).</summary>
[JsonPropertyName("sounds_volume")]public int SoundsVolume{get;set;}=100;/// <summary>Music volume, integer 0–100 (default 5).</summary>
[JsonPropertyName("musics_volume")]public int MusicsVolume{get;set;}=5;/// <summary>Ambient volume, integer 0–100 (default 5).</summary>
[JsonPropertyName("ambient_volume")]public int AmbientVolume{get;set;}=5;}}

===== FILE Core/Settings/Configs/DisplaySection.cs =====
using System;using System.Text.Json.Serialization;namespace Cthangover.Core.Settings.Configs{/// <summary> Serialisable display defaults read from game_config.json. WindowMode uses Godot's DisplayServer.WindowMode enum values: 0=Windowed, 1=Minimized, 2=Maximized, 3=Fullscreen, 4=ExclusiveFullscreen. Scale is the integer stretch multiplier applied to the base viewport resolution (1920×1024) — kept at 1 by default for pixel-art sharpness. </summary>
[Serializable]public class DisplaySection{/// <summary>Godot <c>DisplayServer.WindowMode</c> value (default 3 = Fullscreen).</summary>
[JsonPropertyName("window_mode")]public int WindowMode{get;set;}=3;/// <summary>Horizontal resolution in pixels (default 1920).</summary>
[JsonPropertyName("resolution_width")]public int ResolutionWidth{get;set;}=1920;/// <summary>Vertical resolution in pixels (default 1080).</summary>
[JsonPropertyName("resolution_height")]public int ResolutionHeight{get;set;}=1080;/// <summary>Zero-based index of the monitor to use (default 0).</summary>
[JsonPropertyName("current_screen")]public int CurrentScreen{get;set;}=0;/// <summary>Toggles vertical synchronisation (default off).</summary>
[JsonPropertyName("vsync_enabled")]public bool VsyncEnabled{get;set;}=false;/// <summary>Integer viewport stretch multiplier (default 1).</summary>
[JsonPropertyName("scale")]public int Scale{get;set;}=1;}}

===== FILE Core/Settings/Configs/LoggingSection.cs =====
using System;using System.Collections.Generic;using System.Text.Json.Serialization;namespace Cthangover.Core.Settings.Configs{/// <summary> Serialisable logging preferences loaded from game_config.json. Allows toggling file and console output independently, filtering by minimum severity level, and whitelisting named categories. Used by <see cref="GameConfig.Logging"/> to initialise <see cref="Cthangover.Core.Utils.GameLogger"/> at boot. </summary>
[Serializable]public class LoggingSection{/// <summary>Master toggle; when <c>false</c> all logging is suppressed.</summary>
[JsonPropertyName("enabled")]public bool Enabled{get;set;}=true;/// <summary>If <c>true</c>, log entries are also printed to the Godot console.</summary>
[JsonPropertyName("console_enabled")]public bool ConsoleEnabled{get;set;}=false;/// <summary>Minimum severity level ("debug", "info", "warning", "error"). Entries below this level are dropped.</summary>
[JsonPropertyName("minimum_level")]public string MinimumLevel{get;set;}="debug";/// <summary>Category whitelist; when non-empty, only messages tagged with one of these categories are recorded.</summary>
[JsonPropertyName("enabled_categories")]public List<string>EnabledCategories{get;set;}=new();}}

===== FILE Core/Settings/DataBlob.cs =====
using System;using System.Collections.Generic;using System.Text.Json;using Cthangover.Core.Utils;namespace Cthangover.Core.Settings{/// <summary> Smart container for a single logical entity (e.g. player stats, world map, textures). Each block carries its own schema version and a byte payload. Provides three data access modes: <list type="number"> <item><b>Key-value API</b> (GetInt/SetInt etc.) — lazy access via an internal dictionary, serialised to JSON on flush.</item> <item><b>JSON API</b> (ReadJson/WriteJson) — direct serialisation/deserialisation of an object into the payload.</item> <item><b>Binary API</b> (GetBytes/SetBytes) — raw byte access for textures, compressed data, etc.</item> </list> Key-value and JSON methods conflict: calling <c>WriteJson</c> overwrites the internal dictionary. Calling <c>SetInt</c> after <c>WriteJson</c> replaces the JSON object with a dictionary. </summary>
public class DataBlob{/// <summary>Schema version of the data in this block (default 1).</summary>
public int Version{get;set;}=1;private byte[]_payload=Array.Empty<byte>();/// <summary> Raw byte payload. Assigning <c>null</c> is coerced to an empty array. Unlike <see cref="GetBytes"/>, reading this property does <b>not</b> flush pending key-value entries into the bytes first. </summary>
public byte[]Payload{get=>_payload;set=>_payload=value??Array.Empty<byte>();}// Lazy cache of _payload deserialised as Dictionary. Private field, not persisted by System.Text.Json serialisation.
private Dictionary<string,object>_dict;/// <summary>Empty blob with the default schema version 1.</summary>
public DataBlob(){}/// <summary>Empty blob with an explicit schema version.</summary>
public DataBlob(int version){Version=version;}/// <summary>Creates a blob of the given version with <paramref name="data"/> serialized as the JSON payload.</summary>
public DataBlob(int version,object data){Version=version;WriteJson(data);}/// <summary>Creates a raw binary blob (version stays at the default 1).</summary>
public DataBlob(byte[]data){_payload=data??Array.Empty<byte>();}/// <summary>Reads an integer key, falling back to <paramref name="defaultValue"/> when missing or unparseable.</summary>
public int GetInt(string key,int defaultValue=0){EnsureDictLoaded();if(_dict.TryGetValue(key,out var val)){if(val is JsonElement je&&je.ValueKind==JsonValueKind.Number)return je.GetInt32();try{return Convert.ToInt32(val);}catch{return defaultValue;}}return defaultValue;}/// <summary>Reads a float key, falling back to <paramref name="defaultValue"/> when missing or unparseable.</summary>
public float GetFloat(string key,float defaultValue=0f){EnsureDictLoaded();if(_dict.TryGetValue(key,out var val)){if(val is JsonElement je&&je.ValueKind==JsonValueKind.Number)return je.GetSingle();try{return Convert.ToSingle(val);}catch{return defaultValue;}}return defaultValue;}/// <summary>Reads a string key, falling back to <paramref name="defaultValue"/> when missing.</summary>
public string GetString(string key,string defaultValue=""){EnsureDictLoaded();if(_dict.TryGetValue(key,out var val)){if(val is JsonElement je&&je.ValueKind==JsonValueKind.String)return je.GetString()??defaultValue;return val?.ToString()??defaultValue;}return defaultValue;}/// <summary>Reads a bool key, falling back to <paramref name="defaultValue"/> when missing or unparseable.</summary>
public bool GetBool(string key,bool defaultValue=false){EnsureDictLoaded();if(_dict.TryGetValue(key,out var val)){if(val is JsonElement je&&(je.ValueKind==JsonValueKind.True||je.ValueKind==JsonValueKind.False))return je.GetBoolean();try{return Convert.ToBoolean(val);}catch{return defaultValue;}}return defaultValue;}/// <summary>Writes an integer key (lazily flushed to the payload on <see cref="GetBytes"/>).</summary>
public void SetInt(string key,int value){EnsureDictLoaded();_dict[key]=value;}/// <summary>Writes a float key (lazily flushed to the payload on <see cref="GetBytes"/>).</summary>
public void SetFloat(string key,float value){EnsureDictLoaded();_dict[key]=value;}/// <summary>Writes a string key; null is stored as an empty string (lazily flushed on <see cref="GetBytes"/>).</summary>
public void SetString(string key,string value){EnsureDictLoaded();_dict[key]=value??"";}/// <summary>Writes a bool key (lazily flushed to the payload on <see cref="GetBytes"/>).</summary>
public void SetBool(string key,bool value){EnsureDictLoaded();_dict[key]=value;}/// <summary> Deserialises the payload as the given type. Returns <c>new T()</c> when the payload is empty or invalid. </summary>
public T ReadJson<T>()where T:new(){if(_payload==null||_payload.Length==0)return new T();try{var result=JsonSerializer.Deserialize<T>(_payload,JsonOptionsProvider.ReadOptions);return result??new T();}catch{return new T();}}/// <summary> Serialises an object into the payload, overwriting the internal dictionary if one exists. </summary>
public void WriteJson<T>(T data){if(data==null){_payload=Array.Empty<byte>();_dict=null;return;}_payload=JsonSerializer.SerializeToUtf8Bytes(data,JsonOptionsProvider.WriteCompactOptions);_dict=null;}/// <summary> Returns the raw byte payload. If data was written via the key-value API, flushes the dictionary to JSON first. </summary>
public byte[]GetBytes(){EnsurePayloadFromDict();return _payload;}/// <summary>Replaces the payload with raw bytes and discards any pending key-value dictionary.</summary>
public void SetBytes(byte[]data){_payload=data??Array.Empty<byte>();_dict=null;}private void EnsureDictLoaded(){if(_dict!=null)return;_dict=new Dictionary<string,object>();if(_payload==null||_payload.Length==0)return;try{var deserialized=JsonSerializer.Deserialize<Dictionary<string,object>>(_payload,JsonOptionsProvider.ReadOptions);if(deserialized!=null)_dict=deserialized;}catch{_dict=new Dictionary<string,object>();}}private void EnsurePayloadFromDict(){if(_dict!=null){_payload=JsonSerializer.SerializeToUtf8Bytes(_dict,JsonOptionsProvider.WriteCompactOptions);}}}}

===== FILE Core/Settings/GameConfig.cs =====
using System;using System.IO;using System.Text.Json;using System.Text.Json.Serialization;using Cthangover.Core.Bootstrap;using Cthangover.Core.Settings.Configs;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Settings{/// <summary> Thread-safe singleton holding deserialised application configuration from <c>res://config/game_config.json</c>. Wraps audio, display, language, and logging sections. Initialisation is lazy — the static <see cref="Instance"/> accessor triggers <see cref="Load"/> on first use. If the Godot engine is not running (e.g. in unit tests), loading is silently skipped so the class degrades to defaults. <para/> The single instance is owned by the <see cref="AppServices"/> composition root; the static facade stays as the public API during migration, with laziness preserved by the owning <c>Lazy</c> slot. </summary>
public class GameConfig{/// <summary> Facade over the AppServices-owned instance; kept as the public API during migration. Constructed on first access through the owning <c>Lazy</c> slot. </summary>
public static GameConfig Instance=>AppServices.Current.GameConfigService.Value;/// <summary> Creates the instance for the composition root: a fresh config with defaults, populated from game_config.json when the file is reachable (runtime uses the Godot-globalized path; outside the runtime the relative path is tried and defaults are kept). </summary>
internal static GameConfig CreateInstance(){var config=new GameConfig();config.Load();return config;}/// <summary>Audio bus preferences (volume, toggles).</summary>
[JsonPropertyName("audio")]public AudioSection Audio{get;set;}=new();/// <summary>Display mode, resolution, vsync, and scale.</summary>
[JsonPropertyName("display")]public DisplaySection Display{get;set;}=new();/// <summary>Logging verbosity and category whitelist.</summary>
[JsonPropertyName("logging")]public LoggingSection Logging{get;set;}=new();/// <summary>Locale code (e.g. "ru-ru", "en-us").</summary>
[JsonPropertyName("language")]public string Language{get;set;}="ru-ru";/// <summary> JSON constructor kept private to enforce singleton access through <see cref="Instance"/>. The parameterless constructor is required by <c>System.Text.Json</c> for deserialization. </summary>
[JsonConstructor]private GameConfig(){}/// <summary> Checks whether the Godot engine main loop is reachable. The result is cached after the first call so subsequent queries are cheap. Used by <see cref="Load"/> to decide whether file-system access via <c>ProjectSettings</c> is safe. </summary>
public static bool IsGodotRunning(){return GameEnvironment.IsRuntime;}/// <summary> Reads <c>res://config/game_config.json</c>, deserializes it via <c>System.Text.Json</c>, and copies each section into the corresponding property. Missing keys or malformed JSON leave defaults intact; parse errors are logged. </summary>
private void Load(){var configPath=GameEnvironment.IsRuntime?ProjectSettings.GlobalizePath("res://config/game_config.json"):"config/game_config.json";if(!File.Exists(configPath))return;try{var json=File.ReadAllText(configPath);var parsed=JsonSerializer.Deserialize<GameConfig>(json,JsonOptionsProvider.ReadOptions);if(parsed==null)return;Audio=parsed.Audio??new AudioSection();Display=parsed.Display??new DisplaySection();Language=parsed.Language??"ru-ru";Logging=parsed.Logging??new LoggingSection();}catch(Exception ex){GameLogger.Log("CONFIG",$"Failed to parse game_config.json: {ex.Message}",LogLevel.Error);}}}}

===== FILE Core/Settings/GameData.cs =====
using Cthangover.Core.Bootstrap;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Settings{/// <summary> Unity-style Godot autoload that serves as the single entry point for the game state subsystem. Holds the <see cref="SettingsData"/> (persisted user preferences) and <see cref="RuntimeData"/> (session state). On <c>_Ready</c> it loads saved settings. In-game time advancement is owned by the time_base engine mod, which registers its clock tick controller on boot. The current locale and the session <see cref="RuntimeData"/> are created by the unified boot pipeline (see <see cref="Boot"/>), which runs after this autoload. </summary>
public partial class GameData:Node{static GameData(){GameEnvironment.IsRuntime=true;GameEnvironment.MarkMainThread();}/// <summary>Global singleton, set once during <c>_EnterTree</c>.</summary>
public static GameData Instance{get;private set;}/// <summary> Constructs the game data root and performs the earliest initialization pass: <see cref="GameLogger.Init"/> must run before any subsystem logs, and this constructor is the first point where that is guaranteed. </summary>
public GameData(){GameLogger.Init();}/// <summary> Registers this node as the global <see cref="Instance"/>. If a previous valid instance still exists (e.g. scene reload without proper cleanup), logs a diagnostic warning before overwriting. </summary>
public override void _EnterTree(){if(Instance!=null&&GodotObject.IsInstanceValid(Instance)){var scene=GetTree()?.CurrentScene?.Name??"?";var existingPath=Instance.GetPath().ToString();var myPath=GetPath().ToString();GameLogger.Log("DUPLICATE",$"GameData._EnterTree: Instance ALREADY SET by '{existingPath}', overwriting with duplicate at '{myPath}' on scene '{scene}'",LogLevel.Error);}Instance=this;GameLogger.Init();}/// <summary>Persisted user-facing settings (audio, display, language, etc.).</summary>
public SettingsData Settings{get;private set;}=new();private RuntimeData _runtime;/// <summary> Set when <see cref="Runtime"/> is first touched before <see cref="Boot.ModsReady"/>. Such an instance seeded its starter inventory against an empty registry — the skips are permanent — so <see cref="Boot.EnsureRuntime"/> recreates it via <see cref="ConsumePrematureRuntimeCreation"/> once the mod set is final. </summary>
private bool _runtimeCreatedPrematurely;/// <summary> All mutable session state: time, characters, inventory, recipes, lamp. Created by the boot pipeline after the mod set is final, so constructing it seeds the starter inventory through fully loaded mod factories. The getter stays lazy only as a safety net for tests and tools that touch <see cref="Runtime"/> outside the boot window: in a real runtime such a first touch before boot finished logs a loud error, because the starter inventory would then depend on who got there first instead of on the boot ordering. (xUnit tests run with GameEnvironment.IsRuntime == false and stay silent.) </summary>
public RuntimeData Runtime{get{if(_runtime==null){if(GameEnvironment.IsRuntime&& !Boot.ModsReady){_runtimeCreatedPrematurely=true;GameLogger.Log("GAME",$"RuntimeData created before boot finished (phase={Boot.Phase}) — starter inventory may be empty; the boot pipeline is supposed to create it first",LogLevel.Error);}_runtime=new RuntimeData();GameLogger.Log("GAME","RuntimeData created");}return _runtime;}}/// <summary> Reports whether <see cref="Runtime"/> was created before the boot pipeline had mods ready, resetting the flag. One-shot: the caller (boot) recreates the instance so the starter inventory seeds from fully loaded factories. </summary>
public bool ConsumePrematureRuntimeCreation(){if(!_runtimeCreatedPrematurely)return false;_runtimeCreatedPrematurely=false;return true;}/// <summary> Discards the current <see cref="RuntimeData"/> and creates a fresh one. Must be called when starting a new game: the autoload (and therefore <see cref="Runtime"/>) survives returning to the main menu, so without a reset, state from a previous playthrough — including completed one-run scenario history — would leak into the new game. </summary>
public void ResetRuntime(string reason="new game"){_runtime=new RuntimeData();GameLogger.Log("GAME",$"RuntimeData reset ({reason})");}/// <summary> Begins a "New Game": wipes the runtime state and resets every registered <see cref="IResettable"/> state owner through <see cref="AppServices.ResetForNewGame"/> — quests, world map open/hidden overrides, and any mod-registered owner. The reset loop logs each owner and isolates its exceptions, so one broken reset cannot abort the others. Afterwards publishes <see cref="Events.NewGameStartedEvent"/>: the event stays the public contract for mod listeners that re-seed per-session data on top of the fresh state — the core mod restores the starter party there, because <see cref="ResetRuntime"/> discards it and <c>IModInitializer.OnModResourcesReady</c> only runs once at boot. State owners must not reset themselves through that event; they register an <see cref="IResettable"/> instead. Call this instead of <see cref="ResetRuntime"/> whenever a fresh game starts. </summary>
public void StartNewGame(){ResetRuntime();SaveService.ClearDormant();AppServices.Current.ResetForNewGame();Events.ModEventBus.Publish(new Events.NewGameStartedEvent());}/// <summary> Called once after entering the scene tree. Loads previously saved <see cref="SettingsData"/> from disk and emits a boot log message. In-game time is driven by the time_base engine mod, which registers its own tick controller with the event system on <c>OnModResourcesReady</c>. The current locale is activated by the boot pipeline (phase <c>Locale</c>, after the mod set is final), in both launcher and sync modes. </summary>
public override void _Ready(){Settings.Load();GameLogger.Log("GAME",$"Game initialized, version={ProjectSettings.GetSetting("application/config/version")}");}}}

===== FILE Core/Settings/IRecipeData.cs =====
using System.Collections.Generic;using Cthangover.Core.Items;namespace Cthangover.Core.Settings{/// <summary> Contract for unlocked-recipe tracking. The concrete implementation (<c>RecipeData</c>) lives in the items mod; the core and other subsystems depend only on this interface through <see cref="RuntimeData.RecipeData"/>. </summary>
public interface IRecipeData{/// <summary>Set of known recipe IDs (duplicate insertion is silently ignored).</summary>
HashSet<string>Data{get;set;}/// <summary>Unlocks a recipe by ID; returns <c>false</c> if it was already known.</summary>
bool Add(string id);/// <summary><c>true</c> if the recipe with the given ID is already unlocked.</summary>
bool Has(string id);/// <summary>Returns all known recipes that belong to the specified workbench type.</summary>
List<IRecipe>GetRecipesByType(WorkbenchType type);}}

===== FILE Core/Settings/LampData.cs =====
using System;namespace Cthangover.Core.Settings{/// <summary> Stores the current state of the lamp light source — its radius and influence. <see cref="Radius"/> controls the visible light circle (pixels from the lamp origin); <see cref="Influence"/> modulates the power (0–1) affecting enemy detection and visibility. Persisted inside the <c>"Lamp"</c> blob of the <c>"__core__"</c> <see cref="ModSlot"/> in <see cref="SavePackage.ModData"/> on save. </summary>
[Serializable]public class LampData{/// <summary>Visible radius of the lamp light in screen pixels (default 300).</summary>
public float Radius{get;set;}=300f;/// <summary>Modulation factor 0–1 affecting how strongly the lamp reveals enemies and influences stealth mechanics (default 1.0).</summary>
public float Influence{get;set;}=1f;}}

===== FILE Core/Settings/LastSessionStore.cs =====
using System;using System.IO;using System.Text.Json;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Settings{/// <summary> The last game and save the player touched (<c>user://last_session.json</c>), recorded on every save and load of a game collection. Drives the main menu's "Continue" from any mode — including the front-end, which then restarts into that game with <c>--load</c>. </summary>
public static class LastSessionStore{private const string FilePath="user://last_session.json";public sealed class Entry{public string GameId{get;set;}public string FileName{get;set;}public DateTime Time{get;set;}}/// <summary>Remembers the save; ignored for saves made without a game collection.</summary>
public static void Record(string gameId,string fileName){if(string.IsNullOrEmpty(gameId)||string.IsNullOrEmpty(fileName)|| !GameEnvironment.IsRuntime)return;try{var json=JsonSerializer.Serialize(new Entry{GameId=gameId,FileName=fileName,Time=DateTime.UtcNow});File.WriteAllText(ProjectSettings.GlobalizePath(FilePath),json);}catch(Exception ex){GameLogger.Log("SAVE",$"Could not record the last session: {ex.Message}",LogLevel.Warning);}}/// <summary>The last session whose save still exists, or null.</summary>
public static Entry Load(){if(!GameEnvironment.IsRuntime)return null;try{var path=ProjectSettings.GlobalizePath(FilePath);if(!File.Exists(path))return null;var entry=JsonSerializer.Deserialize<Entry>(File.ReadAllText(path));return entry!=null&&SaveService.ReadPackage(entry.GameId,entry.FileName)!=null?entry:null;}catch(Exception){return null;}}}}

===== FILE Core/Settings/ModMetadata.cs =====
namespace Cthangover.Core.Settings{/// <summary> Per-mod metadata stored alongside its data in the save file. Filled by the mod during <c>OnSave</c> and inspected by the core during the validation phase before <c>OnLoad</c>. <b>Required fields</b> (<c>ModId</c>, <c>ModName</c>, <c>MinCoreVersion</c>, <c>IsRequired</c>) are validated by the core. <b>Optional fields</b> (<c>ModVersion</c>, <c>CustomData</c>) are ignored by the core — they exist for the mod's own use. </summary>
public class ModMetadata{/// <summary>Mod ID (duplicated here for self-verification).</summary>
public string ModId{get;set;}/// <summary>Human-readable mod name shown in error messages.</summary>
public string ModName{get;set;}/// <summary>Minimum core version required by this save.</summary>
public string MinCoreVersion{get;set;}/// <summary> Historical flag kept for save-format compatibility. Loading no longer blocks on it: a missing mod's slot becomes dormant (<see cref="SaveDormancy"/>) so games survive composition changes. </summary>
public bool IsRequired{get;set;}=true;/// <summary>Version of the mod that created this save (optional).</summary>
public string ModVersion{get;set;}/// <summary>JSON string for any extra metadata the mod wants to store (optional).</summary>
public string CustomData{get;set;}/// <summary>True while the slot is carried forward without its mod.</summary>
public bool Dormant{get;set;}/// <summary>Why the slot is dormant (<see cref="SaveDormancy.Reason"/> name).</summary>
public string DormantReason{get;set;}/// <summary>When the slot first became dormant.</summary>
public System.DateTime?DormantSince{get;set;}}}

===== FILE Core/Settings/ModSlot.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Settings{/// <summary> Per-mod data container inside a save file. Holds two "shelves": <list type="number"> <item><b>Metadata</b> — structured metadata the core reads for validation before calling OnLoad.</item> <item><b>Data</b> — dictionary of named <see cref="DataBlob"/> blocks, each with its own version. Keys are defined by the mod.</item> </list> </summary>
public class ModSlot{/// <summary>Structured metadata the core validates before calling <c>OnLoad</c>.</summary>
public ModMetadata Metadata{get;set;}=new();/// <summary>Named data blocks owned by the mod; each blob carries its own schema version.</summary>
public Dictionary<string,DataBlob>Data{get;set;}=new();}}

===== FILE Core/Settings/PhaseType.cs =====
namespace Cthangover.Core.Settings{/// <summary> Enumerates the four canonical time-of-day phases used across the game. Values are explicit hex flags (<c>0x01</c>–<c>0x04</c>) to support potential bitmask queries (e.g. "all daytime phases"). Phase is derived by <see cref="TimeData.Phase"/> from the current in-game hour. </summary>
public enum PhaseType:int{/// <summary>06:00–09:59.</summary>
Morning=0x01,/// <summary>10:00–17:59.</summary>
Day=0x02,/// <summary>18:00–21:59.</summary>
Evening=0x03,/// <summary>22:00–05:59.</summary>
Night=0x04,};}

===== FILE Core/Settings/PlayerCardInitializer.cs =====
namespace Cthangover.Core.Settings{/// <summary> Placeholder class reserved for initializing the player character's card representation at new-game start. Currently empty; expected to hold factory logic that constructs the default <see cref="Character"/> blueprint and registers it with <see cref="CharacterData"/>. </summary>
public class PlayerCardInitializer{}}

===== FILE Core/Settings/RuntimeData.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Characters;using Cthangover.Core.Items;using Cthangover.Core.Scenes;using Cthangover.Core.Skills;namespace Cthangover.Core.Settings{/// <summary> Central mutable state bag for the entire game session. Every subsystem — time, characters, inventory, recipes, lamp and skills — lives here. <see cref="SaveService.Save"/> samples all these fields into a <see cref="SavePackage"/> DTO; <see cref="SaveService.Load"/> writes them back. The <see cref="CurrentScene"/> property resolves the active scene name into a <see cref="Cthangover.Core.Scenes.GodotSceneType"/> enum for conditional logic elsewhere. </summary>
public class RuntimeData{/// <summary>In-game clock (minutes-based) driving day/night cycles.</summary>
public TimeData Time{get;set;}=new TimeData(143546335);/// <summary>Recruited character roster with attributes and battle party set.</summary>
public CharacterData CharacterData{get;set;}=new();/// <summary>Unlocked skills and their cooldown state.</summary>
public SkillData SkillData{get;set;}=new();/// <summary> Factory producing the player inventory implementation. Installed by the items mod (<c>ItemsModInitializer</c>) at resources-ready; the property below invokes it on first access, so the state bag can be constructed before the mod set is final without freezing an empty inventory. A <c>null</c> factory (items mod absent) yields a null inventory. </summary>
public static Func<IInventory>InventoryFactory{get;set;}/// <summary>Factory producing the unlocked-recipe state; see <see cref="InventoryFactory"/>.</summary>
public static Func<IRecipeData>RecipeDataFactory{get;set;}private IInventory _inventory;private IRecipeData _recipeData;/// <summary>Player inventory of item stacks; lazily produced by <see cref="InventoryFactory"/> on first access.</summary>
public IInventory Inventory{get=>_inventory??=InventoryFactory?.Invoke();set=>_inventory=value;}/// <summary>Unlocked recipe IDs and lookup helpers; lazily produced by <see cref="RecipeDataFactory"/>.</summary>
public IRecipeData RecipeData{get=>_recipeData??=RecipeDataFactory?.Invoke();set=>_recipeData=value;}/// <summary>Lamp light radius and influence state.</summary>
public LampData LampData{get;set;}=new();/// <summary>Persistent collection of action IDs available for assignment via the character panel.</summary>
public ActionPoolData ActionPool{get;set;}=new();/// <summary> IDs of one-run scenarios completed in the current game. Grows as one-run scenario dialogs finish (see <c>DialogRuntime.End</c>), is replaced by the save's <c>CompletedScenarios</c> blob on load and cleared when a new game starts (see <c>GameData.ResetRuntime</c>). The scenario runner filters scene events against this set, which makes <c>is_one_run: true</c> hold across scene revisits and save/load cycles instead of only within a single scene visit. </summary>
public HashSet<string>CompletedScenarioIds{get;}=new();/// <summary> IDs of one-shot scenarios restored from a loaded save. Populated by <see cref="SaveService.Load"/> and consumed by the scenario runner to skip re-triggering. When <c>null</c>, no load has occurred yet (fresh game). Call <see cref="ClearLoadState"/> to reset after processing. </summary>
public HashSet<string>LoadedCompletedScenarioIds{get;set;}/// <summary> Clears the post-load scenario block list. Should be called after the scenario runner has consumed the IDs to prevent stale data from persisting across scene transitions. The persistent completion history itself lives in <see cref="CompletedScenarioIds"/> and is not affected. </summary>
public void ClearLoadState(){LoadedCompletedScenarioIds=null;}/// <summary> Resolves the currently active scene into a <see cref="Cthangover.Core.Scenes.GodotSceneType"/> enum value. Queries <see cref="Cthangover.Core.Scenes.SceneManager"/> first, then falls back to parsing the current scene's resource path. Defaults to <c>GodotSceneType.MainMenu</c> when unresolvable. </summary>
public GodotSceneType CurrentScene{get{var sceneManager=SceneContextNode.FindNode<SceneManager>("SceneManager");if(sceneManager!=null&& !string.IsNullOrEmpty(sceneManager.CurrentSceneName)){if(System.Enum.TryParse<GodotSceneType>(sceneManager.CurrentSceneName,true,out var result))return result;}var scene=SceneContextNode.CurrentScene;if(scene!=null){var path=scene.SceneFilePath;if(!string.IsNullOrEmpty(path)){var name=System.IO.Path.GetFileNameWithoutExtension(path);if(!string.IsNullOrEmpty(name)&&System.Enum.TryParse<GodotSceneType>(name,true,out var result))return result;}}return GodotSceneType.MainMenu;}}}}

===== FILE Core/Settings/RuntimeSerializer.cs =====
using System.Collections.Generic;using System.Linq;using System.Text.Json;using Cthangover.Core.Utils;namespace Cthangover.Core.Settings{/// <summary> Pure marshalling helper: packs <see cref="RuntimeData"/> into a <c>"__core__"</c> <see cref="ModSlot"/> (named <see cref="DataBlob"/> blocks per subsystem) and restores it back. Does not touch filesystem, validation, or mod dispatch — that is the responsibility of <see cref="SaveService"/>. </summary>
public static class RuntimeSerializer{/// <summary> Lightweight DTO serialised into <see cref="ModMetadata.CustomData"/> of the <c>"__core__"</c> slot so that <see cref="SaveService.GetSaveSlots"/> can extract preview info without deserialising full game-state blobs. </summary>
public class CoreSlotPreview{/// <summary>Scene the player was in when the save was written.</summary>
public string CurrentSceneName{get;set;}/// <summary>In-game clock tick at save moment.</summary>
public long GameTime{get;set;}/// <summary>Number of recruited characters at save moment.</summary>
public int CharacterCount{get;set;}}/// <summary> Builds the <c>"__core__"</c> <see cref="ModSlot"/> with named <see cref="DataBlob"/> blocks for each subsystem (Time, Lamp, ActionPool, Skills, CompletedScenarios, MapZones) and slot-level preview metadata in <see cref="ModMetadata.CustomData"/>. Subsystem-owned state is not part of the core slot: quest state persists through the quests engine mod's own <c>IModSaveable</c> slot, inventory and recipe state through the items engine mod's slot, and the character roster (<c>Characters</c>/<c>BattleSet</c>) through the characters engine mod's slot (<c>CharactersSaveable</c>). </summary>
public static ModSlot BuildCoreSlot(RuntimeData runtime,string coreVersion,string currentSceneName,HashSet<string>completedScenarioIds){var slot=new ModSlot{Metadata=new ModMetadata{ModId=SavePackage.CoreSlotKey,ModName="Core Game",MinCoreVersion=coreVersion,IsRequired=true,}};slot.Data["Time"]=new DataBlob(1,runtime.Time.Tick);var lampBlob=new DataBlob(1);lampBlob.SetFloat("Radius",runtime.LampData.Radius);lampBlob.SetFloat("Influence",runtime.LampData.Influence);slot.Data["Lamp"]=lampBlob;slot.Data["ActionPool"]=new DataBlob(1,runtime.ActionPool.ActionIds.ToList());slot.Data["Skills"]=new DataBlob(1,runtime.SkillData.Skills.ToList());slot.Data["CompletedScenarios"]=new DataBlob(1,completedScenarioIds.ToList());slot.Data["MapZones"]=new DataBlob(1,Cthangover.Core.Actions.MapStateBridge.ExportState());var preview=new CoreSlotPreview{CurrentSceneName=currentSceneName,GameTime=runtime.Time.Tick,CharacterCount=runtime.CharacterData.Characters?.Count??0,};slot.Metadata.CustomData=JsonSerializer.Serialize(preview,JsonOptionsProvider.WriteCompactOptions);return slot;}/// <summary> Restores core state from the <c>"__core__"</c> slot's blobs back into the given <paramref name="runtime"/> instance. Silently skips any missing blob key — the corresponding subsystem keeps its previous value, except <see cref="RuntimeData.CompletedScenarioIds"/> which is always reset first: a loaded save is the authoritative completion history of its own playthrough, so completions from a previously running game must never leak into it. </summary>
public static void RestoreCoreState(ModSlot coreSlot,RuntimeData runtime){if(coreSlot.Data==null)return;runtime.CompletedScenarioIds.Clear();if(coreSlot.Data.TryGetValue("Time",out var timeBlob))runtime.Time.SetTime(timeBlob.ReadJson<long>());if(coreSlot.Data.TryGetValue("Lamp",out var lampBlob)){runtime.LampData.Radius=lampBlob.GetFloat("Radius",100f);runtime.LampData.Influence=lampBlob.GetFloat("Influence",0.5f);}if(coreSlot.Data.TryGetValue("ActionPool",out var apBlob))runtime.ActionPool.ActionIds=apBlob.ReadJson<List<string>>()??new List<string>();if(coreSlot.Data.TryGetValue("Skills",out var skillsBlob)){runtime.SkillData.Skills.Clear();var skills=skillsBlob.ReadJson<List<string>>();if(skills!=null)foreach(var id in skills)runtime.SkillData.AddSkill(id);}if(coreSlot.Data.TryGetValue("CompletedScenarios",out var csBlob)){runtime.LoadedCompletedScenarioIds=csBlob.ReadJson<List<string>>()?.ToHashSet()??new HashSet<string>();runtime.CompletedScenarioIds.UnionWith(runtime.LoadedCompletedScenarioIds);}if(coreSlot.Data.TryGetValue("MapZones",out var mapBlob))Cthangover.Core.Actions.MapStateBridge.ImportState(mapBlob.ReadJson<Cthangover.Core.UI.Tool.MapZoneStateData>());}/// <summary> Reads slot preview metadata from <see cref="ModMetadata.CustomData"/> JSON. Returns defaults when the field is absent or malformed. </summary>
public static CoreSlotPreview ReadSlotPreview(ModMetadata meta){var result=new CoreSlotPreview();if(meta?.CustomData==null)return result;try{var preview=JsonSerializer.Deserialize<CoreSlotPreview>(meta.CustomData,JsonOptionsProvider.ReadOptions);if(preview!=null)return preview;}catch{}return result;}}}

===== FILE Core/Settings/SaveCompositionDiff.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Utils;namespace Cthangover.Core.Settings{/// <summary> How a game's composition changed between a save and now: mods added (they start fresh), removed (their data goes dormant) and updated (migration runs; a downgrade keeps that mod's data dormant). Shown before loading so the player knows what happens to the save. </summary>
public sealed class SaveCompositionDiff{public List<string>Added{get;}=new();public List<string>Removed{get;}=new();public List<(string ModId,string From,string To,bool Downgrade)>Updated{get;}=new();public bool IsEmpty=>Added.Count==0&&Removed.Count==0&&Updated.Count==0;/// <summary> Compares the save's snapshot with the composition the game runs now. A save without a snapshot (written without a game) yields an empty diff — there is nothing reliable to compare. </summary>
public static SaveCompositionDiff Compute(IEnumerable<SaveGameMod>saved,IEnumerable<SaveGameMod>current){var diff=new SaveCompositionDiff();if(saved==null||current==null)return diff;var before=saved.Where(m=>m?.Id!=null).GroupBy(m=>m.Id,StringComparer.OrdinalIgnoreCase).ToDictionary(g=>g.Key,g=>g.First().Version,StringComparer.OrdinalIgnoreCase);var now=current.Where(m=>m?.Id!=null).GroupBy(m=>m.Id,StringComparer.OrdinalIgnoreCase).ToDictionary(g=>g.Key,g=>g.First().Version,StringComparer.OrdinalIgnoreCase);foreach(var pair in now.OrderBy(p=>p.Key,StringComparer.Ordinal)){if(!before.TryGetValue(pair.Key,out var was)){diff.Added.Add(pair.Key);continue;}if(!string.Equals(was,pair.Value,StringComparison.Ordinal)&& !string.IsNullOrEmpty(was)&& !string.IsNullOrEmpty(pair.Value))diff.Updated.Add((pair.Key,was,pair.Value,Compare(was,pair.Value)>0));}foreach(var id in before.Keys.OrderBy(k=>k,StringComparer.Ordinal)){if(!now.ContainsKey(id))diff.Removed.Add(id);}return diff;}private static int Compare(string a,string b){try{return VersionHelper.Compare(a,b);}catch(Exception){return 0;}}}}

===== FILE Core/Settings/SaveDormancy.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Utils;namespace Cthangover.Core.Settings{/// <summary> Decides which mod slots of a save the running game restores and which stay dormant. A game keeps its saves while the player changes its mechanics, so a slot is never dropped: data of a mod that is not loaded now (or whose installed version is older than the save's) is carried forward verbatim in <see cref="SavePackage.Dormant"/> and revived as soon as the mod is back. Pure logic — the version lookup is injected — so it is covered by unit tests without Godot. </summary>
public static class SaveDormancy{public enum Reason{/// <summary>The mod is not part of the game's current composition.</summary>
NotLoaded,/// <summary>The installed mod is older than the one that wrote the slot (no downgrade migration).</summary>
Downgrade,/// <summary>The slot needs a newer core than the running one.</summary>
CoreTooOld,/// <summary>The mod rejected the slot in <c>OnLoad</c>; the data is kept for a later fix.</summary>
LoadFailed,}public sealed class Result{/// <summary>Slots to hand to the loaded mods, keyed by mod id.</summary>
public Dictionary<string,ModSlot>Live{get;}=new();/// <summary>Slots carried forward untouched, keyed by mod id.</summary>
public Dictionary<string,ModSlot>Dormant{get;}=new();/// <summary>Slots that just became dormant, with the reason.</summary>
public List<(string ModId,Reason Reason)>NewlyDormant{get;}=new();/// <summary>Previously dormant slots whose mod is back.</summary>
public List<string>Revived{get;}=new();}/// <summary> Classifies <paramref name="modData"/> (live slots of the save, core slot excluded) and <paramref name="dormant"/> (slots already dormant in the save). <paramref name="installedVersion"/> returns the loaded mod's version or null when the mod is not loaded. </summary>
public static Result Classify(IReadOnlyDictionary<string,ModSlot>modData,IReadOnlyDictionary<string,ModSlot>dormant,Func<string,string>installedVersion,string coreVersion,DateTime now){var result=new Result();foreach(var pair in modData??new Dictionary<string,ModSlot>()){if(pair.Key==SavePackage.CoreSlotKey||pair.Value==null)continue;var reason=Check(pair.Key,pair.Value,installedVersion,coreVersion);if(reason==null){result.Live[pair.Key]=pair.Value;continue;}result.Dormant[pair.Key]=MarkDormant(pair.Value,reason.Value,now);result.NewlyDormant.Add((pair.Key,reason.Value));}foreach(var pair in dormant??new Dictionary<string,ModSlot>()){if(pair.Value==null||result.Dormant.ContainsKey(pair.Key))continue;// A live slot for the same mod wins: the dormant one is kept (it may hold newer data after a downgrade) until purged.
if(result.Live.ContainsKey(pair.Key)||Check(pair.Key,pair.Value,installedVersion,coreVersion)!=null){result.Dormant[pair.Key]=pair.Value;continue;}Revive(pair.Value);result.Live[pair.Key]=pair.Value;result.Revived.Add(pair.Key);}return result;}/// <summary>Flags a slot as dormant, keeping the first dormancy date.</summary>
public static ModSlot MarkDormant(ModSlot slot,Reason reason,DateTime now){slot.Metadata??=new ModMetadata();slot.Metadata.Dormant=true;slot.Metadata.DormantReason=reason.ToString();slot.Metadata.DormantSince??=now;return slot;}private static void Revive(ModSlot slot){if(slot.Metadata==null)return;slot.Metadata.Dormant=false;slot.Metadata.DormantReason=null;slot.Metadata.DormantSince=null;}private static Reason?Check(string modId,ModSlot slot,Func<string,string>installedVersion,string coreVersion){var installed=installedVersion?.Invoke(modId);if(installed==null)return Reason.NotLoaded;var meta=slot.Metadata;if(!string.IsNullOrEmpty(meta?.ModVersion)&&Compare(meta.ModVersion,installed)>0)return Reason.Downgrade;if(!string.IsNullOrEmpty(meta?.MinCoreVersion)&& !string.IsNullOrEmpty(coreVersion)&&Compare(coreVersion,meta.MinCoreVersion)<0)return Reason.CoreTooOld;return null;}private static int Compare(string a,string b){try{return VersionHelper.Compare(a,b);}catch(Exception){return 0;}}}}

===== FILE Core/Settings/SaveGameInfo.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Settings{/// <summary> Which game wrote a save and with what composition. The id ties the save to the game (it lives in <c>user://saves/&lt;id&gt;/</c>); the mod list is a snapshot for comparing compositions on load, never a requirement — the game may have changed its mechanics since. </summary>
public class SaveGameInfo{/// <summary>Game collection id (stable across the player's edits).</summary>
public string Id{get;set;}/// <summary>Collection display name at save time.</summary>
public string Name{get;set;}/// <summary>Collection version at save time.</summary>
public string Version{get;set;}/// <summary>Mods the game ran with, in load order.</summary>
public List<SaveGameMod>Mods{get;set;}=new();}/// <summary>One mod of a <see cref="SaveGameInfo"/> composition snapshot.</summary>
public class SaveGameMod{public string Id{get;set;}public string Version{get;set;}}}

===== FILE Core/Settings/SaveGameSummary.cs =====
using System;namespace Cthangover.Core.Settings{/// <summary>One game with saves on disk, for the save browser's game selector.</summary>
public class SaveGameSummary{/// <summary>Game collection id; null for saves made without a game.</summary>
public string GameId{get;set;}/// <summary>Name recorded in the newest save.</summary>
public string GameName{get;set;}public int SaveCount{get;set;}public DateTime LastSave{get;set;}/// <summary>Slot name of the newest save ("Continue" of that game).</summary>
public string LastFileName{get;set;}}}

===== FILE Core/Settings/SavePackage.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Core.Settings{/// <summary> Root DTO of the save file. Contains save identification (SaveId, Timestamp, CoreVersion) and a <see cref="ModData"/> dictionary holding data slots from every mod that implements <see cref="Cthangover.Core.Mods.IModSaveable"/>, plus the reserved <c>"__core__"</c> slot that stores all core game state. The core itself does not inspect or deserialise mod blob payloads — it only routes byte arrays between the save file and the mod that owns each slot. </summary>
public class SavePackage{/// <summary>Reserved <see cref="ModData"/> key holding the core game state; mods must not write it.</summary>
public const string CoreSlotKey="__core__";/// <summary>User-facing save name (e.g. "slot_1").</summary>
public string SaveId{get;set;}/// <summary>UTC timestamp when the save was created.</summary>
public DateTime Timestamp{get;set;}/// <summary>Version of the core that created this save.</summary>
public string CoreVersion{get;set;}/// <summary> Data slots from mods, keyed by ModId. The reserved key <c>"__core__"</c> holds core game state. </summary>
public Dictionary<string,ModSlot>ModData{get;set;}=new();/// <summary> Game that wrote the save (null for saves made without an active game collection, e.g. development launches with every mod). </summary>
public SaveGameInfo Game{get;set;}/// <summary> Slots of mods the game ran without (removed mechanic, older installed version, rejected data), carried forward untouched so the data comes back with the mod; see <see cref="SaveDormancy"/>. Purged on request from the save settings. </summary>
public Dictionary<string,ModSlot>Dormant{get;set;}}}

===== FILE Core/Settings/SaveScreenshotService.cs =====
using System;using System.Linq;using Cthangover.Core.Scenes;using Cthangover.Core.UI;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Settings{/// <summary> Static utility for capturing and loading save-slot thumbnail images. On save (<see cref="CaptureAndSave"/>), it grabs the current background texture from the shell's <c>ViewBox</c> (interface mod, behind the <see cref="Cthangover.Core.UI.IBackgroundView"/> contract), scales it down to a 320×180 thumbnail, and writes a PNG next to the JSON save file. On load (<see cref="LoadScreenshot"/>), it reads the PNG back and returns a <see cref="Texture2D"/> suitable for the slot UI. A fallback solid-colour image is used when no background texture is available. </summary>
public static class SaveScreenshotService{private static string SaveDir=>SaveService.SaveDirectory;/// <summary> Captures the current background image, downscales it to 320×180, and saves it as a PNG thumbnail alongside the save file. If the background texture is unavailable, a solid fallback image is used. Silently returns on error (logged internally). </summary>
public static void CaptureAndSave(string fileName){var absDir=ProjectSettings.GlobalizePath(SaveDir);if(!DirAccess.DirExistsAbsolute(absDir))DirAccess.MakeDirRecursiveAbsolute(absDir);var safe=new string(fileName.Select(c=>char.IsLetterOrDigit(c)||c=='_'||c=='-'?c:'_').ToArray());var path=$"{SaveDir}/{safe}.png";try{var image=CaptureBackgroundImage();if(image==null)return;var smallImage=ScaleImage(image,320,180);smallImage.SavePng(ProjectSettings.GlobalizePath(path));}catch(Exception e){GameLogger.Log("SAVE",$"Error capturing screenshot: {e.Message}",LogLevel.Error);}}/// <summary> Loads the thumbnail PNG for a save slot and returns it as a <see cref="Texture2D"/>. Returns <c>null</c> if the file does not exist or fails to decode. </summary>
public static Texture2D LoadScreenshot(string fileName){var safe=new string(fileName.Select(c=>char.IsLetterOrDigit(c)||c=='_'||c=='-'?c:'_').ToArray());return LoadScreenshotFromPath($"{SaveDir}/{safe}.png");}/// <summary> Loads a thumbnail by its Godot path (<see cref="SaveSlotInfo.ScreenshotPath"/>), so the save browser can show saves of any game, not only the active one. </summary>
public static Texture2D LoadScreenshotFromPath(string path){if(string.IsNullOrEmpty(path))return null;var absPath=ProjectSettings.GlobalizePath(path);if(!System.IO.File.Exists(absPath))return null;try{var image=new Image();var err=image.Load(path);if(err!=Error.Ok)return null;return ImageTexture.CreateFromImage(image);}catch{return null;}}/// <summary> Retrieves the current background texture from the shell's <c>ViewBox</c> node through the kernel <see cref="IBackgroundView"/> contract. Falls back to a 64×64 solid-dark image if no valid texture exists. </summary>
private static Image CaptureBackgroundImage(){var viewBox=SceneContextNode.FindNode<Node>("ViewBox")as IBackgroundView;if(viewBox?.Background?.Texture==null)return CreateFallbackImage();var image=viewBox.Background.Texture.GetImage();if(image==null||image.IsEmpty())return CreateFallbackImage();return image;}/// <summary> Produces a 64×64 solid dark-grey fallback image used when the actual background texture is unavailable. </summary>
private static Image CreateFallbackImage(){var image=Image.CreateEmpty(64,64,false,Image.Format.Rgba8);image.Fill(new Color(0.12f,0.12f,0.12f,1));return image;}/// <summary> Resizes <paramref name="source"/> to the given dimensions using Lanczos interpolation for high-quality downscaling of thumbnails. </summary>
private static Image ScaleImage(Image source,int width,int height){source.Resize(width,height,Image.Interpolation.Lanczos);return source;}}}

===== FILE Core/Settings/SaveService.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text.Json;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;using Cthangover.Core.UI.Executable;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Settings{/// <summary> Save/load dispatcher for the game core. Serialises game state into a <see cref="SavePackage"/> (JSON) and drives a <b>three-phase</b> load with mod metadata validation. <b>Architecture:</b> core game state is stored in a reserved <c>"__core__"</c> slot inside <see cref="SavePackage.ModData"/> — exactly like any other mod. Other mods add their own slots via <see cref="IModSaveable"/>. The core never inspects mod blob payloads; it only routes byte arrays to the owning mod. <see cref="RuntimeSerializer"/> handles the actual marshalling between <see cref="RuntimeData"/> and the <see cref="DataBlob"/> blocks inside the core slot. <b>Save flow:</b> <list type="number"> <item>Build a <c>"__core__"</c> <see cref="ModSlot"/> via <see cref="RuntimeSerializer.BuildCoreSlot"/>.</item> <item>Call <see cref="ModSaveableRegistry.CollectSaveData"/> — each mod fills its own slot. Skip any slot keyed <c>"__core__"</c>.</item> <item>Serialise <see cref="SavePackage"/> to JSON and write to disk.</item> </list> <b>Load flow (three phases):</b> <list type="number"> <item><b>Phase 1 — validation via <see cref="ModMetadata"/>:</b> walk all slots, check required mods and core version compatibility.</item> <item><b>Phase 2 — core state restoration:</b> <see cref="RuntimeSerializer.RestoreCoreState"/> reads blobs from <c>ModData["__core__"]</c> and pushes them into <see cref="RuntimeData"/>.</item> <item><b>Phase 3 — <see cref="ModSaveableRegistry.DispatchLoad"/>:</b> distribute remaining slots to mods, catch exceptions.</item> </list> </summary>
public static class SaveService{private const string SavesRoot="user://saves";/// <summary> Saves of the active game live in <c>user://saves/&lt;game id&gt;/</c>, so editing the game's mechanics keeps them; without an active game (development launches with every mod) the shared root is used. </summary>
private static string SaveDir=>Games.ActiveGame.IsActive?$"{SavesRoot}/{Sanitize(Games.ActiveGame.Id)}":SavesRoot;/// <summary>Slots carried forward for mods the current game runs without; see <see cref="SaveDormancy"/>.</summary>
private static Dictionary<string,ModSlot>_dormant=new();/// <summary>Scene stored in the last successfully loaded save (null before any load).</summary>
public static string LastLoadedSceneName{get;private set;}/// <summary>Composition changes found by the last load (mods that went dormant or came back).</summary>
public static SaveDormancy.Result LastLoadDormancy{get;private set;}/// <summary>Forgets dormant slots; called when a new game starts.</summary>
public static void ClearDormant()=>_dormant=new Dictionary<string,ModSlot>();/// <summary>Directory of the active game's saves (Godot path).</summary>
public static string SaveDirectory=>SaveDir;private static string _cachedCoreVersion;private static string CoreVersion{get{if(_cachedCoreVersion==null){try{_cachedCoreVersion=(string)ProjectSettings.GetSetting("application/config/version");}catch{_cachedCoreVersion="0.0.0";}}return _cachedCoreVersion;}}/// <summary>Saves directory: globalised <c>user://saves</c> at runtime, relative fallback otherwise.</summary>
public static string Path=>GameEnvironment.IsRuntime?ProjectSettings.GlobalizePath(SaveDir):"saves";/// <summary> Builds a sanitised file path for a save slot. Non-alphanumeric characters (except <c>_</c> and <c>-</c>) are replaced with <c>_</c> to prevent path traversal. </summary>
private static string GetSavePath(string fileName)=>$"{SaveDir}/{Sanitize(fileName)}.json";private static string Sanitize(string name)=>new string((name??string.Empty).Select(c=>char.IsLetterOrDigit(c)||c=='_'||c=='-'?c:'_').ToArray());/// <summary> Builds the PNG screenshot path for a save slot using the same sanitisation rules as <see cref="GetSavePath"/>. </summary>
private static string GetScreenshotPath(string fileName)=>$"{SaveDir}/{Sanitize(fileName)}.png";/// <summary> Ensures <c>user://saves/</c> directory exists, creating it recursively if needed. </summary>
private static void EnsureSaveDir(){var absDir=Path;if(!DirAccess.DirExistsAbsolute(absDir))DirAccess.MakeDirRecursiveAbsolute(absDir);}/// <summary> Resolves the currently active scene name by querying the <see cref="Cthangover.Core.Scenes.SceneManager"/> singleton first, then falling back to the current scene's filename. Returns "unknown" if neither source is available. </summary>
private static string GetCurrentSceneName(){var sceneManager=SceneContextNode.FindNode<SceneManager>("SceneManager");if(sceneManager!=null&& !string.IsNullOrEmpty(sceneManager.CurrentSceneName))return sceneManager.CurrentSceneName;var scene=SceneContextNode.CurrentScene;if(scene!=null){var path=scene.SceneFilePath;if(!string.IsNullOrEmpty(path))return System.IO.Path.GetFileNameWithoutExtension(path);}return"unknown";}/// <summary> Walks the active <see cref="Cthangover.Core.UI.Executable.ExecutableMainEventChain"/> scene tree node and collects IDs of all one-shot events that have already executed. These IDs are stored in the save file so that reloaded games do not re-trigger completed scenarios. </summary>
private static HashSet<string>CollectLiveChainOneRunIds(){var result=new HashSet<string>();var tree=(SceneTree)Engine.GetMainLoop();if(tree==null)return result;var chainNode=tree.GetFirstNodeInGroup("main_event_chain");var eventChain=chainNode as IExecutableEventChain;if(chainNode==null||eventChain==null)return result;foreach(var child in chainNode.GetChildren()){if(child is IExecutableEvent evt&&evt.IsOneRun&& !evt.IsFirstRun)result.Add(evt.ID);}return result;}/// <summary> Merges the two sources of one-run scenario completion state before writing a save: the persistent per-game history in <see cref="RuntimeData.CompletedScenarioIds"/> (survives scene switches, so scenarios completed long ago are not lost when the player saves from another scene) and IDs of one-run events still alive in the current event chain. Pure function — safe to call without a running game tree. </summary>
public static HashSet<string>MergeCompletedScenarioIds(IEnumerable<string>liveChainIds,RuntimeData runtime){var result=new HashSet<string>();if(runtime?.CompletedScenarioIds!=null)result.UnionWith(runtime.CompletedScenarioIds);if(liveChainIds!=null)result.UnionWith(liveChainIds);return result;}/// <summary> Builds the full game state into a <see cref="SavePackage"/>, queries mod data via <see cref="ModSaveableRegistry.CollectSaveData"/>, and writes JSON to <c>user://saves/{fileName}.json</c>. </summary>
public static void Save(string fileName)=>Save(fileName,rememberSession:true);/// <summary> Prefix of technical saves (<see cref="HotReloadSlot"/>): written and read like any slot, but hidden from the save browser and never recorded as the "Continue" session. </summary>
public const string HiddenSlotPrefix="__";/// <summary>The slot a hot reload parks the running session in while the mod set is replaced.</summary>
public const string HotReloadSlot=HiddenSlotPrefix+"hot_reload";/// <summary> <see cref="Save(string)"/> with control over the "Continue" record: technical saves pass <paramref name="rememberSession"/> = false so the player's last real save stays the one "Continue" loads. </summary>
public static void Save(string fileName,bool rememberSession){var pkg=BuildPackage(fileName);EnsureSaveDir();try{string json=JsonSerializer.Serialize(pkg,JsonOptionsProvider.WriteCompactOptions);using var file=Godot.FileAccess.Open(GetSavePath(fileName),Godot.FileAccess.ModeFlags.Write);if(file==null){GameLogger.Log("SAVE",$"Failed to open save file for writing: {fileName}",LogLevel.Error);return;}file.StoreString(json);GameLogger.Log("SAVE",$"Saved '{fileName}' (core={pkg.CoreVersion}, mods={pkg.ModData.Count})");if(rememberSession)LastSessionStore.Record(Games.ActiveGame.Id,fileName);}catch(Exception e){GameLogger.Log("SAVE",$"Error saving file '{fileName}': {e.Message}",LogLevel.Error);}}/// <summary> Collects the complete state of the running game into a package — the core slot, every <see cref="IModSaveable"/> slot, the game info and the dormant slots — without touching disk. <see cref="Save"/> writes it to a slot file; tools keep it in memory as a snapshot and bring it back with <see cref="ApplyPackage"/> (the scenario studio rolls a scenario back to its start this way). </summary>
public static SavePackage BuildPackage(string saveId){var gameData=GameData.Instance;var pkg=new SavePackage{SaveId=saveId,Timestamp=DateTime.UtcNow,CoreVersion=CoreVersion,};pkg.ModData[SavePackage.CoreSlotKey]=RuntimeSerializer.BuildCoreSlot(gameData.Runtime,CoreVersion,GetCurrentSceneName(),MergeCompletedScenarioIds(CollectLiveChainOneRunIds(),gameData.Runtime));var modSlots=ModSaveableRegistry.CollectSaveData();foreach(var kvp in modSlots){if(kvp.Key==SavePackage.CoreSlotKey){GameLogger.Log("SAVE",$"Mod uses reserved slot key '__core__', skipping",LogLevel.Warning);continue;}pkg.ModData[kvp.Key]=kvp.Value;}pkg.Game=BuildGameInfo();pkg.Dormant=_dormant.Count>0?new Dictionary<string,ModSlot>(_dormant):null;return pkg;}/// <summary> Loads a save file of the active game. The game may have changed its mechanics since the save was written, so the load adapts instead of refusing: <list type="number"> <item><b>Phase 1</b> — <see cref="SaveDormancy.Classify"/>: slots of mods that are not loaded, older than the save or need a newer core become dormant; dormant slots whose mod is back are revived.</item> <item><b>Phase 2</b> — reset every state owner, then restore core state via <see cref="RuntimeSerializer.RestoreCoreState"/>.</item> <item><b>Phase 3</b> — <see cref="ModSaveableRegistry.DispatchLoad"/>; a rejected slot is kept dormant; mods without a slot get <see cref="IModSaveable.OnJoinedExistingGame"/>.</item> </list> Only a missing core slot fails the load. Returns <c>true</c> on success. </summary>
public static bool Load(string fileName){var path=GetSavePath(fileName);using var file=Godot.FileAccess.Open(path,Godot.FileAccess.ModeFlags.Read);if(file==null){GameLogger.Log("SAVE",$"Save file not found: {fileName}",LogLevel.Error);return false;}var json=file.GetAsText();var pkg=JsonSerializer.Deserialize<SavePackage>(json,JsonOptionsProvider.ReadOptions);if(pkg==null){GameLogger.Log("SAVE",$"Failed to deserialize save file '{fileName}'",LogLevel.Error);return false;}if(!ApplyPackage(pkg,fileName))return false;LastSessionStore.Record(Games.ActiveGame.Id,fileName);return true;}/// <summary> Restores the game state from a package built by <see cref="BuildPackage"/> or read from a slot file — the three phases described on <see cref="Load"/>. Does not touch the last-session record; <paramref name="sourceName"/> is only used in the log. The package's slots may be reused by the restore, so a snapshot that is applied repeatedly should be kept serialized and deserialized per apply. </summary>
public static bool ApplyPackage(SavePackage pkg,string sourceName){var fileName=sourceName;GameLogger.Log("SAVE",$"Loading '{fileName}' (core={pkg.CoreVersion}, mods={pkg.ModData?.Count ?? 0})");// ─── Phase 1: decide which slots the current composition restores ─── Nothing blocks the load except a missing core slot: slots of mods the game runs without stay dormant and come back with the mod.
if(pkg.ModData==null|| !pkg.ModData.TryGetValue(SavePackage.CoreSlotKey,out var coreSlot)){GameLogger.Log("SAVE","Save file is missing '__core__' slot — cannot restore game state",LogLevel.Error);return false;}if(pkg.Game!=null&&Games.ActiveGame.IsActive&& !string.Equals(pkg.Game.Id,Games.ActiveGame.Id,StringComparison.OrdinalIgnoreCase)){GameLogger.Log("SAVE",$"Save '{fileName}' belongs to game '{pkg.Game.Id}', loading it into '{Games.ActiveGame.Id}'",LogLevel.Warning);}var dormancy=SaveDormancy.Classify(pkg.ModData,pkg.Dormant,id=>ModManager.Instance.GetMod(id)is{}mod?mod.Version??"0.0.0":null,CoreVersion,DateTime.UtcNow);foreach(var(modId,reason)in dormancy.NewlyDormant)GameLogger.Log("SAVE",$"Mod '{modId}' data kept dormant ({reason}) — it returns when the mod is back",LogLevel.Warning);foreach(var modId in dormancy.Revived)GameLogger.Log("SAVE",$"Mod '{modId}' is back — restoring its dormant data");// ─── Phase 2: fresh state, then core state ─── Every state owner resets first, so a mod without a slot in this save starts from new-game defaults instead of the previous session.
GameData.Instance.ResetRuntime("load");Bootstrap.AppServices.Current.ResetForNewGame();RuntimeSerializer.RestoreCoreState(coreSlot,GameData.Instance.Runtime);// ─── Phase 3: mod slots; a rejected slot is kept dormant, not lost ───
var failed=new List<string>();var modErrors=ModSaveableRegistry.DispatchLoad(dormancy.Live,failed);foreach(var error in modErrors)GameLogger.Log("SAVE",$"Mod load error (data kept dormant): {error}",LogLevel.Error);foreach(var modId in failed){dormancy.Dormant[modId]=SaveDormancy.MarkDormant(dormancy.Live[modId],SaveDormancy.Reason.LoadFailed,DateTime.UtcNow);dormancy.Live.Remove(modId);dormancy.NewlyDormant.Add((modId,SaveDormancy.Reason.LoadFailed));}ModSaveableRegistry.NotifyJoined(dormancy.Live.Keys);_dormant=dormancy.Dormant;LastLoadDormancy=dormancy;LastLoadedSceneName=RuntimeSerializer.ReadSlotPreview(coreSlot.Metadata).CurrentSceneName;return true;}/// <summary> Slots of the active game for the classic fixed grid: existing saves <c>slot_1..slot_N</c> plus empty placeholders. Kept for callers that still think in a fixed count; the save browser uses <see cref="ListSaves"/> with unlimited slots. </summary>
public static List<SaveSlotInfo>GetSaveSlots(int slotCount){var existing=ListSaves(Games.ActiveGame.Id).ToDictionary(s=>s.FileName);var result=new List<SaveSlotInfo>();for(var i=0;i<slotCount;i++){var slotName=$"slot_{i + 1}";result.Add(existing.TryGetValue(slotName,out var info)?info:EmptySlot(Games.ActiveGame.Id,slotName));}return result;}/// <summary>Godot directory of a game's saves; null/empty id is the shared root (saves made without a game).</summary>
public static string GameDirectory(string gameId)=>string.IsNullOrEmpty(gameId)?SavesRoot:$"{SavesRoot}/{Sanitize(gameId)}";/// <summary> Every save of one game (null = saves made without a game), ordered by slot number. Slots are unlimited: the browser appends an empty slot named by <see cref="NextSlotName"/>. </summary>
public static List<SaveSlotInfo>ListSaves(string gameId){var result=new List<SaveSlotInfo>();var dir=GameDirectory(gameId);var absDir=GameEnvironment.IsRuntime?ProjectSettings.GlobalizePath(dir):dir;if(!Directory.Exists(absDir))return result;foreach(var path in Directory.EnumerateFiles(absDir,"*.json",SearchOption.TopDirectoryOnly)){var slotName=System.IO.Path.GetFileNameWithoutExtension(path);if(slotName.StartsWith(HiddenSlotPrefix,StringComparison.Ordinal))continue;var info=ReadSlotInfo(dir,slotName,path);if(info!=null)result.Add(info);}result.Sort((a,b)=>{var byNumber=SlotNumber(a.FileName).CompareTo(SlotNumber(b.FileName));return byNumber!=0?byNumber:string.CompareOrdinal(a.FileName,b.FileName);});return result;}/// <summary> Games that have saves on disk (newest first), plus the shared root as an entry with a null id when saves without a game exist. </summary>
public static List<SaveGameSummary>ListGamesWithSaves(){var result=new List<SaveGameSummary>();var absRoot=GameEnvironment.IsRuntime?ProjectSettings.GlobalizePath(SavesRoot):SavesRoot;if(!Directory.Exists(absRoot))return result;foreach(var gameDir in Directory.EnumerateDirectories(absRoot)){var gameId=System.IO.Path.GetFileName(gameDir);if(gameId==BackupDirectoryName)continue;var saves=ListSaves(gameId);if(saves.Count>0)result.Add(Summarize(gameId,saves));}var legacy=ListSaves(null);if(legacy.Count>0)result.Add(Summarize(null,legacy));result.Sort((a,b)=>b.LastSave.CompareTo(a.LastSave));return result;}/// <summary>First free <c>slot_N</c> after the highest used number of the game.</summary>
public static string NextSlotName(string gameId){var max=0;foreach(var save in ListSaves(gameId))max=Math.Max(max,SlotNumber(save.FileName)==int.MaxValue?0:SlotNumber(save.FileName));return$"slot_{max + 1}";}/// <summary>Reads a save of any game without loading it (composition preview, game id).</summary>
public static SavePackage ReadPackage(string gameId,string fileName){var path=ProjectSettings.GlobalizePath($"{GameDirectory(gameId)}/{Sanitize(fileName)}.json");try{return File.Exists(path)?JsonSerializer.Deserialize<SavePackage>(File.ReadAllText(path),JsonOptionsProvider.ReadOptions):null;}catch(Exception ex){GameLogger.Log("SAVE",$"Could not read save '{path}': {ex.Message}",LogLevel.Warning);return null;}}/// <summary> Copies a save of <paramref name="gameId"/> into <c>backup/</c> next to it before it is loaded into a changed composition; returns the backup path. </summary>
public static string BackupSave(string gameId,string fileName){var gameDir=ProjectSettings.GlobalizePath(GameDirectory(gameId));var source=System.IO.Path.Combine(gameDir,Sanitize(fileName)+".json");if(!File.Exists(source))return null;var backupDir=System.IO.Path.Combine(gameDir,BackupDirectoryName);Directory.CreateDirectory(backupDir);var target=System.IO.Path.Combine(backupDir,$"{Sanitize(fileName)}_{DateTime.Now:yyyyMMdd_HHmmss}.json");File.Copy(source,target,overwrite:true);GameLogger.Log("SAVE",$"Backup of '{fileName}' before a composition change: {target}");return target;}/// <summary> Moves a save made without a game (shared root) into <paramref name="gameId"/>'s folder under a free slot name, together with its screenshot. Returns the new slot name. </summary>
public static string AdoptLegacySave(string fileName,string gameId){var slot=NextSlotName(gameId);var fromDir=ProjectSettings.GlobalizePath(GameDirectory(null));var toDir=ProjectSettings.GlobalizePath(GameDirectory(gameId));Directory.CreateDirectory(toDir);foreach(var extension in new[]{".json",".png"}){var from=System.IO.Path.Combine(fromDir,Sanitize(fileName)+extension);if(File.Exists(from))File.Move(from,System.IO.Path.Combine(toDir,slot+extension));}GameLogger.Log("SAVE",$"Save '{fileName}' without a game moved to game '{gameId}' as '{slot}'");return slot;}/// <summary> Copies every save of <paramref name="fromGameId"/> into <paramref name="toGameId"/> (a duplicated game), rewriting the game id inside each file so the copy owns them. Returns the number copied. </summary>
public static int CopyGameSaves(string fromGameId,string toGameId){var fromDir=ProjectSettings.GlobalizePath(GameDirectory(fromGameId));var toDir=ProjectSettings.GlobalizePath(GameDirectory(toGameId));if(!Directory.Exists(fromDir)||string.IsNullOrEmpty(toGameId))return 0;Directory.CreateDirectory(toDir);var copied=0;foreach(var path in Directory.EnumerateFiles(fromDir,"*.json",SearchOption.TopDirectoryOnly)){try{var pkg=JsonSerializer.Deserialize<SavePackage>(File.ReadAllText(path),JsonOptionsProvider.ReadOptions);if(pkg==null)continue;pkg.Game??=new SaveGameInfo();pkg.Game.Id=toGameId;var name=System.IO.Path.GetFileName(path);File.WriteAllText(System.IO.Path.Combine(toDir,name),JsonSerializer.Serialize(pkg,JsonOptionsProvider.WriteCompactOptions));var screenshot=System.IO.Path.ChangeExtension(path,".png");if(File.Exists(screenshot))File.Copy(screenshot,System.IO.Path.Combine(toDir,System.IO.Path.GetFileName(screenshot)),overwrite:true);copied++;}catch(Exception ex){GameLogger.Log("SAVE",$"Could not copy save '{path}': {ex.Message}",LogLevel.Warning);}}GameLogger.Log("SAVE",$"Copied {copied} save(s) from game '{fromGameId}' to '{toGameId}'");return copied;}private const string BackupDirectoryName="backup";private static SaveGameSummary Summarize(string gameId,List<SaveSlotInfo>saves){var newest=saves.OrderByDescending(s=>s.SaveTime).First();return new SaveGameSummary{GameId=gameId,GameName=newest.GameName??gameId,SaveCount=saves.Count,LastSave=newest.SaveTime,LastFileName=newest.FileName,};}private static SaveSlotInfo ReadSlotInfo(string godotDir,string slotName,string absolutePath){try{var pkg=JsonSerializer.Deserialize<SavePackage>(File.ReadAllText(absolutePath),JsonOptionsProvider.ReadOptions);var preview=new RuntimeSerializer.CoreSlotPreview();if(pkg?.ModData!=null&&pkg.ModData.TryGetValue(SavePackage.CoreSlotKey,out var coreSlot))preview=RuntimeSerializer.ReadSlotPreview(coreSlot.Metadata);var screenshot=$"{godotDir}/{slotName}.png";return new SaveSlotInfo{FileName=slotName,SaveTime=pkg?.Timestamp??DateTime.MinValue,SceneName=preview.CurrentSceneName??"unknown",HasScreenshot=File.Exists(System.IO.Path.ChangeExtension(absolutePath,".png")),IsEmpty=false,ScreenshotPath=screenshot,GameTime=preview.GameTime,CharacterCount=preview.CharacterCount,LampPercent=0f,GameId=pkg?.Game?.Id,GameName=pkg?.Game?.Name,Mods=pkg?.Game?.Mods,};}catch(Exception ex){GameLogger.Log("SAVE",$"Skipping unreadable save '{absolutePath}': {ex.Message}",LogLevel.Warning);return null;}}/// <summary>Empty placeholder for a free slot of a game.</summary>
public static SaveSlotInfo EmptySlot(string gameId,string slotName)=>new(){FileName=slotName,SaveTime=DateTime.MinValue,SceneName="",HasScreenshot=false,IsEmpty=true,ScreenshotPath=$"{GameDirectory(gameId)}/{slotName}.png",GameId=gameId,};private static int SlotNumber(string fileName)=>fileName!=null&&fileName.StartsWith("slot_",StringComparison.Ordinal)&&int.TryParse(fileName.Substring(5),out var number)?number:int.MaxValue;/// <summary> Removes dormant slots from every save of every game (and from the running session), returning how many slots were dropped. Offered in the save settings: dormant data is kept indefinitely otherwise. </summary>
public static int PurgeDormantData(){var purged=_dormant.Count;ClearDormant();var root=GameEnvironment.IsRuntime?ProjectSettings.GlobalizePath(SavesRoot):"saves";if(!Directory.Exists(root))return purged;foreach(var path in Directory.EnumerateFiles(root,"*.json",SearchOption.AllDirectories)){try{var pkg=JsonSerializer.Deserialize<SavePackage>(File.ReadAllText(path),JsonOptionsProvider.ReadOptions);if(pkg?.Dormant==null||pkg.Dormant.Count==0)continue;purged+=pkg.Dormant.Count;pkg.Dormant=null;File.WriteAllText(path,JsonSerializer.Serialize(pkg,JsonOptionsProvider.WriteCompactOptions));}catch(Exception ex){GameLogger.Log("SAVE",$"Could not purge dormant data in '{path}': {ex.Message}",LogLevel.Warning);}}GameLogger.Log("SAVE",$"Purged {purged} dormant slot(s)");return purged;}/// <summary>Snapshot of the active game for <see cref="SavePackage.Game"/>; null without an active game.</summary>
private static SaveGameInfo BuildGameInfo(){if(!Games.ActiveGame.IsActive)return null;var info=new SaveGameInfo{Id=Games.ActiveGame.Id,Name=Games.ActiveGame.Collection?.DisplayTitle,Version=Games.ActiveGame.Collection?.Version,};foreach(var modId in ModRegistry.Instance.GetOrderedModIds())info.Mods.Add(new SaveGameMod{Id=modId,Version=ModManager.Instance.GetMod(modId)?.Version});return info;}/// <summary> Deletes both the JSON save file and its associated PNG screenshot for the given slot. Failures (e.g. file already missing) are logged but do not throw. </summary>
public static void DeleteSave(string fileName){var jsonPath=ProjectSettings.GlobalizePath(GetSavePath(fileName));var pngPath=ProjectSettings.GlobalizePath(GetScreenshotPath(fileName));try{if(File.Exists(jsonPath))File.Delete(jsonPath);if(File.Exists(pngPath))File.Delete(pngPath);}catch(Exception e){GameLogger.Log("SAVE",$"Error deleting save '{fileName}': {e.Message}",LogLevel.Error);}}}}

===== FILE Core/Settings/SaveSlotInfo.cs =====
using System;namespace Cthangover.Core.Settings{/// <summary> Metadata about a single save slot, constructed by <see cref="SaveService.GetSaveSlots"/> to populate the load-game UI. Contains both the header info read from the JSON file and derived flags such as <see cref="HasScreenshot"/> and <see cref="IsEmpty"/>. Even empty slots (no file on disk) produce an instance so the UI always has the full grid of slot entries. </summary>
public class SaveSlotInfo{/// <summary>Logical slot name (e.g. "slot_1").</summary>
public string FileName{get;set;}/// <summary>UTC timestamp of when the save was written.</summary>
public DateTime SaveTime{get;set;}/// <summary>Name of the scene the player was in at save time.</summary>
public string SceneName{get;set;}/// <summary>Godot resource path to the thumbnail PNG (e.g. "user://saves/slot_1.png").</summary>
public string ScreenshotPath{get;set;}/// <summary><c>true</c> if a thumbnail PNG exists on disk for this slot.</summary>
public bool HasScreenshot{get;set;}/// <summary><c>true</c> when no JSON save file exists for this slot.</summary>
public bool IsEmpty{get;set;}/// <summary>In-game time in minutes (<see cref="TimeData.Tick"/>) at save moment.</summary>
public long GameTime{get;set;}/// <summary>Number of recruited characters in the party.</summary>
public int CharacterCount{get;set;}/// <summary>Known placeholder: <see cref="SaveService.GetSaveSlots"/> currently always writes <c>0f</c> — no real lamp percentage is computed yet, so the UI must not rely on this value.</summary>
public float LampPercent{get;set;}/// <summary>Game that wrote the save (null for saves made without a game).</summary>
public string GameId{get;set;}/// <summary>Game display name at save time.</summary>
public string GameName{get;set;}/// <summary>Composition snapshot of the save, for the load-time comparison.</summary>
public System.Collections.Generic.List<SaveGameMod>Mods{get;set;}}}

===== FILE Core/Settings/SettingsData.cs =====
using System.IO;using System.Text.Json;using Cthangover.Core.Utils;namespace Cthangover.Core.Settings{/// <summary> User-adjusted settings (audio, display, language, battle speed, launcher flag) persisted as indented JSON at <c>user://settings.json</c> — a separate, per-device file that only supplies overrides on top of the defaults read from <see cref="GameConfig"/>. Constructing the instance applies the config defaults; <see cref="Load"/> then layers the saved user values on top. </summary>
public class SettingsData{private const string ConfigPath="user://settings.json";/// <summary>SFX master toggle.</summary>
public bool SoundsEnabled{get;set;}/// <summary>Music master toggle.</summary>
public bool MusicsEnabled{get;set;}/// <summary>Ambient audio master toggle.</summary>
public bool AmbientEnabled{get;set;}/// <summary>SFX volume 0–100.</summary>
public int SoundsVolume{get;set;}/// <summary>Music volume 0–100.</summary>
public int MusicsVolume{get;set;}/// <summary>Ambient volume 0–100.</summary>
public int AmbientVolume{get;set;}/// <summary>Locale code string (e.g. "ru-ru").</summary>
public string Language{get;set;}/// <summary>Battle animation speed multiplier (1 = normal).</summary>
public int BattleSpeed{get;set;}/// <summary>Godot <c>DisplayServer.WindowMode</c> value.</summary>
public int WindowMode{get;set;}/// <summary>Horizontal resolution override (pixels).</summary>
public int ResolutionWidth{get;set;}/// <summary>Vertical resolution override (pixels).</summary>
public int ResolutionHeight{get;set;}/// <summary>Zero-based monitor index.</summary>
public int CurrentScreen{get;set;}/// <summary>Vertical synchronization toggle.</summary>
public bool VsyncEnabled{get;set;}/// <summary>Viewport stretch multiplier.</summary>
public int Scale{get;set;}/// <summary><c>true</c> if the launcher window has been shown at least once, suppressing it on subsequent launches.</summary>
public bool LauncherShown{get;set;}/// <summary> Initialises all properties from <see cref="GameConfig.Instance"/> defaults (audio, display, language) and hard-coded fallbacks for settings that have no JSON counterpart (battle speed = 1). </summary>
public SettingsData(){ApplyDefaults();}/// <summary> Resets every property to the factory defaults read from <see cref="GameConfig.Instance"/>. Called during construction and can be re-invoked to discard user overrides. </summary>
public void ApplyDefaults(){var cfg=GameConfig.Instance;SoundsEnabled=cfg.Audio.SoundsEnabled;MusicsEnabled=cfg.Audio.MusicsEnabled;AmbientEnabled=cfg.Audio.AmbientEnabled;SoundsVolume=cfg.Audio.SoundsVolume;MusicsVolume=cfg.Audio.MusicsVolume;AmbientVolume=cfg.Audio.AmbientVolume;Language=cfg.Language;BattleSpeed=1;WindowMode=cfg.Display.WindowMode;ResolutionWidth=cfg.Display.ResolutionWidth;ResolutionHeight=cfg.Display.ResolutionHeight;CurrentScreen=cfg.Display.CurrentScreen;VsyncEnabled=cfg.Display.VsyncEnabled;Scale=cfg.Display.Scale;}/// <summary>Resolves the settings file path for the current environment.</summary>
private static string ResolvePath(){if(GameEnvironment.IsRuntime)return Godot.ProjectSettings.GlobalizePath(ConfigPath);return"settings.json";}/// <summary> Reads <c>user://settings.json</c> and copies every saved value over the in-memory defaults (including <see cref="LauncherShown"/>). Missing file or malformed JSON is silently ignored, keeping defaults. </summary>
public void Load(){var path=ResolvePath();if(!File.Exists(path))return;try{var json=File.ReadAllText(path);var loaded=JsonSerializer.Deserialize<SettingsData>(json,JsonOptionsProvider.ReadOptions);if(loaded!=null){Set(loaded);LauncherShown=loaded.LauncherShown;}}catch{}}/// <summary> Serialises all current property values as indented JSON to <c>user://settings.json</c>, truncating and rewriting the file on each call. <see cref="LauncherShown"/> is persisted with the rest. </summary>
public void Save(){var path=ResolvePath();var dir=Path.GetDirectoryName(path);if(!string.IsNullOrEmpty(dir)&& !Directory.Exists(dir))Directory.CreateDirectory(dir);var json=JsonSerializer.Serialize(this,JsonOptionsProvider.WriteIndentedOptions);File.WriteAllText(path,json);}/// <summary> Bulk-copies all property values from another <see cref="SettingsData"/> instance. Used to apply preset configurations (e.g. from a settings dialog) before saving. Note: <see cref="LauncherShown"/> is intentionally NOT copied. </summary>
public void Set(SettingsData other){SoundsEnabled=other.SoundsEnabled;MusicsEnabled=other.MusicsEnabled;AmbientEnabled=other.AmbientEnabled;SoundsVolume=other.SoundsVolume;MusicsVolume=other.MusicsVolume;AmbientVolume=other.AmbientVolume;Language=other.Language;BattleSpeed=other.BattleSpeed;WindowMode=other.WindowMode;ResolutionWidth=other.ResolutionWidth;ResolutionHeight=other.ResolutionHeight;CurrentScreen=other.CurrentScreen;VsyncEnabled=other.VsyncEnabled;Scale=other.Scale;}}}

===== FILE Core/Settings/TimeData.cs =====
using System;namespace Cthangover.Core.Settings{/// <summary> In-game clock driven by a minute counter (<see cref="Tick"/>). Decomposes the total minutes into years, months, days, hours, and minutes, and exposes time-of-day convenience properties (<see cref="IsMorning"/>, <see cref="IsDay"/>, <see cref="IsEvening"/>, <see cref="IsNight"/>) that map to a <see cref="PhaseType"/>. The clock advances one minute per timer tick via <see cref="AddTick"/>, or can be jumped forward in bulk via <see cref="AddTime"/>. All arithmetic assumes 30-day months and 365-day years — the game does not simulate a real calendar. </summary>
[Serializable]public class TimeData{/// <summary>Total elapsed in-game minutes. Incremented by <c>TimeTickController.OnTimerTick</c> (time_base mod) each timer cycle.</summary>
public long Tick;/// <summary>Year component extracted from <see cref="Tick"/>.</summary>
public int Years{get;private set;}/// <summary>Month component (0–11).</summary>
public int Months{get;private set;}/// <summary>Day component (0–29).</summary>
public int Days{get;private set;}/// <summary>Hour component (0–23).</summary>
public int Hours{get;private set;}/// <summary>Minute component (0–59).</summary>
public int Minutes{get;private set;}/// <summary>Creates the clock seeded with an absolute minute value.</summary> <param name="time">Initial minute value to seed the clock.</param>
public TimeData(long time){SetTime(time);}/// <summary>True between 06:00 and 09:59 inclusive.</summary>
public bool IsMorning=>Hours>=6&&Hours<10;/// <summary>True between 10:00 and 17:59 inclusive.</summary>
public bool IsDay=>Hours>=10&&Hours<18;/// <summary>True between 18:00 and 21:59 inclusive.</summary>
public bool IsEvening=>Hours>=18&&Hours<22;/// <summary>True between 22:00 and 05:59 inclusive (spans midnight).</summary>
public bool IsNight=>(Hours>=22&&Hours<24)||(Hours>=0&&Hours<6);/// <summary>Floating-point hour of day (0.0–23.98), used for smooth interpolation of day/night visual effects. Equals <c>Hours + Minutes / 60f</c>.</summary>
public float Normalized=>Hours+(Minutes/60f);/// <summary>Derived <see cref="PhaseType"/> based on the current hour.</summary>
public PhaseType Phase{get{if(IsMorning)return PhaseType.Morning;if(IsDay)return PhaseType.Day;return IsEvening?PhaseType.Evening:PhaseType.Night;}}/// <summary>Formatted clock string (e.g. "14:05").</summary>
public string Text=>$"{Hours:D2}:{Minutes:D2}";/// <summary> Sets the absolute time from a minute value. Decomposes <paramref name="time"/> into years/months/days/hours/minutes using the game's simplified calendar divisions: 60 min/hour, 1440 min/day, 43200 min/month, 525600 min/year. </summary>
public void SetTime(long time){const int minutesPerHour=60;const int minutesPerDay=1440;const int minutesPerMonth=43200;const int minutesPerYear=525600;Tick=time;Years=(int)(time/minutesPerYear);time%=minutesPerYear;Months=(int)(time/minutesPerMonth);time%=minutesPerMonth;Days=(int)(time/minutesPerDay);time%=minutesPerDay;Hours=(int)(time/minutesPerHour);time%=minutesPerHour;Minutes=(int)time;}/// <summary> Advances the clock by exactly one minute. Called by <c>TimeTickController.OnTimerTick</c> (time_base mod) on each scene-event timer pulse. </summary>
public void AddTick(){SetTime(++Tick);}/// <summary> Jumps the clock forward by the given amounts. Each unit is added to its respective component with carry propagation (60 min → 1 hour, 24 hours → 1 day, 30 days → 1 month, 12 months → 1 year). <see cref="Tick"/> is recalculated from the final components to stay consistent. </summary>
public void AddTime(int years,int months,int days,int hours,int minutes){Years+=years;Months+=months;Days+=days;Hours+=hours;Minutes+=minutes;Hours+=Minutes/60;Minutes%=60;Days+=Hours/24;Hours%=24;Months+=Days/30;Days%=30;Years+=Months/12;Months%=12;Tick+=years*525600+months*43200+days*1440+hours*60+minutes;}}}

===== FILE Core/Shaders/PixelArtPostProcess.cs =====
using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;using Godot;/// <summary> Full-screen post-processing overlay that applies a pixel-art downscale effect via a mod-provided <c>pixel_art_post_process.gdshader</c>. On <c>_Ready</c>, creates a <c>ColorRect</c> covering the full viewport (mouse passthrough, <c>FullRect</c> anchors) and loads the shader through <c>ModManager.ResolveShader</c>. The shader receives a single uniform — <c>pixel_size</c> — via the <see cref="PixelSize"/> property, which can be adjusted at runtime through the Godot inspector. This node is typically added as an <c>AddChild</c> to the scene root during scene construction from a mod subscription or factory. </summary>
public partial class PixelArtPostProcess:Control{/// <summary> The pixel-art block size passed to the shader as the <c>pixel_size</c> uniform. Setting this at runtime immediately updates the shader via <c>SetShaderParameter</c>. </summary>
[Export]public float PixelSize{get=>_pixelSize;set{_pixelSize=value;UpdateShaderParameter();}}private float _pixelSize=1.0f;private ColorRect _colorRect;private ShaderMaterial _shaderMaterial;/// <summary> Creates the full-screen <c>ColorRect</c>, loads the <c>pixel_art_post_process</c> shader from the mod system, and sets the initial <c>pixel_size</c> uniform value. </summary>
public override void _Ready(){_colorRect=new ColorRect();_colorRect.Name="PixelArtPostProcessRect";AddChild(_colorRect);var shader=Shaders.Resolve("pixel_art_post_process");if(shader==null){GameLogger.Log("SHADER","PixelArtPostProcess: Failed to load pixel_art_post_process.gdshader",LogLevel.Error);return;}_shaderMaterial=new ShaderMaterial();_shaderMaterial.Shader=shader;_colorRect.Material=_shaderMaterial;_colorRect.MouseFilter=MouseFilterEnum.Ignore;_colorRect.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);UpdateShaderParameter();}/// <summary> Ensures the overlay <c>ColorRect</c> always covers the entire viewport, even after window resize or resolution changes. </summary>
public override void _Process(double delta){if(_colorRect!=null){_colorRect.Position=Vector2.Zero;_colorRect.Size=GetViewportRect().Size;}}private void UpdateShaderParameter(){if(_shaderMaterial!=null){_shaderMaterial.SetShaderParameter("pixel_size",_pixelSize);}}}

===== FILE Core/Shaders/ShaderModReplacer.cs =====
using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;using Godot;/// <summary> Runtime shader and texture override system for modded scenes. Walks the entire node tree of a freshly-instantiated scene, matching each <c>ShaderMaterial</c> by its resource-path name against the mod shader registry and each <c>TextureRect</c> / <c>Sprite2D</c> texture by name against the mod texture registry. Replacement is name-based (matching <c>Path.GetFileNameWithoutExtension</c> of the original resource path to a mod file entry), so a mod providing <c>shaders/outline.gdshader</c> will automatically override every node whose material originally referenced <c>res://shaders/outline.gdshader</c>. Called from <c>SceneLoader</c> immediately after <c>Instantiate</c>, before <c>_Ready</c> fires on any child — thus overridden materials are in place before any node runs its init logic. </summary>
public static class ShaderModReplacer{/// <summary> Entry point called after scene instantiation. Runs both the shader-material pass and the texture pass in one tree walk, logging counts of each replacement type. </summary>
public static void PatchScene(Node root){var shaderReplaced=PatchShaderMaterials(root);var textureReplaced=PatchTextures(root);if(shaderReplaced>0)GameLogger.Log("SHADER",$"ShaderModReplacer patched {shaderReplaced} shader material(s)");if(textureReplaced>0)GameLogger.Log("SHADER",$"ShaderModReplacer patched {textureReplaced} texture(s)");}private static int PatchShaderMaterials(Node root){var shaders=Shaders.Collect();if(shaders==null||shaders.Count==0)return 0;var replaced=0;PatchShaderNode(root,shaders,ref replaced);return replaced;}private static void PatchShaderNode(Node node,System.Collections.Generic.Dictionary<string,FileEntry>shaders,ref int replaced){if(node is CanvasItem canvasItem&&canvasItem.Material is ShaderMaterial mat){var shader=mat.Shader;if(shader!=null&& !string.IsNullOrEmpty(shader.ResourcePath)){var name=System.IO.Path.GetFileNameWithoutExtension(shader.ResourcePath);if(shaders.TryGetValue(name,out var entry)){var overrideShader=Shaders.TryGetCached(name)??Shaders.Resolve(name);if(overrideShader!=null&&overrideShader!=shader){mat.Shader=overrideShader;replaced++;GameLogger.Log("SHADER",$"  replaced shader '{name}' → mod '{entry.ModId}'");}}}}foreach(var child in node.GetChildren())PatchShaderNode(child,shaders,ref replaced);}private static int PatchTextures(Node root){var textures=Textures.Collect();if(textures==null||textures.Count==0)return 0;var replaced=0;PatchTextureNode(root,textures,ref replaced);return replaced;}private static void PatchTextureNode(Node node,System.Collections.Generic.Dictionary<string,FileEntry>textures,ref int replaced){Texture2D currentTexture=null;if(node is TextureRect textureRect)currentTexture=textureRect.Texture;else if(node is Sprite2D sprite)currentTexture=sprite.Texture;if(currentTexture!=null&& !string.IsNullOrEmpty(currentTexture.ResourcePath)){var name=System.IO.Path.GetFileNameWithoutExtension(currentTexture.ResourcePath);if(textures.TryGetValue(name,out var entry)){var overrideTexture=Textures.TryGetCached(name)??Textures.Resolve(name);if(overrideTexture!=null&&overrideTexture!=currentTexture){if(node is TextureRect tr)tr.Texture=overrideTexture;else if(node is Sprite2D sp)sp.Texture=overrideTexture;replaced++;GameLogger.Log("SHADER",$"  replaced texture '{name}' → mod '{entry.ModId}'");}}}foreach(var child in node.GetChildren())PatchTextureNode(child,textures,ref replaced);}}

===== FILE Core/Skills/SkillData.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Skills{/// <summary> Mutable runtime container tracking which skills a character or the player currently owns. Maintains a set of lowercase string IDs (e.g. "slave_master", the canonical identifier format of the skill definitions owned by the skills_base engine mod). Owned skills are resolved to their definitions by the mod's SkillFactory when the UI needs to display the available skill pool. </summary>
public class SkillData{/// <summary> The collection of owned skill IDs in lowercase string form (e.g. "slave_master"). Exposed as <see cref="ISet{T}"/> to allow external read access while mutation is funneled through <see cref="AddSkill(string)"/> and <see cref="HasSkill(string)"/>. </summary>
public ISet<string>Skills{get;private set;}=new HashSet<string>();/// <summary> Initialises an empty skill collection with no owned skills. </summary>
public SkillData(){}/// <summary> Checks whether the given lowercase string skill ID is present in the owned skill set. </summary> <param name="id">The lowercase skill ID (e.g. "slave_master").</param> <returns><c>true</c> if the skill is owned; otherwise <c>false</c>.</returns>
public bool HasSkill(string id){return Skills.Contains(id);}/// <summary> Adds a skill to the owned set using its raw lowercase string ID. Duplicate additions are silently ignored. </summary> <param name="id">The lowercase skill ID (e.g. "slave_master").</param>
public void AddSkill(string id){Skills.Add(id);}}}

===== FILE Core/UI/Base/ModSpriteLoader.cs =====
using Cthangover.Core.Factories.Impls;using Cthangover.Core.Mods;using Godot;namespace Cthangover.Core.UI.Base{/// <summary> Editor-configured texture indirection: resolves a TextureId string through UITextureFactory and applies the result to the parent TextureRect. This allows artists to reference textures by logical ID in the Godot inspector instead of hardcoding paths, and mods can override which texture a given ID resolves to. </summary>
public partial class ModSpriteLoader:Node{/// <summary>Logical texture identifier resolved through <see cref="UITextureFactory"/>. Set in the Godot inspector.</summary>
[Export]public string TextureId{get;set;}public override void _Ready(){Reload();}/// <summary> Resolves <see cref="TextureId"/> through <see cref="UITextureFactory"/> and applies it to the parent TextureRect. Can be called again after the mods load completes — during launcher boot the texture may not be resolvable yet, so the boot flow re-invokes this once mods are ready. </summary>
public void Reload(){if(string.IsNullOrEmpty(TextureId))return;// During launcher boot the scene is instantiated before the background mod load finishes; the launcher re-invokes Reload once mods are ready, so skip the guaranteed miss instead of logging a false error.
if(!ModManager.Instance.IsInitialized)return;var texture=UITextureFactory.Instance.Get(TextureId);if(texture!=null&&GetParent()is TextureRect sprite)sprite.Texture=texture;}}}

===== FILE Core/UI/Dialog/IDialogBox.cs =====
using Godot;namespace Cthangover.Core.UI.Dialog{/// <summary> Dialog box contract retained in the core as the transition surface for scene-level consumers. The concrete DialogBox implementation and the extended runtime surface (queue execution, variant selection, runtime inspection) live in the dialog_base engine mod; core code (SceneManager, InteractiveManager) only activates, deactivates and inspects the box through this contract. </summary>
public interface IDialogBox:IWidget{/// <summary>Whether the answer/choice box is currently displayed.</summary>
bool IsAnswerBoxShowed{get;}/// <summary>Whether a dialog queue is currently executing (locker present).</summary>
bool IsRunning{get;}/// <summary>Gate for new dialog queues. Set to false to prevent dialogs during non-dialog scenes.</summary>
bool IsActive{get;set;}/// <summary>Sets the main dialog text. Body visibility toggles based on whether text is empty.</summary>
void SetText(string text);/// <summary>Sets the title bar text. Null or empty hides the title bar.</summary>
void SetTitle(string title);/// <summary>Sets the left (primary) avatar texture. Use <paramref name="hideColor"/> for shader-based silhouette mode. Null hides the avatar.</summary>
void SetFirstAvatar(Texture2D avatar,bool hideColor=false);/// <summary>Sets the right (secondary) avatar texture. Use <paramref name="hideColor"/> for shader-based silhouette mode. Null hides the avatar.</summary>
void SetSecondAvatar(Texture2D avatar,bool hideColor=false);/// <summary>Advances past the current action if its wait condition is satisfied. Called by click/key input and update polling.</summary>
void NextAction();/// <summary>Whether the current dialog queue reached its end (also true before the first queue loads).</summary>
bool IsDialogEnded{get;}/// <summary>Whether the dialog is waiting for the player to pick a variant.</summary>
bool IsWaitingAnswer{get;}/// <summary>Number of answer variants currently shown (0 when the answer box is hidden).</summary>
int AnswerVariantCount{get;}/// <summary>Selects the shown answer variant at <paramref name="index"/>. Returns false when no variants are shown or the index is out of range.</summary>
bool TrySelectVariant(int index);}}

===== FILE Core/UI/Event/IEventPriority.cs =====
namespace Cthangover.Core.UI.Event{/// <summary> Priority marker for event subscriber sorting. Lower values execute first. Used by SceneEventController to sort listener lists so high-priority handlers (e.g. input blocking) run before general-purpose subscribers. </summary>
public interface IEventPriority{int Priority{get;}}}

===== FILE Core/UI/Event/IOnTimerEvent.cs =====
namespace Cthangover.Core.UI.Event{/// <summary> Fires on each timer tick (~1 second intervals) from SceneEventController. Named IOnTimeEvent in code despite the file name being IOnTimerEvent. Used by systems that need periodic updates slower than per-frame. <para/> <b>Boot-window contract (do not skip reading this):</b> the controller ticks from the very first frames of the app — <c>ProcessMode.Always</c>, <c>IsActive = true</c> by default — including the launcher boot window, while mods are still compiling in the background. <see cref="OnTimerTick"/> implementations therefore must NOT touch <see cref="Settings.GameData.Instance.Runtime"/>, <see cref="Mods.ModRegistry"/>, <see cref="Mods.ModManager"/> or any factory until <see cref="Bootstrap.Boot.ModsReady"/>: an early Runtime access creates <see cref="Settings.RuntimeData"/> before the starter inventory can be seeded through loaded mod factories (it stays empty for the whole session) and spams "Initialize refused" errors. Guard with <c>if (!Bootstrap.Boot.ModsReady) return;</c> — see <c>TimeTickController.OnTimerTick</c> in the time_base engine mod for the reference implementation and RecruitTickController in the recruiting mod for a mod-side one. </summary>
public interface IOnTimeEvent:IEventPriority{/// <summary>Called every ~1s from the first frames of the app, boot window included; observe the boot-window contract documented on this interface.</summary>
void OnTimerTick();}}

===== FILE Core/UI/Event/IOnUpdateEvent.cs =====
namespace Cthangover.Core.UI.Event{/// <summary> Per-frame update callback. Subscribers receive <see cref="OnUpdate"/> on every frame when the <see cref="SceneEventController"/> is active. </summary>
public interface IOnUpdateEvent:IEventPriority{void OnUpdate();}}

===== FILE Core/UI/Event/SceneEventController.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Executable;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.UI.Event{/// <summary> Central event bus for the UI subsystem. Maintains priority-sorted subscriber lists for Update, TimerTick, and DialogStart events (dialog-end notifications are published on the ModEventBus as DialogEndedEvent). When subscribers are added or removed, the list is re-sorted by Priority (ascending — lower value = earlier execution). Runs at ProcessPriority.MaxValue to execute before other nodes. Timer ticks fire at 1-second intervals from an accumulator to avoid drift — from the first processed frames of the app, never paused during the launcher boot. Each tick subscriber is wrapped in try/catch to prevent one faulty handler from breaking all others. IsActive lets the bus be paused mid-scene without unregistering all listeners. <para/> Tick subscribers: see the boot-window contract on <see cref="IOnTimeEvent"/> before touching game state in <see cref="IOnTimeEvent.OnTimerTick"/>. </summary>
public partial class SceneEventController:Node{private string guid;private List<IOnUpdateEvent>OnUpdateEventList=new();private List<IOnTimeEvent>OnTimerTickEventList=new();/// <summary> <c>true</c> after <see cref="_ExitTree"/> runs and <see cref="ClearData"/> has been called. External code should check this before registering new listeners on a dead controller. </summary>
public bool IsDestroyed{get;private set;}/// <summary> Controls whether <c>_Process</c> dispatches events. When <c>false</c>, the event pump continues running but all subscriber lists are paused. Distinct from <see cref="IsActive"/> — use this for flow control. </summary>
public bool IsRunning{get;set;}/// <summary> Master enable/disable for event dispatch. When <c>false</c>, <c>_Process</c> skips all subscriber lists entirely. Use during scene transitions or loading screens to suppress UI events without unregistering listeners. </summary>
public bool IsActive{get;set;}=true;private double timerAccum;private const double TickInterval=1.0;/// <summary>Runs last among process callbacks (max priority) so listeners observe fully updated state, and keeps ticking while the game is paused.</summary>
public override void _Ready(){guid=Guid.NewGuid().ToString();ProcessPriority=int.MaxValue;ProcessMode=ProcessModeEnum.Always;}/// <summary>Drops all listener registrations and marks the controller destroyed when it leaves the tree.</summary>
public override void _ExitTree(){ClearData();IsDestroyed=true;}/// <summary>Accumulates time into one-second timer ticks and, when active, dispatches every registered per-frame update listener.</summary>
public override void _Process(double delta){timerAccum+=delta;while(timerAccum>=TickInterval){timerAccum-=TickInterval;TickTimer();}if(!IsActive)return;for(int i=OnUpdateEventList.Count-1;i> -1;i--){var eventSubscriber=OnUpdateEventList[i];if(eventSubscriber==null){OnUpdateEventList.RemoveAt(i);continue;}// Same contract as TickTimer: one faulty (e.g. disposed across a scene switch) handler must not break the remaining subscribers, so each update is isolated.
try{eventSubscriber.OnUpdate();}catch(ObjectDisposedException ex){OnUpdateEventList.RemoveAt(i);GameLogger.Log("EVENT",$"Update subscriber {eventSubscriber.GetType().Name} was disposed, unregistered: {ex.Message}",LogLevel.Error);}catch(Exception ex){GameLogger.Log("EVENT",$"Update subscriber {eventSubscriber.GetType().Name} threw: {ex.Message}",LogLevel.Error);}}}private void TickTimer(){for(int i=OnTimerTickEventList.Count-1;i> -1;i--){var eventSubscriber=OnTimerTickEventList[i];if(eventSubscriber==null){OnTimerTickEventList.RemoveAt(i);continue;}try{eventSubscriber.OnTimerTick();}catch(Exception ex){GameLogger.Log("EVENT",$"TickTimer subscriber {eventSubscriber.GetType().Name} threw: {ex.Message}",LogLevel.Error);}}}
#region Add
/// <summary>Registers an <see cref="IOnUpdateEvent"/> subscriber for per-frame updates.</summary>
public void AddUpdateEventListener(IOnUpdateEvent listener)=>AddListener(listener,OnUpdateEventList);/// <summary> Registers a timer-tick subscriber for ~1 second interval callbacks. Ticks start with the first processed frame and keep firing while the launcher boot compiles mods (this node is <c>ProcessMode.Always</c>) — a subscriber touching <c>GameData.Instance.Runtime</c>, the mod registry or factories inside <see cref="IOnTimeEvent.OnTimerTick"/> must guard with <c>Boot.ModsReady</c>; see the boot-window contract on <see cref="IOnTimeEvent"/> and the time mod's TimeTickController for the reference implementation. </summary>
public void AddTimerTickEventListener(IOnTimeEvent listener)=>AddListener(listener,OnTimerTickEventList);
#endregion
#region Remove
/// <summary>Unregisters a per-frame update subscriber.</summary>
public void RemoveUpdateEventListener(IOnUpdateEvent listener)=>RemoveListener(listener,OnUpdateEventList);/// <summary>Unregisters a timer-tick subscriber.</summary>
public void RemoveTimerTickEventListener(IOnTimeEvent listener)=>RemoveListener(listener,OnTimerTickEventList);
#endregion
private void AddListener<T>(T listener,List<T>eventList)where T:class,IEventPriority{if(listener==null||eventList.Contains(listener))return;eventList.Add(listener);eventList.Sort((o1,o2)=>o1.Priority-o2.Priority);}private void RemoveListener<T>(T listener,List<T>eventList)where T:class,IEventPriority{if(listener==null)return;if(eventList.Remove(listener))eventList.Sort((o1,o2)=>o1.Priority-o2.Priority);}/// <summary> Empties all subscriber lists without calling callbacks. Called in <see cref="_ExitTree"/> to cleanly detach all listeners. </summary>
public void ClearData(){OnUpdateEventList.Clear();OnTimerTickEventList.Clear();}}}

===== FILE Core/UI/Executable/IConditionedEvent.cs =====
namespace Cthangover.Core.UI.Executable{/// <summary> Contract for events carrying a scenario condition expression. Implemented by the dialog mod's ScenarioEvent; SceneManager assigns conditions through this interface without referencing the concrete mod type. </summary>
public interface IConditionedEvent{/// <summary>Condition expression evaluated by the event before triggering. Empty means always pass.</summary>
string Condition{get;set;}}}

===== FILE Core/UI/Executable/IEventObject.cs =====
using Cthangover.Core.Factories;namespace Cthangover.Core.UI.Executable{/// <summary> Contract for event-tracked objects: extends IIdentifiable (has an ID string) and must support Destruct for cleanup. </summary>
public interface IEventObject:IIdentifiable{void Destruct();}}

===== FILE Core/UI/Executable/IExecutableEvent.cs =====
namespace Cthangover.Core.UI.Executable{/// <summary> Core contract for executable scene events. The concrete ExecutableEvent implementation lives in the dialog_base engine mod; core consumers (SceneManager, SaveService, SceneEventRegistry) work against this interface so they never reference mod types. </summary>
public interface IExecutableEvent{/// <summary>True until the event runs for the first time. Used by chains to decide whether a one-shot event has fired.</summary>
bool IsFirstRun{get;}/// <summary>When true, the event fires exactly once and never again, even across scene revisits.</summary>
bool IsOneRun{get;set;}/// <summary>Stable identifier of the event (type name or scenario id) used for save/load tracking and locker matching.</summary>
string ID{get;}}}

===== FILE Core/UI/Executable/IExecutableEventChain.cs =====
namespace Cthangover.Core.UI.Executable{/// <summary> Contract for event chains. ExecutableEvent uses it to decide whether the event should self-register with SceneEventController or be managed by its parent chain. The concrete chains (ExecutableEventListChain, ExecutableMainEventChain) live in the dialog_base engine mod; core code (SceneManager, SaveService) drives chains through this interface. </summary>
public interface IExecutableEventChain{/// <summary>Registers an event with the chain and parents it to the chain node.</summary>
void AddEvent(IExecutableEvent evt);/// <summary>Removes and frees all currently registered events.</summary>
void ClearEvents();}}

===== FILE Core/UI/Executable/IScenarioEventFactory.cs =====
namespace Cthangover.Core.UI.Executable{/// <summary> Factory for scenario-backed executable events. Implemented by the dialog_base engine mod and published through <c>AppServices.Current.SceneEvents.ScenarioEventFactory</c> so core scene code can build scenario events without referencing mod types. </summary>
public interface IScenarioEventFactory{/// <summary>Builds a one-shot event that executes the inline <paramref name="scenarioText"/> (test-play path).</summary>
IExecutableEvent CreateFromText(string scenarioText);/// <summary> Builds an event from a scene event definition's scenario fields. <paramref name="modId"/> and <paramref name="filePath"/> name the file the text came from, so tools (the scenario studio) can map a running event back to its source; both may be null for generated text. </summary>
IExecutableEvent CreateFromDefinition(string scenarioText,string scenarioId,string condition,bool?lightUseTime,bool isOneRun,string modId,string filePath);}}

===== FILE Core/UI/IBackgroundView.cs =====
using Godot;namespace Cthangover.Core.UI{/// <summary> Kernel seam for the shell's background viewport (the interface engine mod's ViewBox). SceneManager pushes random scene backgrounds through it and SaveScreenshotService grabs the current texture for save thumbnails. The kernel knows this contract, never the implementing mod type — the same direction as IDialogBox and the LightingBridge delegate slots. </summary>
public interface IBackgroundView{/// <summary>The background TextureRect; may be null until the node is ready.</summary>
TextureRect Background{get;}/// <summary>Replaces the background texture immediately (used mid-scene-transition).</summary>
void SetBackgroundTexture(Texture2D texture);/// <summary>Crossfades to the texture through the black overlay (normal scene entry).</summary>
void TransitionBackground(Texture2D newTexture);}}

===== FILE Core/UI/ISaveIconHost.cs =====
namespace Cthangover.Core.UI{/// <summary> Kernel seam for the shell's save indicator (the interface engine mod's ToolBox/SaveIcon pair). SceneManager refreshes the indicator after a scene's event set changes; the kernel never references the implementing mod type. </summary>
public interface ISaveIconHost{/// <summary>Re-evaluates the save-allowed state of the current scene and updates the indicator.</summary>
void UpdateSaveIconVisibility();}}

===== FILE Core/UI/IToolbarHost.cs =====
using System.Collections.Generic;using Cthangover.Core.Mods;namespace Cthangover.Core.UI{/// <summary> Kernel seam for the shell toolbar (the interface engine mod's ToolBox). UiBuilder hands the pre-sorted panel entries to the toolbar once per shell without a compile-time reference to the implementing mod type. </summary>
public interface IToolbarHost{/// <summary>Materializes a toolbar button for every panel entry (arrived pre-sorted from the UiRegistry).</summary>
void ApplyPanelButtons(IReadOnlyList<UiEntry>entries);}}

===== FILE Core/UI/IUiLazyConstruct.cs =====
namespace Cthangover.Core.UI{/// <summary> Kernel seam for lazily constructed mod controls (the interface engine mod's ModWidget). UiHost runs the one-time construction right after a panel instance is built and parented, so the panel is initialized even when it is shown through plain Control visibility. </summary>
public interface IUiLazyConstruct{/// <summary>Idempotent one-time construction gate.</summary>
void EnsureConstructed();}}

===== FILE Core/UI/IWidget.cs =====
using Godot;namespace Cthangover.Core.UI{/// <summary> Core widget contract: every visible UI piece exposes Show/Hide, a Body node (the actual rendered subtree), a Rect (the layout Control), and a Visible flag. The separation of Body from Rect allows the widget's root Control to handle layout while a different child renders the content. <para/> Kernel seam: the widget implementation (<c>Widget</c>) lives in the interface engine mod; the kernel (UiHost) drives widget instances only through this contract, exactly like the IDialogBox / IExecutableEvent contracts it already owns. </summary>
public interface IWidget{/// <summary>Whether the widget is currently visible. Setting this routes through <see cref="Show"/> and <see cref="Hide"/> respectively.</summary>
bool Visible{get;set;}/// <summary>The root Control used for layout and positioning of this widget within its parent.</summary>
Control Rect{get;}/// <summary>The subtree that actually renders content. May differ from <see cref="Rect"/> when layout and rendering are split across nodes.</summary>
Node Body{get;}/// <summary>Makes the widget visible and triggers the construct lifecycle if this is the first display.</summary>
void Show();/// <summary>Hides the widget and triggers destruct lifecycle to clean up state.</summary>
void Hide();/// <summary>Toggles visibility: shows when hidden, hides when shown. Drives the kernel UiHost toggle path.</summary>
void Switch();}}

===== FILE Core/UI/Menu/BootLogPanel.cs =====
using System.Collections.Generic;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.UI.Menu{/// <summary> Terminal-style boot log feed shown by the launcher while mods load on a background thread. Renders the most recent line (the "active" one) plus up to four older lines, each fading out smoothly over time. Log entries arrive from the worker thread via <see cref="GameLogger.LogEmitted"/>, so the handler only appends to a lock-protected queue; the actual Godot label updates happen on the main thread inside <see cref="_Process"/>. </summary>
public partial class BootLogPanel:Control{private const int VisibleLines=5;private const float FadeSeconds=3.0f;private readonly Queue<LogLine>_pending=new();private readonly object _queueLock=new();private readonly List<LogLine>_lines=new();private Label[]_labels;private struct LogLine{/// <summary>Formatted log line, including its category prefix.</summary>
public string Text;/// <summary>Seconds since the line appeared; drives the fade-out.</summary>
public float Age;}/// <summary>Builds the label stack, seeds it with recent log lines and subscribes to <see cref="GameLogger.LogEmitted"/>.</summary>
public override void _Ready(){MouseFilter=MouseFilterEnum.Ignore;var box=new VBoxContainer();box.SetAnchorsPreset(LayoutPreset.FullRect);box.Alignment=BoxContainer.AlignmentMode.End;box.AddThemeConstantOverride("separation",2);AddChild(box);_labels=new Label[VisibleLines];for(var i=0;i<VisibleLines;i++){var label=new Label();label.AddThemeFontSizeOverride("font_size",15);label.AddThemeColorOverride("font_color",UiTokens.Body);label.HorizontalAlignment=HorizontalAlignment.Left;label.Modulate=UiTokens.Alpha(UiTokens.White,0f);box.AddChild(label);_labels[i]=label;}foreach(var line in GameLogger.GetRecentLines(VisibleLines)){_lines.Add(new LogLine{Text=line,Age=0f});}GameLogger.LogEmitted+=OnLogEmitted;}/// <summary>Unsubscribes from the logger so a freed panel cannot receive worker-thread events.</summary>
public override void _ExitTree(){GameLogger.LogEmitted-=OnLogEmitted;}private void OnLogEmitted(string category,string message,LogLevel level){lock(_queueLock){_pending.Enqueue(new LogLine{Text=$"[{category}] {message}",Age=0f});}}/// <summary>Main-thread drain: pulls queued lines, ages them out and repaints the fading label stack.</summary>
public override void _Process(double delta){lock(_queueLock){while(_pending.Count>0)_lines.Add(_pending.Dequeue());}// Age every line; drop ones that have faded completely.
for(var i=_lines.Count-1;i>=0;i--){var line=_lines[i];line.Age+=(float)delta;_lines[i]=line;if(line.Age>=FadeSeconds)_lines.RemoveAt(i);}// Cap history so a fast log burst (e.g. mod discovery) cannot grow the list without bound before lines age out.
while(_lines.Count>256)_lines.RemoveAt(0);// Show the newest line + up to 4 before it; older = more transparent. The newest line sits in the bottom-most label (index VisibleLines-1), so with few lines the top labels stay empty. A negative line index marks exactly those top labels; it also covers the first seconds of a run, when the logger has emitted fewer than VisibleLines lines.
var start=_lines.Count-VisibleLines;for(var i=0;i<VisibleLines;i++){var lineIndex=start+i;if(lineIndex>=0){var line=_lines[lineIndex];var alpha=Mathf.Clamp(1f-line.Age/FadeSeconds,0f,1f);_labels[i].Text=line.Text;_labels[i].Modulate=UiTokens.Alpha(UiTokens.White,alpha);}else{_labels[i].Text="";_labels[i].Modulate=UiTokens.Alpha(UiTokens.White,0f);}}}}}

===== FILE Core/UI/Menu/BootProgressBar.cs =====
using Cthangover.Core.Bootstrap;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Core.UI.Menu{/// <summary> Thin progress bar for the launcher boot overlay. Polls the thread-safe <see cref="BootProgress"/> snapshot every frame — the boot pipeline and the mod compiler run on worker threads and never touch Godot nodes, so all rendering-side updates happen here in <see cref="_Process"/>. Custom-drawn instead of a themed ProgressBar to keep the boot overlay's minimal look: a flat accent strip over a faint well. </summary>
public partial class BootProgressBar:Godot.Control{/// <summary>Displayed value chases the snapshot with this smoothing factor per second.</summary>
private const float EasePerSecond=6f;private float _displayed;/// <inheritdoc/>
public BootProgressBar(){MouseFilter=MouseFilterEnum.Ignore;CustomMinimumSize=new(0,10);}/// <summary>Repaints the bar from the latest <see cref="BootProgress.GetOverall"/> snapshot.</summary>
public override void _Process(double delta){var target=BootProgress.GetOverall();// Ease toward the target; never ease backwards so a fast phase transition cannot visually rewind the bar.
if(target>_displayed)_displayed=System.Math.Min(target,_displayed+EasePerSecond*(float)delta*(target-_displayed+0.02f));else _displayed=target;QueueRedraw();}/// <summary>Draws the well and the accent fill for the current progress value.</summary>
public override void _Draw(){var size=GetSize();DrawRect(new Rect2(0,0,size.X,size.Y),UiTokens.Well);DrawRect(new Rect2(0,0,size.X,1),UiTokens.LineSoft);DrawRect(new Rect2(0,size.Y-1,size.X,1),UiTokens.LineSoft);if(_displayed<=0f)return;var fillWidth=size.X*_displayed;DrawRect(new Rect2(0,0,fillWidth,size.Y),UiTokens.Accent);// Bright leading edge so movement stays readable during long phases.
DrawRect(new Rect2(fillWidth-2,0,2,size.Y),UiTokens.AccentBright);}}}

===== FILE Core/UI/Menu/IMenuPanel.cs =====
using System;namespace Cthangover.Core.UI.Menu{/// <summary> Shared panel contracts core menus consume through the <see cref="MenuRegistry"/> without a compile-time dependency on the mod classes behind them. A panel scene registers its <see cref="MenuPanelEntry"/> in an IModInitializer and its root script implements the matching interface; the host menu instantiates the panel via <see cref="MenuRegistry.InstantiatePanel"/> and talks to the interface only. </summary>
public interface IMenuPanel{/// <summary>Makes the panel visible on top of its host menu.</summary>
void Open();}/// <summary> Settings panels opened from other menus. Host menus subscribe to <see cref="LanguageChanged"/> to retranslate their own buttons after a language switch inside the panel. </summary>
public interface ISettingsPanel:IMenuPanel{/// <summary>Raised after the player applied a different language.</summary>
event Action LanguageChanged;}/// <summary>Save/load panels with their two entry modes.</summary>
public interface ISaveLoadPanel:IMenuPanel{/// <summary>Opens the panel in load mode and refreshes the slot list.</summary>
void OpenForLoad();/// <summary>Opens the panel in save mode and refreshes the slot list.</summary>
void OpenForSave();}}

===== FILE Core/UI/Menu/Launcher.cs =====
using System;using System.Threading;using Cthangover.Core.Bootstrap;using Cthangover.Core.Localization;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Testing;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Core.Utils;using Cthangover.Core.UI.Base;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Core.UI.Menu{/// <summary> Pre-game launcher that lets the player configure display settings (window mode, resolution, monitor, V-Sync, scale) before entering the main menu. Applies settings via DisplayServer and ProjectSettings, then transitions to MainMenu.tscn on Play. Built entirely in code (no TSCN layout) so the scene file is minimal and settings population is co-located with the logic. </summary>
public partial class Launcher:Control{private OptionButton _windowModeSelect;private OptionButton _resolutionSelect;private OptionButton _monitorSelect;private OptionButton _scaleSelect;private CheckButton _vsyncToggle;/// <summary> Cancels the background mod compile if the launcher leaves the tree (player quits while the boot overlay is still streaming the log). The token only prevents the pipeline from reaching <see cref="ModManager.FinalizeGodotInit"/> on a dying scene; the running Roslyn emit still completes, see <see cref="ModManager.DiscoverAndCompile"/>. </summary>
private readonly CancellationTokenSource _bootCts=new();private static readonly Vector2I[]CommonResolutions={new(1280,720),new(1366,768),new(1600,900),new(1920,1080),new(2560,1440),new(3840,2160),};private static readonly int[]ScaleOptions={1,2,3,4};/// <summary>Applies the theme, then branches into boot overlay, skip-to-main-menu or the interactive launcher depending on boot state.</summary>
public override void _Ready(){CthangoverTheme.Apply(this);if(BootController.IsLauncherBoot){ShowBootOverlay();CallDeferred(nameof(RunBootAsync));return;}if(GameData.Instance?.Settings?.LauncherShown==true){CallDeferred(nameof(SkipToMainMenu));return;}BuildUI();LoadSettingsToUI();}/// <summary> Aborts the launcher-driven boot pipeline when the scene is torn down, so <see cref="RunBootAsync"/> does not finalize mods or touch freed UI after the player has already left. Only cancellation is performed here — disposing the source could race with the pending boot task reading <c>Token</c>. </summary>
public override void _ExitTree(){_bootCts.Cancel();}private Control _bootOverlay;/// <summary> Full-screen dark overlay with a streaming boot log and a localized first-launch hint. Drawn before mod initialisation so the player sees live progress instead of a frozen window while mods compile on a background thread. </summary>
private void ShowBootOverlay(){_bootOverlay=new Control();_bootOverlay.SetAnchorsPreset(LayoutPreset.FullRect);_bootOverlay.MouseFilter=Control.MouseFilterEnum.Stop;var bg=new ColorRect();bg.SetAnchorsPreset(LayoutPreset.FullRect);bg.Color=UiTokens.Page;_bootOverlay.AddChild(bg);var hint=new Label();hint.Text=BootMessages.FirstLaunchHint(GameData.Instance?.Settings?.Language);hint.SetAnchorsPreset(LayoutPreset.TopWide);hint.OffsetLeft=UiTokens.Space8;hint.OffsetRight= -UiTokens.Space8;hint.OffsetTop=UiTokens.Space9;hint.OffsetBottom=UiTokens.Space9;hint.HorizontalAlignment=HorizontalAlignment.Center;hint.AutowrapMode=TextServer.AutowrapMode.WordSmart;hint.AddThemeFontSizeOverride("font_size",UiTokens.FontLead);hint.AddThemeColorOverride("font_color",UiTokens.Body);_bootOverlay.AddChild(hint);// Boot progress bar: polls the thread-safe BootProgress snapshot in _Process instead of subscribing to worker-thread events, so no Godot API is ever touched off the main thread.
var progress=new BootProgressBar();progress.SetAnchorsPreset(LayoutPreset.TopWide);progress.OffsetLeft=UiTokens.Space8;progress.OffsetRight= -UiTokens.Space8;progress.OffsetTop=UiTokens.Space9+48;progress.OffsetBottom=progress.OffsetTop+10;_bootOverlay.AddChild(progress);var log=new BootLogPanel();log.SetAnchorsPreset(LayoutPreset.FullRect);_bootOverlay.AddChild(log);AddChild(_bootOverlay);}private void HideBootOverlay(){if(_bootOverlay!=null&&GodotObject.IsInstanceValid(_bootOverlay))_bootOverlay.QueueFree();_bootOverlay=null;}/// <summary> Thin UI wrapper over <see cref="Boot.RunLauncherAsync"/>: the unified boot pipeline yields a frame so the boot overlay renders first, then resolves the Godot-bound paths, runs mod discovery + compilation on a background thread and finishes the Godot-bound phases (finalize, runtime, locale, scene registries) on the main thread. After the pipeline completes, reloads the mod-provided background sprites, hides the boot overlay and finishes either with the settings UI or a direct skip to the main menu. </summary>
private async void RunBootAsync(){try{await Boot.RunLauncherAsync(this,_bootCts.Token);}catch(OperationCanceledException){return;}catch(Exception ex){// A failed boot must never soft-lock the launcher behind the boot overlay: log loudly, drop the overlay and keep going so the window stays responsive; readiness guards in ModRegistry keep every too-early mod consumer loud until a re-run.
GameLogger.Log("BOOT",$"Launcher boot FAILED: {ex.Message}",LogLevel.Error);}if(!GodotObject.IsInstanceValid(this))return;// Mod unit-test gate (plan-mod-testing.md §3.1/§5.1): after a fully completed boot the engine — and only on an explicit request — compiles and runs mod tests instead of entering the main menu, then quits with the standard exit code contract.
if(Boot.IsReady&&ModTestEngine.IsTestMode()){var requestedWhileTesting=SceneLaunchArgs.RequestedSceneName();if(requestedWhileTesting!=null)GameLogger.Log("SCENE",$"--scene='{requestedWhileTesting}' ignored: --run-tests takes priority",LogLevel.Warning);await RunModTestsAndQuitAsync();return;}// Direct scene navigation (--scene=<logical>): a first-class launch mode after a fully completed boot. Skips the launcher UI and the main menu, drops the player into the requested mod-defined scene through the same base-scene + deferred SwitchScene sequence every logical navigation uses. An unknown name logs an error and falls through to the normal launcher flow instead of crashing.
if(Boot.IsReady&&SceneLaunchArgs.RequestedSceneName()is{}sceneTarget){await LaunchRequestedSceneAsync(sceneTarget);return;}// Game launch (--game=<id> [--new | --load=<save>]): the boot already kept only that game's mods, so go straight into it. A failed load falls through to the normal launcher flow with the error logged.
if(Boot.IsReady&&Games.ActiveGame.IsActive&&EnterActiveGame())return;GetNodeOrNull<ModSpriteLoader>("Background/SpriteLoader")?.Reload();HideBootOverlay();if(GameData.Instance?.Settings?.LauncherShown==true){SkipToMainMenu();return;}BuildUI();LoadSettingsToUI();}/// <summary> Feature terminus of <c>--scene=&lt;logical&gt;</c>: validates the name against <see cref="SceneManager.HasScene"/>, then replays the canonical logical-scene entry sequence in the core — instantiate the mod-provided base scene shell (the ViewBox/event host the game expects to live in), and issue <see cref="SceneManager.SwitchScene"/> on the next frames once the autoloads have settled. Because only the shell is swapped, all scene routing (routes, native Godot scenes, scenario scenes) works unchanged. On an unknown name the error is logged and the launcher falls back to its normal flow (menu/UI) — a typo must never crash the game. </summary> <summary> Enters the active game from the launch args: loads the requested save or starts a new game at the collection's entry scene. Returns false when the requested save could not be loaded. </summary>
private bool EnterActiveGame(){var args=Games.GameLaunchArgs.Current;HideBootOverlay();if(!string.IsNullOrEmpty(args.LoadSave)){if(Games.GameSession.LoadAndEnter(this,args.LoadSave))return true;GameLogger.Log("GAME",$"Save '{args.LoadSave}' of game '{Games.ActiveGame.Id}' could not be loaded",LogLevel.Error);return false;}Games.GameSession.StartNew(this);return true;}private async System.Threading.Tasks.Task LaunchRequestedSceneAsync(string sceneName){var sceneManager=GetNodeOrNull<SceneManager>("/root/SceneManager");if(sceneManager==null){GameLogger.Log("SCENE","--scene: SceneManager autoload not found, continuing normal launch",LogLevel.Error);return;}if(!sceneManager.HasScene(sceneName)){GameLogger.Log("SCENE",$"--scene: logical scene '{sceneName}' not found, continuing normal launch",LogLevel.Error);return;}GameLogger.Log("SCENE",$"--scene: launching logical scene '{sceneName}'");var baseScenePath=GodotSceneService.GetScenePath(GodotSceneType.BaseScene);var baseScene=TscnScenes.LoadAndBuild(baseScenePath);if(baseScene==null){GameLogger.Log("SCENE",$"--scene: failed to build base scene '{baseScenePath}', continuing normal launch",LogLevel.Error);return;}GetTree().Root.AddChild(baseScene);// Let the shell enter the tree (its _Ready wires the event chain and ViewBox) before the scene pipeline starts publishing events into it.
await ToSignal(GetTree().CreateTimer(0.2),SceneTreeTimer.SignalName.Timeout);if(!GodotObject.IsInstanceValid(this))return;sceneManager.SwitchScene(sceneName);// The launcher scene is no longer the front: free its UI so the base scene shell is the only thing on screen.
GetTree().CurrentScene=baseScene;QueueFree();}/// <summary> Test-mode terminus of the boot pipeline: runs the mod test engine for all eligible mods (or the <c>--mod=</c> filtered one), prints the report through the standard channels and quits the process with exit code 0/1 — the same contract every integration driver uses. </summary>
private async System.Threading.Tasks.Task RunModTestsAndQuitAsync(){GameLogger.Log("MODTEST","Test mode active (--run-tests / run_mod_tests): running mod tests");// Taken up front: the quit must happen even if this node is gone by the end of the run, otherwise the process hangs until an external timeout kills it.
var tree=GetTree();bool success;try{success=await ModTestEngine.RunAllAsync(ModTestEngine.RequestedModFilter(),_bootCts.Token);}catch(OperationCanceledException){success=false;}catch(Exception ex){GameLogger.Log("MODTEST",$"Mod test run failed: {ex.Message}",LogLevel.Error);success=false;}GameLogger.Log("MODTEST",$"Mod test run finished, success={success}, quitting");tree.Quit(success?0:1);}private void SkipToMainMenu(){var s=GameData.Instance?.Settings;if(s!=null){var mode=(DisplayServer.WindowMode)s.WindowMode;DisplayServer.WindowSetMode(mode);if(mode==DisplayServer.WindowMode.Windowed)DisplayServer.WindowSetSize(new Vector2I(s.ResolutionWidth,s.ResolutionHeight));if(s.CurrentScreen>=0&&s.CurrentScreen<DisplayServer.GetScreenCount())DisplayServer.WindowSetCurrentScreen(s.CurrentScreen);DisplayServer.WindowSetVsyncMode(s.VsyncEnabled?DisplayServer.VSyncMode.Enabled:DisplayServer.VSyncMode.Disabled);ProjectSettings.SetSetting("display/window/stretch/scale",(float)s.Scale);}OpenMainMenu();}private void OpenMainMenu()=>Games.GameSession.OpenMainMenu(GetTree());private void BuildUI(){var bg=new ColorRect();bg.AnchorsPreset=(int)LayoutPreset.FullRect;bg.Color=UiTokens.Page;AddChild(bg);var centerPanel=new Panel();centerPanel.SetAnchorsPreset(LayoutPreset.Center);centerPanel.SetSize(new Vector2(480,420));centerPanel.Position= -centerPanel.Size/2;AddChild(centerPanel);var margin=new MarginContainer();margin.SetAnchorsPreset(LayoutPreset.FullRect);margin.AddThemeConstantOverride("margin_left",24);margin.AddThemeConstantOverride("margin_top",20);margin.AddThemeConstantOverride("margin_right",24);margin.AddThemeConstantOverride("margin_bottom",20);centerPanel.AddChild(margin);var vbox=new VBoxContainer();vbox.AddThemeConstantOverride("separation",12);margin.AddChild(vbox);var title=new Label();title.Text=Tr("launcher/title");title.HorizontalAlignment=HorizontalAlignment.Center;title.Uppercase=true;title.AddThemeFontSizeOverride("font_size",UiTokens.FontTitle);title.AddThemeColorOverride("font_color",UiTokens.InkBright);vbox.AddChild(title);vbox.AddChild(new HSeparator());_windowModeSelect=AddDropdownRow(vbox,"settings/window_mode",new[]{Tr("settings/window_mode_windowed"),Tr("settings/window_mode_borderless"),Tr("settings/window_mode_fullscreen"),});_resolutionSelect=AddDropdownRow(vbox,"settings/resolution",System.Array.ConvertAll(CommonResolutions,r=>$"{r.X}×{r.Y}"));_monitorSelect=AddDropdownRow(vbox,"settings/monitor",BuildMonitorLabels());_scaleSelect=AddDropdownRow(vbox,"settings/scale",System.Array.ConvertAll(ScaleOptions,s=>Tr($"settings/scale_{s}x")));var vsyncRow=new HBoxContainer();vsyncRow.AddThemeConstantOverride("separation",12);var vsyncLabel=new Label();vsyncLabel.Text=Tr("settings/vsync");vsyncLabel.SizeFlagsHorizontal=SizeFlags.ExpandFill;vsyncRow.AddChild(vsyncLabel);_vsyncToggle=new CheckButton();vsyncRow.AddChild(_vsyncToggle);vbox.AddChild(vsyncRow);vbox.AddChild(new HSeparator());var btnRow=new HBoxContainer();btnRow.Alignment=BoxContainer.AlignmentMode.End;btnRow.AddThemeConstantOverride("separation",12);var exitBtn=new Button();exitBtn.Text=Tr("launcher/exit");exitBtn.Pressed+=OnExitClick;CthangoverTheme.ApplyDanger(exitBtn);btnRow.AddChild(exitBtn);var playBtn=new Button{Name="LauncherPlay"};playBtn.Text=Tr("launcher/play");playBtn.Pressed+=OnPlayClick;CthangoverTheme.ApplyPrimary(playBtn);btnRow.AddChild(playBtn);vbox.AddChild(btnRow);}private static OptionButton AddDropdownRow(VBoxContainer parent,string labelKey,string[]items){var row=new HBoxContainer();row.AddThemeConstantOverride("separation",12);var label=new Label();label.Text=labelKey;label.SizeFlagsHorizontal=SizeFlags.ExpandFill;row.AddChild(label);var dropdown=new OptionButton();dropdown.SizeFlagsHorizontal=SizeFlags.ExpandFill;foreach(var item in items)dropdown.AddItem(item);row.AddChild(dropdown);parent.AddChild(row);return dropdown;}private string[]BuildMonitorLabels(){var count=DisplayServer.GetScreenCount();if(count<=0)return new[]{"Screen 0"};var labels=new string[count];for(var i=0;i<count;i++){var size=DisplayServer.ScreenGetSize(i);labels[i]=string.Format(Tr("settings/monitor_screen"),i+1,size.X,size.Y);}return labels;}private void LoadSettingsToUI(){var s=GameData.Instance?.Settings;if(s==null)return;_windowModeSelect.Selected=WindowModeToIndex(s.WindowMode);_vsyncToggle.ButtonPressed=s.VsyncEnabled;for(var i=0;i<CommonResolutions.Length;i++){if(CommonResolutions[i].X==s.ResolutionWidth&&CommonResolutions[i].Y==s.ResolutionHeight){_resolutionSelect.Selected=i;break;}}var screenCount=DisplayServer.GetScreenCount();if(s.CurrentScreen>=0&&s.CurrentScreen<screenCount)_monitorSelect.Selected=s.CurrentScreen;for(var i=0;i<ScaleOptions.Length;i++){if(ScaleOptions[i]==s.Scale){_scaleSelect.Selected=i;break;}}}private void OnPlayClick(){ApplyAndSave();OpenMainMenu();}private void OnExitClick(){GetTree().Quit();}private void ApplyAndSave(){var s=GameData.Instance?.Settings;if(s==null)return;var windowMode=IndexToWindowMode(_windowModeSelect.Selected);var resIdx=Mathf.Clamp(_resolutionSelect.Selected,0,CommonResolutions.Length-1);var resolution=CommonResolutions[resIdx];var monitorIdx=_monitorSelect.Selected;var scaleIdx=Mathf.Clamp(_scaleSelect.Selected,0,ScaleOptions.Length-1);var scale=ScaleOptions[scaleIdx];var vsync=_vsyncToggle.ButtonPressed;if(monitorIdx>=0&&monitorIdx<DisplayServer.GetScreenCount())DisplayServer.WindowSetCurrentScreen(monitorIdx);DisplayServer.WindowSetMode(windowMode);if(windowMode==DisplayServer.WindowMode.Windowed)DisplayServer.WindowSetSize(resolution);DisplayServer.WindowSetVsyncMode(vsync?DisplayServer.VSyncMode.Enabled:DisplayServer.VSyncMode.Disabled);ProjectSettings.SetSetting("display/window/stretch/scale",(float)scale);s.WindowMode=(int)windowMode;s.ResolutionWidth=resolution.X;s.ResolutionHeight=resolution.Y;s.CurrentScreen=monitorIdx;s.VsyncEnabled=vsync;s.Scale=scale;s.LauncherShown=true;s.Save();}private static int WindowModeToIndex(int mode){return mode switch{3=>1,// Fullscreen → Borderless
4=>2,// ExclusiveFullscreen → Fullscreen
_=>0,// Windowed or anything else
};}private static DisplayServer.WindowMode IndexToWindowMode(int index){return index switch{1=>DisplayServer.WindowMode.Fullscreen,// Borderless
2=>DisplayServer.WindowMode.ExclusiveFullscreen,// Fullscreen
_=>DisplayServer.WindowMode.Windowed,// Windowed
};}}}

===== FILE Core/UI/Menu/MenuButtonEntry.cs =====
using System;using Godot;namespace Cthangover.Core.UI.Menu{/// <summary> One menu item in the <see cref="MenuRegistry"/>: a button shown in a menu block and the thing it is bound to. A button is bound either to a mod-relative scene path (pressed → the scene opens as a fullscreen child of the menu root, the declarative manifest behaviour) or to a <see cref="Handler"/> delegate (pressed → mod code runs, e.g. the built-in new game / exit actions). <para/> Entries arrive from two sources and live in one ordered list: manifest <c>main_menu</c> declarations (collected by <see cref="MenuRegistry.Rebuild"/>, <c>Section</c> fixed to <see cref="SectionMods"/>) and runtime registrations from mod initializers via <see cref="MenuRegistry.RegisterButton"/>. <see cref="ModOrderIndex"/> orders manifest entries by mod load position; runtime entries use <c>-1</c> so equal-index registrations keep their programmatic priority. </summary>
public class MenuButtonEntry{/// <summary>Buttons whose <see cref="Section"/> is not provided land in the mod block.</summary>
public const string SectionMods="mods";/// <summary>Unique button id, namespaced by convention to the owning mod ("my_mod/my_button").</summary>
public string Id{get;set;}/// <summary>Id of the registering mod, filled by the registry.</summary>
public string ModId{get;set;}/// <summary> Menu block the button belongs to. The host menu maps section names to containers; unknown sections fall back to <see cref="SectionMods"/>. </summary>
public string Section{get;set;}=SectionMods;/// <summary>Translation key for the caption (required — the button must be localizable).</summary>
public string Locale{get;set;}/// <summary>Optional icon key resolved through UIIconFactory.</summary>
public string Icon{get;set;}/// <summary>Optional theme type variation suffix ("primary", "danger"); applied as "Cthangover&lt;Suffix&gt;".</summary>
public string Variation{get;set;}/// <summary>Vertical order inside the section; smaller is higher.</summary>
public int Index{get;set;}=10;/// <summary> Optional visibility predicate evaluated each time the menu is built (e.g. "Continue" only when a last session exists); null = always. </summary>
public System.Func<bool>Visible{get;set;}/// <summary> Mod-relative scene path opened on press. Optional when <see cref="Handler"/> is set; at least one of the two is required. </summary>
public string ScenePath{get;set;}/// <summary> Press callback receiving the host menu root. Optional when <see cref="ScenePath"/> is set; at least one of the two is required. </summary>
public Action<Control>Handler{get;set;}/// <summary>Position of the owning mod in load order; <c>-1</c> for runtime registrations until resolved.</summary>
public int ModOrderIndex{get;set;}= -1;/// <summary>True when the button has a localizable caption and a press target.</summary>
internal bool IsValid=> !string.IsNullOrWhiteSpace(Id)&& !string.IsNullOrWhiteSpace(Locale)&&(!string.IsNullOrWhiteSpace(ScenePath)||Handler!=null);}}

===== FILE Core/UI/Menu/MenuPanelEntry.cs =====
namespace Cthangover.Core.UI.Menu{/// <summary> A reusable overlay panel in the <see cref="MenuRegistry"/> — settings, save/load and any mod-provided screen that other menus open by id instead of by type. The panel content is a mod-relative scene instantiated on demand; the panel shows and hides itself, so the registry only needs the scene path and a stable id. <para/> Panels are registered at runtime by mod initializers (<see cref="MenuRegistry.RegisterPanel"/>); the host menu then opens them through <see cref="MenuRegistry.InstantiatePanel"/> without a compile-time dependency on the mod's classes. Panels that want to expose typed behaviour to core menus implement the small interfaces from <see cref="IMenuPanel"/> on their root script. </summary>
public class MenuPanelEntry{/// <summary>Unique panel id ("settings", "save_load", "my_mod/my_panel").</summary>
public string Id{get;set;}/// <summary>Id of the registering mod, filled by the registry.</summary>
public string ModId{get;set;}/// <summary>Mod-relative path to the .tscn instantiated when the panel opens (required).</summary>
public string ScenePath{get;set;}/// <summary>Optional translation key for the panel title, for menus that render captions.</summary>
public string Locale{get;set;}/// <summary>True when the panel has an id and a scene to instantiate.</summary>
internal bool IsValid=> !string.IsNullOrWhiteSpace(Id)&& !string.IsNullOrWhiteSpace(ScenePath);}}

===== FILE Core/UI/Menu/MenuRegistry.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Bootstrap;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.UI.Menu{/// <summary> The menu registry: a single place that knows which menu items (buttons) exist, which menu blocks (sections) they belong to, and what each button is bound to — a mod scene or a code handler. Parties outside the core register their own buttons and panels into it: <list type="bullet"> <item>declaratively, through the manifest <c>main_menu</c> array of any loaded mod (collected by <see cref="Rebuild"/> into <see cref="MenuButtonEntry"/>s with a scene binding);</item> <item>programmatically, from mod initializers via <see cref="RegisterButton"/> / <see cref="RegisterPanel"/>, which is how the <c>menu_base</c> mod publishes the built-in new game / load / settings / exit items and the settings and save/load panels.</item> </list> <para/> The registry is data-driven like the other IGameRegistry implementations: <see cref="Rebuild"/> re-reads the manifests of the loaded mod set, <see cref="Clear"/> returns it to the empty state and is called on mod reload (runtime registrations are dropped there too and re-added when the initializers run again). Host menus never enumerate their own buttons — they ask this registry. <para/> The instance lives in <see cref="AppServices.Current"/>; the static members are a facade delegating to it so callers keep a short API. </summary>
public class MenuRegistry:IGameRegistry{private readonly List<MenuButtonEntry>manifestButtons=new();private readonly List<MenuButtonEntry>runtimeButtons=new();private readonly List<MenuPanelEntry>panels=new();private readonly Dictionary<string,MenuButtonEntry>manifestById=new(StringComparer.OrdinalIgnoreCase);private readonly Dictionary<string,MenuButtonEntry>runtimeById=new(StringComparer.OrdinalIgnoreCase);private readonly Dictionary<string,MenuPanelEntry>panelsById=new(StringComparer.OrdinalIgnoreCase);private List<MenuButtonEntry>merged=new();private bool built;/// <summary>Shared instance the static facade delegates to.</summary>
private static MenuRegistry Owned=>AppServices.Current.Menus;/// <summary> Creates an empty registry: cheap and side-effect free per the IGameRegistry contract. <see cref="Rebuild"/> fills the manifest part. </summary>
internal MenuRegistry(){}/// <inheritdoc />
public string Id=>"menus";// ---- IGameRegistry ------------------------------------------------- <summary> Drops the manifest-derived entries and re-collects the <c>main_menu</c> arrays of all loaded mods. Runtime registrations are kept and remain part of the combined view. Safe to call repeatedly. </summary>
public void Rebuild(){manifestButtons.Clear();manifestById.Clear();built=true;CollectManifest();RebuildMerged();GameLogger.Log("MENU_REGISTRY",$"Collect → {manifestButtons.Count} manifest button(s), {runtimeButtons.Count} runtime button(s), {panels.Count} panel(s)");}/// <summary> Drops everything, including runtime registrations, so the next build re-reads the manifests. Called on mod reload through AppServices.ClearRegistries; the reload flow re-runs the mod initializers right after, which re-register their items. </summary>
void IGameRegistry.Clear(){manifestButtons.Clear();runtimeButtons.Clear();panels.Clear();manifestById.Clear();runtimeById.Clear();panelsById.Clear();merged=new List<MenuButtonEntry>();built=false;}/// <summary>Collects the manifest part unless already built.</summary>
public void EnsureBuilt(){if(!built)Rebuild();}// ---- Runtime registration API -------------------------------------- <summary> Registers a button from mod code. The entry must have an id, a locale and a binding (scene path or handler); duplicates and invalid entries are logged and skipped, the registry state stays consistent. </summary>
public void RegisterButton(MenuButtonEntry entry){if(entry==null)return;if(string.IsNullOrWhiteSpace(entry.Id)){GameLogger.Log("MENU_REGISTRY","RegisterButton: missing 'id'",LogLevel.Error);return;}if(runtimeById.ContainsKey(entry.Id)||manifestById.ContainsKey(entry.Id)){GameLogger.Log("MENU_REGISTRY",$"RegisterButton '{entry.Id}': duplicate id, keeping the first registration",LogLevel.Error);return;}if(string.IsNullOrWhiteSpace(entry.Locale)){GameLogger.Log("MENU_REGISTRY",$"RegisterButton '{entry.Id}': missing 'locale'",LogLevel.Error);return;}if(string.IsNullOrWhiteSpace(entry.ScenePath)&&entry.Handler==null){GameLogger.Log("MENU_REGISTRY",$"RegisterButton '{entry.Id}': needs a scene_path or a handler",LogLevel.Error);return;}if(string.IsNullOrWhiteSpace(entry.Section))entry.Section=MenuButtonEntry.SectionMods;entry.ModOrderIndex= -1;runtimeButtons.Add(entry);runtimeById[entry.Id]=entry;RebuildMerged();GameLogger.Log("MENU_REGISTRY",$"RegisterButton → '{entry.Id}' (section '{entry.Section}', index {entry.Index})");}/// <summary> Registers a reusable overlay panel from mod code. Panels are opened by id through <see cref="InstantiatePanel"/>; duplicates and entries without a scene path are logged and skipped. </summary>
public void RegisterPanel(MenuPanelEntry entry){if(entry==null)return;if(entry.IsValid==false||string.IsNullOrWhiteSpace(entry.ScenePath)){GameLogger.Log("MENU_REGISTRY","RegisterPanel: entry requires 'id' and 'scene_path'",LogLevel.Error);return;}if(panelsById.ContainsKey(entry.Id)){GameLogger.Log("MENU_REGISTRY",$"RegisterPanel '{entry.Id}': duplicate id, keeping the first registration",LogLevel.Error);return;}panels.Add(entry);panelsById[entry.Id]=entry;GameLogger.Log("MENU_REGISTRY",$"RegisterPanel → '{entry.Id}' ({entry.ScenePath})");}// ---- Query API ----------------------------------------------------- <summary>All buttons — manifest and runtime — sorted by section order (index, then mod order, then id).</summary>
public IReadOnlyList<MenuButtonEntry>Buttons=>merged;/// <summary>All registered panels in registration order.</summary>
public IReadOnlyList<MenuPanelEntry>Panels=>panels;/// <summary>Returns the button with the given id, or null when unknown.</summary>
public MenuButtonEntry GetButton(string id){if(string.IsNullOrWhiteSpace(id))return null;return runtimeById.TryGetValue(id,out var runtime)?runtime:manifestById.TryGetValue(id,out var manifest)?manifest:null;}/// <summary>Returns the panel with the given id, or null when unknown.</summary>
public MenuPanelEntry GetPanel(string id){if(string.IsNullOrWhiteSpace(id))return null;return panelsById.TryGetValue(id,out var entry)?entry:null;}/// <summary> Instantiates the scene of the panel with the given id through the runtime tscn pipeline, or returns null when the id is unknown or the scene cannot be built. The caller owns the node (positioning, visibility, removal). </summary>
public Node InstantiatePanel(string id){var entry=GetPanel(id);if(entry==null){GameLogger.Log("MENU_REGISTRY",$"InstantiatePanel '{id}': panel not registered",LogLevel.Error);return null;}var node=new ModScenePrefab(entry.ScenePath).Instantiate();if(node==null){GameLogger.Log("MENU_REGISTRY",$"InstantiatePanel '{id}': scene '{entry.ScenePath}' failed to build",LogLevel.Error);return null;}GameLogger.Log("MENU_REGISTRY",$"InstantiatePanel '{id}' → {entry.ScenePath}");return node;}// ---- Implementation ------------------------------------------------
private void CollectManifest(){var manager=ModManager.Instance;if(manager?.Mods==null){GameLogger.Log("MENU_REGISTRY","Collect: mods not loaded",LogLevel.Error);return;}var orderedIds=ModRegistry.Instance.GetOrderedModIds();for(var order=0;order<orderedIds.Count;order++){var modId=orderedIds[order];if(!manager.Mods.TryGetValue(modId,out var info)||info is not ModInfo modInfo)continue;var declarations=modInfo.Manifest?.MainMenu;if(declarations==null||declarations.Count==0)continue;foreach(var raw in declarations){if(raw==null)continue;var entry=new MenuButtonEntry{Id=raw.Id,ModId=modId,Section=MenuButtonEntry.SectionMods,Locale=raw.Button?.Locale,Icon=raw.Button?.Icon,Index=raw.Button?.Index??10,ScenePath=raw.ScenePath,ModOrderIndex=order,};if(string.IsNullOrWhiteSpace(entry.Id)){GameLogger.Log("MENU_REGISTRY",$"invalid main_menu entry in mod '{modId}': missing 'id'",LogLevel.Error);continue;}if(manifestById.ContainsKey(entry.Id)||runtimeById.ContainsKey(entry.Id)){GameLogger.Log("MENU_REGISTRY",$"invalid main_menu entry '{modId}/{entry.Id}': duplicate id",LogLevel.Error);continue;}if(!entry.IsValid){GameLogger.Log("MENU_REGISTRY",$"invalid main_menu entry '{modId}/{entry.Id}': 'button.locale' is required",LogLevel.Error);continue;}var provider=modInfo.FileProvider;if(provider!=null&& !provider.FileExists(entry.ScenePath))GameLogger.Log("MENU_REGISTRY",$"main_menu entry '{modId}/{entry.Id}': scene_path '{entry.ScenePath}' not found in mod '{modId}'",LogLevel.Warning);manifestButtons.Add(entry);manifestById[entry.Id]=entry;}}}private void RebuildMerged(){merged=new List<MenuButtonEntry>(manifestButtons.Count+runtimeButtons.Count);merged.AddRange(manifestButtons);merged.AddRange(runtimeButtons);merged.Sort(CompareButtons);}private static int CompareButtons(MenuButtonEntry a,MenuButtonEntry b){var index=a.Index.CompareTo(b.Index);if(index!=0)return index;var modOrder=a.ModOrderIndex.CompareTo(b.ModOrderIndex);if(modOrder!=0)return modOrder;return string.Compare(a.Id,b.Id,StringComparison.OrdinalIgnoreCase);}// ---- Static facade ------------------------------------------------- <summary>All buttons in the AppServices-owned registry, sorted.</summary>
public static IReadOnlyList<MenuButtonEntry>AllButtons=>Owned.Buttons;/// <summary>All panels in the AppServices-owned registry.</summary>
public static IReadOnlyList<MenuPanelEntry>AllPanels=>Owned.Panels;/// <summary>Registers a button in the AppServices-owned registry.</summary>
public static void RegisterMenuButton(MenuButtonEntry entry)=>Owned.RegisterButton(entry);/// <summary>Registers a panel in the AppServices-owned registry.</summary>
public static void RegisterMenuPanel(MenuPanelEntry entry)=>Owned.RegisterPanel(entry);/// <summary>Returns the button with the given id from the AppServices-owned registry.</summary>
public static MenuButtonEntry GetButtonById(string id)=>Owned.GetButton(id);/// <summary>Returns the panel with the given id from the AppServices-owned registry.</summary>
public static MenuPanelEntry GetPanelById(string id)=>Owned.GetPanel(id);/// <summary>Instantiates the panel scene by id in the AppServices-owned registry.</summary>
public static Node OpenPanel(string id)=>Owned.InstantiatePanel(id);}}

===== FILE Core/UI/Menu/ModSetTransitionScreen.cs =====
namespace Cthangover.Core.UI.Menu{using System;using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Core.Settings;using Cthangover.Core.UI.Theming;using Godot;/// <summary> Script of <c>res://scenes/menu/mod_set_transition.tscn</c>, the scene the window shows while the process replaces its mod set (switching games, hot reload). It must be a kernel scene loaded natively, like the launcher: the previous scene belongs to mods that are about to be released, and the next one belongs to mods that are not loaded yet — no mod scene can exist across that gap. The scene's <see cref="BootLogPanel"/> streams the compile progress; on a rejected reload <see cref="ShowErrors"/> swaps it for the compile errors and a way back. The texts come from <see cref="BootMessages"/> because mod locale is not available in the gap either. </summary>
public partial class ModSetTransitionScreen:Control{/// <summary>The prefab with the layout of the screen.</summary>
public const string ScenePath="res://scenes/menu/mod_set_transition.tscn";/// <summary>Makes a new transition screen the current scene (the engine frees the previous scene at the end of the frame).</summary>
public static ModSetTransitionScreen Show(SceneTree tree){var screen=GD.Load<PackedScene>(ScenePath).Instantiate<ModSetTransitionScreen>();tree.ChangeSceneToNode(screen);return screen;}/// <summary>Applies the boot theme and the localized title.</summary>
public override void _Ready(){CthangoverTheme.Apply(this);GetNode<Label>("Title").Text=BootMessages.LoadingMods(Language);var back=GetNode<Button>("Errors/Back");back.Text=BootMessages.Back(Language);CthangoverTheme.ApplyPrimary(back);}/// <summary>Replaces the progress log with <paramref name="errors"/>; the Back button runs <paramref name="onBack"/>.</summary>
public void ShowErrors(IEnumerable<string>errors,Action onBack){GetNode<Label>("Title").Text=BootMessages.LoadingModsFailed(Language);GetNode<Control>("Log").Visible=false;GetNode<RichTextLabel>("Errors/ErrorText").Text=string.Join("\n",errors);GetNode<Button>("Errors/Back").Pressed+=onBack;GetNode<Control>("Errors").Visible=true;}private static string Language=>GameData.Instance?.Settings?.Language;}}

===== FILE Core/UI/Messages/NotificationBuffer.cs =====
using System.Collections.Generic;namespace Cthangover.Core.UI.Messages{/// <summary> Static buffer that queues notification texts before they are displayed. Notifications accumulate here and wait until a visible slot becomes available in <see cref="NotificationPanel"/>. The 10-second display timer starts only when a notification is pulled from the buffer and shown on screen — pending entries in the buffer have no timeout. </summary>
public static class NotificationBuffer{private static readonly Queue<string>_pending=new();/// <summary> Enqueues a notification text into the buffer. Null or empty strings are silently ignored. Notifications are served in FIFO order by <see cref="TryDequeue"/>. </summary> <param name="text">The notification text to enqueue.</param>
public static void Add(string text){if(string.IsNullOrEmpty(text))return;_pending.Enqueue(text);}/// <summary> Dequeues the oldest pending notification, if any. </summary> <param name="text">Receives the dequeued text, or <c>null</c> if the buffer is empty.</param> <returns><c>true</c> if a notification was dequeued; <c>false</c> otherwise.</returns>
public static bool TryDequeue(out string text){if(_pending.Count>0){text=_pending.Dequeue();return true;}text=null;return false;}/// <summary> The number of notifications currently waiting in the buffer. </summary>
public static int PendingCount=>_pending.Count;/// <summary> Removes all pending notifications from the buffer without displaying them. Safe to call at any time. </summary>
public static void Clear(){_pending.Clear();}}}

===== FILE Core/UI/NotificationBridge.cs =====
using System;using Cthangover.Core.Utils;namespace Cthangover.Core.UI{/// <summary> Core-side bridge for floating UI notifications. The implementation (MessagesHelper, interface engine mod) installs its hook through <see cref="Register"/> when the mod initializer runs; the kernel call sites (CharacterData.SendAddNotification) only know this delegate slot. Registration is first-wins; while no hooks are bound the message is logged and dropped instead of throwing, so a kernel-only runtime keeps working without the interface mod. Same pattern as <c>LightingBridge</c> and <see cref="UiThemeBridge"/>. </summary>
public static class NotificationBridge{private static readonly object Gate=new();private static Action<string>_showMessage;/// <summary>Installs the notification hook; first registration wins, re-registrations are logged and ignored.</summary>
public static void Register(Action<string>showMessage){if(showMessage==null)return;lock(Gate){if(_showMessage!=null){GameLogger.Log("UI","NotificationBridge: notification hook already registered; ignoring re-registration",LogLevel.Warning);return;}_showMessage=showMessage;}}/// <summary> Shows a floating UI message through the bound implementation. Logged and dropped when no interface implementation is bound. </summary>
public static void Show(string text){Action<string>hook;lock(Gate)hook=_showMessage;if(hook==null){GameLogger.Log("UI","NotificationBridge: no notification hook bound (interface mod not loaded); message dropped: "+text,LogLevel.Warning);return;}hook(text);}}}

===== FILE Core/UI/Theming/CthangoverTheme.cs =====
using Godot;namespace Cthangover.Core.UI.Theming{/// <summary> Runtime-baked Godot <see cref="Theme"/> for the whole game UI. Built once on first access and assigned to a Control root, from which it cascades to every descendant that does not set its own explicit overrides. Covers standard controls (buttons, inputs, popups, sliders, scrollbars, tabs, lists, windows, tooltips) so panels stop relying on the default Godot skin. Colours come exclusively from <see cref="UiTokens"/>. <para/> Type variations registered here: <see cref="PrimaryVariation"/>, <see cref="DangerVariation"/>, <see cref="IconVariation"/>, <see cref="TabVariation"/>, <see cref="RibbonVariation"/>. Use the <c>Apply*</c> helpers to opt a control into one. </summary>
public static class CthangoverTheme{/// <summary>Name of the gold primary button variation registered by <see cref="Build"/>.</summary>
public const string PrimaryVariation="CthangoverPrimary";/// <summary>Name of the destructive red-accent button variation registered by <see cref="Build"/>.</summary>
public const string DangerVariation="CthangoverDanger";/// <summary>Name of the compact icon-button variation registered by <see cref="Build"/>.</summary>
public const string IconVariation="CthangoverIcon";/// <summary>Name of the tab-shaped button variation registered by <see cref="Build"/>.</summary>
public const string TabVariation="CthangoverTab";/// <summary>Name of the ribbon-tab button variation registered by <see cref="Build"/>.</summary>
public const string RibbonVariation="CthangoverRibbon";private static Theme instance;/// <summary>Shared theme instance, built lazily at runtime.</summary>
public static Theme Get()=>instance??=Build();/// <summary>Assigns the shared theme to a Control or Window so it cascades to children.</summary>
public static void Apply(Node node){switch(node){case Control control:control.Theme=Get();break;case Window window:window.Theme=Get();break;}}/// <summary>Opts a button into the primary gold variation.</summary>
public static void ApplyPrimary(Button button){if(button!=null)button.ThemeTypeVariation=PrimaryVariation;}/// <summary>Opts a button into the danger variation.</summary>
public static void ApplyDanger(Button button){if(button!=null)button.ThemeTypeVariation=DangerVariation;}/// <summary>Opts a button into the compact icon-button variation (close ✕ buttons and glyph buttons).</summary>
public static void ApplyIcon(Button button){if(button!=null)button.ThemeTypeVariation=IconVariation;}/// <summary>Opts a button into the tab variation.</summary>
public static void ApplyTab(Button button){if(button!=null)button.ThemeTypeVariation=TabVariation;}/// <summary>Opts a button into the ribbon-tab variation.</summary>
public static void ApplyRibbon(Button button){if(button!=null)button.ThemeTypeVariation=RibbonVariation;}/// <summary>Builds a fresh theme; prefer <see cref="Get"/> in runtime code.</summary>
public static Theme Build(){var theme=new Theme{DefaultFontSize=UiTokens.FontBodyLarge};ConfigureGlobals(theme);ConfigureButtons(theme);ConfigureInputs(theme);ConfigurePopups(theme);ConfigureSliders(theme);ConfigureProgress(theme);ConfigureScrollbars(theme);ConfigureTabs(theme);ConfigureLists(theme);ConfigureWindows(theme);ConfigureMisc(theme);return theme;}// ── Globals ─────────────────────────────────────────────────────
private static void ConfigureGlobals(Theme theme){theme.SetColor("font_color","Label",UiTokens.Ink);theme.SetColor("font_shadow_color","Label",new Color(0f,0f,0f,0.6f));theme.SetFontSize("font_size","Label",UiTokens.FontBodyLarge);theme.SetColor("default_color","RichTextLabel",UiTokens.Ink);theme.SetFontSize("normal_font_size","RichTextLabel",UiTokens.FontLead);theme.SetFontSize("italics_font_size","RichTextLabel",UiTokens.FontLead);theme.SetFontSize("bold_font_size","RichTextLabel",UiTokens.FontLead);theme.SetFontSize("mono_font_size","RichTextLabel",UiTokens.FontBody);theme.SetConstant("line_separation","RichTextLabel",7);theme.SetStylebox("normal","RichTextLabel",Flat(0,new Color(0f,0f,0f,0f)));theme.SetStylebox("focus","RichTextLabel",new StyleBoxEmpty());theme.SetStylebox("panel","Panel",WindowBox());theme.SetStylebox("panel","PanelContainer",WindowBox());}// ── Buttons ─────────────────────────────────────────────────────
private static void ConfigureButtons(Theme theme){var normal=Flat(UiTokens.RadiusSm,UiTokens.Surface3,UiTokens.Line);normal.ContentMarginLeft=18;normal.ContentMarginRight=18;normal.ContentMarginTop=11;normal.ContentMarginBottom=11;var hover=Flat(UiTokens.RadiusSm,UiTokens.BtnHover,UiTokens.AccentLine);hover.ContentMarginLeft=18;hover.ContentMarginRight=18;hover.ContentMarginTop=11;hover.ContentMarginBottom=11;var pressed=Flat(UiTokens.RadiusSm,UiTokens.BtnHover,UiTokens.AccentLineStrong);pressed.ContentMarginLeft=18;pressed.ContentMarginRight=18;pressed.ContentMarginTop=11;pressed.ContentMarginBottom=11;var disabled=Flat(UiTokens.RadiusSm,UiTokens.BtnDisabled,UiTokens.LineDisabled);disabled.ContentMarginLeft=18;disabled.ContentMarginRight=18;disabled.ContentMarginTop=11;disabled.ContentMarginBottom=11;SetButtonStyles(theme,"Button",normal,hover,pressed,disabled);theme.SetColor("font_color","Button",UiTokens.Body);theme.SetColor("font_hover_color","Button",UiTokens.InkBright);theme.SetColor("font_pressed_color","Button",UiTokens.AccentBright);theme.SetColor("font_focus_color","Button",UiTokens.InkBright);theme.SetColor("font_disabled_color","Button",UiTokens.Faint);theme.SetFontSize("font_size","Button",UiTokens.FontBodyLarge);SetButtonStyles(theme,"OptionButton",normal,hover,pressed,disabled);theme.SetColor("font_color","OptionButton",UiTokens.Body);theme.SetColor("font_hover_color","OptionButton",UiTokens.InkBright);theme.SetColor("font_pressed_color","OptionButton",UiTokens.AccentBright);theme.SetColor("font_disabled_color","OptionButton",UiTokens.Faint);theme.SetFontSize("font_size","OptionButton",UiTokens.FontBody);theme.SetIcon("arrow","OptionButton",MakeArrowTexture());theme.SetColor("font_color","MenuButton",UiTokens.Body);theme.SetColor("font_hover_color","MenuButton",UiTokens.InkBright);theme.SetColor("font_pressed_color","MenuButton",UiTokens.AccentBright);theme.SetColor("font_disabled_color","MenuButton",UiTokens.Faint);theme.SetFontSize("font_size","MenuButton",UiTokens.FontBodyLarge);theme.SetColor("font_color","CheckBox",UiTokens.Body);theme.SetColor("font_hover_color","CheckBox",UiTokens.InkBright);theme.SetColor("font_pressed_color","CheckBox",UiTokens.AccentBright);theme.SetColor("font_disabled_color","CheckBox",UiTokens.Faint);theme.SetFontSize("font_size","CheckBox",UiTokens.FontBodyLarge);theme.SetColor("font_color","CheckButton",UiTokens.Body);theme.SetColor("font_hover_color","CheckButton",UiTokens.InkBright);theme.SetColor("font_pressed_color","CheckButton",UiTokens.AccentBright);theme.SetColor("font_disabled_color","CheckButton",UiTokens.Faint);theme.SetFontSize("font_size","CheckButton",UiTokens.FontBodyLarge);BuildPrimaryVariation(theme);BuildDangerVariation(theme);BuildIconVariation(theme);BuildTabVariation(theme);BuildRibbonVariation(theme);}private static void SetButtonStyles(Theme theme,string type,StyleBox normal,StyleBox hover,StyleBox pressed,StyleBox disabled){theme.SetStylebox("normal",type,normal);theme.SetStylebox("hover",type,hover);theme.SetStylebox("pressed",type,pressed);theme.SetStylebox("disabled",type,disabled);theme.SetStylebox("focus",type,new StyleBoxEmpty());}private static void BuildPrimaryVariation(Theme theme){theme.SetTypeVariation(PrimaryVariation,"Button");var normal=Flat(UiTokens.RadiusSm,UiTokens.BtnPrimary,UiTokens.AccentLine);normal.ContentMarginLeft=18;normal.ContentMarginRight=18;normal.ContentMarginTop=11;normal.ContentMarginBottom=11;var hover=Flat(UiTokens.RadiusSm,UiTokens.BtnPrimaryHover,UiTokens.AccentLineStrong);hover.ContentMarginLeft=18;hover.ContentMarginRight=18;hover.ContentMarginTop=11;hover.ContentMarginBottom=11;var pressed=Flat(UiTokens.RadiusSm,UiTokens.BtnPrimaryHover,UiTokens.Accent);pressed.ContentMarginLeft=18;pressed.ContentMarginRight=18;pressed.ContentMarginTop=11;pressed.ContentMarginBottom=11;theme.SetStylebox("normal",PrimaryVariation,normal);theme.SetStylebox("hover",PrimaryVariation,hover);theme.SetStylebox("pressed",PrimaryVariation,pressed);theme.SetColor("font_color",PrimaryVariation,UiTokens.AccentBright);theme.SetColor("font_hover_color",PrimaryVariation,UiTokens.AccentHover);theme.SetColor("font_pressed_color",PrimaryVariation,UiTokens.AccentHover);theme.SetColor("font_disabled_color",PrimaryVariation,UiTokens.Faint);}private static void BuildDangerVariation(Theme theme){theme.SetTypeVariation(DangerVariation,"Button");var normal=Flat(UiTokens.RadiusSm,UiTokens.Surface3,UiTokens.Line);normal.ContentMarginLeft=18;normal.ContentMarginRight=18;normal.ContentMarginTop=11;normal.ContentMarginBottom=11;var hover=Flat(UiTokens.RadiusSm,UiTokens.DangerSoft,UiTokens.Danger);hover.ContentMarginLeft=18;hover.ContentMarginRight=18;hover.ContentMarginTop=11;hover.ContentMarginBottom=11;theme.SetStylebox("normal",DangerVariation,normal);theme.SetStylebox("hover",DangerVariation,hover);theme.SetStylebox("pressed",DangerVariation,hover);theme.SetColor("font_color",DangerVariation,UiTokens.Body);theme.SetColor("font_hover_color",DangerVariation,UiTokens.DangerText);theme.SetColor("font_pressed_color",DangerVariation,UiTokens.DangerText);theme.SetColor("font_disabled_color",DangerVariation,UiTokens.Faint);}private static void BuildIconVariation(Theme theme){theme.SetTypeVariation(IconVariation,"Button");var normal=Flat(UiTokens.RadiusSm,new Color(0f,0f,0f,0f),UiTokens.Line);normal.ContentMarginLeft=6;normal.ContentMarginRight=6;normal.ContentMarginTop=6;normal.ContentMarginBottom=6;var hover=Flat(UiTokens.RadiusSm,UiTokens.DangerSoft,UiTokens.Danger);hover.ContentMarginLeft=6;hover.ContentMarginRight=6;hover.ContentMarginTop=6;hover.ContentMarginBottom=6;theme.SetStylebox("normal",IconVariation,normal);theme.SetStylebox("hover",IconVariation,hover);theme.SetStylebox("pressed",IconVariation,hover);theme.SetColor("font_color",IconVariation,UiTokens.Muted);theme.SetColor("font_hover_color",IconVariation,UiTokens.White);theme.SetColor("font_pressed_color",IconVariation,UiTokens.White);theme.SetFontSize("font_size",IconVariation,UiTokens.FontLead);}private static void BuildTabVariation(Theme theme){theme.SetTypeVariation(TabVariation,"Button");var normal=Flat(UiTokens.RadiusSm,new Color(0f,0f,0f,0f),new Color(0f,0f,0f,0f));normal.ContentMarginLeft=13;normal.ContentMarginRight=13;normal.ContentMarginTop=7;normal.ContentMarginBottom=7;var hover=Flat(UiTokens.RadiusSm,UiTokens.WhiteTrace,new Color(0f,0f,0f,0f));hover.ContentMarginLeft=13;hover.ContentMarginRight=13;hover.ContentMarginTop=7;hover.ContentMarginBottom=7;var active=Flat(UiTokens.RadiusSm,UiTokens.AccentSoft,UiTokens.AccentLine);active.ContentMarginLeft=13;active.ContentMarginRight=13;active.ContentMarginTop=7;active.ContentMarginBottom=7;theme.SetStylebox("normal",TabVariation,normal);theme.SetStylebox("hover",TabVariation,hover);theme.SetStylebox("pressed",TabVariation,active);theme.SetStylebox("focus",TabVariation,new StyleBoxEmpty());theme.SetColor("font_color",TabVariation,UiTokens.Muted);theme.SetColor("font_hover_color",TabVariation,UiTokens.Ink);theme.SetColor("font_pressed_color",TabVariation,UiTokens.AccentBright);theme.SetFontSize("font_size",TabVariation,UiTokens.FontBody);}private static void BuildRibbonVariation(Theme theme){theme.SetTypeVariation(RibbonVariation,"Button");var normal=Flat(UiTokens.RadiusSm,UiTokens.Ribbon,UiTokens.PaperLine);normal.CornerRadiusBottomLeft=0;normal.CornerRadiusBottomRight=0;normal.BorderWidthBottom=0;normal.ContentMarginLeft=22;normal.ContentMarginRight=22;normal.ContentMarginTop=12;normal.ContentMarginBottom=20;var hover=Flat(UiTokens.RadiusSm,UiTokens.RibbonHover,UiTokens.PaperLine);hover.CornerRadiusBottomLeft=0;hover.CornerRadiusBottomRight=0;hover.BorderWidthBottom=0;hover.ContentMarginLeft=22;hover.ContentMarginRight=22;hover.ContentMarginTop=12;hover.ContentMarginBottom=20;var active=Flat(UiTokens.RadiusSm,UiTokens.Accent,UiTokens.Accent);active.CornerRadiusBottomLeft=0;active.CornerRadiusBottomRight=0;active.BorderWidthBottom=0;active.ContentMarginLeft=22;active.ContentMarginRight=22;active.ContentMarginTop=12;active.ContentMarginBottom=20;theme.SetStylebox("normal",RibbonVariation,normal);theme.SetStylebox("hover",RibbonVariation,hover);theme.SetStylebox("pressed",RibbonVariation,active);theme.SetStylebox("focus",RibbonVariation,new StyleBoxEmpty());theme.SetColor("font_color",RibbonVariation,UiTokens.RibbonText);theme.SetColor("font_hover_color",RibbonVariation,UiTokens.InkBright);theme.SetColor("font_pressed_color",RibbonVariation,UiTokens.RibbonTextActive);theme.SetFontSize("font_size",RibbonVariation,15);}// ── Inputs ──────────────────────────────────────────────────────
private static void ConfigureInputs(Theme theme){foreach(var type in new[]{"LineEdit","TextEdit"}){var normal=Flat(UiTokens.RadiusSm,UiTokens.Well,UiTokens.Line);normal.ContentMarginLeft=12;normal.ContentMarginRight=12;normal.ContentMarginTop=9;normal.ContentMarginBottom=9;var focus=Flat(UiTokens.RadiusSm,UiTokens.Well,UiTokens.AccentLine);focus.ContentMarginLeft=12;focus.ContentMarginRight=12;focus.ContentMarginTop=9;focus.ContentMarginBottom=9;theme.SetStylebox("normal",type,normal);theme.SetStylebox("focus",type,focus);theme.SetStylebox("read_only",type,normal);theme.SetColor("font_color",type,UiTokens.Ink);theme.SetColor("font_placeholder_color",type,UiTokens.Faint);theme.SetColor("font_selected_color",type,UiTokens.InkBright);theme.SetColor("caret_color",type,UiTokens.Accent);theme.SetColor("selection_color",type,UiTokens.AccentSoft);theme.SetColor("clear_button_color",type,UiTokens.Muted);theme.SetColor("clear_button_color_pressed",type,UiTokens.AccentBright);theme.SetFontSize("font_size",type,UiTokens.FontBody);}}private static void ConfigurePopups(Theme theme){var panel=Flat(UiTokens.RadiusMd,UiTokens.Surface2,UiTokens.Line);panel.ContentMarginLeft=4;panel.ContentMarginRight=4;panel.ContentMarginTop=4;panel.ContentMarginBottom=4;var hover=Flat(UiTokens.RadiusSm,UiTokens.AccentTrace,new Color(0f,0f,0f,0f));hover.ContentMarginLeft=8;hover.ContentMarginRight=8;hover.ContentMarginTop=4;hover.ContentMarginBottom=4;theme.SetStylebox("panel","PopupMenu",panel);theme.SetStylebox("hover","PopupMenu",hover);theme.SetColor("font_color","PopupMenu",UiTokens.Body);theme.SetColor("font_hover_color","PopupMenu",UiTokens.AccentBright);theme.SetColor("font_disabled_color","PopupMenu",UiTokens.Faint);theme.SetColor("font_accelerator_color","PopupMenu",UiTokens.Faint);theme.SetColor("font_separator_color","PopupMenu",UiTokens.Muted);theme.SetFontSize("font_size","PopupMenu",UiTokens.FontBody);theme.SetConstant("v_separation","PopupMenu",6);theme.SetConstant("item_start_padding","PopupMenu",8);theme.SetConstant("item_end_padding","PopupMenu",8);theme.SetStylebox("panel","PopupPanel",panel);}// ── Sliders ─────────────────────────────────────────────────────
private static void ConfigureSliders(Theme theme){var track=Flat(3,UiTokens.LineDisabled,new Color(0f,0f,0f,0f));track.ContentMarginTop=2;track.ContentMarginBottom=2;var fill=Flat(3,UiTokens.Accent,new Color(0f,0f,0f,0f));fill.ContentMarginTop=2;fill.ContentMarginBottom=2;var fillHighlight=Flat(3,UiTokens.AccentBright,new Color(0f,0f,0f,0f));fillHighlight.ContentMarginTop=2;fillHighlight.ContentMarginBottom=2;foreach(var type in new[]{"HSlider","VSlider"}){theme.SetStylebox("slider",type,track);theme.SetStylebox("grabber_area",type,fill);theme.SetStylebox("grabber_area_highlight",type,fillHighlight);theme.SetIcon("grabber",type,MakeCircleTexture(14,UiTokens.AccentBright,UiTokens.Accent));theme.SetIcon("grabber_highlight",type,MakeCircleTexture(14,UiTokens.AccentHover,UiTokens.AccentBright));theme.SetIcon("grabber_disabled",type,MakeCircleTexture(14,UiTokens.LineStrong,UiTokens.LineFaint));theme.SetConstant("center_grabber",type,0);theme.SetConstant("grabber_offset",type,0);}}// ── Progress ────────────────────────────────────────────────────
private static void ConfigureProgress(Theme theme){var background=Flat(UiTokens.RadiusSm,UiTokens.LineDisabled,new Color(0f,0f,0f,0f));var fill=Flat(UiTokens.RadiusSm,UiTokens.HpOk,new Color(0f,0f,0f,0f));theme.SetStylebox("background","ProgressBar",background);theme.SetStylebox("fill","ProgressBar",fill);theme.SetColor("font_color","ProgressBar",UiTokens.Muted);theme.SetFontSize("font_size","ProgressBar",UiTokens.FontBodySmall);}// ── Scrollbars ──────────────────────────────────────────────────
private static void ConfigureScrollbars(Theme theme){var track=Flat(0,new Color(0f,0f,0f,0f),new Color(0f,0f,0f,0f));var grabber=Flat(5,UiTokens.ScrollThumb,new Color(0f,0f,0f,0f));grabber.ContentMarginLeft=2;grabber.ContentMarginRight=2;grabber.ContentMarginTop=2;grabber.ContentMarginBottom=2;var grabberHover=Flat(5,UiTokens.LineStrong,new Color(0f,0f,0f,0f));grabberHover.ContentMarginLeft=2;grabberHover.ContentMarginRight=2;grabberHover.ContentMarginTop=2;grabberHover.ContentMarginBottom=2;foreach(var type in new[]{"VScrollBar","HScrollBar"}){theme.SetStylebox("scroll",type,track);theme.SetStylebox("grabber",type,grabber);theme.SetStylebox("grabber_highlight",type,grabberHover);theme.SetStylebox("grabber_pressed",type,grabberHover);}theme.SetStylebox("panel","ScrollContainer",new StyleBoxEmpty());}// ── Tabs ────────────────────────────────────────────────────────
private static void ConfigureTabs(Theme theme){var unselected=Flat(UiTokens.RadiusSm,new Color(0f,0f,0f,0f),new Color(0f,0f,0f,0f));unselected.ContentMarginLeft=13;unselected.ContentMarginRight=13;unselected.ContentMarginTop=7;unselected.ContentMarginBottom=7;var hovered=Flat(UiTokens.RadiusSm,UiTokens.WhiteTrace,new Color(0f,0f,0f,0f));hovered.ContentMarginLeft=13;hovered.ContentMarginRight=13;hovered.ContentMarginTop=7;hovered.ContentMarginBottom=7;var selected=Flat(UiTokens.RadiusSm,UiTokens.AccentSoft,UiTokens.AccentLine);selected.ContentMarginLeft=13;selected.ContentMarginRight=13;selected.ContentMarginTop=7;selected.ContentMarginBottom=7;foreach(var type in new[]{"TabBar","TabContainer"}){theme.SetStylebox("tab_unselected",type,unselected);theme.SetStylebox("tab_hovered",type,hovered);theme.SetStylebox("tab_selected",type,selected);theme.SetStylebox("tab_disabled",type,unselected);theme.SetStylebox("tab_focus",type,new StyleBoxEmpty());theme.SetColor("font_unselected_color",type,UiTokens.Muted);theme.SetColor("font_hovered_color",type,UiTokens.Ink);theme.SetColor("font_selected_color",type,UiTokens.AccentBright);theme.SetColor("font_disabled_color",type,UiTokens.Faint);theme.SetFontSize("font_size",type,UiTokens.FontBody);}theme.SetStylebox("panel","TabContainer",WindowBox());theme.SetStylebox("tabbar_background","TabContainer",new StyleBoxEmpty());}// ── Lists ───────────────────────────────────────────────────────
private static void ConfigureLists(Theme theme){var panel=Flat(UiTokens.RadiusMd,UiTokens.Surface2,UiTokens.Line);panel.ContentMarginLeft=4;panel.ContentMarginRight=4;panel.ContentMarginTop=4;panel.ContentMarginBottom=4;var selected=Flat(UiTokens.RadiusSm,UiTokens.AccentTrace,new Color(0f,0f,0f,0f));var cursor=Flat(UiTokens.RadiusSm,UiTokens.AccentTrace,new Color(0f,0f,0f,0f));theme.SetStylebox("panel","ItemList",panel);theme.SetStylebox("focus","ItemList",new StyleBoxEmpty());theme.SetStylebox("selected","ItemList",selected);theme.SetStylebox("selected_focus","ItemList",selected);theme.SetStylebox("cursor","ItemList",cursor);theme.SetStylebox("cursor_unfocused","ItemList",cursor);theme.SetColor("font_color","ItemList",UiTokens.Ink);theme.SetColor("font_selected_color","ItemList",UiTokens.AccentBright);theme.SetColor("font_focus_color","ItemList",UiTokens.InkBright);theme.SetFontSize("font_size","ItemList",UiTokens.FontBody);theme.SetStylebox("panel","Tree",panel);theme.SetStylebox("focus","Tree",new StyleBoxEmpty());theme.SetStylebox("selected","Tree",selected);theme.SetStylebox("selected_focus","Tree",selected);theme.SetStylebox("cursor","Tree",cursor);theme.SetStylebox("cursor_unfocused","Tree",cursor);theme.SetColor("font_color","Tree",UiTokens.Ink);theme.SetColor("font_selected_color","Tree",UiTokens.AccentBright);theme.SetColor("font_focus_color","Tree",UiTokens.InkBright);theme.SetColor("guide_color","Tree",UiTokens.Line);theme.SetFontSize("font_size","Tree",UiTokens.FontBody);}// ── Windows and tooltips ────────────────────────────────────────
private static void ConfigureWindows(Theme theme){theme.SetStylebox("panel","Window",WindowBox());theme.SetStylebox("embedded_border","Window",WindowBox());theme.SetStylebox("embedded_unfocused_border","Window",WindowBox());theme.SetColor("title_color","Window",UiTokens.InkBright);theme.SetFontSize("title_font_size","Window",UiTokens.FontLead);theme.SetConstant("title_height","Window",36);theme.SetConstant("close_h_offset","Window",16);theme.SetConstant("close_v_offset","Window",8);var tooltip=Flat(UiTokens.RadiusSm,UiTokens.TooltipBg,UiTokens.Line);tooltip.ContentMarginLeft=10;tooltip.ContentMarginRight=10;tooltip.ContentMarginTop=8;tooltip.ContentMarginBottom=8;theme.SetStylebox("panel","TooltipPanel",tooltip);theme.SetColor("font_color","TooltipLabel",UiTokens.Body);theme.SetFontSize("font_size","TooltipLabel",UiTokens.FontBodySmall);}// ── Misc ────────────────────────────────────────────────────────
private static void ConfigureMisc(Theme theme){var separator=new StyleBoxLine{Color=UiTokens.Line,Thickness=1};theme.SetStylebox("separator","HSeparator",separator);theme.SetStylebox("separator","VSeparator",separator);theme.SetColor("font_color","SpinBox",UiTokens.Ink);theme.SetFontSize("font_size","SpinBox",UiTokens.FontBody);}// ── Helpers ─────────────────────────────────────────────────────
private static StyleBoxFlat Flat(int radius,Color background,Color?border=null){var style=new StyleBoxFlat{BgColor=background,CornerRadiusTopLeft=radius,CornerRadiusTopRight=radius,CornerRadiusBottomLeft=radius,CornerRadiusBottomRight=radius,};if(border.HasValue&&border.Value.A>0f){style.BorderColor=border.Value;style.BorderWidthLeft=1;style.BorderWidthRight=1;style.BorderWidthTop=1;style.BorderWidthBottom=1;}return style;}private static StyleBoxFlat WindowBox(){var style=Flat(UiTokens.RadiusLg,UiTokens.Surface0,UiTokens.Line);style.ShadowColor=new Color(0f,0f,0f,0.6f);style.ShadowSize=18;style.ShadowOffset=new Vector2(0,8);style.ContentMarginLeft=16;style.ContentMarginRight=16;style.ContentMarginTop=16;style.ContentMarginBottom=16;return style;}private static ImageTexture MakeCircleTexture(int size,Color fill,Color border){var image=Image.CreateEmpty(size,size,false,Image.Format.Rgba8);image.Fill(new Color(0f,0f,0f,0f));var center=(size-1)/2f;var radius=size/2f-0.5f;for(var y=0;y<size;y++){for(var x=0;x<size;x++){var dx=x-center;var dy=y-center;var distance=Mathf.Sqrt(dx*dx+dy*dy);if(distance<=radius-1.1f)image.SetPixel(x,y,fill);else if(distance<=radius)image.SetPixel(x,y,border);}}return ImageTexture.CreateFromImage(image);}private static ImageTexture MakeArrowTexture(){const int width=11;const int height=6;var image=Image.CreateEmpty(width,height,false,Image.Format.Rgba8);image.Fill(new Color(0f,0f,0f,0f));for(var y=0;y<height;y++){var run=height-y;var start=(width-run)/2;for(var x=start;x<start+run;x++)image.SetPixel(x,y,UiTokens.Body);}return ImageTexture.CreateFromImage(image);}}}

===== FILE Core/UI/Theming/UiTokens.cs =====
using Godot;namespace Cthangover.Core.UI.Theming{/// <summary> Central design tokens of the Cthangover UI. Single source of truth for colours, radii, font sizes and spacing at runtime; mirrors <c>mockups/style_guide/tokens.css</c>. Panels must not hardcode colours — they reference these tokens (directly or through <see cref="CthangoverTheme"/> and <see cref="UiKit"/>). Two materials share one palette: "graphite" (panels, menus, inventory, actions) and "paper" (quest journal pages). </summary>
public static class UiTokens{// ── Backdrop and surfaces: graphite ───────────────────────────── <summary>Page-level background behind everything, near-black graphite.</summary>
public static readonly Color Page=Color.FromHtml("07080b");/// <summary>Full-screen backdrop behind the topmost overlay.</summary>
public static readonly Color Backdrop=new(0f,0f,0f,0.9f);/// <summary>Base surface — panels, buttons, cards.</summary>
public static readonly Color Surface0=Color.FromHtml("0e0f15");/// <summary>Secondary surface for rows and nested panels.</summary>
public static readonly Color Surface1=Color.FromHtml("171923");/// <summary>Primary surface for cards and buttons.</summary>
public static readonly Color Surface2=Color.FromHtml("101118");/// <summary>Tertiary surface for the most inset blocks.</summary>
public static readonly Color Surface3=Color.FromHtml("1b1d26");/// <summary>Raised panel surface for prominent cards.</summary>
public static readonly Color SurfaceRaised=Color.FromHtml("1a1c26");/// <summary>Hover-state surface one step above <see cref="Surface0"/>.</summary>
public static readonly Color SurfaceHover=Color.FromHtml("1b1e2a");/// <summary>Selected-state surface with a warm gold cast.</summary>
public static readonly Color SurfaceSelected=Color.FromHtml("1e1a10");/// <summary>Deepest well — text inputs and inset wells.</summary>
public static readonly Color Well=Color.FromHtml("0d0e14");/// <summary>Fill behind count badges.</summary>
public static readonly Color CountBg=Color.FromHtml("242733");// ── Surfaces: paper journal ───────────────────────────────────── <summary>Top gradient stop of the paper journal surface.</summary>
public static readonly Color PaperTop=Color.FromHtml("261f18");/// <summary>Paper journal surface (quest pages), warm dark brown.</summary>
public static readonly Color Paper=Color.FromHtml("15110d");/// <summary>Border colour of the paper journal surface.</summary>
public static readonly Color PaperLine=Color.FromHtml("3a3229");/// <summary>Inset border of paper wells (journal page frames).</summary>
public static readonly Color PaperInset=new(200f/255f,170f/255f,120f/255f,0.16f);/// <summary>Warm ruled lines drawn across journal pages.</summary>
public static readonly Color PaperRule=new(200f/255f,170f/255f,120f/255f,0.45f);/// <summary>Wax-seal brown for journal stamps and seals.</summary>
public static readonly Color Seal=Color.FromHtml("a2603f");/// <summary>Brighter seal tone for readable stamp text.</summary>
public static readonly Color SealText=Color.FromHtml("cd8f66");/// <summary>Very soft seal wash for stamped backgrounds.</summary>
public static readonly Color SealSoft=new(162f/255f,96f/255f,63f/255f,0.07f);// ── Borders ───────────────────────────────────────────────────── <summary>Default border colour for cards, inputs, and separators.</summary>
public static readonly Color Line=Color.FromHtml("2a2c38");/// <summary>Softest border for nested separators.</summary>
public static readonly Color LineSoft=Color.FromHtml("262935");/// <summary>Border of cards and interactive cells.</summary>
public static readonly Color LineCard=Color.FromHtml("2f3242");/// <summary>Border of raised cards and emphasized containers.</summary>
public static readonly Color LineStrong=Color.FromHtml("4a4e61");/// <summary>Faintest border for subtle row separators.</summary>
public static readonly Color LineFaint=Color.FromHtml("3a3d4d");/// <summary>Disabled-state border colour.</summary>
public static readonly Color LineDisabled=Color.FromHtml("23252f");// ── Text ──────────────────────────────────────────────────────── <summary>Brightest ink — headings and hero copy on dark surfaces.</summary>
public static readonly Color InkBright=Color.FromHtml("f4f0e7");/// <summary>Primary ink for titles and emphasized copy.</summary>
public static readonly Color Ink=Color.FromHtml("e8e5de");/// <summary>Standard body copy.</summary>
public static readonly Color Body=Color.FromHtml("c9c5bc");/// <summary>Secondary text — captions, legends, metadata.</summary>
public static readonly Color Muted=Color.FromHtml("8b8894");/// <summary>Least prominent text — disabled hints and footnotes.</summary>
public static readonly Color Faint=Color.FromHtml("5c5966");// ── Accent: gold ──────────────────────────────────────────────── <summary>Primary gold accent — selection, focus, and highlights.</summary>
public static readonly Color Accent=Color.FromHtml("d9a441");/// <summary>Bright gold for emphasized text and icon glyphs.</summary>
public static readonly Color AccentBright=Color.FromHtml("f0c169");/// <summary>Hover-brightened gold for interactive text and glyphs.</summary>
public static readonly Color AccentHover=Color.FromHtml("ffe1a0");/// <summary>Dimmed gold for captions and secondary accents.</summary>
public static readonly Color AccentDim=Color.FromHtml("a8946a");/// <summary>Soft accent fill for chips and selected rows.</summary>
public static readonly Color AccentSoft=Alpha(Accent,0.08f);/// <summary>Slight accent trace for subtle dividers.</summary>
public static readonly Color AccentTrace=Alpha(Accent,0.12f);/// <summary>Accent glow used behind emphasized cards.</summary>
public static readonly Color AccentGlow=Alpha(Accent,0.1f);/// <summary>Barely-visible accent wash for large surfaces.</summary>
public static readonly Color AccentFaint=Alpha(Accent,0.06f);/// <summary>Focused selection ring around controls.</summary>
public static readonly Color AccentRing=Alpha(Accent,0.5f);/// <summary>Stronger accent border for hover and selected states.</summary>
public static readonly Color AccentLine=Alpha(Accent,0.45f);/// <summary>Standard accent border for gold-outlined elements.</summary>
public static readonly Color AccentLineStrong=Alpha(Accent,0.75f);/// <summary>Weakest accent border, for hairline gold dividers.</summary>
public static readonly Color AccentLineWeak=Alpha(Accent,0.2f);// ── Status ────────────────────────────────────────────────────── <summary>Danger red for destructive buttons and error states.</summary>
public static readonly Color Danger=Color.FromHtml("a63d40");/// <summary>Readable red for danger text on dark surfaces.</summary>
public static readonly Color DangerText=Color.FromHtml("e8a9ab");/// <summary>Soft danger fill behind error banners and locked badges.</summary>
public static readonly Color DangerSoft=Alpha(Danger,0.15f);/// <summary>Saturated success green for fills.</summary>
public static readonly Color Success=Color.FromHtml("8fae6b");/// <summary>Readable green for success text on dark surfaces.</summary>
public static readonly Color SuccessText=Color.FromHtml("b9d3ae");/// <summary>Soft success fill behind badges and banners.</summary>
public static readonly Color SuccessSoft=Alpha(Success,0.1f);/// <summary>Caution amber for warnings.</summary>
public static readonly Color Warning=Color.FromHtml("cfa96a");/// <summary>Informational blue, also the shield/defence colour.</summary>
public static readonly Color Info=Color.FromHtml("7f95b5");/// <summary>Positive checkmark colour, alias of <see cref="SuccessText"/>.</summary>
public static readonly Color Ok=Color.FromHtml("b9d3ae");// ── Type accents (items and actions) ──────────────────────────── <summary>Quest-type accent (gold) for cards and list rows.</summary>
public static readonly Color TypeQuest=Color.FromHtml("d9a441");/// <summary>Used/already-spent accent (orange); second half of the map zone hatch pair.</summary>
public static readonly Color TypeUsed=Color.FromHtml("cfa96a");/// <summary>Target highlight accent (teal).</summary>
public static readonly Color TypeTarget=Color.FromHtml("4fb3a5");/// <summary>Food/consumable accent (green).</summary>
public static readonly Color TypeFood=Color.FromHtml("8fae6b");/// <summary>Resource item accent (blue).</summary>
public static readonly Color TypeResource=Color.FromHtml("7f95b5");/// <summary>Recipe type accent (violet).</summary>
public static readonly Color TypeRecipe=Color.FromHtml("c78fc9");/// <summary>Enemy accent (red) — also the locked-zone marker badge.</summary>
public static readonly Color TypeEnemy=Color.FromHtml("c96b6e");/// <summary>Self-target accent (blue).</summary>
public static readonly Color TypeSelf=Color.FromHtml("7f95b5");/// <summary>Passive ability accent (green).</summary>
public static readonly Color TypePassive=Color.FromHtml("8fae6b");/// <summary>Active action-card accent (purple).</summary>
public static readonly Color TypeActive=Color.FromHtml("c78fc9");// ── Health, AP, cooldown, stacks ──────────────────────────────── <summary>Healthy portion of health bars and positive status numbers.</summary>
public static readonly Color HpOk=Color.FromHtml("8fae6b");/// <summary>Low-health portion of health bars and damage numbers.</summary>
public static readonly Color HpLow=Color.FromHtml("c96b6e");/// <summary>Health bar text tint over the fill.</summary>
public static readonly Color HpText=Color.FromHtml("9dbb8f");/// <summary> Defence segment of the layered health bar: blue fill drawn over the health fill while a shield absorbs damage. Same blue as <see cref="Info"/> and the floating defence numbers of <c>ShowDamageBehaviour.SpawnDefence</c>, so one colour always means "protection". Mirrors <c>--ct-hp-shield</c> in tokens.css. </summary>
public static readonly Color HpShield=Color.FromHtml("7f95b5");/// <summary>Cooldown sweep colour for ability slots.</summary>
public static readonly Color Cooldown=Color.FromHtml("d9c79a");/// <summary>Stack count text over item cells.</summary>
public static readonly Color Stack=Color.FromHtml("f0ece2");// ── Details ───────────────────────────────────────────────────── <summary>Base fill of primary buttons (dark warm brown).</summary>
public static readonly Color BtnPrimary=Color.FromHtml("241f12");/// <summary>Hover fill of primary buttons.</summary>
public static readonly Color BtnPrimaryHover=Color.FromHtml("2f2816");/// <summary>Hover fill of secondary buttons.</summary>
public static readonly Color BtnHover=Color.FromHtml("232633");/// <summary>Disabled button fill.</summary>
public static readonly Color BtnDisabled=Color.FromHtml("15161d");/// <summary>Idle fill of ribbon tabs (top navigation).</summary>
public static readonly Color Ribbon=Color.FromHtml("2a241b");/// <summary>Hover fill of ribbon tabs.</summary>
public static readonly Color RibbonHover=Color.FromHtml("38301f");/// <summary>Ribbon tab text on the idle ribbon fill.</summary>
public static readonly Color RibbonText=Color.FromHtml("d9cdb2");/// <summary>Active ribbon tab text on dark fill.</summary>
public static readonly Color RibbonTextActive=Color.FromHtml("1a1409");/// <summary>Background of character stat blocks.</summary>
public static readonly Color StatBg=Color.FromHtml("161822");/// <summary>Separator lines inside stat blocks.</summary>
public static readonly Color StatLine=Color.FromHtml("2c2f3c");/// <summary>Key text of key/value chips (e.g. quest reward labels).</summary>
public static readonly Color ChipKey=Color.FromHtml("7d7461");/// <summary>Value text of key/value chips (e.g. quest reward counts).</summary>
public static readonly Color ChipValue=Color.FromHtml("b8a06d");/// <summary>Item drop-target tint.</summary>
public static readonly Color DropBg=Color.FromHtml("1a2019");/// <summary>Translucent black sidebar backdrop over scenes.</summary>
public static readonly Color SideBg=new(0f,0f,0f,0.2f);/// <summary>Pure white for icons and highlights that must not tint.</summary>
public static readonly Color White=Colors.White;/// <summary>Near-invisible white wash for subtle surfaces.</summary>
public static readonly Color WhiteTrace=new(1f,1f,1f,0.04f);/// <summary>Soft accent fill behind large count badges.</summary>
public static readonly Color AccentCount=Alpha(Accent,0.22f);/// <summary>Fill of a hovered battle target highlight.</summary>
public static readonly Color TargetHover=Color.FromHtml("13191b");/// <summary>Border of a hovered battle target highlight.</summary>
public static readonly Color TargetLine=new(79f/255f,179f/255f,165f/255f,0.65f);/// <summary>Semi-opaque backdrop of slot tags and map labels.</summary>
public static readonly Color SlotTagBg=new(13f/255f,14f/255f,20f/255f,0.85f);/// <summary>Tooltip backdrop.</summary>
public static readonly Color TooltipBg=new(16f/255f,17f/255f,24f/255f,0.97f);/// <summary>Toast notification backdrop.</summary>
public static readonly Color ToastBg=new(33f/255f,33f/255f,33f/255f,0.8f);/// <summary>Scrollbar thumb fill.</summary>
public static readonly Color ScrollThumb=Color.FromHtml("252734");/// <summary>Fill of the cooldown sweep overlay.</summary>
public static readonly Color CooldownSoft=Alpha(Cooldown,0.08f);/// <summary>Border of the cooldown sweep arc.</summary>
public static readonly Color CooldownLine=Alpha(Cooldown,0.45f);// ── Radii ───────────────────────────────────────────────────────
public const int RadiusXs=3;/// <summary>Chip, pill and small tag radius.</summary>
public const int RadiusSm=4;/// <summary>Default button and input radius.</summary>
public const int RadiusMd=6;/// <summary>Card and panel corner radius.</summary>
public const int RadiusLg=8;// ── Spacing ─────────────────────────────────────────────────────
public const int Space1=4;/// <summary>Hairline gap for paired elements.</summary>
public const int Space2=6;/// <summary>Minimal gap for icon stacks.</summary>
public const int Space3=8;/// <summary>Tight gap inside chips and badges.</summary>
public const int Space4=10;/// <summary>Compact gap between label and value.</summary>
public const int Space5=12;/// <summary>Gap inside cards and list rows.</summary>
public const int Space6=14;/// <summary>Standard gap between related controls.</summary>
public const int Space7=16;/// <summary>Large gap around page-level blocks.</summary>
public const int Space8=24;/// <summary>Extra-large gap between major panel sections.</summary>
public const int Space9=32;// ── Font sizes ────────────────────────────────────────────────── <summary>Micro text — marker state notes and fine print.</summary>
public const int FontMicro=9;/// <summary>Badges, notes and tiny captions.</summary>
public const int FontTiny=10;/// <summary>Small labels, legend text and hints.</summary>
public const int FontSmall=11;/// <summary>Compact body size for dense panels.</summary>
public const int FontBodySmall=12;/// <summary>Default body text size.</summary>
public const int FontBody=13;/// <summary>Large body text for readable primary copy.</summary>
public const int FontBodyLarge=14;/// <summary>Lead paragraph size for emphasized intros.</summary>
public const int FontLead=16;/// <summary>Minor heading size for in-panel section names.</summary>
public const int FontH3=19;/// <summary>Card heading size (location card name, panel titles).</summary>
public const int FontTitle=24;/// <summary>Secondary heading size (e.g. map HUD title).</summary>
public const int FontH2=25;/// <summary>Largest display size — hero titles of full-screen menus.</summary>
public const int FontH1=30;/// <summary>Returns the colour with a replaced alpha channel.</summary>
public static Color Alpha(Color color,float alpha)=>new(color.R,color.G,color.B,alpha);/// <summary>Badge fill (9%) and border (50%) derived from a type accent.</summary>
public static Color BadgeFill(Color accent)=>Alpha(accent,0.09f);/// <summary>Border half of a type badge: 50% accent alpha.</summary>
public static Color BadgeLine(Color accent)=>Alpha(accent,0.5f);}}

===== FILE Core/UI/Tool/IToolBoxButton.cs =====
namespace Cthangover.Core.UI.Tool{/// <summary> Contract for dynamically-discovered toolbar buttons. ToolId links to a ToolFactory entry; IconPath provides the icon texture; LocaleKey enables translation; IsVisible() allows buttons to conditionally appear (e.g. dev tools that only show in debug builds). </summary>
public interface IToolBoxButton{/// <summary>Matches an <see cref="IToolProvider.Id"/> in <see cref="ToolFactory"/>, wiring the button to a specific tool.</summary>
string ToolId{get;}/// <summary>Resource path to the icon texture loaded for this button.</summary>
string IconPath{get;}/// <summary>Translation key for the button's tooltip or accessible label.</summary>
string LocaleKey{get;}/// <summary>Return <c>false</c> to hide the button dynamically (e.g. dev-only buttons in release builds).</summary>
bool IsVisible();}}

===== FILE Core/UI/Tool/IToolProvider.cs =====
using Godot;namespace Cthangover.Core.UI.Tool{/// <summary> Contract for discoverable developer/modding tools. CreateWindow() returns a Godot Window that the tool system manages as a popup. Id is the registry key; LocaleKey provides localized display names. AddMenuButton() lets the tool append its own launcher button to the main menu's tool block, so the menu stays unaware of concrete tools. </summary>
public interface IToolProvider{/// <summary>Unique identifier used as the lookup key in <see cref="ToolFactory"/>.</summary>
string Id{get;}/// <summary>Translation key for the tool's display name, resolved via <c>TranslationServer.Translate</c>.</summary>
string LocaleKey{get;}/// <summary> Creates a new <see cref="Window"/> instance for this tool. Callers are responsible for adding it to the scene tree and displaying it. </summary>
Window CreateWindow();/// <summary> Appends this tool's launcher button to the main menu's tool block. Called once per main-menu instantiation, so each call must add a fresh button to <paramref name="container"/>. Use <see cref="ToolMenuButton.Attach"/> for the standard presentation; it also marks the button so the menu retranslates it together with its built-in buttons. </summary>
void AddMenuButton(Container container);}}

===== FILE Core/UI/Tool/MapZoneStateData.cs =====
using System.Collections.Generic;using System.Text.Json.Serialization;namespace Cthangover.Core.UI.Tool{/// <summary> Save-format DTO for <see cref="MapZoneService"/> runtime overrides. Both dictionaries store only zones whose live state differs from the authoring defaults (<see cref="MapZone.Locked"/> / <see cref="MapZone.Hidden"/>), keyed by zone <see cref="MapZone.ID"/>. <para/> Storing deltas instead of the full effective state keeps saves small and, more importantly, lets a mod update its authoring defaults without the save silently reverting them: a zone the player never touched always follows the current JSON. Unknown IDs are tolerated on load — they simply never match a zone and cost nothing if a mod is temporarily disabled. </summary>
public class MapZoneStateData{/// <summary> Explicit open/closed overrides. <c>true</c> — zone was opened by a scenario, <c>false</c> — closed; absent IDs follow the authoring default. </summary>
[JsonPropertyName("open")]public Dictionary<string,bool>Open{get;set;}=new();/// <summary> Explicit shown/hidden overrides. <c>true</c> — zone was revealed by a scenario, <c>false</c> — hidden; absent IDs follow the authoring default. </summary>
[JsonPropertyName("visible")]public Dictionary<string,bool>Visible{get;set;}=new();}}

===== FILE Core/UI/Tool/ToolBoxButtonFactory.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Reflection;using Cthangover.Core.Bootstrap;using Cthangover.Core.Utils;namespace Cthangover.Core.UI.Tool{/// <summary> Registry for dynamically-discovered toolbar button definitions. Scans assemblies via Reflections.FindAndCreate on construction, loading all IToolBoxButton implementations. Supports RegisterAssembly for mods to inject their own buttons at runtime. GetVisible() filters by each button's IsVisible() method, so buttons can conditionally hide. <para/> The single instance is owned by the <see cref="AppServices"/> composition root; the static facade stays as the public API during migration. Initialization moves from eager (static field) to lazy (first access through the owning <c>Lazy</c> slot). </summary>
public class ToolBoxButtonFactory{/// <summary>Facade over the AppServices-owned instance; kept as the public API during migration.</summary>
public static ToolBoxButtonFactory Instance=>AppServices.Current.ToolBoxButtons.Value;private readonly List<IToolBoxButton>_buttons=new();private readonly HashSet<Type>_buttonTypes=new();internal ToolBoxButtonFactory(){foreach(var button in Reflections.FindAndCreate<IToolBoxButton>())Register(button);GameLogger.Log("TOOLS",$"ToolBoxButtonFactory: loaded {_buttons.Count} button(s)");}/// <summary> Scans all types in the given assembly for <see cref="IToolBoxButton"/> implementations, instantiates them via <c>Activator.CreateInstance</c>, and registers each one. Primary hook for mods to inject their own toolbar buttons at runtime. </summary>
public void RegisterAssembly(Assembly assembly){ICollection<IToolBoxButton>buttons;try{buttons=Reflections.FindAndCreate<IToolBoxButton>(assembly);}catch(Exception ex){GameLogger.Log("TOOLS",$"ToolBoxButtonFactory: failed to scan assembly '{assembly.GetName().Name}': {ex.Message}",LogLevel.Error);return;}foreach(var button in buttons)Register(button);}/// <summary> Adds a button definition to the registry. Buttons of the same concrete type are deduplicated (the factory may see an assembly both through the initial AppDomain scan and through <see cref="RegisterAssembly"/>). Different button types targeting the same <see cref="IToolBoxButton.ToolId"/> are still allowed because they may have different visibility rules. </summary>
public void Register(IToolBoxButton button){if(!_buttonTypes.Add(button.GetType()))return;_buttons.Add(button);GameLogger.Log("TOOLS",$"ToolBoxButtonFactory: registered button for '{button.ToolId}'");}/// <summary> Removes buttons whose concrete type comes from the given assembly — the unload pass of the assembly consumer pipeline. </summary>
public void UnregisterAssembly(Assembly assembly){_buttons.RemoveAll(b=>b.GetType().Assembly==assembly);_buttonTypes.RemoveWhere(t=>t.Assembly==assembly);}/// <summary> Returns all registered buttons that currently pass <see cref="IToolBoxButton.IsVisible"/>. Used by <see cref="ToolBox.AddToolButtons"/> to dynamically populate the toolbar HUD. </summary>
public IReadOnlyList<IToolBoxButton>GetVisible(){return _buttons.Where(b=>b.IsVisible()).ToList();}}}

===== FILE Core/UI/Tool/ToolFactory.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Reflection;using Cthangover.Core.Bootstrap;using Cthangover.Core.Utils;namespace Cthangover.Core.UI.Tool{/// <summary> Registry for modding/dev tools (IToolProvider implementations). Discovers tools via reflection on construction; supports RegisterAssembly for mod-injected tools. Deduplicates by Id. Get/GetAll provide lookup for the tool block of the main menu and the in-game toolbox. <para/> The single instance is owned by the <see cref="AppServices"/> composition root; the static facade stays as the public API during migration. Initialization moves from eager (static field) to lazy (first access through the owning <c>Lazy</c> slot). </summary>
public class ToolFactory{/// <summary>Facade over the AppServices-owned instance; kept as the public API during migration.</summary>
public static ToolFactory Instance=>AppServices.Current.Tools.Value;private readonly Dictionary<string,IToolProvider>_tools=new();internal ToolFactory(){foreach(var tool in Reflections.FindAndCreate<IToolProvider>())Register(tool);GameLogger.Log("TOOLS",$"ToolFactory: loaded {_tools.Count} tool(s)");}/// <summary> Scans all types in the given assembly for <see cref="IToolProvider"/> implementations, instantiates them via <c>Activator.CreateInstance</c>, and registers each one. Primary hook for mods to register their own tools at runtime. </summary>
public void RegisterAssembly(Assembly assembly){ICollection<IToolProvider>tools;try{tools=Reflections.FindAndCreate<IToolProvider>(assembly);}catch(Exception ex){GameLogger.Log("TOOLS",$"ToolFactory: failed to scan assembly '{assembly.GetName().Name}': {ex.Message}",LogLevel.Error);return;}foreach(var tool in tools)Register(tool);}/// <summary> Registers a tool, deduplicating by <see cref="IToolProvider.Id"/>. If a tool with the same Id was already registered, the new one is silently discarded — first-come-wins. </summary>
public void Register(IToolProvider tool){if(_tools.TryGetValue(tool.Id,out var existing)){if(existing.GetType()!=tool.GetType()){GameLogger.Log("TOOLS",$"ToolFactory: duplicate tool id '{tool.Id}' from '{tool.GetType().FullName}' ignored (already provided by '{existing.GetType().FullName}')",LogLevel.Warning);}return;}_tools[tool.Id]=tool;GameLogger.Log("TOOLS",$"ToolFactory: registered '{tool.Id}'");}/// <summary> Removes tools whose concrete type comes from the given assembly — the unload pass of the assembly consumer pipeline. Core tools survive; a mod reinstall re-registers through the normal fan-out. </summary>
public void UnregisterAssembly(Assembly assembly){foreach(var id in _tools.Where(kvp=>kvp.Value.GetType().Assembly==assembly).Select(kvp=>kvp.Key).ToList())_tools.Remove(id);}/// <summary> Looks up a tool by its <see cref="IToolProvider.Id"/>. Returns <c>null</c> if not found. </summary>
public IToolProvider Get(string id){_tools.TryGetValue(id,out var tool);return tool;}/// <summary> Returns all registered tools as a read-only list. Used by <see cref="MainMenu"/> to populate the Tools dropdown. </summary>
public IReadOnlyList<IToolProvider>GetAll(){return _tools.Values.ToList();}}}

===== FILE Core/UI/UiBuilder.cs =====
using Cthangover.Core.Bootstrap;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.UI{/// <summary> Centralized, one-shot construction of the mod GUI for a freshly loaded shell. Kept deliberately dumb: it never reacts to intermediate states and never rebuilds — <see cref="UiHost"/> calls it exactly once per <c>ShellReadyEvent</c>, and <see cref="IToolbarHost.ApplyPanelButtons"/> owns the actual node creation. </summary>
public static class UiBuilder{/// <summary> Asks the shell toolbar to materialize the buttons declared by panel-kind entries. Panel scenes themselves are built lazily on first toggle. </summary>
public static void Build(Node shellRoot){if(shellRoot==null|| !GodotObject.IsInstanceValid(shellRoot))return;if(shellRoot.FindChild("TopView",true,false)is not IToolbarHost toolBox){GameLogger.Log("UI_BUILDER","Build: ToolBox 'TopView' not found in shell",LogLevel.Warning);return;}toolBox.ApplyPanelButtons(AppServices.Current.Ui.AllPanels);}}}

===== FILE Core/UI/UiHost.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Bootstrap;using Cthangover.Core.Events;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.UI{/// <summary> Persistent autoload that owns the mod-built GUI for the whole session. Panels live under its own <see cref="CanvasLayer"/> instead of the base scene, so entering a separate native or mod-routed scene (e.g. battle) and coming back does not rebuild them — the registry only re-attaches and re-shows what is already cached. <para/> Responsibilities: instance cache keyed by <see cref="UiEntry.Id"/>, lazy construction through <see cref="TscnScenes"/>, scene-based visibility (entries bound to a logical scene hide on exit and restore on re-entry), and tree access for mod code (<see cref="FindNode"/>, <see cref="Join"/>). It subscribes to <see cref="ShellReadyEvent"/> to learn the current shell root and to trigger <see cref="UiBuilder.Build"/>. </summary>
public partial class UiHost:Node{/// <summary>Autoload singleton; null outside runtime.</summary>
public static UiHost Instance{get;private set;}private CanvasLayer layer;private Node shellRoot;private readonly Dictionary<string,Control>instances=new(StringComparer.OrdinalIgnoreCase);private readonly Dictionary<string,bool>toggled=new(StringComparer.OrdinalIgnoreCase);private readonly HashSet<string>hiddenByScene=new(StringComparer.OrdinalIgnoreCase);/// <summary>Root of the currently loaded shell, or null before the first ShellReadyEvent.</summary>
public Node ShellRoot=>shellRoot;/// <summary>Creates the persistent canvas layer and subscribes to shell and scene lifetime events.</summary>
public override void _Ready(){Instance=this;ProcessMode=ProcessModeEnum.Always;layer=new CanvasLayer{Name="UiHostLayer",Layer=100};AddChild(layer);ModEventBus.Subscribe<ShellReadyEvent>(OnShellReady);ModEventBus.Subscribe<SceneEnteredEvent>(OnSceneEntered,priority:30);ModEventBus.Subscribe<SceneExitedEvent>(OnSceneExited,priority:30);}/// <summary>Clears the autoload singleton when the host is torn down.</summary>
public override void _ExitTree(){if(Instance==this)Instance=null;}/// <summary> Finds a node by name in the current shell. Backs both the registry anchoring and mod snippets that need to reach into the shell tree. </summary>
public Node FindNode(string name){if(string.IsNullOrEmpty(name)||shellRoot==null|| !GodotObject.IsInstanceValid(shellRoot))return null;return shellRoot.FindChild(name,true,false);}/// <summary> Parents an already built control to an arbitrary existing node, for mod code that wants its element to be part of that node's layout instead of a hosted overlay. Joined elements are freed together with their parent and therefore lose the app-lifetime guarantee panels normally get. </summary>
public bool Join(Node parent,Control panel){if(parent==null||panel==null)return false;parent.AddChild(panel);return true;}/// <summary> Shows or hides the entry with the given id, creating its instance on first use. Panels are toggled exclusively through this method. </summary>
public void Toggle(string id){var entry=AppServices.Current.Ui.GetById(id);if(entry==null){GameLogger.Log("UI_HOST",$"Toggle('{id}'): entry not found",LogLevel.Warning);return;}var instance=EnsureInstance(entry);if(instance==null)return;hiddenByScene.Remove(id);if(instance is IWidget widget){widget.Switch();toggled[id]=widget.Visible;}else{var visible= !instance.Visible;instance.Visible=visible;toggled[id]=visible;}}/// <summary> Returns the cached instance for an entry <b>without</b> creating it, for mod code that wants to reach an already-built panel (e.g. the map editor asking the map widget to reload). Returns <c>false</c> when the panel was never opened; use <see cref="EnsureInstance"/> to force construction. </summary>
public bool TryGetInstance(string id,out Control control){control=null;if(string.IsNullOrEmpty(id))return false;if(!instances.TryGetValue(id,out var cached)|| !GodotObject.IsInstanceValid(cached))return false;control=cached;return true;}/// <summary> Returns the cached instance for an entry, building it on first use. Hosted entries (panels and once-overlays) are parented to the host layer; transient overlays are joined to their slot in the current shell. </summary>
public Control EnsureInstance(UiEntry entry){if(instances.TryGetValue(entry.Id,out var cached)){if(GodotObject.IsInstanceValid(cached))return cached;instances.Remove(entry.Id);}var node=TscnScenes.LoadAndBuild(entry.ScenePath);if(node is not Control control){GameLogger.Log("UI_HOST",$"EnsureInstance('{entry.Id}'): '{entry.ScenePath}' did not build a Control",LogLevel.Error);return null;}control.Name=entry.Id;UiThemeBridge.Apply(control);if(IsHosted(entry)){layer.AddChild(control);control.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);}else{var slot=FindNode(entry.Slot);if(slot==null){GameLogger.Log("UI_HOST",$"EnsureInstance('{entry.Id}'): slot '{entry.Slot}' not found",LogLevel.Warning);control.QueueFree();return null;}slot.AddChild(control);control.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);}if(control is IUiLazyConstruct lazyConstruct)lazyConstruct.EnsureConstructed();instances[entry.Id]=control;SetVisible(control,false);return control;}/// <summary> Frees every cached instance and forgets the shell. Called by <see cref="ModManager"/> so a hot-reloaded mod set starts clean. </summary>
public void ClearAll(){foreach(var instance in instances.Values){if(GodotObject.IsInstanceValid(instance))instance.QueueFree();}instances.Clear();toggled.Clear();hiddenByScene.Clear();shellRoot=null;}private static bool IsHosted(UiEntry entry)=>UiRegistry.IsPanel(entry)||entry.Once;private void OnShellReady(ShellReadyEvent e){PurgeInvalidInstances();shellRoot=e.Root;UiThemeBridge.Apply(e.Root);UiBuilder.Build(e.Root);foreach(var entry in AppServices.Current.Ui.AllEntries){if(IsSceneBound(entry)||UiRegistry.IsPanel(entry)|| !entry.ShowOnStart)continue;toggled[entry.Id]=true;var instance=EnsureInstance(entry);SetVisible(instance,true);}}private void OnSceneEntered(SceneEnteredEvent e){foreach(var entry in AppServices.Current.Ui.AllEntries){if(!IsSceneBound(entry)|| !string.Equals(entry.Scene,e.SceneName,StringComparison.OrdinalIgnoreCase))continue;if(!UiRegistry.IsPanel(entry)&&entry.ShowOnStart){toggled[entry.Id]=true;hiddenByScene.Remove(entry.Id);SetVisible(EnsureInstance(entry),true);continue;}if(hiddenByScene.Remove(entry.Id)&&toggled.GetValueOrDefault(entry.Id))SetVisible(EnsureInstance(entry),true);}}private void OnSceneExited(SceneExitedEvent e){foreach(var entry in AppServices.Current.Ui.AllEntries){if(!IsSceneBound(entry)|| !string.Equals(entry.Scene,e.SceneName,StringComparison.OrdinalIgnoreCase))continue;if(!entry.Once){DestroyInstance(entry.Id);continue;}if(toggled.GetValueOrDefault(entry.Id)){hiddenByScene.Add(entry.Id);if(instances.TryGetValue(entry.Id,out var instance))SetVisible(instance,false);}}}private void PurgeInvalidInstances(){var stale=new List<string>();foreach(var pair in instances){if(!GodotObject.IsInstanceValid(pair.Value))stale.Add(pair.Key);}foreach(var id in stale){instances.Remove(id);toggled.Remove(id);hiddenByScene.Remove(id);}}private void DestroyInstance(string id){if(instances.TryGetValue(id,out var instance)){if(GodotObject.IsInstanceValid(instance))instance.QueueFree();instances.Remove(id);}toggled.Remove(id);hiddenByScene.Remove(id);}private static bool IsSceneBound(UiEntry entry)=> !string.IsNullOrEmpty(entry.Scene)&& !string.Equals(entry.Scene,"shell",StringComparison.OrdinalIgnoreCase);private static void SetVisible(Control control,bool visible){if(control==null|| !GodotObject.IsInstanceValid(control))return;if(control is IWidget widget)widget.Visible=visible;else control.Visible=visible;}}}

===== FILE Core/UI/UiRegistry.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Bootstrap;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.UI{/// <summary> Data half of the mod GUI pipeline: collects, validates and orders the <c>ui</c> declarations from every loaded mod manifest. Creates no nodes — <see cref="UiBuilder"/> reads <see cref="PanelEntries"/> to build the toolbar once per shell, and <see cref="UiHost"/> resolves entries by id at runtime. <para/> Validation is mandatory and runs once per build: every problem is logged with its mod id, the offending entry is skipped, and the remaining mods still load. <see cref="IGameRegistry.Clear"/> is called on mod reload so a hot-reloaded mod set is reflected without restarting the game. <para/> The registry instance lives in <see cref="AppServices.Current"/>; the static members below are a facade delegating to it, so existing callers keep working unchanged. </summary>
public class UiRegistry:IGameRegistry{private readonly List<UiEntry>entries=new();private readonly List<UiEntry>panels=new();private readonly Dictionary<string,UiEntry>byId=new(StringComparer.OrdinalIgnoreCase);private bool built;/// <summary>Shared instance the static facade delegates to.</summary>
private static UiRegistry Owned=>AppServices.Current.Ui;/// <summary> Creates an empty registry: cheap and side-effect free per the IGameRegistry contract. <see cref="Rebuild"/> fills the data. </summary>
internal UiRegistry(){}/// <inheritdoc />
public string Id=>"ui";/// <summary> Drops all collected entries, then re-collects and re-validates the <c>ui</c> arrays of all loaded mods. Forced: safe to call repeatedly. </summary>
public void Rebuild(){Reset();built=true;Collect();}/// <summary> Drops all collected entries so the next build re-reads the manifests. Called on mod reload through AppServices.ClearRegistries. </summary>
void IGameRegistry.Clear(){Reset();}/// <summary> Collects and validates the <c>ui</c> arrays of all loaded mods unless already built. Keeps the old Initialize idempotence: later calls are no-ops until the registry is cleared. </summary>
public void EnsureBuilt(){if(!built)Rebuild();}/// <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public IReadOnlyList<UiEntry>AllEntries=>entries;/// <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public IReadOnlyList<UiEntry>AllPanels=>panels;// ---- Static facade (legacy API, unchanged for callers) ------------ <summary>All valid UI entries in mod load order (then priority, then id).</summary>
public static IReadOnlyList<UiEntry>Entries=>Owned.entries;/// <summary>Panel-kind entries, pre-sorted for toolbar construction.</summary>
public static IReadOnlyList<UiEntry>PanelEntries=>Owned.panels;/// <summary> Collects and validates the <c>ui</c> arrays of all loaded mods. Safe to call multiple times; later calls are no-ops until <see cref="Clear"/>. </summary>
public static void Initialize()=>Owned.EnsureBuilt();/// <summary> Drops all collected entries so the next <see cref="Initialize"/> re-reads the manifests. Called by <see cref="ModManager"/> on reload/clear. </summary>
public static void Clear()=>Owned.Reset();/// <summary>Returns the entry with the given id, or null when unknown.</summary>
public static UiEntry Get(string id)=>Owned.GetById(id);/// <summary> Shows or hides the GUI element with the given id through <see cref="UiHost"/>. Panels are toggled this way; overlays may be toggled by their own actions. </summary>
public static void Toggle(string id)=>Owned.ToggleEntry(id);/// <summary>True when the entry represents a button-toggled panel.</summary>
public static bool IsPanel(UiEntry entry)=>entry!=null&&entry.Kind==UiEntry.KindPanel;// ---- Instance implementation -------------------------------------- <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public void Reset(){entries.Clear();panels.Clear();byId.Clear();built=false;}/// <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public UiEntry GetById(string id){if(string.IsNullOrEmpty(id))return null;return byId.TryGetValue(id,out var entry)?entry:null;}/// <summary>Direct instance access for core consumers migrating off the static facade.</summary>
public void ToggleEntry(string id){var host=UiHost.Instance;if(host==null){GameLogger.Log("UI_REGISTRY",$"Toggle('{id}'): UiHost not available",LogLevel.Error);return;}host.Toggle(id);}private void Collect(){var manager=ModManager.Instance;if(manager?.Mods==null){GameLogger.Log("UI_REGISTRY","Collect: mods not loaded",LogLevel.Error);return;}var orderedIds=ModRegistry.Instance.GetOrderedModIds();for(var order=0;order<orderedIds.Count;order++){var modId=orderedIds[order];if(!manager.Mods.TryGetValue(modId,out var info)||info is not ModInfo modInfo)continue;var declarations=modInfo.Manifest?.Ui;if(declarations==null||declarations.Count==0)continue;var accepted=new List<UiEntry>();foreach(var raw in declarations){if(raw==null)continue;raw.ModId=modId;raw.ModOrderIndex=order;if(Validate(raw,modInfo))accepted.Add(raw);}accepted.Sort(CompareEntries);foreach(var entry in accepted)AddEntry(entry);}BuildPanelIndex();GameLogger.Log("UI_REGISTRY",$"Collect → {entries.Count} entry(ies), {panels.Count} panel(s)");}private static int CompareEntries(UiEntry a,UiEntry b){var priority=a.Priority.CompareTo(b.Priority);if(priority!=0)return priority;return string.Compare(a.Id,b.Id,StringComparison.OrdinalIgnoreCase);}private void AddEntry(UiEntry entry){entries.Add(entry);byId[entry.Id]=entry;}private void BuildPanelIndex(){panels.Clear();foreach(var entry in entries){if(!IsPanel(entry))continue;entry.Once=true;panels.Add(entry);}panels.Sort(ComparePanelButtons);}private static int ComparePanelButtons(UiEntry a,UiEntry b){var index=a.Button.Index.CompareTo(b.Button.Index);if(index!=0)return index;var modOrder=a.ModOrderIndex.CompareTo(b.ModOrderIndex);if(modOrder!=0)return modOrder;return string.Compare(a.Id,b.Id,StringComparison.OrdinalIgnoreCase);}private bool Validate(UiEntry entry,ModInfo modInfo){var label=$"{modInfo.Id}/{entry.Id ?? "?"}";if(string.IsNullOrWhiteSpace(entry.Id)){GameLogger.Log("UI_REGISTRY",$"invalid ui entry in mod '{modInfo.Id}': missing 'id'",LogLevel.Error);return false;}if(byId.ContainsKey(entry.Id)){GameLogger.Log("UI_REGISTRY",$"invalid ui entry '{label}': duplicate id",LogLevel.Error);return false;}if(entry.Kind!=UiEntry.KindPanel&&entry.Kind!=UiEntry.KindOverlay){GameLogger.Log("UI_REGISTRY",$"invalid ui entry '{label}': kind must be '{UiEntry.KindPanel}' or '{UiEntry.KindOverlay}'",LogLevel.Error);return false;}if(string.IsNullOrWhiteSpace(entry.Slot)){GameLogger.Log("UI_REGISTRY",$"invalid ui entry '{label}': missing 'slot'",LogLevel.Error);return false;}if(string.IsNullOrWhiteSpace(entry.ScenePath)){GameLogger.Log("UI_REGISTRY",$"invalid ui entry '{label}': missing 'scene_path'",LogLevel.Error);return false;}if(entry.Kind==UiEntry.KindPanel){if(entry.Button==null){GameLogger.Log("UI_REGISTRY",$"invalid ui entry '{label}': kind=panel requires 'button'",LogLevel.Error);return false;}if(string.IsNullOrWhiteSpace(entry.Button.Icon)||string.IsNullOrWhiteSpace(entry.Button.Locale)){GameLogger.Log("UI_REGISTRY",$"invalid ui entry '{label}': button requires 'icon' and 'locale'",LogLevel.Error);return false;}}else if(entry.Button!=null){GameLogger.Log("UI_REGISTRY",$"invalid ui entry '{label}': kind=overlay must not declare 'button'",LogLevel.Error);return false;}var provider=modInfo.FileProvider;if(provider!=null&& !provider.FileExists(entry.ScenePath))GameLogger.Log("UI_REGISTRY",$"ui entry '{label}': scene_path '{entry.ScenePath}' not found in mod '{modInfo.Id}'",LogLevel.Warning);return true;}}}

===== FILE Core/UI/UiThemeBridge.cs =====
using System;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.UI{/// <summary> Core-side bridge for UI theming. The implementation (CthangoverTheme, interface engine mod) installs its hook through <see cref="Register"/> when the mod initializer runs; the kernel call sites (UiHost's shell and panel builds) only know this delegate slot. Registration is first-wins: reloading the mod re-runs its static initializers, and a second hook must never stack on top of the first. While no hooks are bound (kernel built without the interface mod, or calls before mods load) invocations are skipped with a warning instead of throwing. Same pattern as <c>LightingBridge</c> for the time mod. </summary>
public static class UiThemeBridge{private static readonly object Gate=new();private static Action<Node>_applyTheme;/// <summary>Installs the theme hook; first registration wins, re-registrations are logged and ignored.</summary>
public static void Register(Action<Node>applyTheme){if(applyTheme==null)return;lock(Gate){if(_applyTheme!=null){GameLogger.Log("UI","UiThemeBridge: theme hook already registered; ignoring re-registration",LogLevel.Warning);return;}_applyTheme=applyTheme;}}/// <summary> Applies the mod theme to a freshly built control or shell root. Skipped with a warning when no interface implementation is bound. </summary>
public static void Apply(Node root){if(root==null)return;Action<Node>hook;lock(Gate)hook=_applyTheme;if(hook==null){GameLogger.Log("UI","UiThemeBridge: no theme hook bound (interface mod not loaded); theme application skipped",LogLevel.Warning);return;}hook(root);}}}

===== FILE Core/Utils/BoundedCache.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Core.Utils{/// <summary> A thread-safe, fixed-capacity cache with least-recently-used (LRU) eviction. Used to avoid redundant I/O and computation when the same data is requested repeatedly — for example, parsed scenario resources, mod asset metadata, or deserialised JSON configs. </summary> <typeparam name="TKey">The lookup key type (typically <c>string</c> or numeric IDs).</typeparam> <typeparam name="TValue">The cached payload type.</typeparam> <remarks> <para> Internally, a <see cref="Dictionary{TKey, TValue}"/> maps keys to <see cref="LinkedList{T}"/> nodes, and the linked list maintains access order. On every <see cref="Get"/> hit the accessed node is moved to the tail (most-recently-used). When the dictionary reaches <see cref="MaxSize"/> an insertion evicts the head (least-recently-used). </para> <para> The loader's <see cref="ICacheLoader{TKey, TValue}.Load"/> method is called <em>outside</em> the internal lock so that slow I/O does not block concurrent reads of already-cached entries. A double-check inside the lock ensures no duplicate loads occur for the same key. </para> <para> Every value that leaves the cache without being handed to a caller is reported to the optional eviction callback passed to the constructor: LRU eviction on insertion, a value that loses the double-check race (two threads loaded the same key, the loser is discarded un-cached), and values dropped by <see cref="Invalidate"/> or <see cref="Clear"/>. Like the loader, the callback is invoked <em>outside</em> the internal lock. This hook is the deterministic release point for payloads that own unmanaged memory — Godot resources such as <c>Texture2D</c>, <c>ImageTexture</c> or <c>AudioStream</c> keep their native/GPU allocation alive until the managed wrapper is disposed, and waiting for the .NET GC finalizer turns a bounded cache into a VRAM/audio memory leak during long sessions or frequent location changes. </para> </remarks>
public class BoundedCache<TKey,TValue>{private readonly int _maxSize;private readonly ICacheLoader<TKey,TValue>_loader;private readonly Dictionary<TKey,LinkedListNode<Entry>>_dict;private readonly LinkedList<Entry>_lru;private readonly object _lock;/// <summary> Optional sink for values leaving the cache (see class remarks); <c>null</c> for caches of plain managed values that need no explicit release. Never invoked while the internal lock is held. </summary>
private readonly Action<TValue>_onEvicted;private class Entry{/// <summary>Cache key of the entry; kept alongside the value so eviction can remove the dictionary slot.</summary>
public TKey Key;/// <summary>Cached payload produced by the loader.</summary>
public TValue Value;}/// <summary> Creates a cache with the given capacity and data-fetching strategy. A <paramref name="maxSize"/> of 0 disables the bound — entries will accumulate without eviction, which is suitable for immutable reference data that fits in memory. Values are dropped silently; use the second constructor for payloads that own unmanaged memory. </summary> <param name="maxSize">Maximum number of entries before the LRU tail is evicted.</param> <param name="loader">Delegate that produces values for cache misses.</param>
public BoundedCache(int maxSize,ICacheLoader<TKey,TValue>loader):this(maxSize,loader,null){}/// <summary> Creates a cache that additionally reports every dropped value through <paramref name="onEvicted"/> — the hook Godot-resource caches use to dispose evicted payloads deterministically (see the class remarks). The callback runs outside the internal lock, may fire from any thread that triggers a drop, and must not call back into the same cache: this instance would deadlock on re-entry. </summary> <param name="maxSize">Maximum number of entries before the LRU tail is evicted; 0 means unbounded.</param> <param name="loader">Delegate that produces values for cache misses.</param> <param name="onEvicted">Called for each value as it leaves the cache: LRU eviction, losing a duplicate-load race, <see cref="Invalidate"/>, <see cref="Clear"/>. May be <c>null</c>.</param>
public BoundedCache(int maxSize,ICacheLoader<TKey,TValue>loader,Action<TValue>onEvicted){_maxSize=maxSize;_loader=loader;_onEvicted=onEvicted;_dict=new Dictionary<TKey,LinkedListNode<Entry>>();_lru=new LinkedList<Entry>();_lock=new object();}/// <summary> The maximum number of entries this cache will hold before evicting the least-recently-used item. A value of 0 means unbounded. </summary>
public int MaxSize{get{return _maxSize;}}/// <summary> Current number of cached entries. This property acquires the internal lock for a consistent snapshot. </summary>
public int Count{get{lock(_lock)return _dict.Count;}}/// <summary> Retrieves the value for <paramref name="key"/>, either from the LRU in-memory store or by calling <see cref="ICacheLoader{TKey, TValue}.Load"/> on a miss. Newly loaded values are inserted into the cache and may trigger eviction of the least-recently-used entry if capacity is exceeded. Returning <c>null</c> from the loader causes the entry to be skipped entirely. </summary>
public TValue Get(TKey key){lock(_lock){if(_dict.TryGetValue(key,out var node)){_lru.Remove(node);_lru.AddLast(node);return node.Value.Value;}}var value=_loader.Load(key);List<TValue>dropped=null;TValue result;lock(_lock){if(_dict.TryGetValue(key,out var existing)){_lru.Remove(existing);_lru.AddLast(existing);result=existing.Value.Value;if(value!=null&& !ReferenceEquals(value,result)){// This thread lost the double-check race: the freshly loaded value is discarded un-cached. Report it so unmanaged payloads don't leak.
if(dropped==null)dropped=new List<TValue>();dropped.Add(value);}}else if(value==null){result=default;}else{if(_maxSize>0){while(_dict.Count>=_maxSize){var first=_lru.First;_lru.RemoveFirst();_dict.Remove(first.Value.Key);if(dropped==null)dropped=new List<TValue>();dropped.Add(first.Value.Value);}}var entry=new Entry{Key=key,Value=value};var newNode=new LinkedListNode<Entry>(entry);_dict[key]=newNode;_lru.AddLast(newNode);result=value;}}if(dropped!=null){for(var i=0;i<dropped.Count;i++)_onEvicted?.Invoke(dropped[i]);}return result;}/// <summary> A non-allocating lookup that returns <c>true</c> and sets <paramref name="value"/> only when the key is already cached; otherwise returns <c>false</c> <em>without</em> invoking the loader. Useful for callers that need to probe the cache without triggering side effects. </summary>
public bool TryGet(TKey key,out TValue value){lock(_lock){if(_dict.TryGetValue(key,out var node)){_lru.Remove(node);_lru.AddLast(node);value=node.Value.Value;return true;}}value=default;return false;}/// <summary> Removes a specific entry from the cache and the LRU tracking list, forcing the next <see cref="Get"/> call for this key to invoke the loader again. The removed value is reported to the eviction callback (outside the lock). A no-op if the key is not present. </summary>
public void Invalidate(TKey key){TValue dropped=default;lock(_lock){if(_dict.TryGetValue(key,out var node)){_lru.Remove(node);_dict.Remove(key);dropped=node.Value.Value;}}if(dropped!=null)_onEvicted?.Invoke(dropped);}/// <summary> Wipes all cached entries and resets the LRU order, typically called when a mod group is reloaded or a global configuration change makes all existing cached data stale. Every removed value is reported to the eviction callback (outside the lock). </summary>
public void Clear(){List<TValue>dropped=null;lock(_lock){if(_dict.Count>0){dropped=new List<TValue>(_dict.Count);foreach(var node in _lru)dropped.Add(node.Value);_dict.Clear();_lru.Clear();}}if(dropped!=null){for(var i=0;i<dropped.Count;i++)_onEvicted?.Invoke(dropped[i]);}}}}

===== FILE Core/Utils/Enums.cs =====
using System;namespace Cthangover.Core.Utils{/// <summary> Generic enum parser that wraps <see cref="Enum.Parse(Type, string, bool)"/> with case-insensitive matching and a natural type-parameter syntax. Used across the project wherever config strings, serialized fields, or console commands must be converted into strongly-typed enum values without per-enum boilerplate. </summary> <typeparam name="T">The enum type to parse into.</typeparam>
public static class Enums<T>{/// <summary> Parses the case-insensitive string <paramref name="value"/> into the enum type <typeparamref name="T"/>. Throws <see cref="ArgumentException"/> if the string does not match any named constant. </summary>
public static T Parse(string value){return(T)Enum.Parse(typeof(T),value,true);}}}

===== FILE Core/Utils/FlagsStringEnumConverter.cs =====
using System;using System.Text.Json;using System.Text.Json.Serialization;namespace Cthangover.Core.Utils{/// <summary> A <see cref="System.Text.Json"/> converter that deserialises a <c>[Flags]</c> enum from either a comma-separated string (e.g. <c>"OptionA, OptionB"</c>) or a raw integer value, and serialises it back as its <see cref="object.ToString"/> representation. </summary> <typeparam name="T">The <c>[Flags]</c> enum type.</typeparam> <remarks> This converter is registered with <c>JsonSerializerOptions</c> in the mod configuration pipeline, allowing human-editable JSON files to express bitmask enums as readable strings instead of opaque integers. </remarks>
public class FlagsStringEnumConverter<T>:JsonConverter<T>where T:struct,Enum{/// <summary> Reads a <typeparamref name="T"/> value from JSON. When the token is a <see cref="JsonTokenType.String"/>, delegates to <see cref="Enum.Parse(Type, string, bool)"/> with case-insensitive matching so that <c>"OptionA, OptionB"</c> maps to the combined flags value. When the token is a <see cref="JsonTokenType.Number"/>, casts the raw <c>uint</c> to the enum's underlying type. </summary>
public override T Read(ref Utf8JsonReader reader,Type typeToConvert,JsonSerializerOptions options){if(reader.TokenType==JsonTokenType.String){var str=reader.GetString();return(T)Enum.Parse(typeof(T),str,ignoreCase:true);}if(reader.TokenType==JsonTokenType.Number){var val=reader.GetUInt32();return(T)Enum.ToObject(typeof(T),val);}throw new JsonException($"Unexpected token {reader.TokenType} for enum {typeof(T).Name}");}/// <summary> Serialises the enum as its default <see cref="object.ToString"/> string, which for <c>[Flags]</c> types produces the comma-separated form (e.g. <c>"OptionA, OptionB"</c>). </summary>
public override void Write(Utf8JsonWriter writer,T value,JsonSerializerOptions options){writer.WriteStringValue(value.ToString());}}}

===== FILE Core/Utils/GameEnvironment.cs =====
namespace Cthangover.Core.Utils{/// <summary> Process-wide runtime environment flags shared across the core. Distinguishes a real Godot runtime from unit-test and design-time hosts and tracks the Godot main thread so subsystems can guard Godot API calls. </summary>
public static class GameEnvironment{/// <summary> True when the process is a real game run whose boot pipeline has executed. Unit tests and design-time tooling leave it <c>false</c>, which routes file access and singleton reloads through safe non-Godot defaults. </summary>
public static bool IsRuntime{get;set;}/// <summary> Managed thread id of the Godot main thread, captured once at startup (see <see cref="MarkMainThread"/>). Used by subsystems that may be invoked from background loading threads to decide whether a Godot API call is safe on the current thread. </summary>
private static int _mainThreadId;/// <summary> True when the calling thread is the Godot main thread. Always true outside the runtime (unit tests) where no real Godot API is involved. </summary>
public static bool IsMainThread=> !IsRuntime||_mainThreadId==0||System.Threading.Thread.CurrentThread.ManagedThreadId==_mainThreadId;/// <summary> Captures the current thread as the Godot main thread. Called once from the static constructor of <c>GameData</c> (the first autoload), which is guaranteed to run on the main thread. </summary>
public static void MarkMainThread(){_mainThreadId=System.Threading.Thread.CurrentThread.ManagedThreadId;}}}

===== FILE Core/Utils/GameLogger.cs =====
using Cthangover.Core.Settings;using Godot;using System;using System.Collections.Generic;using System.IO;using System.Text;
#if WINDOWS
using System.Runtime.InteropServices;
#endif
namespace Cthangover.Core.Utils{/// <summary> Central logging sink for the entire project. Every subsystem — the scenario engine, mod loader, UI framework, and AI behaviours — routes diagnostic output through this static class. </summary> <remarks> <para> The logger writes to three destinations simultaneously when enabled: <list type="number"> <item> <term>Console</term><description>A separate console window allocated via <c>AllocConsole</c> (Windows) or stdout (Linux/macOS) with colour-coded category labels. Controlled by <c>GameConfig.Instance.Logging.ConsoleEnabled</c>.</description> </item> <item> <term>Godot output</term><description>Falls back to <see cref="GD.Print"/> if a dedicated console is unavailable, so messages still appear in the Godot editor's Output panel.</description> </item> <item> <term>Log files</term><description>All entries go to <c>cthangover.log</c>; entries with <see cref="LogLevel.Error"/> are also duplicated to <c>cthangover_errors.log</c> for quick triage.</description> </item> </list> </para> <para> Initialisation is lazy and implicit: the first call to <see cref="Log"/> triggers <see cref="Init"/> automatically, which reads configuration from <see cref="Cthangover.Core.Settings.GameConfig.Instance"/>. </para> </remarks>
public static class GameLogger{
#if WINDOWS
[DllImport("kernel32.dll")]private static extern bool AllocConsole();[DllImport("kernel32.dll")]private static extern bool FreeConsole();
#endif
private static string _logPath;private static string _errorLogPath;private static bool _initialized;private static bool _enabled=true;private static bool _consoleAllocated;private static LogLevel _minimumLevel=LogLevel.Debug;private static HashSet<string>_enabledCategories;private static readonly object _lock=new();/// <summary> Fixed-size ring buffer of the most recent formatted log lines (category + message, no timestamp). Written under <see cref="_lock"/> so it is safe to read from any thread. Used by the boot log panel to replay startup output that was produced before the UI existed. </summary>
private static readonly string[]_recentLines=new string[RecentLineCount];private static int _recentWriteIndex;private static int _recentCount;/// <summary>Capacity of the <see cref="_recentLines"/> ring buffer.</summary>
private const int RecentLineCount=128;/// <summary> Fired for every log line that passes the level filter. Raised on the thread that called <see cref="Log"/> — a background loader will fire it on a worker thread, so subscribers must not touch Godot objects directly and must marshal to the main thread themselves. </summary>
public static event Action<string,string,LogLevel>LogEmitted;/// <summary> Accumulates compilation errors emitted by the scenario scripting subsystem during source parsing. Other modules may inspect this list after a batch compile to surface diagnostics to the user. </summary>
public static readonly List<string>CompilationErrors=new();/// <summary> Explicit initialisation entry point. Reads logging configuration from <see cref="Cthangover.Core.Settings.GameConfig.Instance"/>, ensures the <c>logs/</c> directory exists under the Godot user-data folder, honours the <c>--log-file=</c> command-line override, and optionally allocates a Windows console. Idempotent — subsequent calls are no-ops. </summary>
public static void Init(){if(_initialized)return;_initialized=true;try{if(!IsGodotAvailable()){_enabled=false;return;}_enabled=GameConfig.Instance.Logging.Enabled;_minimumLevel=ParseLogLevel(GameConfig.Instance.Logging.MinimumLevel);var enabledCategories=GameConfig.Instance.Logging.EnabledCategories;if(enabledCategories!=null&&enabledCategories.Count>0)_enabledCategories=new HashSet<string>(enabledCategories,StringComparer.OrdinalIgnoreCase);var userDir=OS.GetUserDataDir();var logsDir=Path.Combine(userDir,"logs");DirAccess.MakeDirRecursiveAbsolute(logsDir);_logPath=Path.Combine(logsDir,"cthangover.log");_errorLogPath=Path.Combine(logsDir,"cthangover_errors.log");var args=OS.GetCmdlineArgs();foreach(var arg in args){if(arg.StartsWith("--log-file=")){_logPath=arg.Substring("--log-file=".Length);break;}}if(GameConfig.Instance.Logging.ConsoleEnabled){
#if WINDOWS
try{_consoleAllocated=AllocConsole();}catch{_consoleAllocated=false;}
#else
_consoleAllocated=true;
#endif
}Log("LOGGER",$"Logger initialized, path={_logPath}");}catch{_enabled=false;}}private static bool IsGodotAvailable(){return GameEnvironment.IsRuntime;}private static bool IsModsCategory(string category){return category.StartsWith("MODS");}private static LogLevel ParseLogLevel(string value){if(string.IsNullOrEmpty(value))return LogLevel.Debug;if(Enum.TryParse<LogLevel>(value,true,out var result))return result;return LogLevel.Debug;}
#if WINDOWS
private static ConsoleColor GetCategoryColor(string category,LogLevel level){return level switch{LogLevel.Error=>ConsoleColor.DarkRed,LogLevel.Warning=>ConsoleColor.DarkYellow,LogLevel.Debug=>ConsoleColor.DarkGray,LogLevel.Message when IsModsCategory(category)=>ConsoleColor.Yellow,_=>ConsoleColor.Gray};}private static void WriteColoredWindows(string timestamp,string category,string message,LogLevel level){var categoryColor=GetCategoryColor(category,level);Console.ForegroundColor=ConsoleColor.Gray;Console.Write($"[{timestamp}] ");Console.ForegroundColor=categoryColor;Console.Write($"[{category}]");Console.ForegroundColor=ConsoleColor.White;Console.WriteLine($" {message}");}
#endif
private static string GetAnsiColorCode(string category,LogLevel level){return level switch{LogLevel.Error=>"\x1b[31m",LogLevel.Warning=>"\x1b[33m",LogLevel.Debug=>"\x1b[90m",LogLevel.Message when IsModsCategory(category)=>"\x1b[33m",_=>"\x1b[37m"};}private static void WriteColoredAnsi(string timestamp,string category,string message,LogLevel level){var color=GetAnsiColorCode(category,level);Console.WriteLine($"[{timestamp}] {color}[{category}]\x1b[0m {message}");}/// <summary> Writes a timestamped, categorised entry to all active sinks (console, Godot output, and log files) provided the entry's <paramref name="level"/> meets the configured <c>MinimumLevel</c> threshold. Thread-safe: file I/O is synchronised with a private lock object. </summary> <param name="category"> A short label identifying the source subsystem. Conventionally uppercase (e.g. <c>"MODS"</c>, <c>"SCENE"</c>). Displayed in colour on the console. </param> <param name="message">The free-form diagnostic text.</param> <param name="level"> Severity classification. Defaults to <see cref="LogLevel.Message"/>. Errors are duplicated to the dedicated error log. </param>
public static void Log(string category,string message,LogLevel level=LogLevel.Message){if(!_initialized)Init();if(level<_minimumLevel|| !IsGodotAvailable())return;//if (_enabledCategories != null && !_enabledCategories.Contains(category)) return;
var timestamp=DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");var line=$"[{timestamp}] [{category}] {message}";if(_consoleAllocated){try{
#if WINDOWS
WriteColoredWindows(timestamp,category,message,level);
#else
WriteColoredAnsi(timestamp,category,message,level);
#endif
}catch{}}else if(_initialized&&GameEnvironment.IsMainThread){try{GD.Print(line);}catch{}}lock(_lock){_recentLines[_recentWriteIndex]=$"[{category}] {message}";_recentWriteIndex=(_recentWriteIndex+1)%RecentLineCount;if(_recentCount<RecentLineCount)_recentCount++;}try{LogEmitted?.Invoke(category,message,level);}catch{}if(!_initialized|| !_enabled)return;lock(_lock){if(_logPath!=null)writeToFile(_logPath,line);if(_errorLogPath!=null&&level==LogLevel.Error)writeToFile(_errorLogPath,line);}}/// <summary> Returns a snapshot of the most recent formatted log lines (newest first, at most <paramref name="maxCount"/> entries). Thread-safe. </summary>
public static List<string>GetRecentLines(int maxCount){lock(_lock){var count=System.Math.Min(_recentCount,maxCount);var result=new List<string>(count);for(var i=0;i<count;i++){var idx=(_recentWriteIndex-1-i+RecentLineCount*2)%RecentLineCount;result.Add(_recentLines[idx]);}return result;}}private static void writeToFile(string fileName,string line){try{using var stream=new FileStream(fileName,FileMode.Append,System.IO.FileAccess.Write,FileShare.Read);using var writer=new StreamWriter(stream,Encoding.UTF8);writer.WriteLine(line);}catch{}}}}

===== FILE Core/Utils/ICacheLoader.cs =====
namespace Cthangover.Core.Utils{/// <summary> Abstraction for on-demand value retrieval consumed by <see cref="BoundedCache{TKey, TValue}"/>. When a cache miss occurs, the bounded cache delegates to the implementation's <see cref="Load"/> method rather than using a closure or factory delegate. This interface-based design allows loaders to carry their own state (e.g. file handles, network connections, database context) without capturing it in lambda allocations inside the cache construction site. Implementations are invoked outside the cache's internal lock to minimise contention. </summary>
public interface ICacheLoader<TKey,TValue>{/// <summary> Produces the value for the given <paramref name="key"/>. May return <c>null</c> for reference-type values, which the cache treats as "not cacheable" and will not insert into the LRU list. </summary>
TValue Load(TKey key);}}

===== FILE Core/Utils/JsonOptionsProvider.cs =====
using System.Text.Json;using System.Text.Json.Serialization;namespace Cthangover.Core.Utils{/// <summary> Central cache of shared <see cref="JsonSerializerOptions"/>. Mod data, saves and configs must all parse and serialize the same way, so the core hands out lazily created single instances instead of letting every subsystem configure its own (which also avoids rebuilding options per call). </summary>
public static class JsonOptionsProvider{private static JsonSerializerOptions _readOptions;private static JsonSerializerOptions _writeIndentedOptions;private static JsonSerializerOptions _writeCompactOptions;private static JsonSerializerOptions _writeIndentedIgnoreNullOptions;/// <summary> Reader options shared by every subsystem: case-insensitive property names and skipped comments, so hand-edited mod files tolerate casing slips and stray commentary. </summary>
public static JsonSerializerOptions ReadOptions{get{if(_readOptions==null){_readOptions=new JsonSerializerOptions{PropertyNameCaseInsensitive=true,ReadCommentHandling=JsonCommentHandling.Skip};}return _readOptions;}}/// <summary> Indented writer options — human-readable output for author-facing files the game rewrites (configs, tool-exported JSON). </summary>
public static JsonSerializerOptions WriteIndentedOptions{get{if(_writeIndentedOptions==null){_writeIndentedOptions=new JsonSerializerOptions{WriteIndented=true};}return _writeIndentedOptions;}}/// <summary> Compact writer options — single-line output used for machine-facing payloads such as save slot preview metadata. </summary>
public static JsonSerializerOptions WriteCompactOptions{get{if(_writeCompactOptions==null){_writeCompactOptions=new JsonSerializerOptions{WriteIndented=false};}return _writeCompactOptions;}}/// <summary> Indented writer options that also omit <c>null</c> properties, keeping emitted JSON free of empty fields (used for optional-heavy DTOs). </summary>
public static JsonSerializerOptions WriteIndentedIgnoreNullOptions{get{if(_writeIndentedIgnoreNullOptions==null){_writeIndentedIgnoreNullOptions=new JsonSerializerOptions{WriteIndented=true,DefaultIgnoreCondition=JsonIgnoreCondition.WhenWritingNull};}return _writeIndentedIgnoreNullOptions;}}private static JsonDocumentOptions _documentOptions;private static bool _documentOptionsInitialized;/// <summary> Options for <see cref="JsonDocument"/> parsing with comment skipping, matching <see cref="ReadOptions"/> for parse-only consumers. </summary>
public static JsonDocumentOptions DocumentOptions{get{if(!_documentOptionsInitialized){_documentOptions=new JsonDocumentOptions{CommentHandling=JsonCommentHandling.Skip};_documentOptionsInitialized=true;}return _documentOptions;}}}}

===== FILE Core/Utils/Lists.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Utils{/// <summary> Lightweight collection helpers that avoid LINQ allocations in hot paths. Designed for the scenario engine's internal state machines and UI iteration where <c>null</c> collections are semantically equivalent to empty collections. </summary>
public static class Lists{/// <summary> Wraps a single element in a <see cref="LinkedList{T}"/> viewable through the <see cref="ICollection{T}"/> interface. Useful where an API expects a collection but the caller has only one item — avoids allocating an array or list with a backing array. </summary>
public static ICollection<T>Singleton<T>(T item){ICollection<T>list=new LinkedList<T>();list.Add(item);return list;}/// <summary> Returns <c>true</c> when <paramref name="collection"/> is <c>null</c> or has zero elements. This null-safe check eliminates boilerplate null guards at every iteration site. </summary>
public static bool IsEmpty<T>(ICollection<T>collection){if(collection==null)return true;return collection.Count==0;}/// <summary> Negation of <see cref="IsEmpty{T}"/> for more readable guard clauses. </summary>
public static bool IsNotEmpty<T>(ICollection<T>collection){return!IsEmpty(collection);}}}

===== FILE Core/Utils/LogLevel.cs =====
namespace Cthangover.Core.Utils{/// <summary> Defines severity tiers for log entries routed through <see cref="GameLogger"/>. Each level acts as a filter threshold: when <see cref="GameLogger"/> is configured with a <c>MinimumLevel</c>, any entry below that threshold is silently dropped before it reaches the console, Godot output, or file sinks. The numeric order is <c>Debug</c> &lt; <c>Message</c> &lt; <c>Warning</c> &lt; <c>Error</c>, enforced by <c>Enum</c> ordinal comparison inside the logger. </summary>
public enum LogLevel{/// <summary>Verbose diagnostic output intended for development troubleshooting only.</summary>
Debug,/// <summary>General informational entries suitable for production runtime.</summary>
Message,/// <summary>Potentially harmful situations that do not interrupt execution.</summary>
Warning,/// <summary>Recoverable or non-recoverable failures that are also written to a separate error log file.</summary>
Error}}

===== FILE Core/Utils/PropertyData.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Characters;using Cthangover.Core.Utils.Types;namespace Cthangover.Core.Utils{/// <summary> A string-keyed property bag with typed getters and setters, serving as the runtime representation of scenario entity state (characters, items, locations) and mod configuration data. Every value is stored as a plain <c>string</c> internally and parsed on demand by the typed accessors. </summary> <remarks> <para> The flat <c>string</c>-to-<c>string</c> dictionary allows the scenario scripting engine and mod JSON deserialisation to use a uniform property model without per-entity schema definitions. Type coercion happens at the getter/setter boundary, keeping the storage layer simple. </para> <para> Floating-point values use a culture-invariant <c>"."</c> decimal separator so that serialised JSON and scenario files produce identical results regardless of the host OS locale. </para> <para> Marked <see cref="SerializableAttribute"/> so that <see cref="System.Text.Json.JsonSerializer"/> can serialise <see cref="Values"/> directly. </para> </remarks>
[Serializable]public class PropertyData{private List<IPropertyInterceptor>_interceptors=new();private HashSet<string>_activeProperties=new();/// <summary> Registers an interceptor for a specific property key (defined by <see cref="IPropertyInterceptor.PropertyName"/>). Fires before every write to that property via any typed setter. </summary>
public void AddInterceptor(IPropertyInterceptor interceptor)=>_interceptors.Add(interceptor);/// <summary> Removes a previously registered interceptor. Safe to call during interception (though the current chain iteration completes before the list is updated). </summary>
public void RemoveInterceptor(IPropertyInterceptor interceptor)=>_interceptors.Remove(interceptor);/// <summary> Removes all registered interceptors at once. Typically used when unloading a mod that registered effect hooks. </summary>
public void ClearInterceptors()=>_interceptors.Clear();/// <summary> The backing key-value store. Keys are property names; values are always strings. Exposed publicly so that serialisers and bulk-copy operations can iterate directly without going through individual getters. </summary>
public IDictionary<string,string>Values{get;set;}=new Dictionary<string,string>();/// <summary> Generic typed getter. Looks up the raw string in <see cref="Values"/>, parses it through the <see cref="IParse{T}"/> registered for <typeparamref name="T"/> in <see cref="TParser"/>, and returns the result. Falls back to <paramref name="defaultValue"/> when the key is absent or the parser returns <c>default(T)</c>. </summary> <typeparam name="T">Must have a parser registered in <see cref="TParser"/>.</typeparam>
public T Get<T>(string name,T defaultValue){if(!Values.TryGetValue(name,out var value))return defaultValue;return TParser.Resolve<T>().Parse(value)??defaultValue;}/// <summary> Generic typed setter. Formats <paramref name="value"/> through the <see cref="IParse{T}"/> registered for <typeparamref name="T"/>, then routes the resulting string through the interceptor chain — interceptors matching <paramref name="name"/> are called in registration order, each potentially modifying the value before it's stored in <see cref="Values"/>. </summary> <remarks> Uses a <c>_activeProperties</c> set to prevent infinite recursion: when interceptor A for "Defence" calls <c>SetInt("Health", ...)</c>, Health's interceptors fire normally, but writing back to "Defence" skips the chain. </remarks> <typeparam name="T">Must have a parser registered in <see cref="TParser"/>.</typeparam>
public void Set<T>(string name,T value){var parser=TParser.Resolve<T>();var text=parser.Format(value);if(!_activeProperties.Add(name)){Values[name]=text;return;}try{var prev=GetStr(name);foreach(var interceptor in _interceptors){if(interceptor.PropertyName==name)text=interceptor.Intercept(prev,text);}Values[name]=text;}finally{_activeProperties.Remove(name);}}/// <summary> Retrieves the raw string stored under <paramref name="name"/>, or returns <paramref name="defaultValue"/> (which defaults to <c>null</c>) when the key is absent. </summary>
public string GetStr(string name,string defaultValue=null){return Get(name,defaultValue);}/// <summary> Parses the stored string as a 32-bit signed integer. Returns <paramref name="defaultValue"/> (0) when the key is missing or the value is blank. </summary>
public int GetInt(string name,int defaultValue=0){return Get(name,defaultValue);}/// <summary> Parses the stored string as a single-precision float using the invariant <c>"."</c> decimal separator, bypassing OS locale settings. Returns <paramref name="defaultValue"/> (0f) on missing/blank keys. </summary>
public float GetFloat(string name,float defaultValue=0f){return Get(name,defaultValue);}/// <summary> Parses the stored string as a 64-bit signed integer. Returns <paramref name="defaultValue"/> (0) when the key is missing or the value is blank. </summary>
public long GetLong(string name,long defaultValue=0){return Get(name,defaultValue);}/// <summary> Parses the stored string as a boolean via <see cref="bool.Parse"/>. Always returns <c>false</c> when the key is missing or blank (there is no overload with a custom default because <c>false</c> is the conventional default for boolean properties). </summary>
public bool GetBool(string name,bool defaultValue=false){return Get(name,defaultValue);}/// <summary> Stores a raw string value, overwriting any existing entry under the same <paramref name="name"/>. </summary>
public void SetStr(string name,string value){Set(name,value);}/// <summary> Converts the integer to its default string representation and stores it under <paramref name="name"/>. </summary>
public void SetInt(string name,int value){Set(name,value);}/// <summary> Converts the float to a culture-invariant string (dot decimal) and stores it under <paramref name="name"/>. </summary>
public void SetFloat(string name,float value){Set(name,value);}/// <summary> Converts the 64-bit integer to its default string representation and stores it under <paramref name="name"/>. </summary>
public void SetLong(string name,long value){Set(name,value);}/// <summary> Converts the boolean to <c>"True"</c> or <c>"False"</c> and stores it under <paramref name="name"/>. </summary>
public void SetBool(string name,bool value){Set(name,value);}/// <summary> Returns <c>true</c> if a property with the given <paramref name="name"/> exists in the backing dictionary, regardless of its value. </summary>
public bool Has(string name){return Values.ContainsKey(name);}/// <summary> Creates a shallow copy where the <see cref="Values"/> dictionary contents are duplicated key-by-key into a new <see cref="Dictionary{String, String}"/>. The string values themselves are shared (immutable reference type), so this is effectively a deep-enough clone for property mutation scenarios. </summary>
public PropertyData Clone(){var clone=new PropertyData();foreach(var kvp in Values)clone.Values[kvp.Key]=kvp.Value;return clone;}/// <summary> Imports a single JSON key-value pair into the property bag. Dispatches by <c>JsonValueKind</c> to the appropriate typed setter. Arrays and objects are stored as raw JSON strings. No merge logic — just a plain copy. </summary>
public static void ImportJsonProperty(PropertyData props,string key,object value){if(value is System.Text.Json.JsonElement je){switch(je.ValueKind){case System.Text.Json.JsonValueKind.Number:if(je.TryGetInt32(out var i))props.SetInt(key,i);else props.SetFloat(key,(float)je.GetDouble());break;case System.Text.Json.JsonValueKind.True:case System.Text.Json.JsonValueKind.False:props.SetBool(key,je.GetBoolean());break;case System.Text.Json.JsonValueKind.String:props.SetStr(key,je.GetString());break;default:props.SetStr(key,je.GetRawText());break;}}else{props.SetStr(key,value?.ToString());}}/// <summary> Imports all entries from a JSON extension-data dictionary into the property bag. No merge — each value is set directly. </summary>
public static void ImportJsonProperties(PropertyData props,Dictionary<string,object>extraProperties){if(extraProperties==null)return;foreach(var kvp in extraProperties)ImportJsonProperty(props,kvp.Key,kvp.Value);}}}

===== FILE Core/Utils/RectUtils.cs =====
using Godot;namespace Cthangover.Core.Utils{/// <summary> Extension methods for Godot <see cref="Godot.Control"/> nodes that simplify conversion from a control's layout properties into a <see cref="Godot.Rect2"/> suitable for intersection tests, viewport culling, and debug drawing. </summary>
public static class RectTransformAdditions{/// <summary> Constructs a <see cref="Godot.Rect2"/> anchored at the control's <see cref="Control.GlobalPosition"/> with dimensions from <see cref="Control.Size"/>. The returned rect uses the global coordinate system, making it directly usable for mouse-hit testing and overlap checks regardless of the control's parent hierarchy. </summary>
public static Rect2 GetRect(this Control control){return new Rect2(control.GlobalPosition,control.Size);}}}

===== FILE Core/Utils/Reflections.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Reflection;namespace Cthangover.Core.Utils{/// <summary> Reflection-based discovery utility that scans the current <see cref="AppDomain"/> for concrete implementations of a given type and instantiates them. This is the backbone of the project's plugin-discovery mechanism: mod behaviours, JSON converters, and command handlers register themselves simply by being public, non-abstract classes implementing a known interface or base class. </summary> <remarks> The returned collection uses <see cref="LinkedList{T}"/> for O(1) append during the scan. Callers may copy to a <see cref="List{T}"/> if random access is needed downstream. </remarks>
public static class Reflections{/// <summary> Walks every loaded assembly in the current <see cref="AppDomain"/>, finds every public, non-abstract class assignable to <typeparamref name="T"/>, creates a parameterless instance via <see cref="Activator.CreateInstance(Type)"/>, and appends it to the result list. </summary> <typeparam name="T">The base type or interface to search for.</typeparam> <returns>A collection of freshly-constructed instances in discovery order.</returns>
public static ICollection<T>FindAndCreate<T>(Assembly assemblyFilter=null,Predicate<Type>filter=null){var list=new LinkedList<T>();var typeT=typeof(T);if(assemblyFilter==null){foreach(var assembly in LoadedAssemblies())CheckAssembly(typeT,list,assembly,filter);}else{CheckAssembly(typeT,list,assemblyFilter,filter);}return list;}/// <summary> The assemblies of the running game: <see cref="AppDomain.GetAssemblies"/> without those of released mod contexts, which linger there until collected (see <see cref="Mods.ModLoadContext.IsReleased"/>). Every "all loaded assemblies" scan of the game uses this instead of the AppDomain directly. </summary>
public static List<Assembly>LoadedAssemblies(){var result=new List<Assembly>();foreach(var assembly in AppDomain.CurrentDomain.GetAssemblies()){if(!Mods.ModLoadContext.IsReleasedAssembly(assembly))result.Add(assembly);}return result;}private static void CheckAssembly<T>(Type typeT,LinkedList<T>list,Assembly assemblyFilter,Predicate<Type>filter=null){Type[]types;try{types=assemblyFilter.GetTypes();}catch(ReflectionTypeLoadException ex){types=ex.Types.Where(t=>t!=null).ToArray();GameLogger.Log("REFLECTIONS",$"FindAndCreate<{typeT.Name}>: assembly '{assemblyFilter.GetName().Name}' partially loaded, continuing with {types.Length} type(s)",LogLevel.Warning);}catch(Exception ex){GameLogger.Log("REFLECTIONS",$"FindAndCreate<{typeT.Name}>: cannot enumerate types of '{assemblyFilter.FullName}': {ex.Message}",LogLevel.Warning);return;}foreach(var type in types){if(!typeT.IsAssignableFrom(type)|| !type.IsPublic||type.IsAbstract||type.GetConstructor(Type.EmptyTypes)==null)continue;if(filter!=null&& !filter.Invoke(type))continue;try{list.AddLast((T)Activator.CreateInstance(type));}catch(Exception ex){GameLogger.Log("REFLECTIONS",$"FindAndCreate<{typeT.Name}>: failed to instantiate '{type.FullName}': {ex.Message}",LogLevel.Warning);}}}}}

===== FILE Core/Utils/StringOrArrayConverter.cs =====
using System;using System.Collections.Generic;using System.Text.Json;using System.Text.Json.Serialization;namespace Cthangover.Core.Utils{/// <summary> A <see cref="System.Text.Json"/> converter that handles JSON fields which can be either a single string or an array of strings, normalising both into a <see cref="List{String}"/>. This accommodates the common mod-authoring convention where a single-value property may be written as a plain string for brevity but expanded to an array later. </summary> <remarks> On serialisation, single-element lists are collapsed back to a plain string value; empty lists emit <c>null</c>; multi-element lists produce a JSON array. This round-trips the "terse authoring" pattern. </remarks>
public class StringOrArrayConverter:JsonConverter<List<string>>{/// <summary> Normalises a JSON string token into a single-element list, or iterates a JSON array token to collect all string elements. Any other token type throws <see cref="JsonException"/>. </summary>
public override List<string>Read(ref Utf8JsonReader reader,Type typeToConvert,JsonSerializerOptions options){if(reader.TokenType==JsonTokenType.String){return new List<string>{reader.GetString()};}if(reader.TokenType==JsonTokenType.StartArray){var list=new List<string>();while(reader.Read()){if(reader.TokenType==JsonTokenType.EndArray)break;if(reader.TokenType==JsonTokenType.String)list.Add(reader.GetString());}return list;}throw new JsonException($"Expected string or array, got {reader.TokenType}");}/// <summary> Writes the list back to JSON. A single-element list is serialised as a string for readability; an empty list emits <c>null</c>; otherwise a full JSON array is produced. </summary>
public override void Write(Utf8JsonWriter writer,List<string>value,JsonSerializerOptions options){if(value==null||value.Count==0){writer.WriteNullValue();return;}if(value.Count==1){writer.WriteStringValue(value[0]);}else{writer.WriteStartArray();foreach(var item in value)writer.WriteStringValue(item);writer.WriteEndArray();}}}}

===== FILE Core/Utils/StringParsing.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Core.Utils{/// <summary> Token-based string extraction helpers used primarily by the scenario scripting subsystem. The main use case is pulling text fragments enclosed between arbitrary delimiter pairs — such as curly braces in templated dialogue or square brackets in markup — without resorting to full regex. </summary>
public static class StringParsing{/// <summary> Scans <paramref name="text"/> for substrings enclosed between <paramref name="firstQuote"/> and <paramref name="secondQuote"/>, returning the inner content of each matching pair. Processing resumes after each closing delimiter, so nested or overlapping delimiters are <em>not</em> handled — only sequential, non-overlapping pairs. </summary> <param name="text">The source string to scan.</param> <param name="firstQuote">The opening delimiter token.</param> <param name="secondQuote">The closing delimiter token.</param> <returns>All extracted inner fragments, in left-to-right order.</returns> <exception cref="ArgumentException"> Thrown when an opening delimiter is found without its corresponding closing delimiter later in the string. </exception>
public static ICollection<string>GetIncludesInQuotes(this string text,string firstQuote,string secondQuote){var pos=0;var list=new List<string>();for(;;){var firstIndex=text.IndexOf(firstQuote,pos,StringComparison.InvariantCulture);if(firstIndex<0)break;var endIndex=text.IndexOf(secondQuote,firstIndex+firstQuote.Length,StringComparison.InvariantCulture);if(endIndex<0)throw new ArgumentException("second token '"+secondQuote+"' parse exception");list.Add(text.Substring(firstIndex+firstQuote.Length,endIndex-firstIndex-firstQuote.Length));pos=endIndex+1;}return list;}}}

===== FILE Core/Utils/TextureUtils.cs =====
using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Godot;namespace Cthangover.Core.Utils{/// <summary> Centralised texture-loading pipeline for the mod system. Because Godot's <c>ResourceLoader</c> cannot load assets embedded inside mod archives, this utility reads raw image bytes through <see cref="ModManager"/> and decodes them into <see cref="Godot.Texture2D"/> instances via Godot's <see cref="Image"/> API. It also performs extension-based key matching so that callers can omit file extensions when requesting textures. </summary>
public static class TextureUtils{/// <summary> Loads a texture identified by <paramref name="imagePath"/> from the mod group named <paramref name="groupName"/>. The path is normalised (slashes, casing, optional extension) before it is matched against the file manifest maintained by <see cref="ModManager.Instance"/>. </summary> <param name="groupName"> The logical mod group whose file manifest will be searched. </param> <param name="imagePath"> A project-relative path to the image. Leading slashes are stripped, any <c>groupName/</c> prefix is removed, and the extension may be omitted — the first file whose key starts with the normalised path and carries a recognised texture extension (from <c>ModConfig.Instance.GetTextureExtensionSet()</c>) will be loaded. </param> <returns> A decoded <see cref="Texture2D"/>, or <c>null</c> if the path is blank, no matching file is found, or decoding fails. </returns>
public static Texture2D LoadFromModGroup(string groupName,string imagePath){if(string.IsNullOrWhiteSpace(imagePath))return null;var normalized=NormalizeImagePath(imagePath,groupName);return LoadTextureFromModGroup(groupName,normalized);}private static string NormalizeImagePath(string path,string groupName){var normalized=ModPath.Normalize(path);var prefix=groupName+"/";if(normalized.StartsWith(prefix,System.StringComparison.OrdinalIgnoreCase))normalized=normalized.Substring(prefix.Length);normalized=normalized.ToLowerInvariant();return normalized;}private static Texture2D LoadTextureFromModGroup(string groupName,string idWithoutGroup){var files=Files.Collect(groupName);var textureExts=ModConfig.Instance.GetTextureExtensionSet();GameLogger.Log("MODS",$"LoadTextureFromModGroup: group='{groupName}' id='{idWithoutGroup}' files.Count={files.Count}",LogLevel.Debug);foreach(var k in files.Keys)GameLogger.Log("MODS",$"  file key='{k}' modId='{files[k].ModId}' fullPath='{files[k].FullPath}'",LogLevel.Debug);string matchedKey=null;foreach(var key in files.Keys){var ext=System.IO.Path.GetExtension(key);if(!textureExts.Contains(ext))continue;if(key==idWithoutGroup||key.StartsWith(idWithoutGroup+".",System.StringComparison.OrdinalIgnoreCase)){matchedKey=key;break;}}if(matchedKey==null|| !files.TryGetValue(matchedKey,out var entry)){GameLogger.Log("MODS",$"LoadTextureFromModGroup: FAILED - key not found for id='{idWithoutGroup}'",LogLevel.Error);return null;}var bytes=ModManager.Instance.ReadFileBinary(entry.ModId,entry.FullPath);if(bytes==null){GameLogger.Log("MODS",$"LoadTextureFromModGroup: FAILED - ReadFileBinary returned null for modId='{entry.ModId}' fullPath='{entry.FullPath}'",LogLevel.Error);return null;}return BytesToTexture(bytes,System.IO.Path.GetExtension(matchedKey));}private static Texture2D BytesToTexture(byte[]bytes,string extension){var image=new Image();Error error;var ext=extension?.ToLowerInvariant();if(ext==".png")error=image.LoadPngFromBuffer(bytes);else if(ext==".jpg"||ext==".jpeg")error=image.LoadJpgFromBuffer(bytes);else if(ext==".webp")error=image.LoadWebpFromBuffer(bytes);else return null;if(error!=Error.Ok)return null;var texture=ImageTexture.CreateFromImage(image);image.Dispose();return texture;}}}

===== FILE Core/Utils/Types/BoolParse.cs =====
using System;namespace Cthangover.Core.Utils.Types;/// <summary> Nullable <c>bool</c> parser. Returns <c>null</c> on null, empty, or unparseable input — never throws. Stores as <c>"True"</c>/<c>"False"</c> via <see cref="Format"/>. </summary>
public class BoolParse:IParse<bool?>{/// <inheritdoc/>
public bool?Parse(string value){if(string.IsNullOrWhiteSpace(value))return null;try{return bool.Parse(value);}catch(Exception){return null;}}/// <inheritdoc/>
public string Format(bool?value){return value?.ToString();}}/// <summary> Non-nullable <c>bool</c> parser. Delegates to <see cref="BoolParse"/> and folds <c>null</c> into <c>false</c> — the conventional default for boolean flags. </summary>
public class BoolPrimitiveParse:IParse<bool>{private static readonly BoolParse parser=new BoolParse();/// <inheritdoc/>
public bool Parse(string value){return parser.Parse(value)??false;}/// <inheritdoc/>
public string Format(bool value){return parser.Format(value);}}

===== FILE Core/Utils/Types/FloatParse.cs =====
using System;using System.Globalization;namespace Cthangover.Core.Utils.Types;/// <summary> Nullable <c>float</c> parser with invariant culture (<c>"."</c> decimal). Returns <c>null</c> on null, empty, or unparseable input — never throws. The invariant formatting ensures JSON round-trip compatibility regardless of OS locale. </summary>
public class FloatParse:IParse<float?>{private NumberFormatInfo format=new(){NumberDecimalSeparator="."};/// <inheritdoc/>
public float?Parse(string value){if(string.IsNullOrWhiteSpace(value))return null;try{return float.Parse(value,format);}catch(Exception){return null;}}/// <inheritdoc/>
public string Format(float?value){return value?.ToString(format);}}/// <summary> Non-nullable <c>float</c> parser. Delegates to <see cref="FloatParse"/> and folds <c>null</c> into <c>0f</c>. </summary>
public class FloatPrimitiveParse:IParse<float>{private static readonly FloatParse parser=new FloatParse();/// <inheritdoc/>
public float Parse(string value){return parser.Parse(value)??0f;}/// <inheritdoc/>
public string Format(float value){return parser.Format(value);}}

===== FILE Core/Utils/Types/IParse.cs =====
namespace Cthangover.Core.Utils.Types;/// <summary> Bidirectional parser for a type <typeparamref name="T"/>. <see cref="Parse"/> converts a string to <typeparamref name="T"/> (safe — returns <c>default</c> on unparseable input, never throws). <see cref="Format"/> converts <typeparamref name="T"/> back to a string (culture-invariant for numeric types). </summary> <remarks> Implementations are registered in <see cref="TParser"/> and used by <see cref="PropertyData.Get{T}"/>/<see cref="PropertyData.Set{T}"/> and <see cref="Cthangover.Core.Characters.PropertyInterceptor{T}"/>. Two implementation families exist: nullable (e.g. <c>IntParse : IParse&lt;int?&gt;</c>) for parsers that distinguish "absent" from "zero", and primitive (e.g. <c>IntPrimitiveParse : IParse&lt;int&gt;</c>) that fold absent into a sensible default. </remarks>
public interface IParse<T>{/// <summary> Converts a string to <typeparamref name="T"/>. Returns <c>default(T)</c> on null, empty, or unparseable input. Never throws. </summary>
T Parse(string value);/// <summary> Converts <typeparamref name="T"/> back to a string. Numeric types use <c>"."</c> as decimal separator (invariant culture). </summary>
string Format(T value);}

===== FILE Core/Utils/Types/IntParse.cs =====
using System;namespace Cthangover.Core.Utils.Types;/// <summary> Nullable <c>int</c> parser. Returns <c>null</c> on null, empty, or unparseable input — never throws. When the property didn't exist, <c>null</c> is distinguishable from a legitimate zero. </summary>
public class IntParse:IParse<int?>{/// <inheritdoc/>
public int?Parse(string value){if(string.IsNullOrWhiteSpace(value))return null;try{return int.Parse(value);}catch(Exception){return null;}}/// <inheritdoc/>
public string Format(int?value){return value?.ToString();}}/// <summary> Non-nullable <c>int</c> parser. Delegates to <see cref="IntParse"/> and folds <c>null</c> into <c>0</c> — a sensible default when the consumer doesn't need to distinguish "property absent" from "zero". </summary>
public class IntPrimitiveParse:IParse<int>{private static readonly IntParse parser=new IntParse();/// <inheritdoc/>
public int Parse(string value){return parser.Parse(value)??0;}/// <inheritdoc/>
public string Format(int value){return parser.Format(value);}}

===== FILE Core/Utils/Types/LongParse.cs =====
using System;namespace Cthangover.Core.Utils.Types;/// <summary> Nullable <c>long</c> parser. Returns <c>null</c> on null, empty, or unparseable input — never throws. </summary>
public class LongParse:IParse<long?>{/// <inheritdoc/>
public long?Parse(string value){if(string.IsNullOrWhiteSpace(value))return null;try{return long.Parse(value);}catch(Exception){return null;}}/// <inheritdoc/>
public string Format(long?value){return value?.ToString();}}/// <summary> Non-nullable <c>long</c> parser. Delegates to <see cref="LongParse"/> and folds <c>null</c> into <c>0L</c>. </summary>
public class LongPrimitiveParse:IParse<long>{private static readonly LongParse parser=new LongParse();/// <inheritdoc/>
public long Parse(string value){return parser.Parse(value)??0L;}/// <inheritdoc/>
public string Format(long value){return parser.Format(value);}}

===== FILE Core/Utils/Types/StringParse.cs =====
namespace Cthangover.Core.Utils.Types;/// <summary> Identity parser for <c>string</c>. <see cref="Parse"/> returns the value as-is (including <c>null</c>). <see cref="Format"/> is also identity — strings need no conversion. </summary>
public class StringParse:IParse<string>{/// <inheritdoc/>
public string Parse(string value){return value;}/// <inheritdoc/>
public string Format(string value){return value;}}

===== FILE Core/Utils/Types/TParser.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Core.Utils.Types;/// <summary> Central registry for <see cref="IParse{T}"/> instances, keyed by <see cref="Type"/>. Resolves a typed parser from a dictionary without reflection — the <c>(IParse&lt;T&gt;)(object)</c> cast is safe because each entry was inserted with the correct <see cref="Type"/> key. </summary> <remarks> New types are registered by adding entries to <see cref="_parsers"/>. Each key must match the generic argument of its value exactly (both nullable and primitive variants are supported). Used by <see cref="PropertyData.Get{T}"/>, <see cref="PropertyData.Set{T}"/>, and <see cref="Cthangover.Core.Characters.PropertyInterceptor{T}"/>. </remarks>
public static class TParser{private static readonly Dictionary<Type,object>_parsers=new(){{typeof(int?),new IntParse()},{typeof(int),new IntPrimitiveParse()},{typeof(float?),new FloatParse()},{typeof(float),new FloatPrimitiveParse()},{typeof(long?),new LongParse()},{typeof(long),new LongPrimitiveParse()},{typeof(bool?),new BoolParse()},{typeof(bool),new BoolPrimitiveParse()},{typeof(string),new StringParse()},};/// <summary> Looks up the <see cref="IParse{T}"/> registered for <typeparamref name="T"/>. Throws <see cref="NotSupportedException"/> if no parser is registered for the requested type. </summary>
public static IParse<T>Resolve<T>(){if(_parsers.TryGetValue(typeof(T),out var boxed))return(IParse<T>)boxed;throw new NotSupportedException($"No parser for {typeof(T)}");}}

===== FILE Core/Utils/Vector2JsonConverter.cs =====
using System;using System.Text.Json;using System.Text.Json.Serialization;using Godot;namespace Cthangover.Core.Utils{/// <summary> Serializes a <see cref="Vector2"/> as a JSON array <c>[x, y]</c>. Applied via <c>[JsonConverter]</c> attribute on individual properties so that <see cref="System.Text.Json"/> can round-trip Godot vectors without a custom contract resolver. </summary>
public class Vector2JsonConverter:JsonConverter<Vector2>{/// <summary> Reads a two-element <c>[x, y]</c> array into a <see cref="Vector2"/>. Malformed JSON raises <see cref="JsonException"/> naming the offending token. </summary>
public override Vector2 Read(ref Utf8JsonReader reader,Type typeToConvert,JsonSerializerOptions options){if(reader.TokenType!=JsonTokenType.StartArray)throw new JsonException($"Expected StartArray for Vector2, got {reader.TokenType}.");reader.Read();var x=reader.GetSingle();reader.Read();var y=reader.GetSingle();reader.Read();if(reader.TokenType!=JsonTokenType.EndArray)throw new JsonException($"Expected EndArray for Vector2, got {reader.TokenType}.");return new Vector2(x,y);}/// <summary>Writes the vector as a compact <c>[x, y]</c> array.</summary>
public override void Write(Utf8JsonWriter writer,Vector2 value,JsonSerializerOptions options){writer.WriteStartArray();writer.WriteNumberValue(value.X);writer.WriteNumberValue(value.Y);writer.WriteEndArray();}}}

===== FILE Core/Utils/Vector2ListJsonConverter.cs =====
using System;using System.Collections.Generic;using System.Text.Json;using System.Text.Json.Serialization;using Godot;namespace Cthangover.Core.Utils{/// <summary> Serializes a <c>List&lt;Vector2&gt;</c> as a JSON array of <c>[x, y]</c> arrays. Used with <c>[JsonConverter]</c> on collection-typed properties to handle polygon point lists in map zone definitions. </summary>
public class Vector2ListJsonConverter:JsonConverter<List<Vector2>>{/// <summary> Reads an array of <c>[x, y]</c> pairs into a list of <see cref="Vector2"/>, validating every element; malformed JSON raises <see cref="JsonException"/>. </summary>
public override List<Vector2>Read(ref Utf8JsonReader reader,Type typeToConvert,JsonSerializerOptions options){if(reader.TokenType!=JsonTokenType.StartArray)throw new JsonException($"Expected StartArray for Vector2 list, got {reader.TokenType}.");var list=new List<Vector2>();while(reader.Read()){if(reader.TokenType==JsonTokenType.EndArray)return list;if(reader.TokenType!=JsonTokenType.StartArray)throw new JsonException($"Expected StartArray for Vector2 element, got {reader.TokenType}.");reader.Read();var x=reader.GetSingle();reader.Read();var y=reader.GetSingle();reader.Read();if(reader.TokenType!=JsonTokenType.EndArray)throw new JsonException($"Expected EndArray for Vector2 element, got {reader.TokenType}.");list.Add(new Vector2(x,y));}return list;}/// <summary>Writes the list as an array of <c>[x, y]</c> pairs.</summary>
public override void Write(Utf8JsonWriter writer,List<Vector2>value,JsonSerializerOptions options){writer.WriteStartArray();foreach(var v in value){writer.WriteStartArray();writer.WriteNumberValue(v.X);writer.WriteNumberValue(v.Y);writer.WriteEndArray();}writer.WriteEndArray();}}}

===== FILE Core/Utils/Vector2NullableJsonConverter.cs =====
using System;using System.Text.Json;using System.Text.Json.Serialization;using Godot;namespace Cthangover.Core.Utils{/// <summary> Serializes a nullable <see cref="Vector2"/> as <c>[x, y]</c> or JSON <c>null</c>. Needed for optional map zone fields such as <c>label_pos</c>: a plain <see cref="Vector2JsonConverter"/> cannot be attributed onto a <c>Vector2?</c> property reliably, and the difference between "coordinate not set" and "coordinate (0,0)" is meaningful for optional overlays. </summary>
public class Vector2NullableJsonConverter:JsonConverter<Vector2?>{/// <summary> Returns <c>null</c> for JSON <c>null</c>, otherwise reads a two-element <c>[x, y]</c> array into a <see cref="Vector2"/>. </summary>
public override Vector2?Read(ref Utf8JsonReader reader,Type typeToConvert,JsonSerializerOptions options){if(reader.TokenType==JsonTokenType.Null)return null;if(reader.TokenType!=JsonTokenType.StartArray)throw new JsonException($"Expected StartArray or Null for Vector2?, got {reader.TokenType}.");reader.Read();var x=reader.GetSingle();reader.Read();var y=reader.GetSingle();reader.Read();if(reader.TokenType!=JsonTokenType.EndArray)throw new JsonException($"Expected EndArray for Vector2?, got {reader.TokenType}.");return new Vector2(x,y);}/// <summary> Writes JSON <c>null</c> for an empty value, otherwise a compact <c>[x, y]</c> array, so "coordinate not set" round-trips distinctly from <c>(0, 0)</c>. </summary>
public override void Write(Utf8JsonWriter writer,Vector2?value,JsonSerializerOptions options){if(value==null){writer.WriteNullValue();return;}writer.WriteStartArray();writer.WriteNumberValue(value.Value.X);writer.WriteNumberValue(value.Value.Y);writer.WriteEndArray();}}}

===== FILE Core/Utils/Vectors.cs =====
using Godot;namespace Cthangover.Core.Utils{/// <summary> Extension helpers for Godot vector types, bridging the gap between 3D world-space coordinates and 2D UI or gameplay projections. </summary>
public static class Vectors{/// <summary> Projects a <see cref="Godot.Vector3"/> onto the XY plane by discarding the Z component. Typically used when converting 3D world positions into 2D screen-space or minimap coordinates where depth is irrelevant. </summary>
public static Vector2 ToVector2(this Vector3 vector){return new Vector2(vector.X,vector.Y);}}}

===== FILE Core/Utils/VersionHelper.cs =====
using System;namespace Cthangover.Core.Utils{/// <summary> Per-segment version comparison. Version is split on '.', segments may contain letters ("1a", "alpha-3", "1b.2.0"). Algorithm: both arrays are padded with "0" to equal length, then lexicographic comparison. Each segment: if both parse as int — numeric comparison, otherwise string.CompareOrdinal. This is the only version comparison algorithm in the project — used in mod deduplication (ModRegistry.RegisterCandidates) and in versioned depends (ModDependency.IsSatisfiedBy). </summary>
public static class VersionHelper{/// <summary> Compares two version strings. Negative if a &lt; b, zero if equal, positive if a &gt; b. </summary>
public static int Compare(string a,string b){a=a?.Trim()??"0.0.0.0";b=b?.Trim()??"0.0.0.0";if(a.Length==0)a="0.0.0.0";if(b.Length==0)b="0.0.0.0";if(a==b)return 0;var segA=a.Split('.');var segB=b.Split('.');return CompareSegments(segA,segB);}/// <summary> Per-segment array comparison. Both are padded with "0" to equal length. Each segment: int comparison when possible, otherwise Ordinal. </summary>
public static int CompareSegments(string[]a,string[]b){var maxLen=Math.Max(a.Length,b.Length);for(int i=0;i<maxLen;i++){var sa=i<a.Length?a[i]:"0";var sb=i<b.Length?b[i]:"0";int cmp;if(int.TryParse(sa,out var ia)&&int.TryParse(sb,out var ib))cmp=ia.CompareTo(ib);else cmp=string.CompareOrdinal(sa,sb);if(cmp!=0)return cmp;}return 0;}/// <summary> Returns true when <c>a &lt; b</c> (a is an older version). </summary>
public static bool IsLessThan(string a,string b){return Compare(a,b)<0;}/// <summary> Returns true when <c>a &gt; b</c> (a is a newer version). </summary>
public static bool IsGreaterThan(string a,string b){return Compare(a,b)>0;}}}

===== FILE mods/audio_base/src/AudioFactory.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Factories;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Mods.Audio{/// <summary> Base factory for loading audio streams from mod archives. <c>.ogg</c> files are parsed through <c>OggPacketParser</c> to produce <c>AudioStreamOggVorbis</c> (Godot needs the raw packet sequence, not just file bytes). <c>.wav</c> files are loaded directly into <c>AudioStreamWav</c>. Inherits the mod-aware caching and fallback logic from <c>PrefabFactory</c>. </summary>
public abstract class AudioFactory:PrefabFactory<AudioStream>{protected AudioFactory(string factoryKey,int fallbackCacheSize):base(factoryKey,fallbackCacheSize){}/// <summary>Audio extensions from mod config (typically .ogg and .wav), tried in order.</summary>
protected override List<string>Extensions{get;}=ModConfig.Instance.AudioExtensions;/// <summary>Builds an <c>AudioStreamOggVorbis</c> from parsed OGG packets, or an <c>AudioStreamWav</c> from raw PCM bytes.</summary>
protected override AudioStream ConvertFromBytes(string id,byte[]data,string extension){if(extension?.Equals(".ogg",StringComparison.OrdinalIgnoreCase)==true){var packetSequence=OggPacketParser.CreateFromOggBytes(data);if(packetSequence==null){GameLogger.Log("FACTORY",$"Failed to parse OGG data for sound '{id}'",LogLevel.Error);return null;}return new AudioStreamOggVorbis{PacketSequence=packetSequence};}if(extension?.Equals(".wav",StringComparison.OrdinalIgnoreCase)==true)return new AudioStreamWav{Data=data};GameLogger.Log("FACTORY",$"Unsupported audio format '{extension}' for sound '{id}'",LogLevel.Error);return null;}}}

===== FILE mods/audio_base/src/AudioModInitializer.cs =====
using Cthangover.Core.Events;using Cthangover.Core.Mods;using Cthangover.Core.Utils;namespace Cthangover.Mods.Audio{/// <summary> Engine-mod initializer for the audio subsystem. Registers the <c>/root/AudioService</c> and <c>/root/MusicPlayer</c> nodes when mod resources become ready and subscribes the music player to scene events, replacing the former core autoloads. Both operations are idempotent: <c>ModManager.Reload()</c> re-runs <c>OnModResourcesReady</c>, so node creation and event subscription must tolerate repeat invocations. </summary>
public class AudioModInitializer:IModInitializer{private static bool subscribed;/// <summary>No per-mod setup is required; node registration happens on resources-ready.</summary>
public void OnModLoaded(string modId){}/// <summary> Ensures the audio nodes exist under the tree root and that the music player listens to scene events. Safe to call again after a mod reload. </summary>
public void OnModResourcesReady(){EnsureAudioNodes();EnsureEventSubscriptions();}private static bool nodesQueued;private static void EnsureAudioNodes(){var root=ModEventBus.Instance?.GetTree()?.Root;if(root==null){GameLogger.Log("AUDIO","AudioModInitializer: tree root not available, skipping node setup",LogLevel.Error);return;}if(root.GetNodeOrNull("AudioService")!=null||nodesQueued)return;nodesQueued=true;// Deferred: OnModResourcesReady fires while the root is still setting up autoload children, so a direct AddChild is rejected. Deferred calls run in FIFO order, which keeps AudioService ahead of MusicPlayerBehaviour — its OnReady resolves AudioService.
root.CallDeferred("add_child",new AudioService{Name="AudioService"});root.CallDeferred("add_child",new MusicPlayerBehaviour{Name="MusicPlayer"});}private static void EnsureEventSubscriptions(){if(subscribed)return;subscribed=true;ModEventBus.Subscribe<SceneEnteredEvent>(OnSceneEntered,priority:10);ModEventBus.Subscribe<SceneRouteActivatingEvent>(OnRouteActivating);}private static void OnSceneEntered(SceneEnteredEvent evt){NotifyMusicPlayers(evt.SceneName);}private static void OnRouteActivating(SceneRouteActivatingEvent evt){NotifyMusicPlayers(evt.MusicScene);}private static void NotifyMusicPlayers(string musicScene){if(string.IsNullOrEmpty(musicScene))return;var tree=ModEventBus.Instance?.GetTree();if(tree==null)return;foreach(var node in tree.GetNodesInGroup("music_player")){if(node is MusicPlayerBehaviour player){GameLogger.Log("AUDIO",$"MusicPlayer notified about scene '{musicScene}'");player.UpdateMusicType(musicScene);}}}}}

===== FILE mods/audio_base/src/AudioService.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Audio;using Cthangover.Core.Bootstrap;using Cthangover.Core.Settings;using Cthangover.Core.UI;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Base;namespace Cthangover.Mods.Audio{/// <summary> Singleton audio hub that owns three independent buses (Music, SFX, Ambient), creating them at runtime if the audio setup lacks them. For sound effects, AudioStreamPlayer nodes are pooled by SoundType so overlapping sounds within the same category are cut off, while different categories stack. Settings are polled each frame rather than event-driven, detecting volume/enabled changes cheaply. Exposes LinearToDb as a public static helper used elsewhere (e.g. UI sliders). GetExpLevel / GetLowLevel provide logarithmic vs linear volume scaling for contexts that need one or the other. <para/> Lives in the audio mod: the node is created and named <c>AudioService</c> at /root by <see cref="AudioModInitializer"/>, so node-path consumers (<c>/root/AudioService</c>) and the <c>AudioService</c> group keep working. Because mod assemblies get no Godot source generation, the class derives from the core <c>ModNode</c> forwarder and hooks the engine through <c>OnReady</c>/<c>OnProcess</c> instead of <c>_Ready</c>/<c>_Process</c>. </summary>
public class AudioService:ModNode,IAudioService{private AudioStreamPlayer musicPlayer;private AudioStreamPlayer ambientPlayer;private readonly Dictionary<SoundType,AudioStreamPlayer>soundPlayers=new();private int musicBusIndex;private int soundBusIndex;private int ambientBusIndex;private const string MusicBusName="Music";private const string SoundBusName="SFX";private const string AmbientBusName="Ambient";private bool?lastSoundsEnabled;private bool?lastMusicsEnabled;private bool?lastAmbientEnabled;private Random rnd=new Random();/// <summary>Creates the three audio buses (Music/SFX/Ambient) if missing, the music and ambient players, and applies saved settings.</summary>
protected override void OnReady(){AddToGroup("AudioService");SetupBuses();SetupMusicPlayer();SetupAmbientPlayer();ApplySettings();}/// <summary>Per-frame settings poll — reapplies volumes only when an enabled/volume setting actually changed.</summary>
protected override void OnProcess(double delta){var settings=GameData.Instance?.Settings;if(settings==null)return;if(settings.SoundsEnabled!=lastSoundsEnabled||settings.SoundsVolume!=lastSoundsVolume||settings.MusicsEnabled!=lastMusicsEnabled||settings.MusicsVolume!=lastMusicsVolume||settings.AmbientEnabled!=lastAmbientEnabled||settings.AmbientVolume!=lastAmbientVolume){ApplySettings();}}private int lastSoundsVolume= -1;private int lastMusicsVolume= -1;private int lastAmbientVolume= -1;private void ApplySettings(){var settings=GameData.Instance?.Settings;if(settings==null)return;GameLogger.Log("AUDIO",$"ApplySettings sounds={settings.SoundsEnabled}/{settings.SoundsVolume} musics={settings.MusicsEnabled}/{settings.MusicsVolume} ambient={settings.AmbientEnabled}/{settings.AmbientVolume}");lastSoundsEnabled=settings.SoundsEnabled;lastMusicsEnabled=settings.MusicsEnabled;lastAmbientEnabled=settings.AmbientEnabled;lastSoundsVolume=settings.SoundsVolume;lastMusicsVolume=settings.MusicsVolume;lastAmbientVolume=settings.AmbientVolume;float soundVol=settings.SoundsEnabled?settings.SoundsVolume:0;float musicVol=settings.MusicsEnabled?settings.MusicsVolume:0;float ambientVol=settings.AmbientEnabled?settings.AmbientVolume:0;AudioServer.SetBusVolumeDb(soundBusIndex,LinearToDb(soundVol/100f));AudioServer.SetBusVolumeDb(musicBusIndex,LinearToDb(musicVol/100f));AudioServer.SetBusVolumeDb(ambientBusIndex,LinearToDb(ambientVol/100f));}private void SetupBuses(){musicBusIndex=AudioServer.GetBusIndex(MusicBusName);if(musicBusIndex<0){musicBusIndex=AudioServer.GetBusCount();AudioServer.AddBus(musicBusIndex);AudioServer.SetBusName(musicBusIndex,MusicBusName);}soundBusIndex=AudioServer.GetBusIndex(SoundBusName);if(soundBusIndex<0){soundBusIndex=AudioServer.GetBusCount();AudioServer.AddBus(soundBusIndex);AudioServer.SetBusName(soundBusIndex,SoundBusName);}ambientBusIndex=AudioServer.GetBusIndex(AmbientBusName);if(ambientBusIndex<0){ambientBusIndex=AudioServer.GetBusCount();AudioServer.AddBus(ambientBusIndex);AudioServer.SetBusName(ambientBusIndex,AmbientBusName);}}private void SetupMusicPlayer(){musicPlayer=new AudioStreamPlayer();musicPlayer.Name="MusicPlayer";musicPlayer.Bus=MusicBusName;AddChild(musicPlayer);}private void SetupAmbientPlayer(){ambientPlayer=new AudioStreamPlayer();ambientPlayer.Name="AmbientPlayer";ambientPlayer.Bus=AmbientBusName;ambientPlayer.Finished+=OnAmbientFinished;AddChild(ambientPlayer);}private void OnAmbientFinished(){if(ambientPlayer?.Stream!=null)ambientPlayer.Play();}private AudioStreamPlayer GetOrCreateSoundPlayer(SoundType type){if(soundPlayers.TryGetValue(type,out var player)&&IsInstanceValid(player))return player;player=new AudioStreamPlayer();player.Name=$"SFX_{type}";player.Bus=SoundBusName;AddChild(player);soundPlayers[type]=player;return player;}/// <summary> Loads a music stream via <c>MusicFactory</c> and hands it to the dedicated Music player. If either the player or the factory result is null the call is silently skipped — no exception is thrown so that callers can fire-and-forget without guarding against missing assets. </summary>
public void PlayMusic(string id,MusicType musicType){if(musicPlayer==null)return;var stream=MusicFactory.Instance?.Get(id);if(stream==null)return;GameLogger.Log("AUDIO",$"AudioService.PlayMusic '{id}' type={musicType}");musicPlayer.Stream=stream;musicPlayer.Play();}/// <summary> Stops playback on the Music player. The stream reference is kept intact so a subsequent <c>Play()</c> would restart the same track — external logic (e.g. auto-advance) is expected to replace the stream before the next play. </summary>
public void StopMusic(){musicPlayer?.Stop();}/// <summary> Toggles the <c>StreamPaused</c> flag on the Music player. When <paramref name="pause"/> is <c>true</c> playback is suspended in-place; when <c>false</c> it resumes from the exact sample. No stream replacement occurs. </summary>
public void PauseMusic(bool pause){if(musicPlayer==null)return;musicPlayer.StreamPaused=pause;}/// <summary> Returns <c>true</c> when the Music player exists and is currently producing audio. Used externally (e.g. UI) to display playback state without coupling to the player node directly. </summary>
public bool IsMusicPlaying(){return musicPlayer?.Playing??false;}/// <summary> Resolves an ambient loop from <c>SoundFactory</c> and plays it on the Ambient player. If the same stream is already playing the call is a no-op, preventing a disruptive restart glitch. When the stream ends the player automatically loops via the <c>Finished</c> signal. </summary>
public void PlayAmbient(string id){if(ambientPlayer==null)return;var stream=SoundFactory.Instance?.Get(id);if(stream==null){GameLogger.Log("AUDIO",$"AudioService.PlayAmbient: stream not found for '{id}'",LogLevel.Error);return;}if(ambientPlayer.Playing&&ambientPlayer.Stream==stream)return;GameLogger.Log("AUDIO",$"AudioService.PlayAmbient '{id}'");ambientPlayer.Stream=stream;ambientPlayer.Play();}/// <summary> Stops the Ambient loop. Only acts when the player exists and is actively playing; otherwise the call is a no-op. There is no auto-resume — the next <c>PlayAmbient</c> loads the stream from scratch. </summary>
public void StopAmbient(){if(ambientPlayer==null|| !ambientPlayer.Playing)return;GameLogger.Log("AUDIO","AudioService.StopAmbient");ambientPlayer.Stop();}/// <summary> Plays a sound effect from <c>SoundFactory</c> with optional random variation. If <paramref name="variations"/> is greater than 1, a suffix <c>"_N"</c> is appended to <paramref name="id"/> where N is uniformly random in [1, variations]. The result is played on the per-<paramref name="soundType"/> pool player, cutting off any previous sound of the same type. </summary>
public void PlaySound(string id,int variations,SoundType soundType){if(variations<1){GameLogger.Log("AUDIO",$"PlaySound '{id}' type={soundType} with invalid variations={variations}");return;}if(variations>1)id+="_"+(rnd.Next(variations)+1).ToString();var stream=SoundFactory.Instance?.Get(id);if(stream==null)return;GameLogger.Log("AUDIO",$"PlaySound '{id}' type={soundType}");var player=GetOrCreateSoundPlayer(soundType);player.Stream=stream;player.Play();}/// <summary> Convenience overload that delegates to <c>PlaySound(id, 1, soundType)</c> — no variation suffix is appended, so the exact asset name is used. </summary>
public void PlaySound(string id,SoundType soundType){PlaySound(id,1,soundType);}/// <summary> Stops the pooled player for a specific <see cref="SoundType"/>. Other sound-type players are not affected, so e.g. stopping <c>CardEffect</c> won't interrupt an ongoing <c>UI</c> sound. </summary>
public void StopSound(SoundType type){if(soundPlayers.TryGetValue(type,out var player)&&IsInstanceValid(player))player.Stop();}/// <summary> Pauses the player for the given <paramref name="type"/> by setting <c>StreamPaused = true</c>. A subsequent <c>PlaySound</c> call will restart it from the paused position because the stream is already loaded. </summary>
public void PauseSound(SoundType type){if(soundPlayers.TryGetValue(type,out var player)&&IsInstanceValid(player))player.StreamPaused=true;}/// <summary> Applies a linear volume (0–1) directly to the Godot audio bus identified by <paramref name="type"/>. The value is converted to dB via <c>LinearToDb</c>. Unlike the per-frame settings poll, this is an immediate one-shot override — useful for fade tweens and manual volume control outside the settings system. </summary>
public void SetVolume(MixerType type,float volume){GameLogger.Log("AUDIO",$"SetVolume {type} = {volume:F2}");float db=LinearToDb(volume);if(type==MixerType.Musics)AudioServer.SetBusVolumeDb(musicBusIndex,db);else if(type==MixerType.Ambient)AudioServer.SetBusVolumeDb(ambientBusIndex,db);else AudioServer.SetBusVolumeDb(soundBusIndex,db);}/// <summary> Silences the bus by forcing -80 dB when <paramref name="enabled"/> is <c>false</c>. When <c>true</c> the method returns immediately without restoring volume — the next settings poll or <c>SetVolume</c> call is expected to bring the bus back to its intended level. This asymmetry exists because the settings system is the primary volume driver and <c>SetEnabled(false)</c> is a hard mute overlay. </summary>
public void SetEnabled(MixerType type,bool enabled){if(enabled)return;float db= -80f;if(type==MixerType.Musics)AudioServer.SetBusVolumeDb(musicBusIndex,db);else if(type==MixerType.Ambient)AudioServer.SetBusVolumeDb(ambientBusIndex,db);else AudioServer.SetBusVolumeDb(soundBusIndex,db);}/// <summary> Computes a logarithmic (dB) level for scenarios that need exponential volume scaling (e.g. UI sliders). First clamps the current bus level scaled by <paramref name="percent"/> to (0.0001, 100], then converts to dB via <c>log10(level/100) * 20</c>. The clamping avoids <c>-infinity</c> dB at zero. </summary>
public float GetExpLevel(MixerType type,float percent=1f){var level=GetLevel(type)*percent;if(level>100)level=100;if(level<=0)level=0.0001f;return Mathf.Log(level/100f)/Mathf.Log(10)*20f;}/// <summary> Returns the current bus level as a 0–1 linear factor by dividing the raw 0–100 settings value by 100. Used where linear interpolation is more appropriate than dB scaling. </summary>
public float GetLowLevel(MixerType type){return GetLevel(type)/100f;}private float GetLevel(MixerType type){var settings=GameData.Instance?.Settings;if(settings==null)return 100;return type switch{MixerType.Sounds=>settings.SoundsEnabled?settings.SoundsVolume:0,MixerType.Musics=>settings.MusicsEnabled?settings.MusicsVolume:0,MixerType.Ambient=>settings.AmbientEnabled?settings.AmbientVolume:0,_=>100,};}/// <summary> Converts a linear amplitude (0–1) to decibels using the <c>20 * log10(linear)</c> formula. Values at or below 0.0001 are clamped to -80 dB to avoid <c>-infinity</c>. Exposed as <c>public static</c> so external code (UI sliders, settings panels) can perform the conversion without holding a reference to the service. </summary>
public static float LinearToDb(float linear){if(linear<=0.0001f)return-80f;return Mathf.Log(linear)/Mathf.Log(10)*20f;}}}

===== FILE mods/audio_base/src/MusicFactory.cs =====
using System;using Cthangover.Core.Factories;namespace Cthangover.Mods.Audio{/// <summary> Thin <c>AudioFactory</c> for background music tracks. The separate <c>"music"</c> group name matters for Godot's audio bus routing — music plays through a dedicated mixer channel with its own volume and reverb profile, distinct from the <c>"sounds"</c> bus used by <c>SoundFactory</c>. <para/> The single instance is owned by this assembly as a lazily created singleton, exposed through the static <see cref="Instance"/> facade. </summary>
public class MusicFactory:AudioFactory{private static readonly Lazy<MusicFactory>instance=new(()=>new MusicFactory());internal MusicFactory():base("music",64){}/// <summary>The lazily created shared instance.</summary>
public static MusicFactory Instance=>instance.Value;/// <summary>Mod asset group scanned by this factory — files live under <c>mods/*/</c><b>music</b>.</summary>
public override string GroupName=>"music";}}

===== FILE mods/audio_base/src/MusicPlayerBehaviour.cs =====
using Cthangover.Core.Audio;using Cthangover.Core.Bootstrap;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Core.UI;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Base;namespace Cthangover.Mods.Audio{/// <summary> Autonomous music player that drives playlist auto-advance and scene-aware track switching. It finds the AudioService's MusicPlayer via the scene tree (not injected), initialises playlists lazily, and advances to a random track when playback ends. The core complexity is the Combat ↔ Ambient handoff: when entering combat, the current ambient track and time are saved; when leaving combat, they're restored — creating a seamless interruption model. FadeMusic uses a Tween with a 1s delay followed by a 6s volume ramp. Disabling auto-play preserves the last track/time; re-enabling resumes from the saved point. <para/> Lives in the audio mod: the node is created and named <c>MusicPlayer</c> at /root by <see cref="AudioModInitializer"/>, keeps the <c>music_player</c> group, and is notified about scene music from the mod's own <c>SceneEnteredEvent</c> / <c>SceneRouteActivatingEvent</c> subscriptions. Because mod assemblies get no Godot source generation, the class derives from the core <c>ModNode</c> forwarder and hooks the engine through <c>OnReady</c>/<c>OnProcess</c> instead of <c>_Ready</c>/<c>_Process</c>. </summary>
public class MusicPlayerBehaviour:ModNode{private AudioStreamPlayer audioPlayer;private readonly PlaylistContext playlistContext=new();/// <summary> Top-level toggle for the entire auto-advance state machine. When <c>false</c>, <c>NextSound</c> is skipped, no new tracks are selected, and <c>UpdateMusicType</c> does not trigger a switch. Set by <c>EnabledAutoPlay</c> / <c>DisabledAutoPlay</c> and also forced to <c>false</c> when a playlist has no tracks. </summary>
public bool IsCanAutoPlay{get;set;}=true;private Tween fadeTween;private bool playlistsInited;private double autoAdvanceCooldown;/// <summary>Registers in the <c>music_player</c> group and locates the AudioService music player.</summary>
protected override void OnReady(){AddToGroup("music_player");TryFindAudioPlayer();}/// <summary> Drives auto-advance: defers playlist init until the mod registry is finalized, then starts a random track when playback has been idle past the 0.5s cooldown and auto-play is enabled. </summary>
protected override void OnProcess(double delta){if(audioPlayer==null|| !IsInstanceValid(audioPlayer)){TryFindAudioPlayer();return;}if(!playlistsInited){// During the launcher boot the mod registry is being populated on a background thread; reading Mods from the main thread would race with that write. Defer playlist init until the registry is finalized (ModRegistry.IsInitialized flips at the end of FinalizeGodotInit) — the playlist is re-initialised on scene transitions anyway.
if(!Cthangover.Core.Mods.ModManager.Instance.IsInitialized)return;var sceneMgr=GetNodeOrNull<Cthangover.Core.Scenes.SceneManager>("/root/SceneManager");var sceneName=sceneMgr?.CurrentSceneName??GameData.Instance?.Runtime?.CurrentScene.ToString()??"MainMenu";InitPlaylists(sceneName);playlistsInited=true;autoAdvanceCooldown=Godot.Time.GetTicksUsec()/1_000_000.0+0.5;}if(!IsCanAutoPlay)return;double now=Godot.Time.GetTicksUsec()/1_000_000.0;if(now>autoAdvanceCooldown&& !audioPlayer.Playing&& !audioPlayer.StreamPaused){NextSound();autoAdvanceCooldown=now+0.5;}}/// <summary> Re-enables auto-play after it was disabled. Stops current playback and, if a <c>LastMusicName</c> is saved, restores that track from <c>MusicFactory</c> — effectively resuming the previously-interrupted song. If no track is saved the auto-advance loop in <c>OnProcess</c> will pick the next random track within 0.5s. </summary>
public void EnabledAutoPlay(){GameLogger.Log("AUDIO","Autoplay enabled");IsCanAutoPlay=true;StopMusic();if(playlistContext.LastMusicName!=null&&audioPlayer!=null){audioPlayer.Stream=MusicFactory.Instance.Get(playlistContext.LastMusicName);audioPlayer.Play();}}/// <summary> Disables auto-play and saves the current playback position into <c>PlaylistContext.LastMusicTime</c> so it can be restored later via <c>EnabledAutoPlay</c>. Stops playback immediately after saving. </summary>
public void DisabledAutoPlay(){GameLogger.Log("AUDIO","Autoplay disabled");IsCanAutoPlay=false;if(playlistContext.LastMusicName!=null&&audioPlayer!=null&&audioPlayer.Playing)playlistContext.LastMusicTime=audioPlayer.GetPlaybackPosition();StopMusic();}/// <summary> Overload that delegates to <c>UpdateMusicType(scene.ToString())</c>. </summary>
public void UpdateMusicType(GodotSceneType scene){UpdateMusicType(scene.ToString());}/// <summary> Scene-transition entry point that orchestrates the Combat ↔ Ambient music handoff. Re-initialises the playlist for the new scene and determines the appropriate <see cref="MusicType"/>: <list type="bullet"> <item>If the type is unchanged and the current track exists in the new playlist, playback continues uninterrupted.</item> <item>If the type is unchanged but the track is absent from the new playlist, a new random track is picked.</item> <item>When transitioning <b>Ambient → Combat</b>: the current ambient track name and playback position are saved to <c>PlaylistContext</c> so they can be restored after combat ends.</item> <item>When transitioning <b>Combat → Ambient</b>: the saved ambient state is restored. If the saved time is past 0.5s, <c>OggPacketParser.CreateTrimmedStream</c> is used to create a truncated OGG stream starting from the saved position — this avoids the O(N) seek penalty of <c>Play(fromPosition)</c> on large OGG files.</item> </list> When auto-play is disabled the entire method is a no-op except for playlist initialisation. </summary>
public void UpdateMusicType(string sceneName){var previousType=playlistContext.LastMusicType;InitPlaylists(sceneName);if(!IsCanAutoPlay)return;var newType=AppServices.Current.SceneRoutes.TryGetRoute(sceneName,out var route)?route.Music:MusicType.Ambient;GameLogger.Log("AUDIO",$"Scene '{sceneName}' -> music type {newType} (was {previousType})");if(newType==previousType){if(!string.IsNullOrEmpty(playlistContext.LastMusicName)){var dict=playlistContext.Playlist?.Musics;if(dict!=null&&dict.TryGetValue(newType,out var list)&&list!=null&&list.Contains(playlistContext.LastMusicName)){GameLogger.Log("AUDIO",$"Keeping current track '{playlistContext.LastMusicName}' (found in scene playlist)");return;}}GameLogger.Log("AUDIO",$"Current track '{playlistContext.LastMusicName}' not in scene playlist, switching");NextSound();return;}if(newType==MusicType.Combat&&previousType==MusicType.Ambient){if(audioPlayer!=null&&audioPlayer.Playing){playlistContext.SavedAmbientMusicName=playlistContext.LastMusicName;playlistContext.SavedAmbientMusicTime=audioPlayer.GetPlaybackPosition();GameLogger.Log("AUDIO",$"Saved ambient state: '{playlistContext.SavedAmbientMusicName}' at {playlistContext.SavedAmbientMusicTime:F1}s");}playlistContext.LastMusicType=newType;StopMusic();NextSound();return;}if(newType==MusicType.Ambient&&previousType==MusicType.Combat){StopMusic();playlistContext.LastMusicType=newType;if(!string.IsNullOrEmpty(playlistContext.SavedAmbientMusicName)&&audioPlayer!=null){var stream=MusicFactory.Instance.Get(playlistContext.SavedAmbientMusicName);if(stream!=null){var savedTime=(double)playlistContext.SavedAmbientMusicTime;GameLogger.Log("AUDIO",$"Restoring ambient: '{playlistContext.SavedAmbientMusicName}' was at {savedTime:F1}s");if(savedTime>0.5f){var trimmed=OggPacketParser.CreateTrimmedStream(stream,savedTime);if(trimmed!=null){stream=trimmed;GameLogger.Log("AUDIO",$"Trimmed stream created — playing from 0 (effective start {savedTime:F1}s)");}}audioPlayer.Stream=stream;playlistContext.LastMusicName=playlistContext.SavedAmbientMusicName;audioPlayer.Play();playlistContext.SavedAmbientMusicName=null;playlistContext.SavedAmbientMusicTime=0;autoAdvanceCooldown=Godot.Time.GetTicksUsec()/1_000_000.0+0.5;return;}GameLogger.Log("AUDIO",$"Restore failed: stream not found for '{playlistContext.SavedAmbientMusicName}'",LogLevel.Error);playlistContext.SavedAmbientMusicName=null;playlistContext.SavedAmbientMusicTime=0;}else{GameLogger.Log("AUDIO","No saved ambient state to restore");}NextSound();return;}playlistContext.LastMusicType=newType;NextSound();}/// <summary> Picks a random track from the current playlist's <c>LastMusicType</c> bucket, avoiding the immediately previous track when the bucket has more than one entry. Stops the current player and starts the new track via <c>PlayMusic</c>. Skips silently when auto-play is disabled or the playlist is empty. </summary>
public void NextSound(){if(!IsCanAutoPlay)return;var dict=playlistContext.Playlist?.Musics;if(dict==null|| !dict.TryGetValue(playlistContext.LastMusicType,out var list)||Lists.IsEmpty(list))return;int iteration=0;for(;;){int index=(int)(GD.Randi()%(uint)list.Count);var nextMusic=list[index];if(list.Count==1||playlistContext.LastMusicName!=nextMusic){GameLogger.Log("AUDIO",$"NextSound -> '{nextMusic}' (type={playlistContext.LastMusicType}, scene={playlistContext.Playlist?.Scene})");StopMusic();PlayMusic(nextMusic);break;}if(iteration++ >10)break;}}/// <summary> Overload that delegates to <c>InitPlaylists(scene.ToString())</c>. </summary>
public void InitPlaylists(GodotSceneType scene){InitPlaylists(scene.ToString());}/// <summary> Builds the playlist for a scene via <c>PlaylistFactory.CreatePlaylist</c>. If the playlist for the same scene is already loaded the call is a no-op. Forces <c>LastMusicType</c> from <c>Force</c> to <c>Ambient</c> so transient forced tracks don't persist across scenes. If the resulting playlist has no tracks, disables auto-play entirely. </summary>
public void InitPlaylists(string sceneName){if(playlistContext.Playlist!=null&&playlistContext.Playlist.Scene==sceneName)return;playlistContext.Playlist=PlaylistFactory.Instance.CreatePlaylist(sceneName);if(playlistContext.LastMusicType==MusicType.Force)playlistContext.LastMusicType=MusicType.Ambient;var trackCount=playlistContext.Playlist.Musics?.Count??0;GameLogger.Log("AUDIO",$"InitPlaylists scene='{sceneName}' tracks={trackCount} autoplay={IsCanAutoPlay}");if(playlistContext.Playlist.Musics==null||playlistContext.Playlist.Musics.Count==0)IsCanAutoPlay=false;}/// <summary> Manually plays a specific track with an explicit <c>AudioStream</c>. Updates <c>LastMusicName</c> and resets the auto-advance cooldown to prevent an immediate skip. The <paramref name="isLooped"/> parameter is accepted but currently unused — OGG streams are expected to be non-looping and auto-advance handles the next-track selection. </summary>
public void PlayMusic(string name,AudioStream music,bool isLooped=false){if(audioPlayer==null|| !IsInstanceValid(audioPlayer))TryFindAudioPlayer();if(audioPlayer==null)return;GameLogger.Log("AUDIO",$"PlayMusic '{name}'");playlistContext.LastMusicName=name;audioPlayer.Stream=music;audioPlayer.Play();autoAdvanceCooldown=Godot.Time.GetTicksUsec()/1_000_000.0+0.5;}/// <summary> Plays a track by name, resolving the stream from <c>MusicFactory</c>. Delegates to the <c>PlayMusic(name, stream, isLooped)</c> overload. </summary>
public void PlayMusic(string name,bool isLooped=false){PlayMusic(name,MusicFactory.Instance.Get(name),isLooped);}/// <summary> Resumes the currently-loaded stream on the audio player. Unlike the named overloads, does not change the stream or <c>LastMusicName</c>. Resets the auto-advance cooldown so the just-resumed track isn't replaced immediately. </summary>
public void PlayMusic(){audioPlayer?.Play();autoAdvanceCooldown=Godot.Time.GetTicksUsec()/1_000_000.0+0.5;}/// <summary> Pauses the music player in-place via <c>StreamPaused</c>. Unlike <c>StopMusic</c>, the stream stays loaded and <c>PlayMusic()</c> will resume from the paused sample. </summary>
public void PauseMusic(){GameLogger.Log("AUDIO","PauseMusic");if(audioPlayer!=null)audioPlayer.StreamPaused=true;}/// <summary> Immediately stops playback on the audio player. The stream reference is preserved; a subsequent <c>PlayMusic()</c> would restart the same track from the beginning. <c>LastMusicName</c> is not cleared, so <c>EnabledAutoPlay</c> can restore it from the factory. </summary>
public void StopMusic(){GameLogger.Log("AUDIO","StopMusic");audioPlayer?.Stop();}/// <summary> Fades the music bus volume from 1 to 0 over 6 seconds after a 1-second delay, then stops playback and resets the bus to full volume. The fade is implemented via a Godot <c>Tween</c> that calls <c>AudioService.SetVolume</c> each frame. If a previous fade is still running it is killed before starting the new one. The final reset to volume=1 is necessary because the settings poll would otherwise keep the bus at zero until the next <c>ApplySettings</c> cycle. </summary>
public void FadeMusic(){GameLogger.Log("AUDIO","FadeMusic start (1s delay + 6s fade)");fadeTween?.Kill();fadeTween=CreateTween();fadeTween.TweenInterval(1.0);fadeTween.TweenMethod(Callable.From<float>(vol=>{var service=GetNodeOrNull<AudioService>("/root/AudioService");service?.SetVolume(MixerType.Musics,vol);}),1f,0f,6.0);fadeTween.TweenCallback(Callable.From(()=>{StopMusic();var service=GetNodeOrNull<AudioService>("/root/AudioService");service?.SetVolume(MixerType.Musics,1f);}));}private void TryFindAudioPlayer(){var service=GetNodeOrNull<AudioService>("/root/AudioService");if(service!=null){audioPlayer=service.GetNodeOrNull<AudioStreamPlayer>("MusicPlayer");}}}}

===== FILE mods/audio_base/src/MusicSceneItem.cs =====
using System;using Cthangover.Core.Audio;namespace Cthangover.Mods.Audio{/// <summary> Serializable scene-to-music mapping with an explicit Index for ordering. Defaults to <c>Ambient</c> type. Used for serialised music configs that need a deterministic per-scene track order rather than random selection. </summary>
[Serializable]public class MusicSceneItem{/// <summary> Asset name used as a key in <c>MusicFactory</c> lookups. </summary>
public string Name{get;set;}/// <summary> Zero-based ordering index within the scene's track list. Used for deterministic playback order rather than random selection. </summary>
public int Index{get;set;}/// <summary> The music category for this track. Defaults to <see cref="MusicType.Ambient"/>. </summary>
public MusicType Type{get;set;}=MusicType.Ambient;/// <summary> Parameterless constructor for serialisation. Initialises <c>Name</c> to <see cref="string.Empty"/>. </summary>
public MusicSceneItem(){Name=string.Empty;}/// <summary> Constructs an entry with a name, an ordering index, and an optional music type (defaults to <c>Ambient</c>). </summary>
public MusicSceneItem(string name,int index,MusicType type=MusicType.Ambient){Name=name;Index=index;Type=type;}/// <summary> Returns the item name for debug/log display. </summary>
public override string ToString(){return$"MusicSceneItem: Name={Name}";}}}

===== FILE mods/audio_base/src/OggPacketParser.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Mods.Audio{/// <summary> Low-level Ogg Vorbis page parser. Reads raw OGG container bytes and produces an <c>OggPacketSequence</c> that Godot's <c>AudioStreamOggVorbis</c> can consume directly. Handles multi-page continuation (a packet split across page boundaries via the continuation flag), extracts granule positions for accurate seeking, and reads the Vorbis identification header to recover the sampling rate. Falls back to 44100 Hz if the header is absent or unparseable. Returns <c>null</c> on any corruption, logging the offset of the invalid page. </summary>
public static class OggPacketParser{private const int OGG_PAGE_HEADER_SIZE=27;/// <summary> Creates a new <c>AudioStreamOggVorbis</c> with audio data starting from the given time. Header packets (identification, comment, setup) are preserved. Granule positions are normalised so <c>Play(0)</c> starts at the target time without the O(N) seek penalty that <c>Play(fromPosition)</c> incurs on a full OGG stream. Returns <c>null</c> if the stream cannot be trimmed (e.g. not an OGG, too short, or corrupted). </summary>
public static AudioStreamOggVorbis CreateTrimmedStream(AudioStream source,double startTimeSeconds){if(source is not AudioStreamOggVorbis ogg||ogg.PacketSequence==null)return null;var seq=ogg.PacketSequence;var packetData=seq.PacketData;var granulePositions=seq.GranulePositions;var samplingRate=seq.SamplingRate;const int HEADER_COUNT=3;if(packetData==null||packetData.Count<=HEADER_COUNT)return null;if(granulePositions==null||granulePositions.Length<packetData.Count)return null;if(startTimeSeconds<=0||samplingRate<=0)return null;int startPacketIndex= -1;long baseGranule=0;for(int i=HEADER_COUNT;i<packetData.Count;i++){if(i>=granulePositions.Length)break;long granule=granulePositions[i];if(granule<0)continue;double timeSeconds=(double)granule/samplingRate;if(timeSeconds>=startTimeSeconds){startPacketIndex=i;baseGranule=granule;break;}}if(startPacketIndex<0)return null;var trimmedPacketData=new Godot.Collections.Array<Godot.Collections.Array>();var trimmedGranulePositions=new long[packetData.Count-startPacketIndex+HEADER_COUNT];for(int i=0;i<HEADER_COUNT;i++){trimmedPacketData.Add(packetData[i]);trimmedGranulePositions[i]=0;}for(int i=startPacketIndex;i<packetData.Count;i++){int trimmedIdx=HEADER_COUNT+(i-startPacketIndex);trimmedPacketData.Add(packetData[i]);if(i<granulePositions.Length){long granule=granulePositions[i];trimmedGranulePositions[trimmedIdx]=granule>=0?granule-baseGranule:-1;}}var trimmedSeq=new OggPacketSequence();trimmedSeq.PacketData=trimmedPacketData;trimmedSeq.GranulePositions=trimmedGranulePositions;trimmedSeq.SamplingRate=samplingRate;var trimmedStream=new AudioStreamOggVorbis();trimmedStream.PacketSequence=trimmedSeq;return trimmedStream;}/// <summary> Parses raw OGG container bytes into a Godot <c>OggPacketSequence</c> ready for consumption by <c>AudioStreamOggVorbis</c>. Walks the page structure: <list type="bullet"> <item>Validates the "OggS" magic at each page header.</item> <item>Reads the segment table to split pages into individual packets, correctly reassembling packets that span page boundaries via the continuation flag.</item> <item>Assigns the page's granule position to each packet extracted from that page, preserving seek metadata.</item> <item>Extracts the sampling rate from the first packet's Vorbis identification header; falls back to 44100 Hz if the header is missing or malformed.</item> <item>Any unflushed pending packet at EOF is appended as a final packet.</item> </list> Returns <c>null</c> on corruption (bad magic, segment table overflow) or if no packets were extracted, logging the error with the offset for debugging. </summary>
public static OggPacketSequence CreateFromOggBytes(byte[]data){if(data==null||data.Length<OGG_PAGE_HEADER_SIZE){GameLogger.Log("AUDIO","OggPacketParser: data is null or too short",LogLevel.Error);return null;}try{var packets=new List<byte[]>();var granulePositions=new List<long>();int offset=0;byte[]pendingPacket=null;float samplingRate=0;bool gotSamplingRate=false;while(offset<data.Length){if(offset+OGG_PAGE_HEADER_SIZE>data.Length)break;if(!IsOggPage(data,offset)){GameLogger.Log("AUDIO",$"OggPacketParser: invalid page marker at offset {offset}",LogLevel.Error);return null;}bool isContinued=(data[offset+5]&0x01)!=0;long granulePos=ReadInt64LE(data,offset+6);int segmentCount=data[offset+26];int segTableOffset=offset+OGG_PAGE_HEADER_SIZE;if(segTableOffset+segmentCount>data.Length){GameLogger.Log("AUDIO",$"OggPacketParser: segment table exceeds data at offset {offset}",LogLevel.Error);return null;}int segDataOffset=segTableOffset+segmentCount;int segDataEnd=CalculateSegmentDataEnd(data,offset,segmentCount);int packetsBefore=packets.Count;packets.AddRange(ExtractPackets(data,segTableOffset,segmentCount,segDataOffset,ref pendingPacket,isContinued));int packetsAdded=packets.Count-packetsBefore;for(int i=0;i<packetsAdded;i++)granulePositions.Add(granulePos);if(!gotSamplingRate&&packets.Count>=1){samplingRate=ExtractSamplingRate(packets[0]);gotSamplingRate=true;}offset=segDataEnd;}if(pendingPacket!=null&&pendingPacket.Length>0){packets.Add(pendingPacket);granulePositions.Add(granulePositions.Count>0?granulePositions[granulePositions.Count-1]:0);}if(packets.Count==0){GameLogger.Log("AUDIO","OggPacketParser: no packets extracted",LogLevel.Error);return null;}if(granulePositions.Count==0){GameLogger.Log("AUDIO","OggPacketParser: no granule positions extracted",LogLevel.Error);return null;}return BuildPacketSequence(packets,granulePositions,samplingRate);}catch(Exception ex){GameLogger.Log("AUDIO",$"OggPacketParser: error: {ex.Message}",LogLevel.Error);return null;}}private static bool IsOggPage(byte[]data,int offset){return data[offset]==0x4F&&data[offset+1]==0x67&&data[offset+2]==0x67&&data[offset+3]==0x53;}private static long ReadInt64LE(byte[]data,int offset){return(long)data[offset]|((long)data[offset+1]<<8)|((long)data[offset+2]<<16)|((long)data[offset+3]<<24)|((long)data[offset+4]<<32)|((long)data[offset+5]<<40)|((long)data[offset+6]<<48)|((long)data[offset+7]<<56);}private static int CalculateSegmentDataEnd(byte[]data,int pageOffset,int segmentCount){int segTableOffset=pageOffset+OGG_PAGE_HEADER_SIZE;int end=segTableOffset+segmentCount;for(int i=0;i<segmentCount;i++)end+=data[segTableOffset+i];return end;}private static float ExtractSamplingRate(byte[]firstPacket){if(firstPacket==null||firstPacket.Length<16)return 44100f;if(firstPacket[0]==0x01&&firstPacket[1]=='v'&&firstPacket[2]=='o'&&firstPacket[3]=='r'&&firstPacket[4]=='b'&&firstPacket[5]=='i'&&firstPacket[6]=='s'){return BitConverter.ToInt32(firstPacket,12);}return 44100f;}private static List<byte[]>ExtractPackets(byte[]data,int segTableOffset,int segmentCount,int segDataOffset,ref byte[]pendingPacket,bool isContinued){var packets=new List<byte[]>();if(!isContinued&&pendingPacket!=null&&pendingPacket.Length>0){packets.Add(pendingPacket);}int currentOffset=segDataOffset;var currentPacketChunks=new List<byte[]>();if(isContinued&&pendingPacket!=null){currentPacketChunks.Add(pendingPacket);}for(int i=0;i<segmentCount;i++){int segSize=data[segTableOffset+i];if(currentOffset+segSize>data.Length)break;if(segSize>0){var chunk=new byte[segSize];Buffer.BlockCopy(data,currentOffset,chunk,0,segSize);currentPacketChunks.Add(chunk);currentOffset+=segSize;}bool isLastSegment=(i==segmentCount-1);bool packetComplete=segSize<255||isLastSegment;if(packetComplete&&currentPacketChunks.Count>0){packets.Add(ConcatChunks(currentPacketChunks));currentPacketChunks.Clear();}}pendingPacket=currentPacketChunks.Count>0?ConcatChunks(currentPacketChunks):null;return packets;}private static byte[]ConcatChunks(List<byte[]>chunks){if(chunks.Count==1)return chunks[0];int total=0;foreach(var c in chunks)total+=c.Length;var result=new byte[total];int offset=0;foreach(var c in chunks){Buffer.BlockCopy(c,0,result,offset,c.Length);offset+=c.Length;}return result;}private static OggPacketSequence BuildPacketSequence(List<byte[]>packets,List<long>granulePositions,float samplingRate){var packetArray=new Godot.Collections.Array<Godot.Collections.Array>();foreach(var packet in packets){var inner=new Godot.Collections.Array();inner.Add(packet);packetArray.Add(inner);}var packetSequence=new OggPacketSequence();packetSequence.PacketData=packetArray;packetSequence.GranulePositions=granulePositions.ToArray();packetSequence.SamplingRate=samplingRate;return packetSequence;}}}

===== FILE mods/audio_base/src/Playlist.cs =====
using System.Collections.Generic;using Cthangover.Core.Audio;namespace Cthangover.Mods.Audio{/// <summary> Runtime playlist model: a scene name mapped to lists of track names grouped by MusicType. Built by PlaylistFactory from the flatter <see cref="PlaylistData"/> JSON format. </summary>
public class Playlist{/// <summary> The scene name this playlist belongs to (e.g. "Tavern", "Battle"). Used as the lookup key in <c>PlaylistContext</c> and <c>MusicPlayerBehaviour</c> to avoid redundant factory calls when the scene hasn't changed. </summary>
public string Scene{get;set;}/// <summary> Tracks grouped by <see cref="MusicType"/>. The dictionary provides O(1) access to the track list for the current music type during auto-advance. An inner <c>List&lt;string&gt;</c> is used (rather than a set) so duplicate entries are preserved and random selection is uniform. </summary>
public IDictionary<MusicType,List<string>>Musics{get;set;}}}

===== FILE mods/audio_base/src/PlaylistContext.cs =====
using Cthangover.Core.Audio;namespace Cthangover.Mods.Audio{/// <summary> Mutable playback state carried across track switches and scene transitions. Holds the active Playlist, the last played track/type/time, and the saved ambient state (<c>SavedAmbientMusicName</c> / <c>SavedAmbientMusicTime</c>) used to restore ambient music after combat interruptions. </summary>
public class PlaylistContext{/// <summary> The currently active playlist, resolved by scene name. </summary>
public Playlist Playlist{get;set;}/// <summary> The <see cref="MusicType"/> of the last played (or currently playing) track. Initialised to <c>Force</c> and normalised to <c>Ambient</c> on the first playlist init. </summary>
public MusicType LastMusicType{get;set;}/// <summary> Asset name of the last track. Saved across stop/start cycles so <c>EnabledAutoPlay</c> can restore it from the factory. </summary>
public string LastMusicName{get;set;}/// <summary> Playback position of the last track, in seconds. Captured when auto-play is disabled so the track can resume near where it left off. </summary>
public float LastMusicTime{get;set;}/// <summary> Ambient track name saved when entering combat. Restored on return to ambient so the player hears the same background music that was interrupted. </summary>
public string SavedAmbientMusicName{get;set;}/// <summary> Ambient track position (seconds) saved alongside <see cref="SavedAmbientMusicName"/>. Used to create a trimmed OGG stream via <c>OggPacketParser</c> when the saved time exceeds 0.5s. </summary>
public float SavedAmbientMusicTime{get;set;}}}

===== FILE mods/audio_base/src/PlaylistFactory.cs =====
using System;using System.Collections.Generic;using System.Text.Json;using Cthangover.Core.Audio;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;using Cthangover.Core.Utils;namespace Cthangover.Mods.Audio{/// <summary> Assembles <c>Playlist</c> objects from JSON files under <c>music/playlists/</c> across all loaded mods. Each playlist maps music tracks to a scene name and categorises them by <c>MusicType</c> (ambient, combat, tension, etc.). If a scene has no dedicated playlist the factory falls back to <c>"default"</c>, guaranteeing that music never cuts to silence when entering an unconfigured scene. When several mods define playlists for the same scene, a later-loaded mod <b>replaces</b> the whole track list of a matching <see cref="MusicType"/> bucket (last one wins) — per-type buckets are assigned, not concatenated, so a mod can override stock scene music without editing the base game files. <para/> The single instance is owned by this assembly as a lazily created singleton, exposed through the static <see cref="Instance"/> facade. </summary>
public class PlaylistFactory{private static readonly Lazy<PlaylistFactory>instance=new(()=>new PlaylistFactory());/// <summary>The lazily created shared instance.</summary>
public static PlaylistFactory Instance=>instance.Value;private const string PlaylistsSubDir="music/playlists";/// <summary>Enum overload delegating to <see cref="CreatePlaylist(string)"/> with the scene name.</summary>
public Playlist CreatePlaylist(GodotSceneType sceneName){return CreatePlaylist(sceneName.ToString());}/// <summary> Builds the playlist for a scene from every mod's <c>music/playlists/*.json</c>, falling back to the <c>"default"</c> playlist when the scene has none (or it is empty). </summary>
public Playlist CreatePlaylist(string sceneName){var playlist=CreatePlaylistInternal(sceneName);if(playlist.Musics.Count>0)return playlist;if(string.Equals(sceneName,"default",StringComparison.OrdinalIgnoreCase))return playlist;GameLogger.Log("AUDIO",$"No playlist for scene '{sceneName}', falling back to 'default'");return CreatePlaylistInternal("default");}private Playlist CreatePlaylistInternal(string sceneName){var playlist=new Playlist();playlist.Scene=sceneName;playlist.Musics=new Dictionary<MusicType,List<string>>();var modManager=ModManager.Instance;foreach(var kvp in modManager.Mods){var modId=kvp.Key;var provider=kvp.Value.FileProvider;foreach(var entry in provider.ListFiles(PlaylistsSubDir)){if(entry.EndsWith("/"))continue;if(!entry.EndsWith(".json",StringComparison.OrdinalIgnoreCase))continue;var jsonText=modManager.ReadResolvedText(modId,entry,provider);if(jsonText==null)continue;try{var data=JsonSerializer.Deserialize<PlaylistData>(jsonText,JsonOptionsProvider.ReadOptions);if(data==null||string.IsNullOrEmpty(data.Scene)||data.Musics==null)continue;if(data.Scene!=sceneName)continue;GameLogger.Log("FACTORY",$"Playlist for scene '{sceneName}' from mod '{modId}' ({entry})");foreach(var musicEntry in data.Musics){if(musicEntry.MusicNames==null||musicEntry.MusicNames.Count==0)continue;playlist.Musics[musicEntry.MusicType]=new List<string>(musicEntry.MusicNames);}}catch(JsonException ex){GameLogger.Log("MODS",$"JSON parse failed for playlist '{modId}/{entry}': {ex.Message}",LogLevel.Error);}}}return playlist;}}}

===== FILE mods/audio_base/src/PlaylistItemInfo.cs =====
using System.Collections.Generic;namespace Cthangover.Mods.Audio{/// <summary> JSON deserialization shape for playlist config files. Flat: one scene with a list of <see cref="PlaylistMusicEntry"/> objects. The factory resolves this into a <see cref="Playlist"/> where entries are already grouped by MusicType for O(1) lookup. </summary>
public class PlaylistData{/// <summary> The scene name this config entry targets. </summary>
public string Scene{get;set;}/// <summary> Flat list of per-type entries. The factory groups these into the dictionary form in <see cref="Playlist.Musics"/> for efficient runtime lookup. </summary>
public List<PlaylistMusicEntry>Musics{get;set;}}}

===== FILE mods/audio_base/src/PlaylistMusicEntry.cs =====
using System.Collections.Generic;using System.Text.Json.Serialization;using Cthangover.Core.Audio;namespace Cthangover.Mods.Audio{/// <summary> A single playlist entry in JSON — pairs a MusicType with the list of asset names that belong to it. <c>JsonStringEnumConverter</c> is used so the type is serialised as a string rather than an integer. </summary>
public class PlaylistMusicEntry{/// <summary> The music category for this group of tracks. Serialised as a string (e.g. "Combat", "Ambient") via <c>JsonStringEnumConverter</c> so JSON configs remain human-readable. </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]public MusicType MusicType{get;set;}/// <summary> Asset names belonging to this <see cref="MusicType"/> group. These are resolved by <c>MusicFactory</c> at playback time. </summary>
public List<string>MusicNames{get;set;}}}

===== FILE mods/audio_base/src/SoundFactory.cs =====
using System;using Cthangover.Core.Factories;namespace Cthangover.Mods.Audio{/// <summary> Thin <c>AudioFactory</c> for sound effects. While structurally identical to <c>MusicFactory</c>, the distinct group name <c>"sounds"</c> routes audio through a separate Godot mixer bus — sound effects bypass the music channel's reverb and ducking processing, and have their own volume slider in settings. <para/> The single instance is owned by this assembly as a lazily created singleton, exposed through the static <see cref="Instance"/> facade. </summary>
public class SoundFactory:AudioFactory{private static readonly Lazy<SoundFactory>instance=new(()=>new SoundFactory());internal SoundFactory():base("sounds",64){}/// <summary>The lazily created shared instance.</summary>
public static SoundFactory Instance=>instance.Value;/// <summary>Mod asset group scanned by this factory — files live under <c>mods/*/</c><b>sounds</b>.</summary>
public override string GroupName=>"sounds";}}

===== FILE mods/audio_base/src/SubsystemRegistration.cs =====
namespace Cthangover.Mods.Audio{/// <summary> Migration marker for the coverage pipeline (<c>SubsystemsBridge</c> reflection scan): declares the audio subsystem fully moved out of the core assembly into this engine mod. The id must match the <c>DeclaredTests</c> entry in <c>SubsystemsCoverageTestSetup</c>. </summary>
public static class SubsystemRegistration{public static string SubsystemId=>"audio";}}

===== FILE mods/card_battle/src/Actions/AbstractBattleAction.cs =====
using Cthangover.CardBattle.UI;using Cthangover.Core.Battle.Actions;using Cthangover.Core.Characters;using Godot;namespace Cthangover.CardBattle.Actions{/// <summary> Base class for animated battle actions that move, rotate, and scale card controls through a sequence of visual phases. Subclasses implement <c>DoInternalAction</c> to advance a frame-by-frame animation state machine driven by <see cref="CardBattleCore"/>'s per-frame polling loop (<c>while (!DoAction()) await frame;</c>). Captures source/target positions and rotations in <see cref="DoStart"/> so that <see cref="DoEnd"/> can restore them regardless of what the animation did. </summary>
public abstract class AbstractBattleAction:IBattleAction{/// <summary>Seconds timestamp of the current phase start (used for phase progress); -1 before the first phase.</summary>
protected double Timestamp{get;set;}= -1;/// <summary>Playback speed multiplier applied to all phase durations.</summary>
protected float Speed{get;set;}/// <summary>The action definition whose stats drive the animation.</summary>
protected ActionCharacter Action{get;set;}/// <summary>Acting card node.</summary>
protected CharacterCardNode Source{get;set;}/// <summary>Receiving card node.</summary>
protected CharacterCardNode Target{get;set;}/// <summary>Visual control of the source card (null-safe abort when freed mid-animation).</summary>
protected Control SourceControl{get;set;}/// <summary>Source position captured in <see cref="DoStart"/> for restoration in <see cref="DoEnd"/>.</summary>
protected Vector2 SourcePos{get;set;}/// <summary>Source rotation captured in <see cref="DoStart"/>.</summary>
protected float SourceRot{get;set;}/// <summary>Visual control of the target card.</summary>
protected Control TargetControl{get;set;}/// <summary>Target position captured in <see cref="DoStart"/>.</summary>
protected Vector2 TargetPos{get;set;}/// <summary>Target rotation captured in <see cref="DoStart"/>.</summary>
protected float TargetRot{get;set;}/// <summary> Advances the animation by one frame. Called in a loop by <see cref="CardBattleCore.RunEnemyTurn"/> with <c>await root.ToSignal(frame)</c> between calls. Returns <c>true</c> when the animation has completed all phases and the action result has been applied. Returns <c>true</c> early if any required node became null (card was destroyed mid-animation). </summary> <returns><c>true</c> when the animation is finished or cannot proceed.</returns>
public bool DoAction(){if(Source==null||Target==null||Action==null||SourceControl==null||TargetControl==null)return true;return DoInternalAction();}/// <summary>Frame tick of the animation; implementors advance phases and return true when finished.</summary>
protected abstract bool DoInternalAction();/// <summary>Phase setup called once before the first <see cref="DoInternalAction"/> tick.</summary>
protected abstract void DoInternalStart();/// <summary>Cleanup called once after completion — restore captured positions/rotations here.</summary>
protected abstract void DoInternalEnd();protected AbstractBattleAction(CharacterCardNode source,CharacterCardNode target,ActionCharacter action,float speed=1){Source=source;Target=target;Action=action;Speed=speed;}/// <summary> Captures the current positions, rotations, and <see cref="Control"/> references of both source and target cards before the animation begins, then delegates to the subclass <see cref="DoInternalStart"/> for any additional setup (e.g. resetting timestamps). </summary>
public void DoStart(){if(Source==null||Target==null||Action==null)return;SourceControl=Source.GetControlNode();SourcePos=SourceControl.GlobalPosition;SourceRot=SourceControl.Rotation;TargetControl=Target.GetControlNode();TargetPos=TargetControl.GlobalPosition;TargetRot=TargetControl.Rotation;DoInternalStart();}/// <summary> Restores source and target card controls to their original positions, rotations, and visual state captured during <see cref="DoStart"/>. Delegates specific cleanup to <see cref="DoInternalEnd"/>. </summary>
public void DoEnd(){if(Source==null||Target==null||Action==null)return;DoInternalEnd();}}}

===== FILE mods/card_battle/src/Actions/ActionPhase.cs =====
namespace Cthangover.CardBattle.Actions{/// <summary> Phases of the animated battle action state machine used by <see cref="PhysicsAttackAction"/> and <see cref="PhysicsDefenceAction"/>. Each tick, the action evaluates the current phase, interpolates card positions/rotations/scales, and transitions to the next phase when its duration elapses. The <c>Recover</c> phase signals completion by returning <c>true</c> from <c>DoInternalAction</c>. </summary>
public enum ActionPhase{/// <summary>Initial delay before any movement.</summary>
Wait,/// <summary>Source card rotates toward target and begins slight movement.</summary>
Prepare,/// <summary>Source card travels from its position to the target along a movement pattern.</summary>
MoveForAttack,/// <summary>Damage/effect is applied to the target card at the point of impact.</summary>
AttackImpact,/// <summary>Target card shows knockback, squashing, and shaking reaction.</summary>
TargetReaction,/// <summary>Source card returns toward its original position.</summary>
MoveForBack,/// <summary>All cards smoothly return to their original transforms.</summary>
Recover}}

===== FILE mods/card_battle/src/Actions/CardBattleActionProvider.cs =====
using System.Collections.Generic;using Cthangover.Core.Battle.Actions;using Cthangover.Core.Utils;namespace Cthangover.CardBattle.Actions{/// <summary> Registry that maps action type IDs (<c>"physics/attack"</c>, <c>"physics/defence"</c>, <c>"physics/stun"</c>) to their respective <see cref="IActionExecutor"/> implementations. Created by <see cref="CardBattleCore"/> and registered with <see cref="ActionExecutorHub"/> at battle startup so that actions resolved during drag-and-drop or enemy AI turns go through the correct damage/defence/stun logic. </summary>
public class CardBattleActionProvider:IActionExecutorProvider{private readonly Dictionary<string,IActionExecutor>_executors;/// <summary> Initializes the provider with the three card-battle action executors: <see cref="PhysicsDamageActionCard"/> for attacks, <see cref="PhysicsDefenceActionCard"/> for defence buffs, and <see cref="PhysicsStunActionCard"/> for stun effects. </summary>
public CardBattleActionProvider(){_executors=new Dictionary<string,IActionExecutor>{["physics/attack"]=new PhysicsDamageActionCard(),["physics/defence"]=new PhysicsDefenceActionCard(),["physics/stun"]=new PhysicsStunActionCard(),};}/// <summary> Looks up the <see cref="IActionExecutor"/> for the given <paramref name="actionId"/>. Logs a warning and returns <c>null</c> if no executor is registered, allowing the caller to gracefully skip unrecognized actions. </summary>
public IActionExecutor GetExecutor(string actionId){if(!_executors.TryGetValue(actionId,out var executor))GameLogger.Log("CARD_BATTLE",$"No executor registered for action '{actionId}'",LogLevel.Warning);return executor;}}}

===== FILE mods/card_battle/src/Actions/PhysicsAttackAction.cs =====
using Cthangover.Core.Battle;using Cthangover.Core.Battle.Actions;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Cthangover.TurnBattle;using Cthangover.Core.Characters;using Cthangover.CardBattle.UI;using Godot;namespace Cthangover.CardBattle.Actions{/// <summary> Animated battle action that moves the source card toward an enemy target, applies damage/defence via <see cref="ActionExecutorHub"/>, and animates the target's knockback/squash reaction. The source card follows one of five randomly-selected <see cref="MovementPattern"/> trajectories (direct, arc, zigzag, spiral, dash). This is the visual counterpart of <see cref="PhysicsDamageActionCard"/> — it handles the presentation while the executor handles the stat changes. Created by <see cref="CardBattleCore.CreateAnimatedAction"/> for enemy turns. </summary>
public class PhysicsAttackAction:AbstractBattleAction{/// <summary> Pre-defined movement trajectory patterns for the source card's approach to the target. Selected randomly at construction to add visual variety to enemy attack animations. </summary>
public enum MovementPattern{Direct,Arc,Zigzag,Spiral,Dash}private ActionPhase phase=ActionPhase.Wait;private float attackRotation;private bool damageApplied;private bool stunEffectSpawned;private Vector2 targetOriginalPos;private Vector2 targetOriginalScale;private Vector2 knockbackDirection;private float targetReactionIntensity=0.3f;private Vector2 knockbackPos;private float shake;private Vector2 squashScale;private float waitDuration=0.2f;private float prepareDuration=0.3f;private float moveForAttackDuration=0.4f;private float attackImpactDuration=0.3f;private float targetReactionDuration=0.4f;private float moveForBackDuration=0.5f;private float recoverDuration=0.3f;private MovementPattern movementPattern=MovementPattern.Direct;private Vector2 movementControlPoint;/// <summary> Creates an attack animation from <paramref name="source"/> to <paramref name="target"/> using the stats defined in <paramref name="action"/>. The <see cref="MovementPattern"/> is randomly selected on construction to make enemy attacks visually distinct. <paramref name="speed"/> acts as a multiplier on all phase durations. </summary>
public PhysicsAttackAction(CharacterCardNode source,CharacterCardNode target,ActionCharacter action,float speed=1):base(source,target,action,speed){movementPattern=(MovementPattern)GD.RandRange(0,5);}/// <summary>Resets the phase clock and starts the attack sequence from the Wait phase.</summary>
protected override void DoInternalStart(){Timestamp=Godot.Time.GetTicksUsec()/1_000_000.0;damageApplied=false;stunEffectSpawned=false;phase=ActionPhase.Wait;targetOriginalScale=TargetControl.Scale;}private float GetPhaseProgress(){float elapsed=(float)(Godot.Time.GetTicksUsec()/1_000_000.0-Timestamp)*Speed*(float)Godot.Engine.TimeScale;float duration=GetCurrentPhaseDuration();return Mathf.Clamp(elapsed/duration,0f,1f);}private float GetCurrentPhaseDuration(){switch(phase){case ActionPhase.Wait:return waitDuration;case ActionPhase.Prepare:return prepareDuration;case ActionPhase.MoveForAttack:return moveForAttackDuration;case ActionPhase.AttackImpact:return attackImpactDuration;case ActionPhase.TargetReaction:return targetReactionDuration;case ActionPhase.MoveForBack:return moveForBackDuration;case ActionPhase.Recover:return recoverDuration;default:return 1f;}}private void ResetTimestamp(){Timestamp=Godot.Time.GetTicksUsec()/1_000_000.0;}/// <summary>Advances the multi-phase attack: dash in, impact, damage/stun application, return and recover.</summary>
protected override bool DoInternalAction(){float progress=GetPhaseProgress();switch(phase){case ActionPhase.Wait:if(progress>=1f){phase=ActionPhase.Prepare;ResetTimestamp();targetOriginalPos=TargetControl.GlobalPosition;targetOriginalScale=TargetControl.Scale;knockbackDirection=(TargetControl.GlobalPosition-SourceControl.GlobalPosition).Normalized();InitializeMovementPattern();}break;case ActionPhase.Prepare:{float prepareProgress=EaseOutQuad(Mathf.Min(progress,1f));Vector2 preparePosition=GetMovementPosition(prepareProgress*0.1f);SourceControl.GlobalPosition=SourcePos.Lerp(preparePosition,prepareProgress);SourceControl.Rotation=Mathf.Lerp(SourceRot,TargetRot,prepareProgress*0.3f);if(progress>=1f){phase=ActionPhase.MoveForAttack;ResetTimestamp();}break;}case ActionPhase.MoveForAttack:{float attackProgress=EaseInOutQuad(Mathf.Min(progress,1f));Vector2 targetPosition=GetMovementPosition(attackProgress);SourceControl.GlobalPosition=targetPosition;SourceControl.Rotation=Mathf.Lerp(SourceRot,TargetRot,attackProgress);if(progress>=1f){phase=ActionPhase.AttackImpact;ResetTimestamp();attackRotation=(float)GD.RandRange(-35f,35f)*Mathf.Pi/180f;}break;}case ActionPhase.AttackImpact:{float impactProgress=EaseInOutQuad(Mathf.Min(progress*3f,1f));SourceControl.Rotation=Mathf.Lerp(TargetRot,attackRotation,impactProgress);if(!damageApplied&&progress>=0.3f){damageApplied=true;var result=ActionExecutorHub.Instance.Execute(Action,Source.Card,Target.Card);Source.UpdateInfo();Target.UpdateInfo();if(result.Result){LogAttackResult(result);}else{GameLogger.Log("CARD_ACT",$"PhysicsAttackAction: failed to execute action {Action.Name} (source={Source.Card.Name} target={Target.Card.Name})",LogLevel.Error);}}if(progress>=1f){phase=ActionPhase.TargetReaction;ResetTimestamp();}break;}case ActionPhase.TargetReaction:{float reactionProgress=EaseInOutQuad(Mathf.Min(progress,1f));bool isStun=Action!=null&&Action.GetInt("Turn",0)>1;float currentKnockback=targetReactionIntensity;if(isStun)currentKnockback*=2.5f;knockbackPos=targetOriginalPos+knockbackDirection*currentKnockback;TargetControl.GlobalPosition=targetOriginalPos.Lerp(knockbackPos,reactionProgress);squashScale=targetOriginalScale;float currentSquash=isStun?0.35f:0.2f;squashScale.X*=1f-reactionProgress*currentSquash;squashScale.Y*=1f+reactionProgress*(currentSquash*0.5f);TargetControl.Scale=squashScale;float currentShakeMultiplier=isStun?1.8f:1f;shake=Mathf.Sin(reactionProgress*Mathf.Pi*(isStun?6f:4f))*(1f-reactionProgress)*0.05f*currentShakeMultiplier;TargetControl.Rotation=shake*30f*Mathf.Pi/180f;if(progress>=1f){phase=ActionPhase.MoveForBack;ResetTimestamp();}break;}case ActionPhase.MoveForBack:{float backProgress=EaseOutQuad(Mathf.Min(progress,1f));Vector2 backPosition=GetMovementPosition(1f-progress);SourceControl.GlobalPosition=backPosition.Lerp(SourcePos,backProgress);SourceControl.Rotation=Mathf.Lerp(attackRotation,SourceRot,backProgress);TargetControl.GlobalPosition=knockbackPos.Lerp(targetOriginalPos,backProgress);TargetControl.Scale=squashScale.Lerp(targetOriginalScale,backProgress);float currentShake=Mathf.Sin(backProgress*Mathf.Pi)*shake*(1f-backProgress);TargetControl.Rotation=Mathf.Lerp(currentShake*30f*Mathf.Pi/180f,TargetRot,backProgress);if(progress>=1f){phase=ActionPhase.Recover;ResetTimestamp();}break;}case ActionPhase.Recover:{float recoverProgress=EaseOutQuad(Mathf.Min(progress,1f));SourceControl.GlobalPosition=SourceControl.GlobalPosition.Lerp(SourcePos,recoverProgress);SourceControl.Rotation=Mathf.Lerp(SourceControl.Rotation,SourceRot,recoverProgress);TargetControl.GlobalPosition=TargetControl.GlobalPosition.Lerp(TargetPos,recoverProgress);TargetControl.Rotation=Mathf.Lerp(TargetControl.Rotation,TargetRot,recoverProgress);TargetControl.Scale=TargetControl.Scale.Lerp(targetOriginalScale,recoverProgress);if(progress>=1f)return true;break;}default:return true;}return false;}/// <summary>Restores the target's captured position, scale and rotation after the attack animation.</summary>
protected override void DoInternalEnd(){if(SourceControl!=null){SourceControl.GlobalPosition=SourcePos;SourceControl.Rotation=SourceRot;}if(TargetControl!=null){TargetControl.GlobalPosition=TargetPos;TargetControl.Rotation=TargetRot;TargetControl.Scale=targetOriginalScale;}}/// <summary> Mirrors the resolved action onto the UI: floating damage / absorbed shield numbers, a colour pulse on the target card and journal rows, so an AI attack reads the same as the player's own attack. Stun actions (a "Turn" above 1) flash the electric zap instead of damage numbers. </summary>
private void LogAttackResult(ChangedAttributes result){var targetName=TranslationServer.Translate(Target.Card.Name).ToString();var isStun=Action!=null&&Action.GetInt("Turn",0)>1;var defenceLost= -result.Target.Defence;if(defenceLost>0){ShowDamageBehaviour.SpawnDefence(defenceLost,TargetControl,TargetControl.GlobalPosition);BattleLogHub.Log(string.Format(Translate("battle/log_shield_absorb"),targetName,defenceLost),UI.BattleLogKind.Shield);}if(isStun){Target.PlayZap();BattleLogHub.Log(string.Format(Translate("battle/log_stun"),targetName,Action.GetInt("Turn",0)),UI.BattleLogKind.Status);return;}if(result.Target.Damage>0){ShowDamageBehaviour.SpawnDamage(result.Target.Damage,TargetControl,TargetControl.GlobalPosition);Target.Flash(UiTokens.DangerText);BattleLogHub.Log(string.Format(Translate("battle/log_damage"),Translate(Action.Name),TranslationServer.Translate(Source.Card.Name).ToString(),targetName,result.Target.Damage),UI.BattleLogKind.Damage);}}private static string Translate(string key)=>TranslationServer.Translate(key).ToString();private void InitializeMovementPattern(){Vector2 direction=(TargetPos-SourcePos).Normalized();Vector2 perpendicular=new Vector2(-direction.Y,direction.X);switch(movementPattern){case MovementPattern.Arc:movementControlPoint=SourcePos+direction*0.5f+perpendicular*2f;break;case MovementPattern.Zigzag:movementControlPoint=SourcePos+direction*0.5f+perpendicular*1.5f;break;case MovementPattern.Spiral:movementControlPoint=TargetPos+perpendicular*1.5f;break;case MovementPattern.Dash:movementControlPoint=SourcePos+direction*0.3f;break;default:movementControlPoint=TargetPos;break;}}private Vector2 GetMovementPosition(float progress){switch(movementPattern){case MovementPattern.Direct:return SourcePos.Lerp(TargetPos,progress);case MovementPattern.Arc:return SourcePos.Lerp(movementControlPoint,progress).Lerp(movementControlPoint.Lerp(TargetPos,progress),progress);case MovementPattern.Zigzag:if(progress<0.5f)return SourcePos.Lerp(movementControlPoint,progress*2f);else return movementControlPoint.Lerp(TargetPos,(progress-0.5f)*2f);case MovementPattern.Spiral:{Vector2 toTarget=TargetPos-SourcePos;float angle=progress*Mathf.Pi*2f;float radius=(1f-progress)*1.5f;Vector2 spiralOffset=new Vector2(Mathf.Cos(angle),Mathf.Sin(angle))*radius;return SourcePos.Lerp(TargetPos,progress)+spiralOffset;}case MovementPattern.Dash:if(progress<0.3f)return SourcePos.Lerp(movementControlPoint,progress/0.3f);else if(progress<0.7f)return movementControlPoint;else return movementControlPoint.Lerp(TargetPos,(progress-0.7f)/0.3f);default:return SourcePos.Lerp(TargetPos,progress);}}private float EaseOutQuad(float t){return 1f-(1f-t)*(1f-t);}private float EaseInOutQuad(float t){return t<0.5f?2f*t*t:1f-Mathf.Pow(-2f*t+2f,2f)/2f;}}}

===== FILE mods/card_battle/src/Actions/PhysicsDamageActionCard.cs =====
using Cthangover.Core.Cards.StatusEffect;using Cthangover.Core.Characters;using Godot;namespace Cthangover.CardBattle.Actions{/// <summary> Executor that applies physical damage from an attack card to a target. Calculates damage as <c>card_attack_multiplier × user_attack_stat</c>, reduces it by the target's current defence value (which is consumed in the process), and applies the remainder to the target's health. Invokes <c>OnTakeDamage</c>/<c>OnDealDamage</c> hooks on both characters' <see cref="StatusEffectQueue"/> so that damage-modifying status effects can intercept and adjust the values before they are applied. Registered in <see cref="CardBattleActionProvider"/> under <c>"physics/attack"</c>. </summary>
public class PhysicsDamageActionCard:ActionBase{/// <summary> Unique identifier registered in <see cref="CardBattleActionProvider"/> as <c>"physics/attack"</c>. </summary>
public override string ID=>"PhysicsDamageActionCard";/// <summary> Resolves the attack: deducts action points from <paramref name="user"/> via <c>CheckRequiredAndUsePoint</c>, computes raw damage from the card's attack multiplier and the user's attack stat, applies status-effect damage hooks, consumes <paramref name="target"/>'s defence shield first, then subtracts remaining damage from health. Returns a <see cref="ChangedAttributes"/> with the actual damage and defence-loss deltas for the floating text display. </summary>
public override ChangedAttributes Execute(ActionCharacter actionCard,Character user,Character target){if(target==null||user==null|| !CheckRequiredAndUsePoint(actionCard,user))return new ChangedAttributes{Result=false};var damageDelta=Mathf.RoundToInt(actionCard.GetFloat("Attack",1f)*user.Attributes.Attack.Value);StatusEffectService.Of(target).OnTakeDamage(user,ref damageDelta);StatusEffectService.Of(user).OnDealDamage(target,ref damageDelta);var defenceDelta=0;var ignoreArmor=user.HasPassiveEffect("IgnoreArmor");if(!ignoreArmor&&target.Attributes.Defence.Value>0){defenceDelta=target.Attributes.Defence.Value>=damageDelta?damageDelta:target.Attributes.Defence.Value;damageDelta-=defenceDelta;}target.Attributes.Defence.Value-=defenceDelta;target.Attributes.Health.Value-=damageDelta;return new ChangedAttributes{Result=true,Target={Damage=damageDelta,Defence= -defenceDelta}};}}}

===== FILE mods/card_battle/src/Actions/PhysicsDefenceAction.cs =====
using Cthangover.Core.Battle;using Cthangover.Core.Battle.Actions;using Cthangover.Core.UI.Theming;using Cthangover.Core.Characters;using Cthangover.TurnBattle;using Cthangover.CardBattle.UI;using Godot;namespace Cthangover.CardBattle.Actions{/// <summary> Animated battle action that plays a self-buff or ally-support animation: the source card briefly rotates toward the target, applies a defence boost via <see cref="ActionExecutorHub"/>, and shows a subtle block/stagger reaction on the target. Used for both <c>ToSelf</c> and <c>ToAlias</c> action types. This is the visual counterpart of <see cref="PhysicsDefenceActionCard"/>. Created by <see cref="CardBattleCore.CreateAnimatedAction"/> for enemy self-buff/support turns. </summary>
public class PhysicsDefenceAction:AbstractBattleAction{private ActionPhase phase=ActionPhase.Prepare;private float defendRotation;private bool effectApplied;private Vector2 targetOriginalScale;private Color targetOriginalColor;private Vector2 blockScale;private Vector2 blockKnockback;private Color blockColor;private float prepareDuration=0.3f;private float attackImpactDuration=0.4f;private float targetReactionDuration=0.3f;private float moveForBackDuration=0.4f;private float recoverDuration=0.3f;/// <summary> Creates a defence/support animation. Both <paramref name="source"/> and <paramref name="target"/> can be the same card (for self-buffs) or different (for ally support). <paramref name="speed"/> multiplies all phase durations. </summary>
public PhysicsDefenceAction(CharacterCardNode source,CharacterCardNode target,ActionCharacter action,float speed=1):base(source,target,action,speed){}/// <summary>Resets the phase clock and captures the target's original scale and colour.</summary>
protected override void DoInternalStart(){Timestamp=Godot.Time.GetTicksUsec()/1_000_000.0;effectApplied=false;phase=ActionPhase.Prepare;targetOriginalScale=TargetControl.Scale;targetOriginalColor=TargetControl.Modulate;}private float GetPhaseProgress(){float elapsed=(float)(Godot.Time.GetTicksUsec()/1_000_000.0-Timestamp)*Speed*(float)Godot.Engine.TimeScale;float duration=GetCurrentPhaseDuration();return Mathf.Clamp(elapsed/duration,0f,1f);}private float GetCurrentPhaseDuration(){switch(phase){case ActionPhase.Prepare:return prepareDuration;case ActionPhase.AttackImpact:return attackImpactDuration;case ActionPhase.TargetReaction:return targetReactionDuration;case ActionPhase.MoveForBack:return moveForBackDuration;case ActionPhase.Recover:return recoverDuration;default:return 1f;}}private void ResetTimestamp(){Timestamp=Godot.Time.GetTicksUsec()/1_000_000.0;}/// <summary>Advances the guard animation: approach, impact, defence stat application, return and recover.</summary>
protected override bool DoInternalAction(){float progress=GetPhaseProgress();switch(phase){case ActionPhase.Prepare:{float prepareProgress=EaseOutQuad(Mathf.Min(progress,1f));SourceControl.Rotation=Mathf.Lerp(SourceRot,TargetRot,prepareProgress);if(progress>=1f){phase=ActionPhase.AttackImpact;ResetTimestamp();defendRotation=(float)GD.RandRange(-15f,15f)*Mathf.Pi/180f;targetOriginalScale=TargetControl.Scale;targetOriginalColor=TargetControl.Modulate;}break;}case ActionPhase.AttackImpact:{float impactProgress=EaseInOutQuad(Mathf.Min(progress*2f,1f));SourceControl.Rotation=Mathf.Lerp(TargetRot,defendRotation,impactProgress);blockScale=targetOriginalScale*(1f+impactProgress*0.1f);TargetControl.Scale=blockScale;blockColor=targetOriginalColor;blockColor.G=Mathf.Min(1f,blockColor.G+impactProgress*0.3f);blockColor.B=Mathf.Min(1f,blockColor.B+impactProgress*0.2f);TargetControl.Modulate=blockColor;if(!effectApplied&&progress>=0.4f){effectApplied=true;var result=ActionExecutorHub.Instance.Execute(Action,Source.Card,Target.Card);Source.UpdateInfo();Target.UpdateInfo();if(result.Result&&result.Target.Defence>0){ShowDamageBehaviour.SpawnDefence(result.Target.Defence,TargetControl,TargetControl.GlobalPosition);Target.Flash(UiTokens.Info);BattleLogHub.Log(string.Format(Translate("battle/log_shield_gain"),TranslationServer.Translate(Target.Card.Name).ToString(),result.Target.Defence),UI.BattleLogKind.Buff);}}if(progress>=1f){phase=ActionPhase.TargetReaction;ResetTimestamp();}break;}case ActionPhase.TargetReaction:{float reactionProgress=EaseInOutQuad(Mathf.Min(progress*1.5f,1f));blockKnockback=TargetPos+(TargetPos-SourcePos).Normalized()*0.1f;TargetControl.GlobalPosition=TargetPos.Lerp(blockKnockback,reactionProgress*0.5f);TargetControl.Scale=blockScale.Lerp(targetOriginalScale,reactionProgress);if(progress>=1f){phase=ActionPhase.MoveForBack;ResetTimestamp();}break;}case ActionPhase.MoveForBack:{float backProgress=EaseOutQuad(Mathf.Min(progress,1f));SourceControl.GlobalPosition=TargetPos.Lerp(SourcePos,backProgress);SourceControl.Rotation=Mathf.Lerp(defendRotation,SourceRot,backProgress);TargetControl.GlobalPosition=TargetControl.GlobalPosition.Lerp(TargetPos,backProgress);TargetControl.Scale=TargetControl.Scale.Lerp(targetOriginalScale,backProgress);Color currentColor=TargetControl.Modulate;TargetControl.Modulate=currentColor.Lerp(targetOriginalColor,backProgress);if(progress>=1f){phase=ActionPhase.Recover;ResetTimestamp();}break;}case ActionPhase.Recover:{float recoverProgress=EaseOutQuad(Mathf.Min(progress,1f));SourceControl.GlobalPosition=SourceControl.GlobalPosition.Lerp(SourcePos,recoverProgress);SourceControl.Rotation=Mathf.Lerp(SourceControl.Rotation,SourceRot,recoverProgress);TargetControl.Rotation=Mathf.Lerp(TargetControl.Rotation,TargetRot,recoverProgress);if(progress>=1f)return true;break;}default:return true;}return false;}/// <summary>Restores the target's captured visual state after the defence animation.</summary>
protected override void DoInternalEnd(){if(SourceControl!=null){SourceControl.GlobalPosition=SourcePos;SourceControl.Rotation=SourceRot;}if(TargetControl!=null){TargetControl.GlobalPosition=TargetPos;TargetControl.Rotation=TargetRot;TargetControl.Scale=targetOriginalScale;TargetControl.Modulate=targetOriginalColor;}}private float EaseOutQuad(float t){return 1f-(1f-t)*(1f-t);}private float EaseInOutQuad(float t){return t<0.5f?2f*t*t:1f-Mathf.Pow(-2f*t+2f,2f)/2f;}private static string Translate(string key)=>TranslationServer.Translate(key).ToString();}}

===== FILE mods/card_battle/src/Actions/PhysicsDefenceActionCard.cs =====
using Cthangover.Core.Characters;namespace Cthangover.CardBattle.Actions{/// <summary> Executor that adds a fixed amount of defence to the target character. Defence acts as a damage-absorbing shield that is consumed before health when the character takes damage (see <see cref="PhysicsDamageActionCard"/>). The defence delta comes from the <c>"Defence"</c> property on the action card. Registered in <see cref="CardBattleActionProvider"/> under <c>"physics/defence"</c>. </summary>
public class PhysicsDefenceActionCard:ActionBase{/// <summary> Unique identifier registered in <see cref="CardBattleActionProvider"/> as <c>"physics/defence"</c>. </summary>
public override string ID=>"PhysicsDefenceActionCard";/// <summary> Adds a flat defence value to <paramref name="target"/> after deducting action points from <paramref name="user"/>. The returned <see cref="ChangedAttributes"/> carries the defence delta for floating text display. </summary>
public override ChangedAttributes Execute(ActionCharacter actionCard,Character user,Character target){if(target==null||user==null|| !CheckRequiredAndUsePoint(actionCard,user))return new ChangedAttributes{Result=false};var defenceDelta=actionCard.GetInt("Defence");target.Attributes.Defence.Value+=defenceDelta;return new ChangedAttributes{Result=true,Target={Defence=defenceDelta}};}}}

===== FILE mods/card_battle/src/Actions/PhysicsStunActionCard.cs =====
using Cthangover.Core.Cards.StatusEffect;using Cthangover.Core.Characters;namespace Cthangover.CardBattle.Actions{/// <summary> Executor that applies a stun status effect to the target character. The stun duration (number of turns) is read from the <c>"Turn"</c> property on the action card. During a stunned character's turn, <see cref="StatusEffectQueue.HasStun"/> returns <c>true</c>, causing <see cref="CardBattleCore.RunEnemyTurn"/> to skip that character's actions entirely. Registered in <see cref="CardBattleActionProvider"/> under <c>"physics/stun"</c>. </summary>
public class PhysicsStunActionCard:ActionBase{/// <summary> Unique identifier registered in <see cref="CardBattleActionProvider"/> as <c>"physics/stun"</c>. </summary>
public override string ID=>"PhysicsStunActionCard";/// <summary> Adds a <see cref="StunStatusEffect"/> with the configured turn count to <paramref name="target"/>'s <see cref="StatusEffectQueue"/> after deducting action points from <paramref name="user"/>. </summary>
public override ChangedAttributes Execute(ActionCharacter actionCard,Character user,Character target){if(target==null||user==null|| !CheckRequiredAndUsePoint(actionCard,user))return new ChangedAttributes{Result=false};if(target.HasPassiveEffect("StunImmune"))return new ChangedAttributes{Result=true};StatusEffectService.Of(target).Add("effect/physics/stun",actionCard.GetInt("Turn",3));return new ChangedAttributes{Result=true};}}}

===== FILE mods/card_battle/src/BattleLogHub.cs =====
using System;using Cthangover.CardBattle.UI;namespace Cthangover.CardBattle{/// <summary> Static sink for battle journal entries. Player action strategies and the battle core run without a reference to the UI screen, so they publish here instead of reaching into the scene tree; the <see cref="BattleLogPanel"/> subscribes while it exists and unsubscribes on tree exit, which keeps the hub empty outside of a battle. This is the same service-locator pattern the battle code already uses for <c>ActionExecutorHub</c> and <c>ShowDamageBehaviour</c>. </summary>
public static class BattleLogHub{/// <summary>Fired for every entry; handlers receive the text and its kind.</summary>
public static event Action<string,BattleLogKind>Entry;/// <summary>Publishes one journal row. No-op when no battle UI is listening.</summary>
public static void Log(string text,BattleLogKind kind=BattleLogKind.Info)=>Entry?.Invoke(text,kind);}}

===== FILE mods/card_battle/src/CardBattleCore.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Threading.Tasks;using Cthangover.Core.Battle;using Cthangover.Core.Battle.Actions;using Cthangover.Core.Cards.StatusEffect;using Cthangover.Core.Scenes;using Cthangover.Core.UI.CharacterPanel;using Cthangover.Core.Utils;using Cthangover.Core.Characters;using Cthangover.CardBattle.Player;using Cthangover.CardBattle.UI;using Godot;namespace Cthangover.CardBattle{/// <summary> Core orchestrator for the card-based turn battle system. Owns the full battle lifecycle: it instantiates the UI screen rebuilt from the mockup (mockups/card_battle, defined in <c>scenes/card_battle_screen.tscn</c>), cycles between player and enemy turns, drives the animated enemy actions through <see cref="AbstractBattleAction"/>, applies status effects each turn and detects win/loss when one side loses all cards. <para/> The core talks to the screen through its assembled widgets (<see cref="CardBattleScreen.Hud"/>, <see cref="BattleLogPanel"/>, <see cref="BattleBanner"/>, panels) instead of walking the scene tree, and publishes journal rows via <see cref="BattleLogHub"/> so other battle code can log without a UI reference. Implements <see cref="IBattleCore"/> so the battle engine can select this mod as the active battle type. </summary>
public class CardBattleCore:IBattleCore,IBattleAutoPlayable{private const int PlayerCardLimit=4;private const float BaseCardWidth=230f;private const float BaseCardHeight=420f;private const float CellOffset=10f;private Character[]_playerChars;private Character[]_enemyChars;private IBattleContext _ctx;private CardBattleScreen _screen;private BattleCardPanel _playerPanel;private BattleCardPanel _enemyPanel;private CardController _cardController;private EndTurnButton _endTurnButton;private BattleSide _currentOrder;private bool _playerTurnActive;private int _round;/// <summary>Unique identifier for this battle core, used by the battle engine to select the card battle mod.</summary>
public string Id=>"card_battle";/// <summary> Provides <see cref="IActionExecutor"/> instances for the three card action types (physics/attack, physics/defence, physics/stun) used during action resolution. </summary>
public IActionExecutorProvider ActionProvider{get;}=new Actions.CardBattleActionProvider();/// <summary> Instantiates the battle screen from <c>scenes/card_battle_screen.tscn</c>, attaches it to the battle scene's <c>Panel</c> and binds the panels, end-turn button and input controller. Enemy characters are deep-copied so defeats are tracked independently per battle instance. The <see cref="CardBattleActionProvider"/> is registered with <see cref="ActionExecutorHub"/>. </summary>
public void Init(Character[]playerChars,Character[]enemyChars,IBattleContext ctx){_playerChars=playerChars;_ctx=ctx;_enemyChars=new Character[enemyChars.Length];for(var i=0;i<enemyChars.Length;i++)_enemyChars[i]=enemyChars[i]?.Copy()??enemyChars[i];ActionExecutorHub.Instance.SetActiveProvider(ActionProvider);var root=ctx.RootNode as Node;var panel=root.GetNodeOrNull<Control>("Panel")??root;_screen=(CardBattleScreen)TscnScenes.LoadAndBuild("scenes/card_battle_screen.tscn");_screen.Name="CardBattleScreen";_screen.EnsureConstructed();panel.AddChild(_screen);_playerPanel=_screen.PlayerPanel;_enemyPanel=_screen.EnemyPanel;_endTurnButton=_screen.EndTurnButton;_endTurnButton.SetVisible(false);_endTurnButton.OnPressed+=OnEndTurnPressed;_cardController=_screen.Controller;_cardController.Setup(_screen);}/// <summary> Calculates the pointer scale fitting both sides on screen, initializes every card and starts the first player turn. Subscribes to <c>OnCardDead</c> to record defeated enemies for the victory screen and to <c>OnActionExecuted</c> to detect automatic turn completion. </summary>
public void Start(){var enemyCount=_enemyChars?.Length??1;_enemyPanel.UseRows=enemyCount>6;var cardScale=CalculateCardScale(enemyCount);_cardController.CardScale=cardScale;_playerPanel.Init(_playerChars,true,cardScale);_enemyPanel.Init(_enemyChars,false,cardScale);_enemyPanel.OnCardDead+=OnEnemyCardDead;_cardController.OnActionExecuted+=CheckPlayerTurnEnd;BattleSceneContext.Instance.OnBattleCleared+=OnBattleCleared;_round=0;_currentOrder=BattleSide.Player;StartPlayerTurn();}private float CalculateCardScale(int enemyCount){var viewport=((Node)_ctx.RootNode).GetViewport().GetVisibleRect().Size;var enemyPanelWidth=viewport.X*0.96f;var playerPanelWidth=viewport.X*0.55f;var enemyPanelHeight=viewport.Y*0.446f;float maxCardScale;if(_enemyPanel.UseRows){var maxPerRow=_enemyPanel.GetMaxCardsPerRow();var rows=_enemyPanel.GetCurrentRowCount(enemyCount);var widthScale=(enemyPanelWidth-(maxPerRow-1)*CellOffset)/(maxPerRow*BaseCardWidth);var heightScale=(enemyPanelHeight-(rows-1)*CellOffset)/(rows*BaseCardHeight);maxCardScale=Mathf.Max(0.1f,Mathf.Min(widthScale,heightScale));}else{var effectiveCount=Mathf.Max(enemyCount,1);maxCardScale=Mathf.Max(0.1f,(enemyPanelWidth-(effectiveCount-1)*CellOffset)/(effectiveCount*BaseCardWidth));}var playerFitScale=Mathf.Max(0.1f,(playerPanelWidth-(PlayerCardLimit-1)*CellOffset)/(PlayerCardLimit*BaseCardWidth));return Mathf.Min(Mathf.Min(maxCardScale,playerFitScale),1f);}private void OnEnemyCardDead(CharacterCardNode card){if(card?.Card==null)return;var ctx=BattleSceneContext.Instance;if(ctx!=null){ctx.RecordEnemyDefeated(card.Card);ctx.NotifyCharacterDied(card.Card);}_screen.Hud.SetEnemiesAlive(CountAlive(_enemyPanel));BattleLogHub.Log(string.Format(Translate("battle/log_dead"),card.Card.Name),BattleLogKind.Dead);}private void StartPlayerTurn(){_round+=1;_currentOrder=BattleSide.Player;_playerTurnActive=true;BattleSceneContext.Instance.IsWait=false;_cardController.ClearSelections();_cardController.ClearActions();_screen.ActionsPanel.ClearBody();_screen.ActionsPanel.ClearSubject();_endTurnButton?.SetVisible(true);foreach(var card in _playerPanel.CardList){StatusEffectService.Of(card.Card).OnTurnStart();card.Card.Attributes.Point.Value=card.Card.Attributes.Point.BaseValue;card.UpdateInfo();}_screen.Hud.SetTurn(true);_screen.Hud.SetRound(_round);_screen.Hud.SetEnemiesAlive(CountAlive(_enemyPanel));_screen.LogPanel.SetRound(_round);_screen.Banner.Show(Translate("battle/turn_player"));BattleLogHub.Log(Translate("battle/log_turn_player")+" · "+string.Format(Translate("battle/log_round"),_round),BattleLogKind.Turn);}private void OnEndTurnPressed()=>OnPlayerTurnEnd();private void CheckPlayerTurnEnd(){if(_currentOrder!=BattleSide.Player)return;if(!HasAliveCards(_enemyPanel)){_ctx.EndBattle(BattleSide.Player);return;}foreach(var card in _playerPanel.CardList){if(!card.IsDead&&card.Card?.Attributes?.Point?.Value>0)return;}BattleLogHub.Log(Translate("battle/log_out_of_points"),BattleLogKind.Info);OnPlayerTurnEnd();}/// <summary> Ends the player's turn: hides the end-turn button, pauses the battle, plays the enemy-turn banner and journal line, waits for two frames plus a short pause so the UI settles, then runs the enemy AI. Ends the battle immediately when no enemies remain. </summary>
public async void OnPlayerTurnEnd(){_playerTurnActive=false;_endTurnButton?.SetVisible(false);BattleSceneContext.Instance.IsWait=true;if(!HasAliveCards(_enemyPanel)){_ctx.EndBattle(BattleSide.Player);return;}_screen.Hud.SetTurn(false);_screen.Banner.Show(Translate("battle/turn_enemy"));BattleLogHub.Log(Translate("battle/log_turn_enemy"),BattleLogKind.Turn);var root=(Node)_ctx.RootNode;await root.ToSignal(root.GetTree(),"process_frame");await root.ToSignal(root.GetTree().CreateTimer(0.5f),"timeout");await RunEnemyTurn();}private async Task RunEnemyTurn(){_currentOrder=BattleSide.Enemy;_endTurnButton?.SetVisible(false);var root=(Node)_ctx.RootNode;foreach(var card in _enemyPanel.CardList.ToList()){if(card.IsDead||card.Card?.Actions==null||card.Card.Actions.Count==0)continue;StatusEffectService.Of(card.Card).OnTurnStart();if(StatusEffectService.Of(card.Card).HasStun()){card.UpdateInfo();card.PlayZap();BattleLogHub.Log(string.Format(Translate("battle/log_stun_skip"),card.Card.Name),BattleLogKind.Status);continue;}card.Card.Attributes.Point.Value=card.Card.Attributes.Point.BaseValue;var action=PickEnemyAction(card);if(action==null)continue;var target=PickEnemyTarget(card,action);if(target==null)continue;card.UpdateInfo();if(action.Type==ActionCharacterType.ToEnemy){BattleLogHub.Log(string.Format(Translate("battle/log_enemy_action"),card.Card.Name,ActionCharacterUi.Translate(action.Name),target.Card.Name),BattleLogKind.Info);}var animatedAction=CreateAnimatedAction(card,target,action);if(animatedAction!=null){animatedAction.DoStart();while(!animatedAction.DoAction())await root.ToSignal(root.GetTree(),"process_frame");animatedAction.DoEnd();}await root.ToSignal(root.GetTree().CreateTimer(0.2f),"timeout");if(!HasAliveCards(_playerPanel)){_ctx.EndBattle(BattleSide.Enemy);return;}}if(!HasAliveCards(_playerPanel)){_ctx.EndBattle(BattleSide.Enemy);return;}StartPlayerTurn();}private ActionCharacter PickEnemyAction(CharacterCardNode card){var actions=card.Card.Actions;if(actions==null||actions.Count==0)return null;return actions[GD.RandRange(0,actions.Count-1)];}private CharacterCardNode PickEnemyTarget(CharacterCardNode source,ActionCharacter action){if(action.Type==ActionCharacterType.ToEnemy){var alivePlayers=_playerPanel.CardList.Where(c=> !c.IsDead&&c.Card?.Attributes?.Health?.Value>0).ToList();if(alivePlayers.Count==0)return null;return alivePlayers[GD.RandRange(0,alivePlayers.Count-1)];}if(action.Type==ActionCharacterType.ToAlias||action.Type==ActionCharacterType.ToSelf)return source;GameLogger.Log("CARD_BATTLE",$"PickEnemyTarget: unsupported action type {action.Type}",LogLevel.Warning);return null;}private Actions.AbstractBattleAction CreateAnimatedAction(CharacterCardNode source,CharacterCardNode target,ActionCharacter action){if(action.Type==ActionCharacterType.ToEnemy)return new Actions.PhysicsAttackAction(source,target,action);if(action.Type==ActionCharacterType.ToAlias||action.Type==ActionCharacterType.ToSelf)return new Actions.PhysicsDefenceAction(source,target,action);GameLogger.Log("CARD_BATTLE",$"CreateAnimatedAction: unsupported action type {action.Type}",LogLevel.Warning);return null;}private static bool HasAliveCards(BattleCardPanel panel){foreach(var card in panel.CardList){if(!card.IsDead&&card.Card?.Attributes?.Health?.Value>0)return true;}return false;}private static int CountAlive(BattleCardPanel panel){var alive=0;foreach(var card in panel.CardList){if(!card.IsDead&&card.Card?.Attributes?.Health?.Value>0)alive++;}return alive;}private void OnBattleCleared(){_screen?.Clear();}private static string Translate(string key)=>TranslationServer.Translate(key).ToString();/// <summary> <c>true</c> between <see cref="StartPlayerTurn"/> and the start of <see cref="OnPlayerTurnEnd"/> — matches the visibility window of the end-turn button, so automation presses exactly one turn per window. </summary>
public bool IsWaitingForPlayerInput=>_playerTurnActive;/// <summary>Automation hook: ends the player's turn without selecting cards.</summary>
public void AdvancePlayerTurn()=>OnPlayerTurnEnd();}}

===== FILE mods/card_battle/src/ICard.cs =====
using Godot;namespace Cthangover.CardBattle{/// <summary> Common contract for every card the input controller can hit-test. Both the character cards on the field and the draggable action cards expose their root <see cref="Control"/>, because layout, scale and the drag ghost all operate on the whole card rect rather than on an inner sprite. </summary>
public interface ICard{/// <summary>Root control used for hit-testing and for reparenting during a drag.</summary>
Control GetControlNode();}}

===== FILE mods/card_battle/src/Player/AttackToEnemyStrategy.cs =====
using Cthangover.CardBattle.UI;using Cthangover.Core.Battle;using Cthangover.Core.Battle.Actions;using Cthangover.Core.UI.Theming;using Cthangover.TurnBattle;using Cthangover.Core.Characters;using Godot;namespace Cthangover.CardBattle.Player{/// <summary> Strategy for <c>ToEnemy</c> actions. Validates that the target is an enemy, highlights both cards with a red attack outline, executes the action via <see cref="ActionExecutorHub"/> and reports the outcome: floating damage / defence-loss numbers on the target, a colour pulse on the card and a journal row through <see cref="BattleLogHub"/>. Stun actions (those with a "Turn" above 1) additionally flash the electric zap over the target instead of printing damage, matching the mockup's stun feedback. </summary>
public class AttackToEnemyStrategy:ICardActionStrategy{/// <summary>Returns <c>true</c> if the action type is <c>ToEnemy</c> and the target is an enemy card.</summary>
public bool Check(ActionCharacter action,CharacterCardNode source,CharacterCardNode target){return action.Type==ActionCharacterType.ToEnemy&&target!=null&& !target.IsPlayer;}/// <summary>Shows a red attack overlay on both the dragged action card and the target enemy card.</summary>
public void HighlightTarget(ActionCardNode actionCard,CharacterCardNode target){actionCard.Attack();target.Attack();}/// <summary> Resolves the attack and publishes its result: damage and absorbed shield as floating numbers plus journal rows, or the stun effect with its zap flash for control actions. </summary>
public void Execute(CharacterCardNode source,CharacterCardNode target,ActionCharacter action){if(source?.Card==null||target?.Card==null||action==null)return;var result=ActionExecutorHub.Instance.Execute(action,source.Card,target.Card);source.UpdateInfo();target.UpdateInfo();if(!result.Result)return;var actionName=Translate(action.Name);var sourceName=Translate(source.Card.Name);var targetName=Translate(target.Card.Name);var isStun=action.GetInt("Turn",0)>1;var defenceLost= -result.Target.Defence;if(defenceLost>0){ShowDamageBehaviour.SpawnDefence(defenceLost,target,target.GlobalPosition);BattleLogHub.Log(string.Format(Translate("battle/log_shield_absorb"),targetName,defenceLost),BattleLogKind.Shield);}if(isStun){target.PlayZap();BattleLogHub.Log(string.Format(Translate("battle/log_stun"),targetName,action.GetInt("Turn",0)),BattleLogKind.Status);return;}if(result.Target.Damage>0){ShowDamageBehaviour.SpawnDamage(result.Target.Damage,target,target.GlobalPosition);target.Flash(UiTokens.DangerText);BattleLogHub.Log(string.Format(Translate("battle/log_damage"),actionName,sourceName,targetName,result.Target.Damage),BattleLogKind.Damage);}else if(defenceLost==0){BattleLogHub.Log(string.Format(Translate("battle/log_absorbed"),actionName,sourceName,targetName),BattleLogKind.Shield);}}private static string Translate(string key)=>TranslationServer.Translate(key).ToString();}}

===== FILE mods/card_battle/src/Player/CardActionStrategyFactory.cs =====
using Cthangover.CardBattle.UI;using Cthangover.Core.Characters;namespace Cthangover.CardBattle.Player{/// <summary> Factory that returns the appropriate <see cref="ICardActionStrategy"/> implementation for a given <see cref="ActionCharacter"/>. Dispatches based on <see cref="ActionCharacterType"/>: <c>ToEnemy</c> → <see cref="AttackToEnemyStrategy"/>, <c>ToAlias</c> → <see cref="SupportStrategy"/>, <c>ToSelf</c> → <see cref="SelfStrategy"/>. Used by <see cref="CardController"/> during drag-and-drop to determine valid targeting behavior and by <see cref="CanTarget"/> for pre-validation. </summary>
public static class CardActionStrategyFactory{/// <summary> Returns the matching strategy for the action's type, or <c>null</c> if any argument is null or the type is unrecognized. </summary>
public static ICardActionStrategy Get(ActionCharacter action,CharacterCardNode source,CharacterCardNode target){if(action==null||source==null||target==null)return null;switch(action.Type){case ActionCharacterType.ToEnemy:return new AttackToEnemyStrategy();case ActionCharacterType.ToAlias:return new SupportStrategy();case ActionCharacterType.ToSelf:return new SelfStrategy();default:return null;}}/// <summary> Convenience method that gets the strategy via <see cref="Get"/> and immediately calls <see cref="ICardActionStrategy.Check"/> on it. Returns <c>false</c> if no strategy matches or any argument is null. </summary>
public static bool CanTarget(ActionCharacter action,CharacterCardNode source,CharacterCardNode target){if(action==null||source==null||target==null)return false;return Get(action,source,target)?.Check(action,source,target)??false;}}}

===== FILE mods/card_battle/src/Player/ICardActionStrategy.cs =====
using Cthangover.CardBattle.UI;using Cthangover.Core.Characters;namespace Cthangover.CardBattle.Player{/// <summary> Strategy interface that defines validation, visual highlighting, and execution for a specific type of card action during the player's drag-and-drop targeting flow. The <see cref="CardActionStrategyFactory"/> dispatches to the correct strategy based on the action's <see cref="ActionCharacterType"/>. <see cref="CardController"/> calls <see cref="Check"/> during drag to validate targets, <see cref="HighlightTarget"/> to show valid/invalid visual feedback, and <see cref="Execute"/> when the card is dropped on a valid target. </summary>
public interface ICardActionStrategy{/// <summary> Validates whether <paramref name="action"/> can be performed by <paramref name="source"/> on <paramref name="target"/>. Called each frame during drag to determine visual feedback. </summary>
bool Check(ActionCharacter action,CharacterCardNode source,CharacterCardNode target);/// <summary> Applies visual highlighting to both the <paramref name="actionCard"/> being dragged and the <paramref name="target"/> character card (e.g. red outline for attack, green for support). </summary>
void HighlightTarget(ActionCardNode actionCard,CharacterCardNode target);/// <summary> Executes the action via <see cref="ActionExecutorHub"/> and spawns floating damage/defence text. Called on successful card drop by <see cref="CardController.OnEndDrag"/>. </summary>
void Execute(CharacterCardNode source,CharacterCardNode target,ActionCharacter action);}}

===== FILE mods/card_battle/src/Player/SelfStrategy.cs =====
using Cthangover.CardBattle.UI;using Cthangover.Core.Battle;using Cthangover.Core.Battle.Actions;using Cthangover.Core.UI.Theming;using Cthangover.TurnBattle;using Cthangover.Core.Characters;using Godot;namespace Cthangover.CardBattle.Player{/// <summary> Strategy for self-targeting actions (<c>ToSelf</c>). Validates that the source and target are the same card, highlights with a green selection outline and applies the effect to the caster. Shield gains print a floating blue number and a journal row; the card pulses to make the buff readable without reading the log. </summary>
public class SelfStrategy:ICardActionStrategy{/// <summary>Returns <c>true</c> only when <paramref name="source"/> and <paramref name="target"/> are the same card.</summary>
public bool Check(ActionCharacter action,CharacterCardNode source,CharacterCardNode target){return source!=null&&source==target;}/// <summary>Shows a green selection overlay on both the dragged action card and the source card.</summary>
public void HighlightTarget(ActionCardNode actionCard,CharacterCardNode target){actionCard.Select();target.Select();}/// <summary>Executes the self-targeted action and reports shields and healing.</summary>
public void Execute(CharacterCardNode source,CharacterCardNode target,ActionCharacter action){if(source?.Card==null||action==null)return;var result=ActionExecutorHub.Instance.Execute(action,source.Card,source.Card);source.UpdateInfo();if(!result.Result)return;if(result.Target.Defence>0){ShowDamageBehaviour.SpawnDefence(result.Target.Defence,source,source.GlobalPosition);source.Flash(UiTokens.Info);BattleLogHub.Log(string.Format(Translate("battle/log_shield_gain"),Translate(source.Card.Name),result.Target.Defence),BattleLogKind.Buff);}if(result.Target.Damage>0)ShowDamageBehaviour.SpawnDamage(result.Target.Damage,source,source.GlobalPosition,true);}private static string Translate(string key)=>TranslationServer.Translate(key).ToString();}}

===== FILE mods/card_battle/src/Player/StunToEnemyStrategy.cs =====
using Cthangover.CardBattle.UI;using Cthangover.Core.Battle.Actions;using Cthangover.Core.Characters;namespace Cthangover.CardBattle.Player{/// <summary> Strategy for stun actions targeting enemies. Extends the standard <c>ToEnemy</c> check by also verifying that the action has at least 2 turns of stun duration (<c>"Turn" > 1</c>). Highlights with red attack overlays and applies the stun effect via <see cref="ActionExecutorHub"/> without spawning damage text (stun has no numerical effect to display). Not currently returned by <see cref="CardActionStrategyFactory"/> — available for future use when stun cards need distinct drag-and-drop behavior from attack cards. </summary>
public class StunToEnemyStrategy:ICardActionStrategy{/// <summary> Returns <c>true</c> when the action targets an enemy and has a turn count greater than 1. </summary>
public bool Check(ActionCharacter action,CharacterCardNode source,CharacterCardNode target){return action.Type==ActionCharacterType.ToEnemy&&target!=null&& !target.IsPlayer&&action.GetInt("Turn",0)>1;}/// <summary> Shows a red attack overlay on both cards, same as <see cref="AttackToEnemyStrategy"/>. </summary>
public void HighlightTarget(ActionCardNode actionCard,CharacterCardNode target){actionCard.Attack();target.Attack();}/// <summary> Applies the stun via <see cref="ActionExecutorHub"/>. Does not spawn damage/defence text. </summary>
public void Execute(CharacterCardNode source,CharacterCardNode target,ActionCharacter action){if(source?.Card==null||target?.Card==null||action==null)return;ActionExecutorHub.Instance.Execute(action,source.Card,target.Card);source.UpdateInfo();target.UpdateInfo();}}}

===== FILE mods/card_battle/src/Player/SupportStrategy.cs =====
using Cthangover.CardBattle.UI;using Cthangover.Core.Battle;using Cthangover.Core.Battle.Actions;using Cthangover.Core.UI.Theming;using Cthangover.TurnBattle;using Cthangover.Core.Characters;using Godot;namespace Cthangover.CardBattle.Player{/// <summary> Strategy for ally-support actions (<c>ToAlias</c>). Validates that the target is a friendly card, highlights with green selection overlays and applies the effect via <see cref="ActionExecutorHub"/>. Shield gains are shown as floating blue numbers, a colour pulse on the ally and a journal row; healing, when the executor produces it, prints a green number. </summary>
public class SupportStrategy:ICardActionStrategy{/// <summary>Returns <c>true</c> when the action type is <c>ToAlias</c> and the target is a player card.</summary>
public bool Check(ActionCharacter action,CharacterCardNode source,CharacterCardNode target){return action.Type==ActionCharacterType.ToAlias&&target!=null&&target.IsPlayer;}/// <summary>Shows a green selection overlay on both the dragged action card and the target ally card.</summary>
public void HighlightTarget(ActionCardNode actionCard,CharacterCardNode target){actionCard.Select();target.Select();}/// <summary>Resolves the support action and reports shields and healing.</summary>
public void Execute(CharacterCardNode source,CharacterCardNode target,ActionCharacter action){if(source?.Card==null||target?.Card==null||action==null)return;var result=ActionExecutorHub.Instance.Execute(action,source.Card,target.Card);source.UpdateInfo();target.UpdateInfo();if(!result.Result)return;if(result.Target.Defence>0){ShowDamageBehaviour.SpawnDefence(result.Target.Defence,target,target.GlobalPosition);target.Flash(UiTokens.Info);BattleLogHub.Log(string.Format(Translate("battle/log_shield_gain"),Translate(target.Card.Name),result.Target.Defence),BattleLogKind.Buff);}if(result.Target.Damage>0)ShowDamageBehaviour.SpawnDamage(result.Target.Damage,target,target.GlobalPosition,true);}private static string Translate(string key)=>TranslationServer.Translate(key).ToString();}}

===== FILE mods/card_battle/src/StatusEffect/IStatusActions.cs =====
namespace Cthangover.CardBattle.StatusEffect{/// <summary> Interface exposed by <see cref="StatusEffectQueue"/> to <see cref="IStatusEffect"/> instances so that status effects can communicate back to the queue during turn lifecycle callbacks. <c>SkipTurn</c> allows an effect (e.g. stun) to mark the character's entire turn as skipped. <c>RemoveStatus</c> allows an effect to self-remove from the queue. </summary>
public interface IStatusActions{/// <summary> Marks the character's current turn as skipped. Called by <see cref="StunStatusEffect.OnTurnStart"/>. </summary>
void SkipTurn();/// <summary> Removes the specified status effect from the queue. Called by effects that want to self-terminate before their turn count expires. </summary>
void RemoveStatus(IStatusEffect status);}}

===== FILE mods/card_battle/src/StatusEffect/IStatusEffect.cs =====
using Cthangover.Core.Characters;namespace Cthangover.CardBattle.StatusEffect{/// <summary> Defines a status effect that is applied to a <see cref="Character"/> during battle and processed each turn by <see cref="StatusEffectQueue"/>. Effects receive callbacks at turn start and turn end, and self-report expiration via <see cref="IsExpired"/> when their <see cref="Turns"/> counter reaches zero. The <see cref="IStatusActions"/> callback parameter allows effects to influence the character's turn (e.g. skip it). </summary>
public interface IStatusEffect{/// <summary>Integer identifier for the effect type (1 = stun). Used for serialization.</summary>
int Type{get;}/// <summary>Remaining turn count before expiry. Decremented by <see cref="StatusEffectQueue"/>.</summary>
int Turns{get;set;}/// <summary>Returns <c>true</c> when <see cref="Turns"/> ≤ 0, signalling removal from the queue.</summary>
bool IsExpired{get;}/// <summary>Called by <see cref="StatusEffectQueue.OnTurnStart"/> before the turn counter is decremented.</summary>
void OnTurnStart(Character character,IStatusActions actions);/// <summary>Called by <see cref="StatusEffectQueue.OnTurnEnd"/> after the character's actions have resolved.</summary>
void OnTurnEnd(Character character,IStatusActions actions);}}

===== FILE mods/card_battle/src/StatusEffect/StatusEffectItem.cs =====
namespace Cthangover.CardBattle.StatusEffect{/// <summary> Data-only container for a status effect's serializable fields (<see cref="Type"/> and <see cref="Turns"/>). Used in <see cref="StatusEffectQueue.Copy"/> to deep-clone status effect state when a character is copied for battle. Cast to <see cref="IStatusEffect"/> when added back to a queue. </summary>
public class StatusEffectItem{/// <summary>Effect type identifier (matches <see cref="IStatusEffect.Type"/>).</summary>
public int Type{get;set;}/// <summary>Remaining turns before expiry.</summary>
public int Turns{get;set;}/// <summary> Creates a field-by-field copy for deep-cloning during character duplication. </summary>
public StatusEffectItem Copy(){return new StatusEffectItem{Type=Type,Turns=Turns};}}}

===== FILE mods/card_battle/src/StatusEffect/StatusEffectQueue.cs =====
using System.Collections.Generic;using Cthangover.Core.Characters;namespace Cthangover.CardBattle.StatusEffect{/// <summary> Manages the collection of <see cref="IStatusEffect"/> instances attached to a single <see cref="Character"/>. Implements <see cref="IStatusActions"/> so that individual effects can call back into the queue (e.g. to skip the turn). During <see cref="OnTurnStart"/>, each effect's callback is invoked and its turn counter is decremented; expired effects are removed. <see cref="SkipTurn"/> is checked by <see cref="CardBattleCore.RunEnemyTurn"/> to skip stunned enemies. Supports deep copying for character duplication via <see cref="Copy"/>. </summary>
public class StatusEffectQueue:IStatusActions{private readonly Character _character;private readonly List<IStatusEffect>_effects=new();/// <summary> Set to <c>true</c> when an effect calls <see cref="IStatusActions.SkipTurn"/> during <see cref="OnTurnStart"/>. Reset to <c>false</c> at the start of each turn before effects are processed. </summary>
public bool SkipTurn{get;private set;}/// <summary> Creates a new queue bound to the given <paramref name="character"/>. </summary>
public StatusEffectQueue(Character character){_character=character;}/// <summary> Creates a deep copy of this queue bound to <paramref name="newCharacter"/>. <see cref="StatusEffectItem"/> effects are field-copied; other effect types are shared by reference. </summary>
public StatusEffectQueue Copy(Character newCharacter){var clone=new StatusEffectQueue(newCharacter);foreach(var effect in _effects){if(effect is StatusEffectItem item)clone._effects.Add(item.Copy()as IStatusEffect);else clone._effects.Add(effect);}return clone;}/// <summary> Adds a new status effect to the end of the processing list. Effects are processed in reverse order during turn callbacks (last-added first). </summary>
public void AddEffect(IStatusEffect effect){_effects.Add(effect);}/// <summary> Processes all effects at the start of the character's turn: resets <see cref="SkipTurn"/>, calls <see cref="IStatusEffect.OnTurnStart"/> for each effect in reverse order, decrements the turn counter, and removes expired effects. Called by <see cref="CardBattleCore.StartPlayerTurn"/> and <see cref="CardBattleCore.RunEnemyTurn"/>. </summary>
public void OnTurnStart(){SkipTurn=false;for(int i=_effects.Count-1;i>=0;i--){var effect=_effects[i];effect.OnTurnStart(_character,this);effect.Turns--;if(effect.IsExpired)_effects.RemoveAt(i);}}/// <summary> Processes all effects at the end of the character's turn: calls <see cref="IStatusEffect.OnTurnEnd"/> in reverse order and removes expired effects. </summary>
public void OnTurnEnd(){for(int i=_effects.Count-1;i>=0;i--){var effect=_effects[i];effect.OnTurnEnd(_character,this);if(effect.IsExpired)_effects.RemoveAt(i);}}void IStatusActions.SkipTurn(){SkipTurn=true;}void IStatusActions.RemoveStatus(IStatusEffect status){_effects.Remove(status);}}}

===== FILE mods/card_battle/src/StatusEffect/StunStatusEffect.cs =====
using Cthangover.Core.Characters;namespace Cthangover.CardBattle.StatusEffect{/// <summary> Status effect that causes a character to skip their entire turn. On <see cref="OnTurnStart"/>, calls <see cref="IStatusActions.SkipTurn"/> which sets <see cref="StatusEffectQueue.SkipTurn"/> to <c>true</c>. <see cref="CardBattleCore.RunEnemyTurn"/> checks this flag and skips the character's action selection and resolution entirely. Created by <see cref="PhysicsStunActionCard"/> when a stun card is played, and added to the target's <see cref="StatusEffectQueue"/> via <c>Add("effect/physics/stun", turns)</c>. </summary>
public class StunStatusEffect:IStatusEffect{/// <summary>Type identifier for stun (1).</summary>
public int Type=>1;/// <summary>Number of turns this stun remains active.</summary>
public int Turns{get;set;}/// <summary>Expired when the turn counter reaches zero or below.</summary>
public bool IsExpired=>Turns<=0;/// <summary> Creates a stun effect lasting for <paramref name="turns"/> turns. </summary>
public StunStatusEffect(int turns){Turns=turns;}/// <summary> Parameterless constructor for deserialization. Sets <see cref="Turns"/> to 0 (expired immediately). </summary>
public StunStatusEffect(){}/// <summary> Calls <see cref="IStatusActions.SkipTurn"/> to mark the character's turn as skipped. </summary>
public void OnTurnStart(Character character,IStatusActions actions){actions.SkipTurn();}/// <summary> Stun has no end-of-turn effect. </summary>
public void OnTurnEnd(Character character,IStatusActions actions){}}}

===== FILE mods/card_battle/src/UI/ActionCardNode.cs =====
using Cthangover.Core.Characters;using Cthangover.Core.UI.CharacterPanel;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.CardBattle.UI{/// <summary> Draggable action card from the interface mockup (mockups/card_battle): the horizontal kit card with a type-coloured accent strip, action icon, name and ID, badges (targeting type, action-point cost, stun turns) and a property-chip row. The node tree lives in <c>scenes/action_card.tscn</c>; this class fetches the nodes, dresses them and fills the dynamic badge / chip rows using the shared <see cref="ActionCharacterUi"/> presentation rules, so the battle UI can never disagree with the character panel about an action's colour, caption or cost. <para/> Implements <see cref="ICard"/> so the input controller can hit-test drag targets; the hover style, disabled state and the preview builder (<see cref="DescribeFrom"/>) are the only UI surface the controller and the actions panel need. </summary>
public partial class ActionCardNode:ModWidget,ICard{private PanelContainer _panel;private ColorRect _accent;private TextureRect _icon;private Label _nameLabel;private Label _idLabel;private HBoxContainer _badges;private HBoxContainer _props;private StyleBoxFlat _idleStyle;private StyleBoxFlat _hoverStyle;private bool _disabled;/// <summary>The action data model backing this card.</summary>
public ActionCharacter Card{get;set;}/// <summary>True while the character cannot pay the card's action-point cost.</summary>
public bool IsDisabled=>_disabled;/// <summary>Raised when the cursor enters the card; the actions panel uses it to print the preview.</summary>
public event System.Action<ActionCardNode>Hovered;/// <summary>Raised when the cursor leaves the card; the preview returns to its default text.</summary>
public event System.Action<ActionCardNode>Unhovered;/// <inheritdoc />
public Control GetControlNode()=>this;/// <summary>Builds the action card visuals: artwork, name label and point cost chip from the bound ActionCharacter.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);_panel=GetNode<PanelContainer>("Panel");_accent=GetNode<ColorRect>("Panel/Row/Accent");_icon=GetNode<TextureRect>("Panel/Row/IconWrap/Icon");_nameLabel=GetNode<Label>("Panel/Row/Body/Column/NameRow/NameLabel");_idLabel=GetNode<Label>("Panel/Row/Body/Column/NameRow/IdLabel");_badges=GetNode<HBoxContainer>("Panel/Row/Body/Column/Badges");_props=GetNode<HBoxContainer>("Panel/Row/Body/Column/Props");_idleStyle=UiKit.Frame(UiTokens.RadiusMd,UiTokens.Surface1,UiTokens.LineCard);_hoverStyle=UiKit.Frame(UiTokens.RadiusMd,UiTokens.SurfaceHover,UiTokens.LineStrong);_panel.AddThemeStyleboxOverride("panel",_idleStyle);_nameLabel.AddThemeColorOverride("font_color",UiTokens.InkBright);_idLabel.AddThemeColorOverride("font_color",UiTokens.Faint);MouseEntered+=OnMouseEntered;MouseExited+=OnMouseExited;}/// <summary> Fills the card from the action definition: icon, name, ID, accent strip, badges and property chips. Called by the input controller when the action deck of a character is opened. </summary>
public void Init(ActionCharacter card){Card=card;if(_icon!=null&&card?.Image!=null)_icon.Texture=card.Image;_accent.Color=ActionCharacterUi.AccentColor(card.Type);_nameLabel.Text=ActionCharacterUi.Translate(card.Name);_idLabel.Text=card.ID;TooltipText=ActionCharacterUi.BuildTooltip(card);BuildBadges();BuildProps();}/// <summary>Refreshes the texts of an already initialised card.</summary>
public void UpdateInfo(){if(Card==null)return;_nameLabel.Text=ActionCharacterUi.Translate(Card.Name);}/// <summary> Dims the card when the selected character cannot pay its cost; hover feedback is suppressed so the disabled state reads as inert. </summary>
public void SetDisabled(bool disabled){_disabled=disabled;Modulate=UiTokens.Alpha(UiTokens.White,disabled?0.45f:1f);_panel.AddThemeStyleboxOverride("panel",_idleStyle);}/// <summary> Builds the hint printed in the actions panel when the card is hovered: the expected effect computed from the source character's stats ("Урон 5 (АТК 5 × 1.0) · расход 1 ОД"). </summary>
public string DescribeFrom(Character source){if(Card==null||source?.Attributes==null)return string.Empty;var cost=ActionCharacterUi.CostOf(Card);if(Card.Type==ActionCharacterType.ToEnemy&&Card.GetInt("Turn",0)>1)return string.Format(Translate("battle/preview_stun"),Card.GetInt("Turn",0),cost);if(Card.Type==ActionCharacterType.ToEnemy){var multiplier=Card.GetFloat("Attack",1f);var damage=Mathf.RoundToInt(multiplier*source.Attributes.Attack.Value);return string.Format(Translate("battle/preview_damage"),damage,source.Attributes.Attack.Value,multiplier.ToString("0.0"),cost);}return string.Format(Translate("battle/preview_defence"),Card.GetInt("Defence"),cost);}/// <summary>Placeholder for the source-card highlight; the drag ghost covers it.</summary>
public void Select(){}/// <summary>Placeholder: target highlighting is applied by the action strategy.</summary>
public void Attack(){}/// <summary>Placeholder: validity is painted on the target card instead.</summary>
public void Invalid(){}/// <summary>Restores the neutral Z-index after a cancelled drag.</summary>
public void Unselect(){ZIndex=0;}private void BuildBadges(){foreach(var child in _badges.GetChildren()){_badges.RemoveChild(child);child.QueueFree();}var accent=ActionCharacterUi.AccentColor(Card.Type);_badges.AddChild(ActionCharacterUi.MakeBadge(ActionCharacterUi.Translate(ActionCharacterUi.TypeLabelKey(Card.Type)).ToUpperInvariant(),accent));var cost=ActionCharacterUi.CostText(Card);if(!string.IsNullOrEmpty(cost))_badges.AddChild(ActionCharacterUi.MakeBadge(cost,UiTokens.Info));var turns=Card.GetInt("Turn",0);if(turns>1&&Card.Type==ActionCharacterType.ToEnemy)_badges.AddChild(ActionCharacterUi.MakeBadge(string.Format(Translate("battle/turns_badge"),turns),UiTokens.Cooldown));}private void BuildProps(){foreach(var child in _props.GetChildren()){_props.RemoveChild(child);child.QueueFree();}foreach(var pair in ActionCharacterUi.SortedProperties(Card))_props.AddChild(ActionCharacterUi.MakePropertyChip(pair.Key,pair.Value));}private void OnMouseEntered(){if(!_disabled)_panel.AddThemeStyleboxOverride("panel",_hoverStyle);Hovered?.Invoke(this);}private void OnMouseExited(){_panel.AddThemeStyleboxOverride("panel",_idleStyle);Unhovered?.Invoke(this);}private static string Translate(string key)=>TranslationServer.Translate(key).ToString();}}

===== FILE mods/card_battle/src/UI/BattleActionsPanel.cs =====
using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.CardBattle.UI{/// <summary> "Действия" window from the interface mockup (mockups/card_battle): the lower-right panel that opens when a party card is clicked. Layout lives in <c>scenes/battle_actions_panel.tscn</c>: a header with the character name and action-point pips, a two-column grid that receives the <see cref="ActionCardNode"/> instances, a description well and a footer hint. The class owns only content: it repaints pips, swaps the empty state and prints the hovered action's numbers. </summary>
public partial class BattleActionsPanel:ModWidget{private const int PipSize=11;private Label _subject;private HBoxContainer _pips;private Label _apHint;private Label _emptyLabel;private GridContainer _grid;private PanelContainer _descWell;private Label _descLabel;/// <summary>Container that receives action cards; owned by the input controller.</summary>
public Control Body=>_grid;/// <summary>Builds the bottom action bar shown while a card is selected.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);_subject=GetNode<Label>("Window/Column/Header/HeaderRow/SubjectLabel");_pips=GetNode<HBoxContainer>("Window/Column/Header/HeaderRow/Pips");_apHint=GetNode<Label>("Window/Column/Header/HeaderRow/ApHint");_emptyLabel=GetNode<Label>("Window/Column/Body/BodyColumn/EmptyLabel");_grid=GetNode<GridContainer>("Window/Column/Body/BodyColumn/Grid");_descWell=GetNode<PanelContainer>("Window/Column/DescWrap/DescWell");_descLabel=GetNode<Label>("Window/Column/DescWrap/DescWell/DescLabel");var windowStyle=UiKit.Frame(UiTokens.RadiusLg,UiTokens.Alpha(UiTokens.Surface0,0.84f),UiTokens.Line);windowStyle.ContentMarginLeft=0;windowStyle.ContentMarginRight=0;windowStyle.ContentMarginTop=0;windowStyle.ContentMarginBottom=0;windowStyle.ShadowColor=new Color(0f,0f,0f,0.6f);windowStyle.ShadowSize=18;windowStyle.ShadowOffset=new Vector2(0f,8f);GetNode<PanelContainer>("Window").AddThemeStyleboxOverride("panel",windowStyle);foreach(var path in new[]{"Window/Column/Header/HeaderRow/TitleLabel","Window/Column/Header/HeaderRow/ApCaption","Window/Column/Body/BodyColumn/EmptyLabel","Window/Column/Footer/FooterRow/HintLabel","Window/Column/Footer/FooterRow/AutoEndLabel",}){var label=GetNode<Label>(path);label.Text=Translate(label.Text);}GetNode<Label>("Window/Column/Header/HeaderRow/TitleLabel").AddThemeColorOverride("font_color",UiTokens.Muted);GetNode<Label>("Window/Column/Header/HeaderRow/ApCaption").AddThemeColorOverride("font_color",UiTokens.Muted);_apHint.AddThemeColorOverride("font_color",UiTokens.Faint);_emptyLabel.AddThemeColorOverride("font_color",UiTokens.Faint);GetNode<Label>("Window/Column/Footer/FooterRow/HintLabel").AddThemeColorOverride("font_color",UiTokens.Faint);GetNode<Label>("Window/Column/Footer/FooterRow/AutoEndLabel").AddThemeColorOverride("font_color",UiTokens.Faint);var descStyle=UiKit.Frame(UiTokens.RadiusMd,UiTokens.Surface2,UiTokens.Line);descStyle.ContentMarginLeft=12;descStyle.ContentMarginRight=12;descStyle.ContentMarginTop=10;descStyle.ContentMarginBottom=10;_descWell.AddThemeStyleboxOverride("panel",descStyle);ClearSubject();}/// <summary>Shows the selected character's name in the header.</summary>
public void SetSubject(string name){_subject.RemoveThemeColorOverride("font_color");_subject.AddThemeColorOverride("font_color",UiTokens.InkBright);_subject.Text=name;}/// <summary>Restores the "no character selected" header and the empty state.</summary>
public void ClearSubject(){_subject.AddThemeColorOverride("font_color",UiTokens.Faint);_subject.Text=Translate("battle/no_character");_pips.Visible=false;_apHint.Text=string.Empty;SetDescriptionDefault();}/// <summary>Repaints the action-point pips for the selected character.</summary>
public void SetPoints(int current,int basePoints){_pips.Visible=true;foreach(var child in _pips.GetChildren()){_pips.RemoveChild(child);child.QueueFree();}for(var i=0;i<basePoints;i++){var on=i<current;var pip=new Panel{MouseFilter=MouseFilterEnum.Ignore,CustomMinimumSize=new Vector2(PipSize,PipSize),};pip.AddThemeStyleboxOverride("panel",UiKit.Frame(PipSize/2,on?UiTokens.AccentBright:UiTokens.LineDisabled,on?UiTokens.Accent:UiTokens.LineFaint));_pips.AddChild(pip);}_apHint.Text=current+" / "+basePoints;}/// <summary>Frees every action card and restores the empty state.</summary>
public void ClearBody(){foreach(var child in _grid.GetChildren()){_grid.RemoveChild(child);child.QueueFree();}RefreshEmpty();}/// <summary>Hides the empty state as soon as the grid holds action cards.</summary>
public void RefreshEmpty()=>_emptyLabel.Visible=_grid.GetChildCount()==0;/// <summary>Prints the hovered action preview ("Урон 5 · расход 1 ОД") in the well.</summary>
public void SetDescription(string text){_descLabel.AddThemeColorOverride("font_color",UiTokens.Muted);_descLabel.Text=text;}/// <summary>Restores the well's invitation text.</summary>
public void SetDescriptionDefault(){_descLabel.AddThemeColorOverride("font_color",UiTokens.Faint);_descLabel.Text=Translate("battle/desc_default");}private static string Translate(string key)=>TranslationServer.Translate(key).ToString();}}

===== FILE mods/card_battle/src/UI/BattleBanner.cs =====
using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.CardBattle.UI{/// <summary> Centre-screen turn banner ("Ход игрока" / "Ход врага") from the interface mockup (mockups/card_battle). The plate lives in <c>scenes/battle_banner.tscn</c>; the class only repaints the caption and runs the fade-in / hold / fade-out tween. Tweens are driven by the process delta, so the battle speed control scales them together with the action animations. </summary>
public partial class BattleBanner:ModWidget{private const float FadeInSeconds=0.25f;private const float HoldSeconds=1.0f;private const float FadeOutSeconds=0.3f;private PanelContainer _plate;private Label _label;private Tween _tween;/// <summary>Builds the centre-screen turn/victory banner label.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);_plate=GetNode<PanelContainer>("Plate");_label=GetNode<Label>("Plate/Label");var style=UiKit.Frame(0,UiTokens.Alpha(UiTokens.Page,0.62f),new Color(0f,0f,0f,0f));style.BorderWidthTop=1;style.BorderWidthBottom=1;style.BorderColor=UiTokens.AccentLine;style.ContentMarginLeft=44;style.ContentMarginRight=44;style.ContentMarginTop=12;style.ContentMarginBottom=12;_plate.AddThemeStyleboxOverride("panel",style);_label.AddThemeColorOverride("font_color",UiTokens.InkBright);_plate.Modulate=UiTokens.Alpha(UiTokens.White,0f);}/// <summary>Pops the banner with the given caption, then fades it away.</summary>
public void Show(string text){_label.Text=text;_tween?.Kill();_plate.Modulate=UiTokens.Alpha(UiTokens.White,0f);_tween=CreateTween();_tween.TweenProperty(_plate,"modulate:a",1f,FadeInSeconds).SetTrans(Tween.TransitionType.Cubic);_tween.TweenInterval(HoldSeconds);_tween.TweenProperty(_plate,"modulate:a",0f,FadeOutSeconds).SetTrans(Tween.TransitionType.Cubic);}/// <summary>Hides the banner immediately (battle cleared).</summary>
public void Clear(){_tween?.Kill();_plate.Modulate=UiTokens.Alpha(UiTokens.White,0f);}}}

===== FILE mods/card_battle/src/UI/BattleCardPanel.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Characters;using Cthangover.Core.Scenes;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.View;namespace Cthangover.CardBattle.UI{/// <summary> Container panel that manages a list of <see cref="CharacterCardNode"/> instances for one side of the battle (player or enemy). Handles card creation, multi-row layout when enemy count exceeds <c>MAX_CARDS_ROW</c> (10 per row), smooth animated repositioning, health-triggered death, and the <see cref="CardDeathAnimation"/> dissolve sequence. Two instances are created by <see cref="CardBattleCore.Init"/>: a left-aligned player panel and a right-aligned enemy panel. </summary>
public partial class BattleCardPanel:ModWidget{private const int MAX_CARDS_ROW=10;private const float BASE_CARD_HEIGHT=280f;[Export]private Control content;/// <summary> Controls card alignment within the panel. <c>"Left"</c> for the player side, <c>"Right"</c> for the enemy side. Affects <see cref="CalcTargetPosition"/>. </summary>
[Export]public string AlignType{get;set;}="Left";[Export]private float cellOffset=10f;/// <summary> All character cards currently managed by this panel. Populated by <see cref="Init"/> and modified by <see cref="Dead"/> (removal) and <see cref="ClearAll"/> (full clear). </summary>
public List<CharacterCardNode>CardList{get;}=new();private Dictionary<CharacterCardNode,Tween>activeTweens=new();/// <summary> When <c>true</c>, cards are laid out in multiple rows (up to 3) instead of a single row. Set by <see cref="CardBattleCore.Start"/> when enemy count exceeds 6. </summary>
public bool UseRows{get;set;}/// <summary> Fired when a card's death animation completes. <see cref="CardBattleCore"/> subscribes to record the defeated character in <see cref="BattleSceneContext"/>. </summary>
public event System.Action<CharacterCardNode>OnCardDead;/// <summary>Builds the card grid container rows for one battle side.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);content??=GetNodeOrNull<Control>("Content");}/// <summary> Clears the current card list and creates new <see cref="CharacterCardNode"/> instances for each <paramref name="cards"/> entry. Sets player/enemy team colors and subscribes the health-change handler. Returns the created card list. Called by <see cref="CardBattleCore.Start"/> with the full character roster for each side. </summary>
public List<CharacterCardNode>Init(IEnumerable<Character>cards,bool isPlayer,float cardScale){CardList.Clear();activeTweens.Clear();int idx=0;foreach(var card in cards){CardList.Add(Create(card,isPlayer,cardScale));idx++;}Redraw(animate:false);return CardList;}/// <summary> Instantiates a single <see cref="CharacterCardNode"/>, adds it to the content panel, sets its scale, initializes it with card data, applies team colors, and hooks the health-change event. Called by <see cref="Init"/> for each character. </summary>
public CharacterCardNode Create(Character card,bool isPlayer,float cardScale){var behaviour=(CharacterCardNode)TscnScenes.LoadAndBuild("scenes/character_card.tscn");behaviour.EnsureConstructed();if(content!=null)content.AddChild(behaviour);else AddChild(behaviour);behaviour.Scale=new Vector2(cardScale,cardScale);behaviour.Init(card);behaviour.IsPlayer=isPlayer;behaviour.SetTeamColors();if(card!=null)card.Attributes.Health.OnChange+=CheckHealthAttribute;return behaviour;}private void CheckHealthAttribute(float value,float basevalue){if(value>0)return;foreach(var cardItem in CardList.ToList()){if(cardItem.Card.Attributes.Health.Value<=0)Dead(cardItem);}}/// <summary> Frees all card nodes from the scene tree, clears the card list, and triggers a redraw. Called by <see cref="CardBattleCore.OnBattleCleared"/> when the battle scene is cleaned up. </summary>
public void ClearAll(){foreach(var card in CardList.ToList()){if(GodotObject.IsInstanceValid(card))card.QueueFree();}CardList.Clear();activeTweens.Clear();Redraw(animate:false);}/// <summary> Recalculates and applies positions for all cards in the list. When <paramref name="animate"/> is <c>true</c>, cards tween to their new positions with a cubic ease-out; otherwise they snap instantly. Called after cards are added, removed, or when a selection/unselection changes the sort order. </summary>
public void Redraw(bool animate=true){for(int i=0;i<CardList.Count;i++){var card=CardList[i];if(card==null)continue;var(row,col)=GetRowCol(i);SetCardPosition(card,i,row,col,animate);}}private(int row,int col)GetRowCol(int index){if(!UseRows||index<MAX_CARDS_ROW)return(0,index);if(index<MAX_CARDS_ROW*2)return(1,index-MAX_CARDS_ROW);return(2,index-MAX_CARDS_ROW*2);}private int CalcBaseZIndex(int row,int col){return row*20+col;}private Vector2 CalcTargetPosition(CharacterCardNode card,int row,int col){var panelSize=Size;float effectiveWidth=card.Size.X*card.Scale.X;float effectiveHeight=card.Size.Y*card.Scale.Y;float y=row*(effectiveHeight+cellOffset);if(AlignType=="Left")return new Vector2((cellOffset+effectiveWidth)*col,y);else return new Vector2(panelSize.X-(cellOffset+effectiveWidth)*(col+1),y);}/// <summary> Calculates how many rows are needed to display <paramref name="enemyCount"/> enemies when <see cref="UseRows"/> is enabled. Returns 1, 2, or 3. Called by <see cref="CardBattleCore.CalculateCardScale"/> to determine maximum card scale. </summary>
public int GetCurrentRowCount(int enemyCount){if(!UseRows)return 1;if(enemyCount<=MAX_CARDS_ROW)return 1;if(enemyCount<=MAX_CARDS_ROW*2)return 2;return 3;}/// <summary> Returns the maximum cards per row constant (<c>MAX_CARDS_ROW = 10</c>). Used by <see cref="CardBattleCore.CalculateCardScale"/> for scale calculations. </summary>
public int GetMaxCardsPerRow(){return MAX_CARDS_ROW;}private void SetCardPosition(CharacterCardNode card,int index,int row,int col,bool animate){if(card==null)return;var targetPos=CalcTargetPosition(card,row,col);if(activeTweens.TryGetValue(card,out var prevTween)&&prevTween!=null&&prevTween.IsValid())prevTween.Kill();if(animate){var tween=CreateTween();tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);tween.TweenProperty(card,"position",targetPos,0.3f);activeTweens[card]=tween;}else{card.Position=targetPos;activeTweens.Remove(card);}var baseZ=CalcBaseZIndex(row,col);card.SetBaseZIndex(baseZ);if(content!=null)content.MoveChild(card,index);else MoveChild(card,index);card.UpdateInfo();}/// <summary> Marks the card as dead and starts the <see cref="CardDeathAnimation"/> dissolve sequence. Once the animation completes, the card is removed from <see cref="CardList"/>, <see cref="OnCardDead"/> is fired, and the remaining cards are redrawn. Triggered by the health-change handler when a character's health reaches zero. </summary>
public void Dead(CharacterCardNode characterCardNode){if(characterCardNode==null||characterCardNode.IsDead)return;characterCardNode.IsDead=true;var animator=new Cthangover.CardBattle.UI.CardDeathAnimation();characterCardNode.AddChild(animator);animator.StartAnimation(characterCardNode,()=>{OnCardDead?.Invoke(characterCardNode);CardList.Remove(characterCardNode);activeTweens.Remove(characterCardNode);Redraw();});}}}

===== FILE mods/card_battle/src/UI/BattleHud.cs =====
using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.CardBattle.UI{/// <summary> Battle header from the interface mockup (mockups/card_battle): a compact window in the upper left with the battle title, a phase badge that flips between player and enemy turns, the round counter and the number of enemies still standing. The node tree lives in <c>scenes/battle_hud.tscn</c>; this class only fetches the nodes and repaints the badge for the current phase. </summary>
public partial class BattleHud:ModWidget{private Label _phaseLabel;private PanelContainer _phaseBadge;private Label _roundValue;private Label _enemiesValue;/// <summary>Builds the battle HUD frame: turn indicator, speed control and log button.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);_phaseBadge=GetNode<PanelContainer>("Panel/Column/TitleRow/PhaseBadge");_phaseLabel=GetNode<Label>("Panel/Column/TitleRow/PhaseBadge/PhaseLabel");_roundValue=GetNode<Label>("Panel/Column/MetaRow/RoundValue");_enemiesValue=GetNode<Label>("Panel/Column/MetaRow/EnemiesValue");var panelStyle=UiKit.Frame(UiTokens.RadiusLg,UiTokens.Alpha(UiTokens.Surface0,0.86f),UiTokens.Line);panelStyle.ContentMarginLeft=18;panelStyle.ContentMarginRight=18;panelStyle.ContentMarginTop=12;panelStyle.ContentMarginBottom=13;panelStyle.ShadowColor=new Color(0f,0f,0f,0.6f);panelStyle.ShadowSize=18;panelStyle.ShadowOffset=new Vector2(0f,8f);GetNode<PanelContainer>("Panel").AddThemeStyleboxOverride("panel",panelStyle);var title=GetNode<Label>("Panel/Column/TitleRow/TitleLabel");title.Text=Translate(title.Text);title.AddThemeColorOverride("font_color",UiTokens.InkBright);foreach(var caption in new[]{"RoundCaption","EnemiesCaption"}){var label=GetNode<Label>("Panel/Column/MetaRow/"+caption);label.Text=Translate(label.Text);label.AddThemeColorOverride("font_color",UiTokens.Muted);}_phaseLabel.Text=Translate(_phaseLabel.Text);_roundValue.AddThemeColorOverride("font_color",UiTokens.AccentBright);_enemiesValue.AddThemeColorOverride("font_color",UiTokens.AccentBright);}/// <summary> Repaints the phase badge: blue for the player phase, red for the enemy phase — the same colour language as the targeting highlights. </summary>
public void SetTurn(bool player){var accent=player?UiTokens.Info:UiTokens.TypeEnemy;var style=UiKit.Frame(UiTokens.RadiusXs,UiTokens.BadgeFill(accent),UiTokens.BadgeLine(accent));style.ContentMarginLeft=7;style.ContentMarginRight=7;style.ContentMarginTop=2;style.ContentMarginBottom=2;_phaseBadge.AddThemeStyleboxOverride("panel",style);_phaseLabel.AddThemeColorOverride("font_color",accent);_phaseLabel.Text=Translate(player?"battle/turn_player":"battle/turn_enemy");}/// <summary>Writes the round number into the meta row.</summary>
public void SetRound(int round)=>_roundValue.Text=round.ToString();/// <summary>Writes the number of enemies still alive into the meta row.</summary>
public void SetEnemiesAlive(int count)=>_enemiesValue.Text=count.ToString();private static string Translate(string key)=>TranslationServer.Translate(key).ToString();}}

===== FILE mods/card_battle/src/UI/BattleLogKind.cs =====
namespace Cthangover.CardBattle.UI{/// <summary> Semantic category of a battle journal row, mirrored from the mockup's colour scheme (mockups/card_battle). The panel maps each kind to a left border accent, a background tint and a text colour, so callers only decide what happened, never how it is painted. </summary>
public enum BattleLogKind{/// <summary>Neutral note: enemy intent, hints, odds and ends.</summary>
Info,/// <summary>Turn or battle boundary (gold, highlighted row).</summary>
Turn,/// <summary>Health loss (red).</summary>
Damage,/// <summary>Shield gain or absorption (blue).</summary>
Shield,/// <summary>Support buff (green).</summary>
Buff,/// <summary>Status effect such as stun (warm).</summary>
Status,/// <summary>Character defeat (faint).</summary>
Dead,}}

===== FILE mods/card_battle/src/UI/BattleLogPanel.cs =====
using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.CardBattle.UI{/// <summary> Battle journal docked between the player and action panels — the "Журнал боя" window from the interface mockup (mockups/card_battle). The node tree lives in <c>scenes/battle_log_panel.tscn</c>; rows are created at runtime because they are data, not layout, and are capped to <see cref="MaxRows"/> so a long battle cannot grow unbounded. <para/> The panel is the single subscriber of <see cref="BattleLogHub"/>, so any battle code can publish entries without knowing about the UI. The subscription is dropped on <see cref="Destruct"/> to keep the static hub clean between battles. </summary>
public partial class BattleLogPanel:ModWidget{private const int MaxRows=80;private Label _title;private Label _roundLabel;private ScrollContainer _scroll;private VBoxContainer _rows;/// <summary>Builds the scrollable battle log with coloured entries per <see cref="BattleLogKind"/>.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);_title=GetNode<Label>("Window/Column/Header/HeaderRow/TitleLabel");_roundLabel=GetNode<Label>("Window/Column/Header/HeaderRow/RoundLabel");_scroll=GetNode<ScrollContainer>("Window/Column/Scroll");_rows=GetNode<VBoxContainer>("Window/Column/Scroll/Rows");var windowStyle=UiKit.Frame(UiTokens.RadiusLg,UiTokens.Alpha(UiTokens.Surface0,0.84f),UiTokens.Line);windowStyle.ContentMarginLeft=0;windowStyle.ContentMarginRight=0;windowStyle.ContentMarginTop=0;windowStyle.ContentMarginBottom=0;windowStyle.ShadowColor=new Color(0f,0f,0f,0.6f);windowStyle.ShadowSize=18;windowStyle.ShadowOffset=new Vector2(0f,8f);GetNode<PanelContainer>("Window").AddThemeStyleboxOverride("panel",windowStyle);_title.Text=Translate(_title.Text);_title.AddThemeColorOverride("font_color",UiTokens.Muted);_roundLabel.AddThemeColorOverride("font_color",UiTokens.Faint);BattleLogHub.Entry+=Append;}/// <summary>Unsubscribes from the shared <see cref="BattleLogHub"/> so the freed panel stops receiving entries.</summary>
protected override void Destruct(){BattleLogHub.Entry-=Append;}/// <summary>Updates the "раунд N" caption in the header.</summary>
public void SetRound(int round)=>_roundLabel.Text=string.Format(Translate("battle/log_round"),round);/// <summary>Drops every row; called when the battle is cleared or restarted.</summary>
public void Clear(){foreach(var child in _rows.GetChildren()){_rows.RemoveChild(child);child.QueueFree();}}/// <summary> Appends one colour-coded row and trims the oldest entries past the cap. The scroll is pushed to the bottom so the newest event stays visible. </summary>
public void Append(string text,BattleLogKind kind){var style=UiKit.Frame(0,BackgroundOf(kind),new Color(0f,0f,0f,0f));style.BorderWidthLeft=2;style.BorderColor=BorderOf(kind);style.ContentMarginLeft=12;style.ContentMarginRight=12;style.ContentMarginTop=7;style.ContentMarginBottom=7;var row=new PanelContainer{MouseFilter=MouseFilterEnum.Ignore};row.AddThemeStyleboxOverride("panel",style);var label=UiKit.Label(text,UiTokens.FontBodySmall,TextOf(kind));label.AutowrapMode=TextServer.AutowrapMode.WordSmart;row.AddChild(label);_rows.AddChild(row);while(_rows.GetChildCount()>MaxRows){var first=_rows.GetChild(0);_rows.RemoveChild(first);first.QueueFree();}_scroll.ScrollVertical=(int)_scroll.GetVScrollBar().MaxValue;}private static Color BackgroundOf(BattleLogKind kind)=>kind==BattleLogKind.Turn?UiTokens.AccentSoft:new Color(0f,0f,0f,0f);private static Color BorderOf(BattleLogKind kind){switch(kind){case BattleLogKind.Turn:return UiTokens.Accent;case BattleLogKind.Damage:return UiTokens.Danger;case BattleLogKind.Shield:return UiTokens.TypeResource;case BattleLogKind.Buff:return UiTokens.TypePassive;case BattleLogKind.Status:return UiTokens.Cooldown;case BattleLogKind.Dead:return UiTokens.LineFaint;default:return UiTokens.Line;}}private static Color TextOf(BattleLogKind kind){switch(kind){case BattleLogKind.Turn:return UiTokens.AccentBright;case BattleLogKind.Damage:return UiTokens.DangerText;case BattleLogKind.Shield:return UiTokens.Info;case BattleLogKind.Buff:return UiTokens.SuccessText;case BattleLogKind.Status:return UiTokens.Cooldown;case BattleLogKind.Dead:return UiTokens.Faint;default:return UiTokens.Muted;}}private static string Translate(string key)=>TranslationServer.Translate(key).ToString();}}

===== FILE mods/card_battle/src/UI/CardBattleScreen.cs =====
using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.View;namespace Cthangover.CardBattle.UI{/// <summary> Root of the card battle interface rebuilt from the mockup (mockups/card_battle). The whole layout lives in <c>scenes/card_battle_screen.tscn</c> and is composed of the mockup's parts: the HUD window, the enemy and party card panels, the "Действия" window, the battle journal, the turn banner and the end-turn button. This class is the single assembly point: it constructs every child widget once (mod widgets are not auto-constructed by Godot) and exposes them to the battle core and the input controller, so neither has to walk the scene tree by hand. </summary>
public partial class CardBattleScreen:ModWidget{/// <summary>Top-left window with the phase badge, round and enemy count.</summary>
public BattleHud Hud{get;private set;}/// <summary>Panel that manages the enemy card row(s); aligned right.</summary>
public BattleCardPanel EnemyPanel{get;private set;}/// <summary>Panel that manages the party card row; aligned left.</summary>
public BattleCardPanel PlayerPanel{get;private set;}/// <summary>Lower-right "Действия" window with the action card grid.</summary>
public BattleActionsPanel ActionsPanel{get;private set;}/// <summary>Battle journal window that renders <see cref="BattleLogHub"/> entries.</summary>
public BattleLogPanel LogPanel{get;private set;}/// <summary>Centre-screen turn banner.</summary>
public BattleBanner Banner{get;private set;}/// <summary>End-turn button in the top-right corner, next to the battle speed bar.</summary>
public EndTurnButton EndTurnButton{get;private set;}/// <summary>Global input handler that drives selection and drag-and-drop.</summary>
public CardController Controller{get;private set;}/// <summary> Canvas layer above the whole battle UI (layer 100). The dragged action card is temporarily reparented here, so it always renders over enemy and party cards regardless of their Z-index inside the panels. </summary>
public CanvasLayer DragLayer{get;private set;}/// <summary>Assembles the full card-battle screen: panels, HUD, banner, log and drag layer.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);Hud=GetNode<BattleHud>("Hud");Hud.EnsureConstructed();EnemyPanel=GetNode<BattleCardPanel>("EnemyPanel");EnemyPanel.EnsureConstructed();EnemyPanel.AlignType="Right";PlayerPanel=GetNode<BattleCardPanel>("PlayerPanel");PlayerPanel.EnsureConstructed();ActionsPanel=GetNode<BattleActionsPanel>("ActionsPanel");ActionsPanel.EnsureConstructed();LogPanel=GetNode<BattleLogPanel>("LogPanel");LogPanel.EnsureConstructed();Banner=GetNode<BattleBanner>("Banner");Banner.EnsureConstructed();EndTurnButton=GetNode<EndTurnButton>("EndTurnButton");EndTurnButton.EnsureConstructed();Controller=GetNode<CardController>("CardController");DragLayer=GetNode<CanvasLayer>("DragLayer");}/// <summary> Releases every widget when the battle scene is cleared: cards, action deck, journal rows, banner and the end-turn button. Called by the battle core on <c>OnBattleCleared</c>. </summary>
public void Clear(){EnemyPanel.ClearAll();PlayerPanel.ClearAll();ActionsPanel.ClearBody();ActionsPanel.ClearSubject();LogPanel.Clear();Banner.Clear();EndTurnButton.SetVisible(false);}}}

===== FILE mods/card_battle/src/UI/CardController.cs =====
using System.Collections.Generic;using Cthangover.Core.Cards;using Cthangover.Core.Audio;using Cthangover.Core.Scenes;using Cthangover.Core.Utils;using Cthangover.Core.Characters;using Cthangover.Core.UI;using Cthangover.Core.UI.CharacterPanel;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base;namespace Cthangover.CardBattle.UI{/// <summary> Central input handler for the card battle UI rebuilt from the mockup (mockups/card_battle). Processes mouse clicks and drags: click a party card to open its action deck in the "Действия" window, drag an action card onto a valid target (enemy for attacks, ally for support, self for self-buffs) and execute it on drop. Valid targets are decided by <see cref="Player.CardActionStrategyFactory"/>; after every executed action the controller fires <see cref="OnActionExecuted"/> so the core can end the turn when all action points are spent. <para/> Actions are instantiated from <c>scenes/action_card.tscn</c> and parented into the grid of <see cref="BattleActionsPanel"/>, which owns the empty state, the point pips and the effect preview well; the controller only feeds it. Hit-testing uses the card root rects exposed through <see cref="ICard.GetControlNode"/>. </summary>
public partial class CardController:InputHandlerNode{private CardBattleScreen _screen;private BattleCardPanel[]_panels;private readonly List<CharacterCardNode>_characterCards=new();private readonly List<ActionCardNode>_actionCards=new();private CharacterCardNode _selectedCharacter;private CharacterCardNode _targetCharacter;private ActionCardNode _currentActionCard;private bool _isDragging;private bool _dragValid;private const float DragThreshold=15f;private Vector2 _pressPosition;private Node _originalActionCardParent;/// <summary> Uniform scale factor applied to all action cards created by this controller. Set by the battle core from the viewport fitting calculation. </summary>
public float CardScale{get;set;}=1f;/// <summary> Fired after a successful drag-and-drop action execution. Subscribed by the battle core to auto-end the player's turn when all points are spent. </summary>
public event System.Action OnActionExecuted;/// <summary> Binds the controller to the assembled battle screen: keeps the panels for hit-testing and redraws and the actions panel for the deck grid and the hover preview. Called once by the battle core right after the screen is added to the battle scene. </summary>
public void Setup(CardBattleScreen screen){_screen=screen;_panels=new[]{screen.PlayerPanel,screen.EnemyPanel};}/// <summary>Routes mouse input into card selection and target picking for the player turn.</summary>
protected override void OnInput(InputEvent@event){if(Cthangover.Core.Battle.BattleSceneContext.Instance!=null&&(Cthangover.Core.Battle.BattleSceneContext.Instance.IsWait||Cthangover.Core.Battle.BattleSceneContext.Instance.IsDestroyed)){return;}if(@event is InputEventMouseButton mouseButton&&mouseButton.ButtonIndex==MouseButton.Left){if(mouseButton.Pressed){_pressPosition=mouseButton.GlobalPosition;_isDragging=false;_dragValid=false;OnBeginDrag(mouseButton.GlobalPosition);}else{if(_isDragging&&_dragValid)OnEndDrag(mouseButton.GlobalPosition);else OnPointerClick(mouseButton.GlobalPosition);_isDragging=false;_dragValid=false;}}if(@event is InputEventMouseMotion mouseMotion&&mouseMotion.ButtonMask.HasFlag(MouseButtonMask.Left)&&_dragValid){if(!_isDragging&&(mouseMotion.GlobalPosition-_pressPosition).Length()>DragThreshold)_isDragging=true;if(_isDragging)OnDrag(mouseMotion.GlobalPosition);}}/// <summary> Clears previous action cards and fades in a fresh deck for <paramref name="character"/>: one card per battle-usable action, dimmed when the character cannot pay its cost. Called when a party card is clicked. </summary>
public void ShowActions(CharacterCardNode character){if(character?.Card?.Actions==null){GameLogger.Log("CARD_CTRL","ShowActions: NULL actions for character "+(character?.Card?.Name??"null"),LogLevel.Error);return;}DiscardActionCharacters();var points=character.Card.Attributes.Point.Value;foreach(var action in character.Card.Actions){if(!action.Type.UseInBattle())continue;var cardNode=CreateAction(action,CardScale);cardNode.SetDisabled(points<ActionCharacterUi.CostOf(action));cardNode.Modulate=UiTokens.Alpha(UiTokens.White,0f);cardNode.Hovered+=OnActionCardHovered;cardNode.Unhovered+=OnActionCardUnhovered;_actionCards.Add(cardNode);var tween=CreateTween();tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);tween.TweenProperty(cardNode,"modulate:a",1f,0.25f);}_screen.ActionsPanel.RefreshEmpty();}/// <summary>Fades the current deck out and frees it.</summary>
public void DiscardActionCharacters(){if(_actionCards.Count==0)return;var oldCards=new List<ActionCardNode>(_actionCards);_actionCards.Clear();foreach(var card in oldCards){if(!GodotObject.IsInstanceValid(card))continue;card.MouseFilter=Control.MouseFilterEnum.Ignore;card.Hovered-=OnActionCardHovered;card.Unhovered-=OnActionCardUnhovered;var tween=CreateTween();tween.SetEase(Tween.EaseType.In).SetTrans(Tween.TransitionType.Cubic);tween.TweenProperty(card,"modulate:a",0f,0.15f);tween.TweenCallback(Callable.From(card.QueueFree));}_screen.ActionsPanel.RefreshEmpty();}/// <summary> Drops the source and target highlights, optionally resets the actions window back to its empty state and redraws the panels so the Z-order returns to normal. Called after each executed action, on empty clicks and when a drag is cancelled. </summary>
public void ClearSelections(bool clearSource=true){if(_currentActionCard!=null){_currentActionCard.Unselect();_currentActionCard=null;}if(_selectedCharacter!=null&&clearSource){_selectedCharacter.Unselect();_selectedCharacter=null;DiscardActionCharacters();_screen.ActionsPanel.ClearSubject();}ClearTargetMarkers();if(_panels!=null){foreach(var panel in _panels)panel?.Redraw();}}/// <summary> Removes the targeting frame from the current target and forgets it. Every path that changes or abandons a target must go through here: dragging over the gap between cards first nulls the target, and without the explicit <see cref="CharacterCardNode.Unselect"/> the discarded card would keep its red or grey marker forever. </summary>
private void ClearTargetMarkers(){if(_targetCharacter!=null&&GodotObject.IsInstanceValid(_targetCharacter))_targetCharacter.Unselect();_targetCharacter=null;}/// <summary>Immediately frees all action card nodes without animation (battle cleanup).</summary>
public void ClearActions(){foreach(var card in _actionCards)card.QueueFree();_actionCards.Clear();}/// <summary> Instantiates an <see cref="ActionCardNode"/> inside the actions grid and fills it from the action definition. </summary>
public ActionCardNode CreateAction(ActionCharacter actionCard,float cardScale){var card=(ActionCardNode)TscnScenes.LoadAndBuild("scenes/action_card.tscn");card.EnsureConstructed();_screen.ActionsPanel.Body.AddChild(card);card.Scale=new Vector2(cardScale,cardScale);card.Init(actionCard);return card;}/// <summary> Resolves the currently selected action against the selected target through the matching <see cref="Player.ICardActionStrategy"/>. Called by <see cref="OnEndDrag"/> when a valid drop is confirmed. </summary>
public void DoActionToTarget(){var strategy=Player.CardActionStrategyFactory.Get(_currentActionCard.Card,_selectedCharacter,_targetCharacter);strategy?.Execute(_selectedCharacter,_targetCharacter,_currentActionCard.Card);}private void OnActionCardHovered(ActionCardNode card)=>_screen.ActionsPanel.SetDescription(card.DescribeFrom(_selectedCharacter?.Card));private void OnActionCardUnhovered(ActionCardNode card)=>_screen.ActionsPanel.SetDescriptionDefault();private void OnPointerClick(Vector2 screenPosition){var source=TryFindCardByPosition(screenPosition,CollectCharacters());if(source==null|| !source.IsPlayer||source.IsDead){ClearSelections();return;}if(source!=_selectedCharacter&&_selectedCharacter!=null)_selectedCharacter.Unselect();var audioService=SceneContextNode.Instance?.GetSceneRoot<Node>("AudioService")as IAudioService;audioService?.PlaySound("battle/card_show_actions",SoundType.CardEffect);_selectedCharacter=source;_screen.ActionsPanel.SetSubject(TranslationServer.Translate(source.Card.Name).ToString());_screen.ActionsPanel.SetPoints(source.Card.Attributes.Point.Value,source.Card.Attributes.Point.BaseValue);ShowActions(_selectedCharacter);_selectedCharacter.Select();}private void OnBeginDrag(Vector2 screenPosition){var source=TryFindCardByPosition(screenPosition,_actionCards);if(source==null)return;if(source.IsDisabled){BattleLogHub.Log(string.Format(Translate("battle/log_not_enough_points"),ActionCharacterUi.Translate(source.Card.Name)),BattleLogKind.Status);return;}if(_currentActionCard!=null&&source!=_currentActionCard)_currentActionCard.Unselect();_dragValid=true;var audioService=SceneContextNode.Instance?.GetSceneRoot<Node>("AudioService")as IAudioService;audioService?.PlaySound("battle/card_pick",4,SoundType.CardEffect);_currentActionCard=source;var control=_currentActionCard.GetControlNode();_originalActionCardParent=control.GetParent();var globalPos=control.GlobalPosition;// Отдельный CanvasLayer поверх всего боя: иначе карта рисуется под ветками панелей врагов и союзников.
control.Reparent(_screen.DragLayer);control.GlobalPosition=globalPos;var pickTween=CreateTween();pickTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Back);pickTween.TweenProperty(control,"scale",new Vector2(1.1f,1.1f),0.1f);foreach(var card in _actionCards){if(card!=_currentActionCard&&GodotObject.IsInstanceValid(card))card.MouseFilter=Control.MouseFilterEnum.Ignore;}}private void OnDrag(Vector2 screenPosition){if(_currentActionCard==null)return;var cardControl=_currentActionCard.GetControlNode();cardControl.GlobalPosition=screenPosition-cardControl.Size/2;var target=TryFindCardByRect(cardControl,_characterCards);if(target==_targetCharacter)return;ClearTargetMarkers();if(target==null){_currentActionCard.Unselect();return;}_targetCharacter=target;var strategy=Player.CardActionStrategyFactory.Get(_currentActionCard.Card,_selectedCharacter,target);if(strategy!=null&&strategy.Check(_currentActionCard.Card,_selectedCharacter,target)){strategy.HighlightTarget(_currentActionCard,_targetCharacter);}else{_targetCharacter.Invalid();_currentActionCard.Invalid();}}private void OnEndDrag(Vector2 screenPosition){foreach(var card in _actionCards){if(card!=null&&GodotObject.IsInstanceValid(card))card.MouseFilter=Control.MouseFilterEnum.Stop;}if(_selectedCharacter!=null&&_currentActionCard!=null&&_targetCharacter!=null){var strategy=Player.CardActionStrategyFactory.Get(_currentActionCard.Card,_selectedCharacter,_targetCharacter);if(strategy!=null&&strategy.Check(_currentActionCard.Card,_selectedCharacter,_targetCharacter)){if(_originalActionCardParent!=null){_currentActionCard.GetControlNode().Reparent(_originalActionCardParent);_originalActionCardParent=null;}var audioService=SceneContextNode.Instance?.GetSceneRoot<Node>("AudioService")as IAudioService;audioService?.PlaySound("battle/card_drop",4,SoundType.CardEffect);DoActionToTarget();if(_panels!=null){foreach(var panel in _panels)panel?.Redraw();}ClearSelections();OnActionExecuted?.Invoke();return;}}if(_currentActionCard!=null){var control=_currentActionCard.GetControlNode();if(_originalActionCardParent!=null){control.Reparent(_originalActionCardParent);_originalActionCardParent=null;}var resetTween=CreateTween();resetTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);resetTween.TweenProperty(control,"scale",Vector2.One,0.15f);BattleLogHub.Log(string.Format(Translate("battle/log_invalid_target"),ActionCharacterUi.Translate(_currentActionCard.Card.Name)),BattleLogKind.Status);}ClearSelections(false);}private T TryFindCardByPosition<T>(Vector2 screenPosition,IList<T>cards)where T:class,ICard{foreach(var card in cards){if(card==null|| !GodotObject.IsInstanceValid(card.GetControlNode()))continue;var rect=card.GetControlNode();if(rect.GetGlobalRect().HasPoint(screenPosition))return card;}return null;}private T TryFindCardByRect<T>(Control draggingRect,IList<T>cards)where T:class,ICard{var screenPoint=draggingRect.GlobalPosition+draggingRect.Size/2;foreach(var card in cards){if(card==null|| !GodotObject.IsInstanceValid(card.GetControlNode()))continue;var rect=card.GetControlNode();if(rect.GetGlobalRect().HasPoint(screenPoint))return card;}return null;}private List<CharacterCardNode>CollectCharacters(){_characterCards.Clear();foreach(var panel in _panels){if(panel==null)continue;foreach(var card in panel.CardList)_characterCards.Add(card);}return _characterCards;}private static string Translate(string key)=>TranslationServer.Translate(key).ToString();}}

===== FILE mods/card_battle/src/UI/CardDeathAnimation.cs =====
using System;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.UI;using Godot;namespace Cthangover.CardBattle.UI{/// <summary> Plays a death animation on a <see cref="CharacterCardNode"/> when its health reaches zero. The card simultaneously fades out, shrinks toward the screen center, spins 360 degrees, and applies a dissolve shader to all its <see cref="CharacterCardNode.AllImages"/> textures. The dissolve shader (<c>"scene_transition"</c>) is resolved once via <see cref="ModManager"/> and cached statically. Created and started by <see cref="BattleCardPanel.Dead"/>. </summary>
public partial class CardDeathAnimation:Node{private CharacterCardNode card;private Vector2 center;private Action onComplete;private static Shader dissolveShader;/// <summary> Begins the death animation sequence. Calculates a target position at the viewport center (relative to the card's parent), starts fading all child <see cref="Label"/> nodes, applies the dissolve shader material to all card images, and tweens position/rotation/alpha. On completion, invokes <paramref name="onComplete"/> and frees this node. </summary>
public void StartAnimation(CharacterCardNode card,Action onComplete){this.card=card;this.onComplete=onComplete;var viewportCenter=card.GetViewport().GetVisibleRect().Size/2;center=card.GetParent<Control>()is Control parent?viewportCenter-parent.GlobalPosition:viewportCenter-card.GlobalPosition+card.Position;RunAnimation();}private void RunAnimation(){float duration=0.8f;Vector2 endPos=center;StartFadeOutRecursive(card,duration);if(dissolveShader==null)dissolveShader=Shaders.Resolve("scene_transition");if(dissolveShader!=null){foreach(var img in card.AllImages){var mat=new ShaderMaterial{Shader=dissolveShader};mat.SetShaderParameter("noise_scale",5f);mat.SetShaderParameter("smoothness",0.25f);mat.SetShaderParameter("distortion",0.092f);mat.SetShaderParameter("glow_intensity",0f);mat.SetShaderParameter("hue_shift",0f);mat.SetShaderParameter("invert_direction",false);mat.SetShaderParameter("progress",0f);img.Material=mat;var matTween=CreateTween();matTween.TweenMethod(Callable.From<float>(t=>mat.SetShaderParameter("progress",t)),0f,1f,duration);}}var tween=CreateTween();tween.SetParallel(true);tween.TweenProperty(card,"position",endPos,duration);tween.TweenProperty(card,"rotation",Mathf.DegToRad(360f),duration);tween.TweenProperty(card,"modulate:a",0f,duration);tween.Finished+=()=>{onComplete?.Invoke();QueueFree();};}private static void StartFadeOutRecursive(Node node,float duration){foreach(Node child in node.GetChildren()){if(child is Label label){var t=child.CreateTween();t.TweenProperty(label,"modulate:a",0f,duration);}StartFadeOutRecursive(child,duration);}}}}

===== FILE mods/card_battle/src/UI/CharacterCardNode.cs =====
using System.Linq;using Cthangover.Core.Cards.StatusEffect;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Characters;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.CardBattle.UI{/// <summary> Party or enemy card on the battle field, rebuilt from the interface mockup (mockups/card_battle): full-bleed portrait, team-coloured frame, bottom info block with the name, four stat chips (HP/ЗАЩ/АТК/ОД) and a layered health bar (<see cref="UiKit.StyleShieldedHealthBar"/>: track → health fill → blue defence segment drawn past the health edge while <see cref="CharacterAttributes.Defence"/> absorbs damage), plus stun badge, zap flash and selection frame in the upper right. The node tree lives in <c>scenes/character_card.tscn</c>; this class fetches the nodes once, dresses them with <see cref="UiTokens"/> styles and exposes the battle-facing API: <see cref="UpdateInfo"/> is the single refresh entry point for mutable stats, <see cref="Select"/> / <see cref="Attack"/> / <see cref="Invalid"/> paint target feedback for the drag-and-drop flow, <see cref="PlayZap"/> and <see cref="Flash"/> give impact feedback, <see cref="AllImages"/> feeds the dissolve shader of <see cref="CardDeathAnimation"/>. <para/> Implements <see cref="ICard"/> for hit-testing during drag-and-drop targeting and keeps the hover lift / Z-index layering rules of the original widget so overlapping cards stay readable. </summary>
public partial class CharacterCardNode:ModWidget,ICard{private const float HoverLift=10f;private const float HoverScale=1.12f;private TextureRect _art;private Panel _teamFrame;private Label _nameLabel;private ProgressBar _shieldBar;private ProgressBar _hpBar;private Label _hpValue;private Label _defValue;private Label _atkValue;private Label _apValue;private VBoxContainer _status;private PanelContainer _stunBadge;private TextureRect _stunIcon;private Label _stunLabel;private TextureRect _zap;private Panel _fx;private TextureRect _selection;private Color _selectionState=Colors.Transparent;private float _baseY;private bool _isHovered;private bool _selected;private int _baseZIndex;private Vector2 _baseScale=Vector2.One;private Tween _hoverTween;private Tween _selectionTween;private static Texture2D _cachedSelectTex;private static bool _texturesLoaded;/// <summary> Distinguishes player-owned cards from enemy cards. Must be assigned by <see cref="BattleCardPanel"/> before <see cref="Init"/>, because the team frame colour is applied during initialisation. </summary>
public bool IsPlayer{get;set;}/// <summary> Marks the card as dead. Set by <see cref="BattleCardPanel.Dead"/>, checked by the battle core when counting alive cards and when the enemy AI picks targets. </summary>
public bool IsDead{get;set;}/// <summary>The character data model backing this card: stats, action deck, status effects.</summary>
public Character Card{get;set;}/// <summary> Textures that receive the dissolve shader during the death animation: the portrait and the selection frame. The team frame and the info block are simple styleboxes and fade with the whole card node. </summary>
public TextureRect[]AllImages=>_allImages??=new[]{_art,_selection};private TextureRect[]_allImages;/// <inheritdoc />
public Control GetControlNode()=>this;/// <summary>Builds the character card: portrait, art frame, selection overlay and stat labels.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);_art=GetNode<TextureRect>("Art");_teamFrame=GetNode<Panel>("TeamFrame");_nameLabel=GetNode<Label>("Info/Column/NameLabel");_shieldBar=GetNode<ProgressBar>("Info/Column/HpBarStack/ShieldBar");_hpBar=GetNode<ProgressBar>("Info/Column/HpBarStack/HpBar");_hpValue=GetNode<Label>("Info/Column/Stats/HpChip/Row/Value");_defValue=GetNode<Label>("Info/Column/Stats/DefChip/Row/Value");_atkValue=GetNode<Label>("Info/Column/Stats/AtkChip/Row/Value");_apValue=GetNode<Label>("Info/Column/Stats/ApChip/Row/Value");_status=GetNode<VBoxContainer>("Status");_stunBadge=GetNode<PanelContainer>("Status/StunBadge");_stunIcon=GetNode<TextureRect>("Status/StunBadge/Row/StunIcon");_stunLabel=GetNode<Label>("Status/StunBadge/Row/StunLabel");_zap=GetNode<TextureRect>("Zap");_fx=GetNode<Panel>("Fx");_selection=GetNode<TextureRect>("Selection");Size=CustomMinimumSize;_baseScale=Scale;if(!_texturesLoaded){_cachedSelectTex=Textures.Resolve("select");_texturesLoaded=true;}if(_cachedSelectTex!=null)_selection.Texture=_cachedSelectTex;ApplyStyles();_selectionState=UiTokens.Alpha(UiTokens.White,0f);MouseEntered+=OnMouseEntered;MouseExited+=OnMouseExited;}/// <summary> Binds the card to a character: sets the portrait, resets selection visuals and refreshes every stat label through <see cref="UpdateInfo"/>. Called by <see cref="BattleCardPanel.Create"/>. </summary>
public void Init(Character characterCard){Card=characterCard;_baseScale=Scale;IsDead=false;if(_art!=null&&characterCard?.Image!=null)_art.Texture=characterCard.Image;if(_selection!=null)_selection.Modulate=UiTokens.Alpha(UiTokens.White,0f);if(_fx!=null)_fx.Modulate=UiTokens.Alpha(UiTokens.White,0f);if(_zap!=null)_zap.Modulate=UiTokens.Alpha(UiTokens.White,0f);_selectionState=UiTokens.Alpha(UiTokens.White,0f);UpdateInfo();}/// <summary> Paints the team frame: blue for the party, gold for enemies — the same accent language the mockup uses for card outlines. Called by <see cref="BattleCardPanel.Create"/> after <see cref="Init"/>. </summary>
public void SetTeamColors(){var accent=IsPlayer?UiTokens.Info:UiTokens.Accent;var style=UiKit.Frame(UiTokens.RadiusMd,new Color(0f,0f,0f,0f),UiTokens.Alpha(accent,0.5f),2);_teamFrame.AddThemeStyleboxOverride("panel",style);}/// <summary> Refreshes every mutable value from the character model: name, layered HP/defence bar and HP number (health turning red below 40%, blue defence segment drawn past the health edge), defence, attack, action points and the stun badge with its remaining turns. Called after each action, on turn start, and by the panel after re-gridding. </summary>
public void UpdateInfo(){if(Card?.Attributes==null)return;_nameLabel.Text=TranslationServer.Translate(Card.Name??string.Empty).ToString();var health=Card.Attributes.Health;var percent=Mathf.Clamp(health.Percent,0f,1f);_hpBar.Value=percent*100f;_hpBar.AddThemeStyleboxOverride("fill",UiKit.Frame(UiTokens.RadiusSm,percent<0.4f?UiTokens.HpLow:UiTokens.HpOk));// Blue defence segment on top of the health fill; collapses onto the health edge when the shield is gone.
UiKit.UpdateShieldedHealthBar(_shieldBar,percent,Card.Attributes.Defence.Value,health.BaseValue);_hpValue.Text=health.Value+"/"+health.BaseValue;_defValue.Text=Card.Attributes.Defence.Value.ToString();_atkValue.Text=Card.Attributes.Attack.Value.ToString();_apValue.Text=Card.Attributes.Point.Value.ToString();RefreshStun();}/// <summary> Sets the base Z-index used for layering cards. Selected cards jump to Z-index 4096 in <see cref="Select"/>; hovered cards rise by 100 so they cover their neighbours. </summary>
public void SetBaseZIndex(int z){_baseZIndex=z;if(!_selected)ZIndex=z;}/// <summary>Highlights the card as the chosen source or a valid support target (teal).</summary>
public void Select(){_selected=true;_selectionState=UiTokens.TypeTarget;AnimateSelection(_selectionState);ZIndex=4096;}/// <summary>Clears the selection highlight and restores the base Z-index.</summary>
public void Unselect(){_selected=false;_selectionState=UiTokens.Alpha(UiTokens.White,0f);AnimateSelection(_selectionState);ZIndex=_baseZIndex;}/// <summary>Highlights a valid enemy target for an attack (red).</summary>
public void Attack(){_selectionState=UiTokens.TypeEnemy;AnimateSelection(_selectionState);}/// <summary>Grays out the card as an invalid target for the dragged action.</summary>
public void Invalid(){_selectionState=UiTokens.Alpha(UiTokens.Muted,0.8f);AnimateSelection(_selectionState);}/// <summary>Plays the electric stun flash over the portrait (additive blend).</summary>
public void PlayZap(){if(_zap==null)return;_zap.Modulate=UiTokens.Alpha(UiTokens.White,0f);var tween=CreateTween();tween.TweenProperty(_zap,"modulate:a",1f,0.12f);tween.TweenProperty(_zap,"modulate:a",0f,0.45f).SetDelay(0.25f);}/// <summary>Pulses the whole card with a colour (damage red, shield blue).</summary>
public void Flash(Color color){_fx.Modulate=UiTokens.Alpha(color,0f);var tween=CreateTween();tween.TweenProperty(_fx,"modulate:a",0.55f,0.12f);tween.TweenProperty(_fx,"modulate:a",0f,0.38f);}private void ApplyStyles(){var infoStyle=UiKit.Frame(UiTokens.RadiusMd,UiTokens.Alpha(UiTokens.Surface2,0.93f),UiTokens.Line);infoStyle.CornerRadiusTopLeft=0;infoStyle.CornerRadiusTopRight=0;infoStyle.ContentMarginLeft=8;infoStyle.ContentMarginRight=8;infoStyle.ContentMarginTop=6;infoStyle.ContentMarginBottom=8;GetNode<PanelContainer>("Info").AddThemeStyleboxOverride("panel",infoStyle);_nameLabel.AddThemeColorOverride("font_color",UiTokens.InkBright);// Layered pie: the shield bar owns the track and the blue defence fill; the health bar paints only its fill on top of it.
UiKit.StyleShieldedHealthBar(_shieldBar,_hpBar);StyleChip("Info/Column/Stats/HpChip",UiTokens.HpText);StyleChip("Info/Column/Stats/DefChip",UiTokens.Ink);StyleChip("Info/Column/Stats/AtkChip",UiTokens.Ink);StyleChip("Info/Column/Stats/ApChip",UiTokens.AccentBright);var stunStyle=UiKit.Frame(UiTokens.RadiusXs,UiTokens.CooldownSoft,UiTokens.CooldownLine);stunStyle.ContentMarginLeft=5;stunStyle.ContentMarginRight=8;stunStyle.ContentMarginTop=3;stunStyle.ContentMarginBottom=3;_stunBadge.AddThemeStyleboxOverride("panel",stunStyle);_stunLabel.AddThemeColorOverride("font_color",UiTokens.Cooldown);_zap.Material=new CanvasItemMaterial{BlendMode=CanvasItemMaterial.BlendModeEnum.Add};_zap.Modulate=UiTokens.Alpha(UiTokens.White,0f);_fx.Modulate=UiTokens.Alpha(UiTokens.White,0f);_selection.Modulate=UiTokens.Alpha(UiTokens.White,0f);}private void StyleChip(string path,Color valueColor){var chip=GetNode<PanelContainer>(path);var style=UiKit.Frame(UiTokens.RadiusXs,UiTokens.StatBg,UiTokens.StatLine);style.ContentMarginLeft=6;style.ContentMarginRight=6;style.ContentMarginTop=2;style.ContentMarginBottom=2;chip.AddThemeStyleboxOverride("panel",style);var row=chip.GetNode<HBoxContainer>("Row");var key=row.GetNode<Label>("Key");key.Text=TranslationServer.Translate(key.Text).ToString();key.AddThemeColorOverride("font_color",UiTokens.Muted);row.GetNode<Label>("Value").AddThemeColorOverride("font_color",valueColor);}private void RefreshStun(){var stun=StatusEffectService.Of(Card)?.ActiveStatusEffects?.FirstOrDefault(effect=>effect.EffectType==StatusEffectType.Stun);if(stun==null){_stunBadge.Visible=false;return;}_stunBadge.Visible=true;if(stun.Icon!=null){// Текстура приходит из данных эффекта: группа "characters" не входит в texture_groups, поэтому Textures.Resolve("stun") её не видит, а StatusEffectInfo.Icon грузится через TextureUtils.
_stunIcon.Texture=stun.Icon;_zap.Texture=stun.Icon;}_stunLabel.Text=string.Format(TranslationServer.Translate("battle/stun_status").ToString(),stun.RemainingTurns);}private void AnimateSelection(Color target){if(_selection==null)return;_selectionTween?.Kill();_selectionTween=CreateTween();_selectionTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad);_selectionTween.TweenProperty(_selection,"modulate",target,0.15f);}private void OnMouseEntered(){if(_isHovered)return;_isHovered=true;_baseY=Position.Y;if(!_selected)ZIndex=_baseZIndex+100;_hoverTween?.Kill();_hoverTween=CreateTween();_hoverTween.SetParallel(true);_hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);_hoverTween.TweenProperty(this,"position:y",_baseY-HoverLift,0.15f);_hoverTween.TweenProperty(this,"scale",_baseScale*HoverScale,0.15f);}private void OnMouseExited(){if(!_isHovered)return;_isHovered=false;if(!_selected)ZIndex=_baseZIndex;_hoverTween?.Kill();_hoverTween=CreateTween();_hoverTween.SetParallel(true);_hoverTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);_hoverTween.TweenProperty(this,"position:y",_baseY,0.15f);_hoverTween.TweenProperty(this,"scale",_baseScale,0.15f);}}}

===== FILE mods/card_battle/src/UI/EndTurnButton.cs =====
using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;namespace Cthangover.CardBattle.UI{/// <summary> A styled button widget that allows the player to manually end their turn. Displayed during the player's turn and hidden during enemy turns and battle cleanup. Fires <see cref="OnPressed"/> when clicked, which <see cref="CardBattleCore"/> binds to <c>OnPlayerTurnEnd</c>. The button text is translated via the <c>"battle/end_turn"</c> key. </summary>
public partial class EndTurnButton:ModWidget{private Button _button;/// <summary> Fired when the button is pressed. Subscribed by <see cref="CardBattleCore.OnEndTurnPressed"/>. </summary>
public event System.Action OnPressed;/// <summary>Builds the end-turn button and binds its pressed event.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);_button=GetNode<Button>("BtnEndTurn");CthangoverTheme.ApplyPrimary(_button);Size=CustomMinimumSize;_button.Pressed+=RaisePressed;}private void RaisePressed()=>OnPressed?.Invoke();/// <summary> Shows or hides the underlying <see cref="Button"/> control. Called by <see cref="CardBattleCore"/> during turn transitions and battle cleanup. </summary>
public void SetVisible(bool visible){if(_button!=null)_button.Visible=visible;}}}

===== FILE mods/characters_base/src/Characters/ActionAssignmentReset.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Settings;namespace Cthangover.Core.Characters{/// <summary> Implements the character panel's "reset assignments" operation: every party member's action slots go back to the first three actions of their factory template, and template actions beyond the third are pushed into the shared action pool. <para/> The critical invariant is that awarded cards survive the reset. Cards can enter the pool from sources that have no template entry at all — a skill granted through <see cref="Skills.SkillService"/> being the canonical example — and once such a card is assigned to a slot it is absent from the pool. Rebuilding the slots without first returning the old slot contents to the pool would silently destroy it. Therefore all non-empty slot IDs are collected before the slots are overwritten; template IDs are filtered back out afterwards, so the pool ends up holding exactly the awarded cards that are no longer assigned. <para/> The template lookup is injected as a delegate because the production caller resolves it through <c>CharacterFactory</c> (a mod-aware JSON factory), while tests can supply plain <see cref="Character"/> objects. </summary>
public static class ActionAssignmentReset{/// <summary> Rewrites every battle-set character's <see cref="CharacterInfoData.ActionSlots"/> from its template and replaces <see cref="ActionPoolData.ActionIds"/> with the surviving pool plus recovered awarded cards. </summary> <param name="characterData">Roster whose battle set and slots are reset.</param> <param name="actionPool">Shared action pool; may be <c>null</c>, in which case pool contents are recomputed but not stored.</param> <param name="resolveTemplate">Resolves a character ID to its factory template; may return <c>null</c> for unknown IDs.</param>
public static void Apply(CharacterData characterData,ActionPoolData actionPool,Func<string,Character>resolveTemplate){if(characterData?.BattleSet==null||characterData.Characters==null)return;var extraPoolIds=new HashSet<string>(actionPool?.ActionIds??new List<string>());var assignedAfterReset=new HashSet<string>();foreach(var characterId in characterData.BattleSet){if(!characterData.Characters.TryGetValue(characterId,out var info))continue;if(info.ActionSlots!=null)foreach(var slotId in info.ActionSlots)if(!string.IsNullOrEmpty(slotId))extraPoolIds.Add(slotId);var defaultActionIds=resolveTemplate?.Invoke(characterId)?.Actions?.Select(action=>action.ID).ToList()??new List<string>();var slots=new List<string>();for(var i=0;i<3;i++){if(i<defaultActionIds.Count){slots.Add(defaultActionIds[i]);assignedAfterReset.Add(defaultActionIds[i]);}else{slots.Add(null);}}for(var i=3;i<defaultActionIds.Count;i++)extraPoolIds.Add(defaultActionIds[i]);info.ActionSlots=slots;}extraPoolIds.ExceptWith(assignedAfterReset);if(actionPool!=null)actionPool.ActionIds=extraPoolIds.ToList();}}}

===== FILE mods/characters_base/src/Characters/ActionCharacterActiveExtension.cs =====
namespace Cthangover.Core.Characters{/// <summary> Cooldown helpers for <see cref="ActionCharacterType.Active"/> cards. Active cards are one-shot abilities usable outside battle (from the character panel). The last-use timestamp is stored in <see cref="ActionCharacter.Properties"/> under 'LastUsed' - the cooldown duration (seconds) under 'ActiveCooldown' Absence of <c>LastUsed</c> means the card has never been activated. </summary>
public static class ActionCharacterActiveExtension{/// <summary> Returns <c>true</c> when the card is <see cref="ActionCharacterType.Active"/> and its cooldown has expired (or it was never used). </summary>
public static bool IsReadyToUse(this ActionCharacter action,long currentTime){if(action.Type!=ActionCharacterType.Active)return false;var lastUsed=action.Properties.GetLong("LastUsed",0);var cooldown=action.GetInt("ActiveCooldown",0);if(lastUsed==0)return true;if(cooldown<=0)return false;return currentTime-lastUsed>=cooldown;}/// <summary> Seconds remaining until the cooldown expires. Returns <c>0</c> when ready, never used, or not an Active card. </summary>
public static long GetRemainingCooldown(this ActionCharacter action,long currentTime){if(action.Type!=ActionCharacterType.Active)return 0;var lastUsed=action.Properties.GetLong("LastUsed",0);var cooldown=action.GetInt("ActiveCooldown",0);if(lastUsed==0||cooldown<=0)return 0;var elapsed=currentTime-lastUsed;return elapsed>=cooldown?0:cooldown-elapsed;}/// <summary> Records the activation timestamp. Has no effect on non-Active cards. </summary>
public static void MarkUsed(this ActionCharacter action,long timestamp){if(action.Type!=ActionCharacterType.Active)return;action.Properties.SetLong("LastUsed",timestamp);}}}

===== FILE mods/characters_base/src/Characters/ActionCharacterInfo.cs =====
using System;using System.Collections.Generic;using System.Text.Json.Serialization;using Cthangover.Core.Factories;namespace Cthangover.Core.Characters{/// <summary> JSON-serializable descriptor for an ActionCharacter. The Values list is a flat string array carrying the PropertyData pairs — serialized as a list rather than a dictionary to maintain JSON readability and avoid key escaping issues. Type uses a string enum converter so the JSON reads "ToSelf"/"ToAlias"/"ToEnemy" instead of integers. The Image field is a resource path string, resolved to Texture2D by the factory at load time. </summary>
[Serializable][EntityKind("action")]public class ActionCharacterInfo:IIdentifiable{/// <summary> Unique action identifier, serialized as "Id" in JSON. </summary>
[JsonPropertyName("Id")]public string ID{get;set;}/// <summary> Display name for the battle action menu. </summary>
public string Name{get;set;}/// <summary> Description text for the battle action tooltip. </summary>
public string Description{get;set;}/// <summary> Targeting category, serialized as string (e.g. "ToSelf", "ToAlias", "ToEnemy") via <see cref="JsonStringEnumConverter"/> for human-readable JSON. </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]public ActionCharacterType Type{get;set;}/// <summary> Resource path string for the action icon. Resolved to <c>Texture2D</c> by <see cref="CharacterFactory"/> at load time. </summary>
public string Image{get;set;}/// <summary> Flat list of alternating key-value pairs that populate the <see cref="ActionCharacter.Properties"/> bag at load time. Index 0 = first key, index 1 = first value, index 2 = second key, etc. Stored as a list rather than a dictionary to simplify JSON authoring and avoid key-escaping issues. </summary>
public List<string>Values{get;set;}}}

===== FILE mods/characters_base/src/Characters/CharacterInfo.cs =====
using System;using System.Collections.Generic;using System.Text.Json.Serialization;using Cthangover.Core.Factories;namespace Cthangover.Core.Characters{/// <summary> JSON-serializable character descriptor loaded from data files. Only the four core battle stats (Health, Defence, Attack, Point) are hardcoded — they map directly to <see cref="Attribute"/> fields in <see cref="CharacterAttributes"/>. All other properties (any mod-defined extras) are captured by <see cref="ExtraProperties"/> and flowed into <see cref="CharacterAttributes.Properties"/> at factory time, so mods can add arbitrary stat keys without changing the DTO schema. </summary>
[Serializable][EntityKind("character")]public class CharacterInfo:IIdentifiable{/// <summary> Unique character ID, serialized as "Id" in JSON. Serves as the factory lookup key and save-data identifier. </summary>
[JsonPropertyName("Id")]public string ID{get;set;}/// <summary> AI behaviour class name for this character in battle. Resolved externally — null means no AI override. </summary>
public string Behaviour{get;set;}/// <summary> Starting level for this character when recruited. </summary>
public int Level{get;set;}/// <summary> Resource path string for the full-body portrait. Resolved to a <c>Texture2D</c> by <see cref="CharacterFactory"/> at load time. </summary>
public string Image{get;set;}/// <summary> Display name, localized via TranslationServer when shown to the player. </summary>
public string Name{get;set;}/// <summary> Starting health points (used as <see cref="Attribute"/>.BaseValue). </summary>
public int Health{get;set;}/// <summary> Starting defence value. </summary>
public int Defence{get;set;}/// <summary> Starting action points (resource pool for abilities). </summary>
public int Point{get;set;}/// <summary> Starting attack power. </summary>
public int Attack{get;set;}/// <summary> Starting experience points. </summary>
public int Exp{get;set;}/// <summary> Recruitment chance percentage (0–100) after defeating this character in battle. </summary>
public int RecruitmentChance{get;set;}/// <summary> Action ID strings, each referencing an <see cref="ActionCharacterInfo"/> entry in the action data file. Stored as a JSON array for clean mod-patching with <c>$add</c>/<c>$remove</c>/<c>$set</c> suffix operations. </summary>
public List<string>Actions{get;set;}/// <summary> Loot drop table for this character when defeated as an enemy. </summary>
public List<LootEntry>Loot{get;set;}/// <summary> Captures any JSON keys not matched by the hardcoded properties above — including former DTO fields like any mod-defined extras. These are imported into <see cref="CharacterAttributes.Properties"/> by <see cref="CharacterFactory.ImportExtraProperty"/>. </summary>
[JsonExtensionData]public Dictionary<string,object>ExtraProperties{get;set;}}}

===== FILE mods/characters_base/src/Characters/CharacterPassiveExtension.cs =====
using System.Linq;namespace Cthangover.Core.Characters{/// <summary> Extension for querying passive effects on a <see cref="Character"/>. Passive actions are <see cref="ActionCharacter"/> entries with <see cref="ActionCharacterType.Passive"/> whose <see cref="ActionCharacter.Properties"/> declare effect flags as boolean keys (e.g. "IgnoreArmor"). The key names are chosen by the mod author — the core imposes no registry or constants. </summary>
public static class CharacterPassiveExtension{/// <summary> Returns <c>true</c> if the character has at least one Passive action whose <see cref="ActionCharacter.Properties"/> contains <paramref name="key"/> with a <c>true</c> boolean value. </summary>
public static bool HasPassiveEffect(this Character character,string key){return character?.Actions?.Any(a=>a.Type==ActionCharacterType.Passive&&a.GetBool(key))??false;}}}

===== FILE mods/characters_base/src/CharactersModInitializer.cs =====
using Cthangover.Core.Bootstrap;using Cthangover.Core.Characters;using Cthangover.Core.Factories;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Mods;using Cthangover.Core.Settings;using Cthangover.Core.Utils;namespace Cthangover.Mods.Characters{/// <summary> Engine-mod initializer for the characters subsystem. Publishes the factories living in this assembly to the kernel contracts (<see cref="CharacterTemplateRegistry"/>, <see cref="ActionTemplateRegistry"/>, <see cref="AvatarProviderRegistry"/>) as soon as the assembly is registered, so core-side consumers — <c>CharacterData</c>, inventory and skills UI, dialog avatar lookups — resolve templates without referencing a runtime-compiled mod assembly. <para/> Registration happens in <see cref="OnModLoaded"/> (the earliest and most frequent initializer callback) rather than on resources-ready: <c>CharacterData.AddCharacterToParty</c> is called from other mods' <c>OnModResourcesReady</c> (starter party), and initializer callbacks are delivered per mod registration, so being first here removes any ordering dependency between mods. The factories themselves only collect entity data lazily on the first <c>Get</c>, after the mod registry is finalized. Registration is guarded and idempotent because <c>ModManager.Reload()</c> re-runs <c>OnModLoaded</c> for every mod. </summary>
public class CharactersModInitializer:IModInitializer{private static bool registered;/// <summary>Publishes this mod's factories to the kernel registries; safe to call again after a mod reload.</summary>
public void OnModLoaded(string modId){if(registered)return;registered=true;CharacterTemplateRegistry.Provider=CharacterFactory.Instance;ActionTemplateRegistry.Provider=ActionCharacterFactory.Instance;AvatarProviderRegistry.Provider=AvatarFactory.Instance;GameLogger.Log("MODS","CharactersModInitializer: template providers registered (character, action, avatar)");}/// <summary> Registers the new-game roster reset owner with AppServices (idempotent — duplicates by Id are ignored). Template collection stays lazy on first lookup. Persistence needs no setup here: <see cref="CharactersSaveable"/> is an <c>IModSaveable</c> discovered by ModSaveableRegistry automatically. </summary>
public void OnModResourcesReady(){AppServices.Current.RegisterResettable(new CharactersResettable());}}/// <summary> New-game state owner of the characters mod: clears the party roster and battle set. Today the roster dies implicitly with <c>GameData.ResetRuntime</c> (it lives inside <c>RuntimeData</c>), which runs before the reset loop, so this reset is a no-op in the normal pipeline; it stays correct if the roster ever moves to mod-owned static storage (the recruiting path) or if the reset loop runs standalone, and makes the characters mod an explicit new-game state owner like quests and recruiting. Registered from <see cref="CharactersModInitializer.OnModResourcesReady"/>; <see cref="AppServices"/> deduplicates by <see cref="IResettable.Id"/>. </summary>
internal sealed class CharactersResettable:IResettable{/// <summary>Resettable key under which AppServices deduplicates registration.</summary>
public string Id=>"characters";/// <summary>Clears the party roster and battle set on "New Game".</summary>
public void ResetForNewGame(){var characterData=GameData.Instance.Runtime.CharacterData;characterData.Characters.Clear();characterData.BattleSet.Clear();}}}

===== FILE mods/characters_base/src/CharactersSaveable.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Settings;namespace Cthangover.Mods.Characters{/// <summary> Persists the character roster (party composition and per-character progression) through the generic <see cref="IModSaveable"/> contract under the mod id <c>characters_base</c>. The core save used to carry the <c>"Characters"</c> and <c>"BattleSet"</c> blobs inside the <c>__core__</c> slot (<c>RuntimeSerializer.BuildCoreSlot</c>); the roster belongs to the characters engine mod, so it moves to the mod slot like every other migrated subsystem (quests, items, recruiting). <para/> The state itself is not duplicated: <see cref="OnSave"/> marshals <c>GameData.Instance.Runtime.CharacterData</c> — the same runtime objects the core, battle and UI read — into the mod slot, and <see cref="OnLoad"/> writes the restored dictionaries back into that runtime, so no other code changes its data path. While the transition is staged (the core slot still carries the old blobs), load order is safe: the core restores <c>__core__</c> first and this saveable re-restores the identical data afterwards from the mod slot. When the core blobs are removed, old saves degrade gracefully — <c>RuntimeSerializer.RestoreCoreState</c> skips missing blob keys, and an absent mod blob keeps the fresh runtime roster. </summary>
public class CharactersSaveable:IModSaveable{private const string CharactersBlobKey="Characters";private const string BattleSetBlobKey="BattleSet";/// <summary>Writes the roster and battle set into the mod slot.</summary>
public void OnSave(ModSlot slot){var characterData=GameData.Instance.Runtime.CharacterData;slot.Data[CharactersBlobKey]=new DataBlob(1,characterData.Characters.Values.ToList());slot.Data[BattleSetBlobKey]=new DataBlob(1,characterData.BattleSet.ToList());}/// <summary> Restores roster and battle set from the mod slot; an absent blob keeps the corresponding part of the current runtime state. </summary>
public bool OnLoad(ModSlot slot,out string errorMessage){errorMessage=null;if(slot?.Data==null)return true;var characterData=GameData.Instance.Runtime.CharacterData;if(slot.Data.TryGetValue(CharactersBlobKey,out var charactersBlob)){var characters=charactersBlob.ReadJson<List<CharacterInfoData>>();characterData.Characters=new Dictionary<string,CharacterInfoData>();if(characters!=null)foreach(var info in characters)characterData.Characters[info.CharacterType]=info;}if(slot.Data.TryGetValue(BattleSetBlobKey,out var battleSetBlob))characterData.BattleSet=battleSetBlob.ReadJson<List<string>>()?.ToHashSet()??new HashSet<string>();return true;}}}

===== FILE mods/characters_base/src/Factories/ActionCharacterFactory.cs =====
using System.Collections.Generic;using Cthangover.Core.Characters;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Factories.Impls{/// <summary> Factory for battle action cards equipped by characters. Returns a <b>copy</b> of the cached <c>ActionCharacter</c> on every <c>Get</c> call — action cards carry mutable battle state (cooldowns, modifiers) and must never be shared across character instances. Properties are parsed from a compact <c>key=value</c> string list rather than a full JSON object, keeping mod JSON terse for authored content. Images are loaded via <c>TextureUtils.LoadFromModGroup</c> — not through a texture factory — because action character assets live inside the same <c>"characters"</c> asset group as their PNGs, making co-location of data and art the default mod layout. The JSON definitions themselves are found by kind, not by that directory. <para/> The factory lives in the characters engine mod; its singleton is also published to the kernel through <see cref="ActionTemplateRegistry"/> by <c>CharactersModInitializer</c>, so core-side consumers resolve templates without referencing this assembly. </summary>
public class ActionCharacterFactory:ICacheLoader<string,ActionCharacter>,IActionTemplateProvider{/// <summary>Process-wide singleton for this mod assembly; the public API for action template lookups.</summary>
public static ActionCharacterFactory Instance{get;}=new ActionCharacterFactory();private Dictionary<string,ActionCharacterInfo>_allInfos;private readonly BoundedCache<string,ActionCharacter>_cache;internal ActionCharacterFactory(){var size=ModConfig.Instance.Cache.GetCacheSize("actions",128);_cache=new BoundedCache<string,ActionCharacter>(size,this);}ActionCharacter ICacheLoader<string,ActionCharacter>.Load(string id){var infos=EnsureInfos();if(infos.TryGetValue(id,out var info))return CreateActionCharacter(info);return null;}/// <summary> Returns the action definitions, collecting them on first use. Publication to <c>_allInfos</c> waits until the mod registry is finalized so an early lookup from the launcher boot window cannot freeze an empty snapshot for the session. </summary>
private Dictionary<string,ActionCharacterInfo>EnsureInfos(){if(_allInfos!=null)return _allInfos;var infos=Entities.Collect<ActionCharacterInfo>();if(ModManager.Instance.IsInitialized)_allInfos=infos;return infos;}/// <summary> Returns an independent copy of the action definition for <paramref name="id"/>, or <c>null</c> when unknown. The copy is deliberate: action cards carry mutable battle state, so callers never share one instance. </summary>
public ActionCharacter Get(string id){EnsureInfos();var item=_cache.Get(id);return item?.Copy();}/// <summary> Returns all known action info IDs. Used by the character panel to determine which actions are not assigned to any character (the action pool). </summary>
public IReadOnlyCollection<string>GetAllActionIds(){return EnsureInfos()?.Keys;}private ActionCharacter CreateActionCharacter(ActionCharacterInfo info){if(info==null)return null;var item=new ActionCharacter{ID=info.ID,Name=info.Name,Description=info.Description,Type=info.Type,Properties=ParseValues(info.Values),Image=LoadImage(info.Image),};return item;}private Texture2D LoadImage(string imagePath){return TextureUtils.LoadFromModGroup("characters",imagePath);}private static PropertyData ParseValues(List<string>values){var props=new PropertyData();if(values==null)return props;foreach(var value in values){var idx=value.IndexOf('=');if(idx>0)props.Values[value.Substring(0,idx)]=value.Substring(idx+1);}return props;}}}

===== FILE mods/characters_base/src/Factories/AvatarFactory.cs =====
using Cthangover.Core.Factories;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Factories.Impls{/// <summary> Thin <c>Texture2DFactory</c> for character portrait avatars. The separate factory (as opposed to reusing <c>UITextureFactory</c>) isolates avatar images into their own mod group with a dedicated cache budget, so frequently-swapped dialog portraits don't evict other UI textures under memory pressure. <para/> The factory lives in the characters engine mod; its singleton is also published to the kernel through <see cref="AvatarProviderRegistry"/> by <c>CharactersModInitializer</c>, so core-side consumers resolve avatars without referencing this assembly. </summary>
public class AvatarFactory:Texture2DFactory,IAvatarProvider{internal AvatarFactory():base("avatars",64){}/// <summary>Process-wide singleton for this mod assembly; the public API for avatar lookups.</summary>
public static AvatarFactory Instance{get;}=new AvatarFactory();/// <summary>Mod asset group scanned by this factory — files live under <c>mods/*/</c><b>avatars</b>.</summary>
public override string GroupName=>"avatars";}}

===== FILE mods/characters_base/src/Factories/CharacterFactory.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Characters;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Factories.Impls{/// <summary> Factory for full character definitions. Returns a <b>copy</b> of the cached <c>Character</c> on every <c>Get</c> call because <c>Character.Attributes</c> mutate during gameplay. Character actions are resolved through <c>ActionCharacterFactory</c>. Attribute fields that are missing or zero in JSON receive nonzero fallback values so incomplete mod data doesn't produce unplayable characters. Patching is handled transparently by the shared <c>EntityResolver</c> — the factory receives already-merged <c>CharacterInfo</c> DTOs routed by their <c>character</c> kind and simply maps them to the runtime model. <para/> The factory lives in the characters engine mod; its singleton is also published to the kernel through <see cref="CharacterTemplateRegistry"/> by <c>CharactersModInitializer</c>, so core-side consumers resolve templates without referencing this assembly. </summary>
public class CharacterFactory:ICacheLoader<string,Character>,ICharacterTemplateProvider{/// <summary>Process-wide singleton for this mod assembly; the public API for template lookups.</summary>
public static CharacterFactory Instance{get;}=new CharacterFactory();private Dictionary<string,CharacterInfo>_allInfos;private readonly BoundedCache<string,Character>_cache;internal CharacterFactory(){var size=ModConfig.Instance.Cache.GetCacheSize("characters",128);_cache=new BoundedCache<string,Character>(size,this);}Character ICacheLoader<string,Character>.Load(string id){var infos=EnsureInfos();GameLogger.Log("FACTORY",$"CharacterFactory.Load('{id}'): infos count={infos?.Count ?? -1}",LogLevel.Debug);if(infos.TryGetValue(id,out var info))return CreateCharacter(info);return null;}/// <summary> Returns the character definitions, collecting them on first use. The result is published to <c>_allInfos</c> only after the mod registry is finalized. A stale empty snapshot cannot appear: the registry refuses mod-data access until <c>Boot.IsReady</c>, so the first collection always sees the final mod set, and the cached snapshot is reset when <c>ModManager.Reload</c> rebuilds the registry. </summary>
private Dictionary<string,CharacterInfo>EnsureInfos(){if(_allInfos!=null)return _allInfos;GameLogger.Log("FACTORY",$"CharacterFactory.EnsureInfos: START, ModRegistry.IsInitialized={ModRegistry.Instance.IsInitialized}, mods count={ModRegistry.Instance.Mods?.Count ?? 0}",LogLevel.Debug);var infos=Entities.Collect<CharacterInfo>();if(ModManager.Instance.IsInitialized)_allInfos=infos;GameLogger.Log("FACTORY",$"CharacterFactory.EnsureInfos: END, loaded {infos?.Count ?? 0} infos",LogLevel.Debug);return infos;}/// <summary> Returns an independent copy of the character template for <paramref name="id"/>, or <c>null</c> (with an error log) when the id is unknown. The copy semantics matter: battle and party systems mutate attributes freely without touching the cached template. </summary>
public Character Get(string id){var infos=EnsureInfos();if(!infos.ContainsKey(id)){GameLogger.Log("FACTORY",$"CharacterFactory.Get('{id}'): NOT FOUND in infos",LogLevel.Error);return null;}var item=_cache.Get(id);if(item?.Image==null)GameLogger.Log("FACTORY",$"CharacterFactory.Get('{id}'): found, image=null",LogLevel.Error);return item?.Copy();}private static Character CreateCharacter(CharacterInfo info){if(info==null)return null;var item=new Character{ID=info.ID,Behaviour=info.Behaviour,Name=info.Name,Level=info.Level,Exp=info.Exp,RecruitmentChance=info.RecruitmentChance,Actions=ResolveActions(info.Actions??new List<string>()),Image=LoadImage(info.Image),Loot=info.Loot,};item.Attributes.Health.Init(info.Health>0?info.Health:1);item.Attributes.Defence.Init(info.Defence);item.Attributes.Attack.Init(info.Attack);item.Attributes.Point.Init(info.Point>0?info.Point:1);PropertyData.ImportJsonProperties(item.Attributes.Properties,info.ExtraProperties);return item;}private static List<ActionCharacter>ResolveActions(List<string>actions){if(actions==null||actions.Count==0)return new List<ActionCharacter>();return actions.Select(id=>ActionCharacterFactory.Instance.Get(id)).Where(card=>card!=null).ToList();}private static Texture2D LoadImage(string imagePath){if(string.IsNullOrWhiteSpace(imagePath)){GameLogger.Log("FACTORY",$"LoadImage: imagePath is null/empty",LogLevel.Error);return null;}var tex=TextureUtils.LoadFromModGroup("characters",imagePath);if(tex==null)GameLogger.Log("FACTORY",$"LoadImage: result=null",LogLevel.Error);return tex;}}}

===== FILE mods/characters_base/src/SubsystemRegistration.cs =====
namespace Cthangover.Mods.Characters{/// <summary> Migration marker scanned by the integration coverage test (<c>SubsystemsBridge</c>): declares the characters subsystem as migrated out of the core assembly into this engine mod. The subsystem id must match the entry in <c>SubsystemsCoverageTestSetup.DeclaredTests</c> and <c>tests/tests.manifest.json</c>. </summary>
public static class SubsystemRegistration{/// <summary>Subsystem id reported to the coverage test.</summary>
public static string SubsystemId=>"characters";}}

===== FILE mods/characters_base/src/UI/CharacterPanel/ActionCardBehaviour.cs =====
using Cthangover.Core.Characters;using Cthangover.Core.Scenes;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Theming;namespace Cthangover.Core.UI.CharacterPanel{/// <summary> Visual representation of a single <see cref="ActionCharacter"/> inside a character slot or in the action pool. The static layout (frame panel, accent stripe, icon well, name row, badge and property containers) lives in <c>scenes/ui/action_card.tscn</c>; this behaviour resolves those nodes and owns only the dynamic layer: type accent colour, translated name, ID watermark, badges, property chips, hover/selected frames and the compact (pool) vs expanded (slot) size states. <para/> Instances are created through <see cref="ModScenePrefab"/> (the card scene), never via <c>new</c> — the node tree comes from the scene. The card never handles input itself: <see cref="CharacterPanelBehaviour"/> owns drag orchestration through its node-level <c>_Input</c> (GuiInput stops firing once the cursor leaves the card mid-drag). The card only exposes state setters (<see cref="SetSelected"/>, <see cref="SetCompact"/>) and is moved between containers by the panel/slot. </summary>
public partial class ActionCardBehaviour:Control{private const float NormalHeight=86f;private const float CompactHeight=60f;/// <summary>Factory action bound to this card; <c>null</c> before <see cref="SetAction"/>.</summary>
public ActionCharacter ActionData{get;private set;}/// <summary>Slot that currently owns the card, or <c>null</c> when the card lives in the pool.</summary>
public ActionSlotBehaviour CurrentSlot{get;set;}private static readonly StyleBoxFlat NormalFrame=UiKit.Frame(UiTokens.RadiusMd,UiTokens.Surface1,UiTokens.LineCard);private static readonly StyleBoxFlat HoverFrame=UiKit.Frame(UiTokens.RadiusMd,UiTokens.SurfaceHover,UiTokens.LineStrong);private static readonly StyleBoxFlat SelectedFrame=UiKit.Frame(UiTokens.RadiusMd,UiTokens.SurfaceSelected,UiTokens.Accent);private Panel _frame;private ColorRect _accent;private TextureRect _icon;private Label _nameLabel;private Label _idLabel;private HBoxContainer _badgesRow;private HFlowContainer _propsRow;private bool _compact;private bool _selected;private bool _hovered;/// <summary>Binds hover feedback; node references resolve lazily so the card can be bound before entering the tree.</summary>
public ActionCardBehaviour(){MouseFilter=MouseFilterEnum.Stop;MouseEntered+=OnMouseEntered;MouseExited+=OnMouseExited;}/// <summary>Re-anchors to the parent's full rect for slot placement, mirroring the scene-root layout.</summary>
public override void _Ready(){}/// <summary> Binds the action data and refreshes every rendered element: icon, translated name, ID watermark, type/cost/cooldown badges, property chips and the rich tooltip. </summary>
public void SetAction(ActionCharacter action){ActionData=action;ResolveNodes();if(action==null||_frame==null)return;_icon.Texture=action.Image;_nameLabel.Text=ActionCharacterUi.Translate(action.Name);_idLabel.Text=action.ID;TooltipText=ActionCharacterUi.BuildTooltip(action);BuildBadges(action);BuildProperties(action);}/// <summary> Compact mode drops the property chip row and shrinks the card to the single-line pool layout; slot cards use the expanded layout. </summary>
public void SetCompact(bool compact){_compact=compact;CustomMinimumSize=new Vector2(0,compact?CompactHeight:NormalHeight);ResolveNodes();if(_propsRow!=null)_propsRow.Visible= !compact&&_propsRow.GetChildCount()>0;if(_icon!=null){var half=compact?22:26;_icon.OffsetTop= -half;_icon.OffsetBottom=half;_icon.OffsetRight=compact?52:60;}}/// <summary>Gold frame for click-selected cards; drag state is handled by the panel.</summary>
public void SetSelected(bool selected){_selected=selected;ApplyFrame();}/// <summary> Returns the card to its owning slot, or leaves pool cards to the panel — a pool card has no slot to return to, so the caller must restore it through <see cref="ActionPoolBehaviour"/>. </summary>
public void ReturnToOrigin(){if(CurrentSlot==null)return;if(GetParent()!=CurrentSlot){GetParent()?.RemoveChild(this);CurrentSlot.AddChild(this);}SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);}/// <summary>Marks the card as detached from every container while it follows the cursor.</summary>
public void SetDragging(bool dragging){Modulate=dragging?UiTokens.Alpha(UiTokens.White,0.55f):UiTokens.White;}private void ResolveNodes(){_frame??=GetNodeOrNull<Panel>("Frame");_accent??=GetNodeOrNull<ColorRect>("Accent");_icon??=GetNodeOrNull<TextureRect>("Icon");_nameLabel??=GetNodeOrNull<Label>("Body/NameRow/NameLabel");_idLabel??=GetNodeOrNull<Label>("Body/NameRow/IdLabel");_badgesRow??=GetNodeOrNull<HBoxContainer>("Body/BadgesRow");_propsRow??=GetNodeOrNull<HFlowContainer>("Body/PropsRow");if(_frame!=null&& !_frame.HasThemeStyleboxOverride("panel"))_frame.AddThemeStyleboxOverride("panel",NormalFrame);}private void BuildBadges(ActionCharacter action){ClearChildren(_badgesRow);var accent=ActionCharacterUi.AccentColor(action.Type);_accent.Color=accent;_badgesRow.AddChild(ActionCharacterUi.MakeBadge(ActionCharacterUi.Translate(ActionCharacterUi.TypeLabelKey(action.Type)).ToUpperInvariant(),accent));var costText=ActionCharacterUi.CostText(action);if(costText!=null)_badgesRow.AddChild(ActionCharacterUi.MakeBadge(costText,UiTokens.AccentBright));var cooldownText=ActionCharacterUi.CooldownText(action);if(cooldownText!=null)_badgesRow.AddChild(ActionCharacterUi.MakeBadge(cooldownText,UiTokens.Cooldown));}private void BuildProperties(ActionCharacter action){ClearChildren(_propsRow);foreach(var pair in ActionCharacterUi.SortedProperties(action))_propsRow.AddChild(ActionCharacterUi.MakePropertyChip(pair.Key,pair.Value));_propsRow.Visible= !_compact&&_propsRow.GetChildCount()>0;}private static void ClearChildren(Node parent){if(parent==null)return;foreach(var child in parent.GetChildren())child.QueueFree();}private void OnMouseEntered(){_hovered=true;ApplyFrame();}private void OnMouseExited(){_hovered=false;ApplyFrame();}private void ApplyFrame(){ResolveNodes();if(_frame==null)return;_frame.AddThemeStyleboxOverride("panel",_selected?SelectedFrame:(_hovered?HoverFrame:NormalFrame));}}}

===== FILE mods/characters_base/src/UI/CharacterPanel/ActionCharacterUi.cs =====
using System;using System.Collections.Generic;using System.Text;using Cthangover.Core.Characters;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Theming;namespace Cthangover.Core.UI.CharacterPanel{/// <summary> Shared presentation rules for battle action cards, used by the card, the slot, the pool and the panel legend so they can never disagree about an action's colour, badge caption, cost or cooldown. The action model itself is deliberately schema-free — <see cref="ActionCharacter.Properties"/> is a free-form string bag — so all "what to show" decisions live here rather than on the model: known keys (<c>RequiredPoint</c>, <c>ActiveCooldown</c>) get typed rendering, unknown keys are listed verbatim. The palette is a thin alias layer over <see cref="UiTokens"/> so the panel shares the central design system. </summary>
public static class ActionCharacterUi{/// <summary> Filter tabs of the action pool: <c>null</c> type means "all". Order defines how the tabs are presented to the player. </summary>
public static readonly(ActionCharacterType?Type,string Key)[]Filters={(null,"ui/character_panel/filter_all"),(ActionCharacterType.ToEnemy,"ui/character_panel/type_enemy"),(ActionCharacterType.ToAlias,"ui/character_panel/type_alias"),(ActionCharacterType.ToSelf,"ui/character_panel/type_self"),(ActionCharacterType.Passive,"ui/character_panel/type_passive"),(ActionCharacterType.Active,"ui/character_panel/type_active"),};/// <summary>Legend order of action types; matches <see cref="Filters"/> minus "all".</summary>
public static readonly ActionCharacterType[]LegendTypes={ActionCharacterType.ToEnemy,ActionCharacterType.ToAlias,ActionCharacterType.ToSelf,ActionCharacterType.Passive,ActionCharacterType.Active,};/// <summary>Standard text alias for card body copy.</summary>
public static readonly Color Ink=UiTokens.Ink;/// <summary>Title ink alias for card names.</summary>
public static readonly Color InkBright=UiTokens.InkBright;/// <summary>Body text alias (descriptions and properties).</summary>
public static readonly Color Body=UiTokens.Body;/// <summary>Muted text alias (badges and legends).</summary>
public static readonly Color Muted=UiTokens.Muted;/// <summary>Secondary text alias (captions and metadata).</summary>
public static readonly Color Faint=UiTokens.Faint;/// <summary>Primary accent alias for active card elements.</summary>
public static readonly Color Gold=UiTokens.Accent;/// <summary>Bright accent alias for emphasized text and glyphs.</summary>
public static readonly Color GoldBright=UiTokens.AccentBright;/// <summary>Default hairline border alias.</summary>
public static readonly Color Line=UiTokens.Line;/// <summary>Card border alias.</summary>
public static readonly Color CardLine=UiTokens.LineCard;/// <summary>Card fill alias.</summary>
public static readonly Color CardBg=UiTokens.Surface1;/// <summary>List cell fill alias.</summary>
public static readonly Color CellBg=UiTokens.Surface2;/// <summary>Danger accent alias for cost and damage captions.</summary>
public static readonly Color Danger=UiTokens.Danger;/// <summary>Type accent colour following the mockup palette: enemy, ally, self, passive, active.</summary>
public static Color AccentColor(ActionCharacterType type){switch(type){case ActionCharacterType.ToEnemy:return UiTokens.TypeEnemy;case ActionCharacterType.ToAlias:return UiTokens.TypeTarget;case ActionCharacterType.ToSelf:return UiTokens.TypeSelf;case ActionCharacterType.Passive:return UiTokens.TypePassive;case ActionCharacterType.Active:return UiTokens.TypeActive;default:return UiTokens.Muted;}}/// <summary>Locale key of the badge caption for an action type.</summary>
public static string TypeLabelKey(ActionCharacterType type){switch(type){case ActionCharacterType.ToEnemy:return"ui/character_panel/type_enemy";case ActionCharacterType.ToAlias:return"ui/character_panel/type_alias";case ActionCharacterType.ToSelf:return"ui/character_panel/type_self";case ActionCharacterType.Passive:return"ui/character_panel/type_passive";case ActionCharacterType.Active:return"ui/character_panel/type_active";default:return"ui/character_panel/type_self";}}/// <summary> Action point cost read from the free-form property bag. Absent or non-positive values mean the action is free to slot (passive/active cards authored without a battle cost). </summary>
public static int CostOf(ActionCharacter action){if(action?.Properties==null)return 0;return action.GetInt("RequiredPoint",0);}/// <summary>Loc-format cost caption ("1 ОД"), or <c>null</c> when the action has no cost.</summary>
public static string CostText(ActionCharacter action){var cost=CostOf(action);return cost>0?string.Format(Translate("ui/character_panel/cost"),cost):null;}/// <summary> Seconds left before an <see cref="ActionCharacterType.Active"/> card can be used again. Zero for every other type, for cards that were never used (<c>LastUsed</c> absent) and for cards without a positive <c>ActiveCooldown</c>. </summary>
public static long CooldownRemaining(ActionCharacter action){if(action==null||action.Type!=ActionCharacterType.Active||action.Properties==null)return 0;var now=DateTimeOffset.UtcNow.ToUnixTimeSeconds();return action.GetRemainingCooldown(now);}/// <summary>Cooldown badge caption ("КД 42:10") or <c>null</c> when the card is ready.</summary>
public static string CooldownText(ActionCharacter action){var remaining=CooldownRemaining(action);return remaining>0?string.Format(Translate("ui/character_panel/cooldown"),FormatDuration(remaining)):null;}/// <summary>Formats seconds as <c>M:SS</c> or <c>H:MM:SS</c> for long cooldowns.</summary>
public static string FormatDuration(long seconds){var span=TimeSpan.FromSeconds(Mathf.Max(0,seconds));return span.TotalHours>=1?$"{(int)span.TotalHours}:{span.Minutes:00}:{span.Seconds:00}":$"{span.Minutes:00}:{span.Seconds:00}";}/// <summary> Property entries of an action sorted by key for a stable display. Keys are free-form mod data, so no whitelist filtering is applied. </summary>
public static List<KeyValuePair<string,string>>SortedProperties(ActionCharacter action){var result=new List<KeyValuePair<string,string>>();if(action?.Properties?.Values==null)return result;foreach(var pair in action.Properties.Values)result.Add(pair);result.Sort((a,b)=>string.CompareOrdinal(a.Key,b.Key));return result;}/// <summary>Plain tooltip assembled from the card fields; markup tags are stripped.</summary>
public static string BuildTooltip(ActionCharacter action){if(action==null)return string.Empty;var builder=new StringBuilder();builder.AppendLine(Translate(action.Name));builder.AppendLine(Translate(TypeLabelKey(action.Type))+" · "+action.ID);var description=StripMarkup(Translate(action.Description));if(!string.IsNullOrEmpty(description))builder.AppendLine(description);var properties=SortedProperties(action);if(properties.Count>0){builder.AppendLine();foreach(var pair in properties)builder.AppendLine($"{pair.Key}: {pair.Value}");}return builder.ToString().TrimEnd();}/// <summary>Removes view tags (e.g. <c>&lt;g&gt;1.0&lt;/g&gt;</c>) from localized descriptions.</summary>
public static string StripMarkup(string text){if(string.IsNullOrEmpty(text))return string.Empty;var builder=new StringBuilder(text.Length);var inTag=false;foreach(var symbol in text){if(symbol=='<'){inTag=true;continue;}if(symbol=='>'){inTag=false;continue;}if(!inTag)builder.Append(symbol);}return builder.ToString();}/// <summary>Translates a locale key, falling back to the key itself for literals.</summary>
public static string Translate(string keyOrLiteral){if(string.IsNullOrEmpty(keyOrLiteral))return string.Empty;return TranslationServer.Translate(keyOrLiteral).ToString();}/// <summary>Small bordered caption chip used for types, costs and cooldowns.</summary>
public static Control MakeBadge(string text,Color color,int fontSize=10)=>UiKit.Badge(text,color,fontSize);/// <summary>Property chip ("Attack 1.0") for the expanded card body.</summary>
public static Control MakePropertyChip(string key,string value)=>UiKit.PropertyChip(key,value);/// <summary>Standard dark frame for cards, slots and list rows.</summary>
public static StyleBoxFlat MakeFrame(Color border,Color background,int radius=6,int borderWidth=1)=>UiKit.Frame(radius,background,border,borderWidth);/// <summary>Convenience label factory mirroring the inventory panel's styling.</summary>
public static Label MakeLabel(string text,int size,Color color)=>UiKit.Label(text,size,color);}}

===== FILE mods/characters_base/src/UI/CharacterPanel/ActionPoolBehaviour.cs =====
using System.Collections.Generic;using Cthangover.Core.Characters;using Cthangover.Core.Factories.Impls;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Theming;namespace Cthangover.Core.UI.CharacterPanel{/// <summary> Right-hand column of the character panel: the player's persistent collection of unassigned action cards. Shows exactly what is stored in <see cref="Settings.ActionPoolData.ActionIds"/> — no filtering by assigned slots, duplicates allowed — and layers display-only search and type filters on top. The static layout (header, search box, filter tab row, scroll list, footer hint) lives in <c>scenes/ui/action_pool.tscn</c>; the panel hosts the scene instance and this behaviour resolves its nodes. Cards are rebuilt from <see cref="ActionCharacterFactory"/> on every refresh, so the pool is a pure projection of save data; drag orchestration stays in <see cref="CharacterPanelBehaviour"/>. </summary>
public partial class ActionPoolBehaviour:Control{private static readonly StyleBoxFlat InputStyle=MakeInputStyle(false);private static readonly StyleBoxFlat InputFocusStyle=MakeInputStyle(true);private readonly List<string>_ids=new();private readonly List<ActionCardBehaviour>_cards=new();private ActionCharacterType?_filter;private string _search=string.Empty;private Label _countLabel;private Label _title;private LineEdit _searchEdit;private HFlowContainer _tabsRow;private ScrollContainer _scroll;private VBoxContainer _list;private Label _footer;/// <summary>Cards currently visible in the list, in list order.</summary>
public IReadOnlyList<ActionCardBehaviour>VisibleCards=>_cards;/// <summary> Repopulates the pool from the persisted ID list. Every ID gets a card, including duplicates; unknown IDs are skipped silently. Search/filter state is preserved. </summary>
public void Refresh(List<string>poolActionIds){_ids.Clear();if(poolActionIds!=null)_ids.AddRange(poolActionIds);Rebuild();}/// <summary>Accepts a dragged card back into the pool; the data list is updated by the panel.</summary>
public void AcceptCard(ActionCardBehaviour card){if(card==null)return;card.CurrentSlot?.RemoveCard();card.CurrentSlot=null;card.SetSelected(false);card.SetCompact(true);if(card.GetParent()!=_list){card.GetParent()?.RemoveChild(card);_list.AddChild(card);}card.SizeFlagsHorizontal=SizeFlags.ExpandFill;_cards.Add(card);}/// <summary>Detaches a card from the pool visuals without touching save data.</summary>
public void RemoveCard(ActionCardBehaviour card){if(card==null)return;_cards.Remove(card);card.GetParent()?.RemoveChild(card);}private void ResolveNodes(){_title??=GetNodeOrNull<Label>("Root/Header/Title");_countLabel??=GetNodeOrNull<Label>("Root/Header/CountLabel");_searchEdit??=GetNodeOrNull<LineEdit>("Root/SearchEdit");_tabsRow??=GetNodeOrNull<HFlowContainer>("Root/TabsRow");_scroll??=GetNodeOrNull<ScrollContainer>("Root/Scroll");_list??=GetNodeOrNull<VBoxContainer>("Root/Scroll/List");_footer??=GetNodeOrNull<Label>("Root/Footer");if(_searchEdit==null)return;if(string.IsNullOrEmpty(_searchEdit.PlaceholderText)){_searchEdit.PlaceholderText=ActionCharacterUi.Translate("ui/character_panel/search");_searchEdit.AddThemeFontSizeOverride("font_size",12);_searchEdit.AddThemeStyleboxOverride("normal",InputStyle);_searchEdit.AddThemeStyleboxOverride("focus",InputFocusStyle);_searchEdit.AddThemeColorOverride("font_color",UiTokens.Ink);_searchEdit.AddThemeColorOverride("font_placeholder_color",UiTokens.Faint);_searchEdit.AddThemeColorOverride("caret_color",UiTokens.Accent);_searchEdit.TextChanged+=OnSearchChanged;}if(_title!=null&&string.IsNullOrEmpty(_title.Text)){_title.Text=ActionCharacterUi.Translate("ui/character_panel/pool_title").ToUpperInvariant();_title.AddThemeColorOverride("font_color",UiTokens.Ink);_title.AddThemeFontSizeOverride("font_size",14);}if(_countLabel!=null&&string.IsNullOrEmpty(_countLabel.Text)){_countLabel.Text="0";_countLabel.AddThemeColorOverride("font_color",UiTokens.Body);_countLabel.AddThemeFontSizeOverride("font_size",11);}if(_footer!=null&&string.IsNullOrEmpty(_footer.Text)){_footer.Text=ActionCharacterUi.Translate("ui/character_panel/pool_hint");_footer.AddThemeColorOverride("font_color",UiTokens.Faint);}}private void Rebuild(){ResolveNodes();if(_list==null||_countLabel==null)return;foreach(var card in _cards)card.QueueFree();_cards.Clear();foreach(var id in _ids){var action=ActionCharacterFactory.Instance.Get(id);if(action==null|| !Matches(action))continue;var card=CharacterPanelPrefabs.InstantiateCard();if(card==null)continue;card.SetCompact(true);card.SetAction(action);card.SizeFlagsHorizontal=SizeFlags.ExpandFill;_list.AddChild(card);_cards.Add(card);}if(_cards.Count==0)_list.AddChild(BuildEmptyLabel());_countLabel.Text=_ids.Count.ToString();BuildTabs();}private Control BuildEmptyLabel(){var hasAny=_ids.Count>0;var label=ActionCharacterUi.MakeLabel(ActionCharacterUi.Translate(hasAny?"ui/character_panel/nothing_found":"ui/character_panel/pool_empty"),12,UiTokens.Faint);label.HorizontalAlignment=HorizontalAlignment.Center;label.AutowrapMode=TextServer.AutowrapMode.WordSmart;label.CustomMinimumSize=new Vector2(0,80);label.VerticalAlignment=VerticalAlignment.Center;return label;}private void BuildTabs(){if(_tabsRow==null)return;foreach(var child in _tabsRow.GetChildren())child.QueueFree();foreach(var(type,key)in ActionCharacterUi.Filters){var button=new Button{Text=ActionCharacterUi.Translate(key)+" "+CountByType(type),ToggleMode=true,ButtonPressed=type==_filter,FocusMode=FocusModeEnum.None,};CthangoverTheme.ApplyTab(button);var captured=type;button.Pressed+=()=>OnFilterPressed(captured);_tabsRow.AddChild(button);}}private int CountByType(ActionCharacterType?type){if(type==null)return _ids.Count;var count=0;foreach(var id in _ids){var action=ActionCharacterFactory.Instance.Get(id);if(action!=null&&action.Type==type)count++;}return count;}private bool Matches(ActionCharacter action){if(_filter!=null&&action.Type!=_filter)return false;if(string.IsNullOrWhiteSpace(_search))return true;var needle=_search.Trim().ToLowerInvariant();if(ActionCharacterUi.Translate(action.Name).ToLowerInvariant().Contains(needle))return true;return action.ID!=null&&action.ID.ToLowerInvariant().Contains(needle);}private void OnFilterPressed(ActionCharacterType?type){_filter=type;Rebuild();}private void OnSearchChanged(string text){_search=text;Rebuild();}private static StyleBoxFlat MakeInputStyle(bool focus){var style=UiKit.Frame(UiTokens.RadiusSm,UiTokens.Well,focus?UiTokens.AccentLine:UiTokens.Line);style.ContentMarginLeft=10;style.ContentMarginRight=10;style.ContentMarginTop=8;style.ContentMarginBottom=8;return style;}}}

===== FILE mods/characters_base/src/UI/CharacterPanel/ActionSlotBehaviour.cs =====
using Cthangover.Core.Characters;using Cthangover.Core.Scenes;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Theming;namespace Cthangover.Core.UI.CharacterPanel{/// <summary> A single action socket on a character card. Holds at most one <see cref="ActionCardBehaviour"/> or shows an empty drop hint. The static visuals (frame panel, "СЛОТ N" caption, empty hint) live in <c>scenes/ui/action_slot.tscn</c>; instances are created by <see cref="CharacterCardBehaviour"/> through <see cref="ModScenePrefab"/>, never via <c>new</c>. The slot owns only its own visuals; drag input, hit-testing and swap rules live in <see cref="CharacterPanelBehaviour"/>, which asks the slot to highlight while a card hovers over it. Assignment changes are written back to save data by <see cref="CharacterCardBehaviour.CommitSlots"/>. </summary>
public partial class ActionSlotBehaviour:Control{/// <summary>Character ID this slot belongs to; part of the save-data key.</summary>
public string CharacterId{get;private set;}/// <summary>Zero-based slot index within the character card.</summary>
public int SlotIndex{get;private set;}/// <summary>Card currently placed in the slot, or <c>null</c> when empty.</summary>
public ActionCardBehaviour CurrentCard{get;private set;}/// <summary>True when no card is placed; empty slots accept any card.</summary>
public bool IsEmpty=>CurrentCard==null;private static readonly StyleBoxFlat EmptyFrame=UiKit.Frame(UiTokens.RadiusMd,UiTokens.Surface0,UiTokens.LineFaint);private static readonly StyleBoxFlat FilledFrame=UiKit.Frame(UiTokens.RadiusMd,UiTokens.Surface1,UiTokens.LineCard);private static readonly StyleBoxFlat HoverFrame=UiKit.Frame(UiTokens.RadiusMd,UiTokens.DropBg,UiTokens.TypePassive,2);private Panel _frame;private Label _slotLabel;private Label _emptyLabel;private bool _highlighted;/// <summary> Binds the save-data identity of the slot and renders the "СЛОТ N" caption. Must be called by the owning card before use. </summary>
public void Init(string characterId,int slotIndex){CharacterId=characterId;SlotIndex=slotIndex;ResolveNodes();_slotLabel.Text=string.Format(ActionCharacterUi.Translate("ui/character_panel/slot"),slotIndex+1).ToUpperInvariant();UpdateState();}/// <summary> Places the given card into this slot, detaching it from any previous slot. Passing <c>null</c> clears the slot. The displaced card (if any) is handed back to the caller by returning it through <see cref="CurrentCard"/> before the call. </summary>
public void PlaceCard(ActionCardBehaviour card){if(card==null){RemoveCard();return;}if(CurrentCard!=null&&CurrentCard!=card){if(CurrentCard.GetParent()==this)RemoveChild(CurrentCard);CurrentCard.CurrentSlot=null;CurrentCard=null;}card.CurrentSlot?.RemoveCard();card.CurrentSlot=this;if(card.GetParent()!=this){card.GetParent()?.RemoveChild(card);AddChild(card);}card.SetCompact(false);card.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);CurrentCard=card;UpdateState();}/// <summary>Detaches the current card without destroying it; the card object stays valid for swapping.</summary>
public void RemoveCard(){if(CurrentCard==null)return;CurrentCard.CurrentSlot=null;CurrentCard=null;UpdateState();}/// <summary>Action ID currently in this slot, or <c>null</c> when empty.</summary>
public string GetActionId(){return CurrentCard?.ActionData?.ID;}/// <summary>Green frame while a dragged card hovers over the slot.</summary>
public void SetHighlight(bool highlighted){_highlighted=highlighted;ApplyFrame();}private void ResolveNodes(){_frame??=GetNodeOrNull<Panel>("Frame");_slotLabel??=GetNodeOrNull<Label>("SlotLabel");_emptyLabel??=GetNodeOrNull<Label>("EmptyLabel");if(_emptyLabel!=null&&string.IsNullOrEmpty(_emptyLabel.Text))_emptyLabel.Text=ActionCharacterUi.Translate("ui/character_panel/slot_empty");}private void UpdateState(){ResolveNodes();if(_emptyLabel!=null)_emptyLabel.Visible=CurrentCard==null;ApplyFrame();}private void ApplyFrame(){ResolveNodes();if(_frame==null)return;_frame.AddThemeStyleboxOverride("panel",_highlighted?HoverFrame:(CurrentCard==null?EmptyFrame:FilledFrame));}}}

===== FILE mods/characters_base/src/UI/CharacterPanel/CharacterCardBehaviour.cs =====
using System.Collections.Generic;using Cthangover.Core.Characters;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Theming;namespace Cthangover.Core.UI.CharacterPanel{/// <summary> Party member column of the character panel: portrait, name, derived stats and the three <see cref="ActionSlotBehaviour"/> sockets that the player fills from the action pool. The static layout (margins, header, stat row, slot stack with the three <c>action_slot.tscn</c> instances) lives in <c>scenes/ui/character_card.tscn</c>; instances are created by the panel through <see cref="ModScenePrefab"/>, never via <c>new</c>. <para/> The card owns the slot→save-data binding: <see cref="RefreshSlots"/> reads <see cref="CharacterInfoData.ActionSlots"/> and materializes action cards (through the card prefab), <see cref="CommitSlots"/> writes the current placement back. Mods extend the card pool through <c>ActionCharacterFactory</c> data, not through scene overrides. </summary>
public partial class CharacterCardBehaviour:PanelContainer{/// <summary>Factory key of the character shown by this card.</summary>
public string CharacterId{get;private set;}/// <summary>Fixed three sockets, index 0..2 maps to save-data slot order.</summary>
public ActionSlotBehaviour[]Slots{get;}=new ActionSlotBehaviour[3];private static readonly StyleBoxFlat CardStyle=UiKit.Frame(UiTokens.RadiusMd,UiTokens.SurfaceRaised,UiTokens.Line);private TextureRect _portrait;private Label _nameLabel;private Label _subLabel;private HFlowContainer _statsRow;private CharacterInfoData _characterInfo;private bool _styled;/// <summary>Applies the card frame; node references resolve lazily so the card can be bound before entering the tree.</summary>
public CharacterCardBehaviour(){MouseFilter=MouseFilterEnum.Stop;}/// <summary> Populates the card from a runtime character entry and its factory template: portrait, localized name, template size caption, derived stats, then the slot cards themselves. </summary>
public void SetCharacter(string characterId,CharacterInfoData info){CharacterId=characterId;_characterInfo=info;ResolveNodes();var template=CharacterFactory.Instance.Get(characterId);if(_portrait!=null)_portrait.Texture=template?.Image;if(_nameLabel!=null)_nameLabel.Text=ActionCharacterUi.Translate(template?.Name??characterId);if(_subLabel!=null)_subLabel.Text=$"{characterId} · "+string.Format(ActionCharacterUi.Translate("ui/character_panel/template"),template?.Actions?.Count??0);BuildStats();RefreshSlots();}/// <summary> Rebuilds all three sockets from <see cref="CharacterInfoData.ActionSlots"/>. Missing list entries are normalized to three slots with trailing <c>null</c>s, matching the save format written by <see cref="CommitSlots"/>. </summary>
public void RefreshSlots(){if(_characterInfo==null)return;if(_characterInfo.ActionSlots==null)_characterInfo.ActionSlots=new List<string>{null,null,null};while(_characterInfo.ActionSlots.Count<3)_characterInfo.ActionSlots.Add(null);for(var i=0;i<3;i++){var slot=Slots[i];if(slot==null)continue;slot.Init(CharacterId,i);if(slot.CurrentCard!=null){slot.CurrentCard.QueueFree();slot.RemoveCard();}var actionId=_characterInfo.ActionSlots[i];if(string.IsNullOrEmpty(actionId))continue;var action=ActionCharacterFactory.Instance.Get(actionId);if(action==null)continue;var card=CharacterPanelPrefabs.InstantiateCard();if(card==null)continue;slot.AddChild(card);card.SetAction(action);slot.PlaceCard(card);}}/// <summary>Writes the current socket assignments back to <see cref="CharacterInfoData.ActionSlots"/>.</summary>
public void CommitSlots(){if(_characterInfo==null)return;if(_characterInfo.ActionSlots==null)_characterInfo.ActionSlots=new List<string>();while(_characterInfo.ActionSlots.Count<3)_characterInfo.ActionSlots.Add(null);for(var i=0;i<3;i++)_characterInfo.ActionSlots[i]=Slots[i]?.GetActionId();}private void ResolveNodes(){_portrait??=GetNodeOrNull<TextureRect>("Margin/Root/Header/PortraitPanel/Portrait");_nameLabel??=GetNodeOrNull<Label>("Margin/Root/Header/Info/NameLabel");_subLabel??=GetNodeOrNull<Label>("Margin/Root/Header/Info/SubLabel");_statsRow??=GetNodeOrNull<HFlowContainer>("Margin/Root/Header/Info/StatsRow");if(!_styled){_styled=true;AddThemeStyleboxOverride("panel",CardStyle);}if(Slots[0]==null){Slots[0]=GetNodeOrNull<ActionSlotBehaviour>("Margin/Root/SlotsBox/Slot0");Slots[1]=GetNodeOrNull<ActionSlotBehaviour>("Margin/Root/SlotsBox/Slot1");Slots[2]=GetNodeOrNull<ActionSlotBehaviour>("Margin/Root/SlotsBox/Slot2");}}private void BuildStats(){ClearChildren(_statsRow);var attributes=_characterInfo?.Attributes;if(attributes==null)return;var health=attributes.Health;var healthText=health.BaseValue>0?$"{health.Value}/{health.BaseValue}":health.Value.ToString();_statsRow.AddChild(UiKit.StatChip(ActionCharacterUi.Translate("ui/character_panel/stat_hp"),healthText,UiTokens.HpText));_statsRow.AddChild(UiKit.StatChip(ActionCharacterUi.Translate("ui/character_panel/stat_ap"),attributes.Point.Value.ToString(),UiTokens.AccentBright));_statsRow.AddChild(UiKit.StatChip(ActionCharacterUi.Translate("ui/character_panel/stat_atk"),attributes.Attack.Value.ToString(),UiTokens.Ink));_statsRow.AddChild(UiKit.StatChip(ActionCharacterUi.Translate("ui/character_panel/stat_def"),attributes.Defence.Value.ToString(),UiTokens.Ink));}private static void ClearChildren(Node parent){if(parent==null)return;foreach(var child in parent.GetChildren())child.QueueFree();}}}

===== FILE mods/characters_base/src/UI/CharacterPanel/CharacterPanelBehaviour.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Characters;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Settings;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.Core.UI.CharacterPanel{/// <summary> Character action panel: a header with the type legend chips and the close button, a party column of <see cref="CharacterCardBehaviour"/> and the <see cref="ActionPoolBehaviour"/> on the right, and a footer with the assignment counter and reset button. The static layout (backdrop, panel frame, headers, spacers, scroll hosts) lives in <c>scenes/ui/character_panel.tscn</c>; this behaviour resolves those nodes and owns only the dynamic layer: legend badges, localized captions, character cards and their action cards (data-driven widgets built from factory data), and the drag input model. Input model: drag orchestration and click-assignment both run through the node-level `_Input`, not per-card `GuiInput` — during a drag the cursor leaves the card rect and GuiInput would stop firing. A press on a card starts a potential drag; passing the 5 px threshold promotes it to a real drag. A press without motion selects the card (gold frame); clicking a socket afterwards moves or swaps it. Dropping outside a socket and outside the pool returns the card to its origin. </summary>
public partial class CharacterPanelBehaviour:Widget{private const int DragThreshold=5;// Scene nodes (character_panel.tscn)
private Label _title;private HFlowContainer _legend;private Label _dragHint;private Button _closeButton;private Label _partyTitle;private Label _partyCountLabel;private Label _partySub;private HFlowContainer _charactersContainer;private Control _actionPoolHost;private Label _statusLabel;private Button _resetButton;private ActionPoolBehaviour _actionPool;private ActionPoolData _actionPoolData;private CanvasLayer _dragCanvas;private readonly List<CharacterCardBehaviour>_characterCards=new();private ActionCardBehaviour _draggedCard;private ActionCardBehaviour _potentialCard;private ActionCardBehaviour _selectedCard;private ActionSlotBehaviour _hoveredSlot;private Vector2 _localMouseOffset;private Vector2 _dragStartMousePos;private int _skipFrames;/// <summary>Binds scene nodes, applies theme and localized captions, and wires the dynamic layer.</summary>
protected override void OnceConstruct(){MouseFilter=MouseFilterEnum.Stop;_actionPoolData=GameData.Instance.Runtime.ActionPool;_title=GetNode<Label>("Panel/Margin/Root/Header/Title");_legend=GetNode<HFlowContainer>("Panel/Margin/Root/Header/Legend");_dragHint=GetNode<Label>("Panel/Margin/Root/Header/DragHint");_closeButton=GetNode<Button>("Panel/Margin/Root/Header/CloseButton");_partyTitle=GetNode<Label>("Panel/Margin/Root/Body/Left/TitleRow/PartyTitle");_partyCountLabel=GetNode<Label>("Panel/Margin/Root/Body/Left/TitleRow/PartyCount");_partySub=GetNode<Label>("Panel/Margin/Root/Body/Left/TitleRow/PartySub");_charactersContainer=GetNode<HFlowContainer>("Panel/Margin/Root/Body/Left/Scroll/CharactersContainer");_actionPoolHost=GetNode<Control>("Panel/Margin/Root/Body/Right/ActionPool");_statusLabel=GetNode<Label>("Panel/Margin/Root/Footer/StatusLabel");_resetButton=GetNode<Button>("Panel/Margin/Root/Footer/ResetButton");ApplyTheme();ApplyCaptions();_dragCanvas=new CanvasLayer{Name="DragCanvas",Layer=100};AddChild(_dragCanvas);_actionPool=CharacterPanelPrefabs.Pool.Instantiate<ActionPoolBehaviour>();_actionPoolHost.AddChild(_actionPool);_closeButton.Pressed+=Hide;_resetButton.Pressed+=OnResetClick;foreach(var type in ActionCharacterUi.LegendTypes){_legend.AddChild(ActionCharacterUi.MakeBadge(ActionCharacterUi.Translate(ActionCharacterUi.TypeLabelKey(type)).ToUpperInvariant(),ActionCharacterUi.AccentColor(type)));}}/// <summary>Theme overrides for scene nodes that depend on UiKit tokens (styles stay code-side like the rest of the toolkit).</summary>
private void ApplyTheme(){var panel=GetNode<Panel>("Panel");panel.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusLg,UiTokens.Surface0,UiTokens.Line));_title.AddThemeColorOverride("font_color",ActionCharacterUi.InkBright);_dragHint.AddThemeColorOverride("font_color",UiTokens.Faint);_dragHint.AddThemeColorOverride("font_outline_color",UiTokens.Alpha(Colors.Black,1f));_dragHint.AddThemeConstantOverride("outline_size",4);_partyTitle.AddThemeColorOverride("font_color",UiTokens.Muted);_partyCountLabel.AddThemeColorOverride("font_color",UiTokens.Body);_partySub.AddThemeColorOverride("font_color",UiTokens.Faint);_statusLabel.AddThemeColorOverride("font_color",UiTokens.Faint);var right=GetNode<PanelContainer>("Panel/Margin/Root/Body/Right");var style=UiKit.Frame(0,UiTokens.SideBg);style.BorderColor=UiTokens.Line;style.BorderWidthLeft=1;style.ContentMarginLeft=16;style.ContentMarginRight=16;style.ContentMarginTop=14;style.ContentMarginBottom=14;right.AddThemeStyleboxOverride("panel",style);foreach(var ruleName in new[]{"Rule1","Rule2"}){var rule=GetNode<Panel>($"Panel/Margin/Root/{ruleName}");rule.AddThemeStyleboxOverride("panel",UiKit.Frame(0,UiTokens.Line,UiTokens.Line));}CthangoverTheme.ApplyIcon(_closeButton);CthangoverTheme.ApplyDanger(_resetButton);var backdrop=GetNode<ColorRect>("Backdrop");UiKit.ApplyPanelBackdrop(backdrop);}/// <summary>Localized captions for all static labels of the scene.</summary>
private void ApplyCaptions(){_title.Text=ActionCharacterUi.Translate("ui/character_panel/title").ToUpperInvariant();_dragHint.Text=ActionCharacterUi.Translate("ui/character_panel/drag_hint");_partyTitle.Text=ActionCharacterUi.Translate("ui/character_panel/party").ToUpperInvariant();_partySub.Text=ActionCharacterUi.Translate("ui/character_panel/party_sub");_closeButton.TooltipText=ActionCharacterUi.Translate("ui/character_panel/close");_resetButton.Text=ActionCharacterUi.Translate("ui/character_panel/reset");}protected override void ShowConstruct(){ReloadCharacters();_skipFrames=2;}protected override void HideDestruct(){ClearCharacterCards();ClearSelection();}/// <summary> Appends an action ID to the persistent pool and refreshes the list. Public entry point for scenarios and mods that award collectible action cards. </summary>
public void AddActionToPool(string actionId){if(string.IsNullOrEmpty(actionId))return;_actionPoolData?.ActionIds.Add(actionId);RefreshPool();}/// <summary>Counts down the post-rebuild input skip frames.</summary>
public override void _Process(double delta){if(_skipFrames>0)_skipFrames--;}/// <summary>Routes mouse input to card dragging; skipped for a few frames after layout rebuilds to avoid accidental drags.</summary>
public override void _Input(InputEvent@event){if(!Visible||_skipFrames>0)return;if(@event is InputEventMouseButton mb&&mb.ButtonIndex==MouseButton.Left){if(mb.Pressed)OnLeftPressed(mb.GlobalPosition);else OnLeftReleased(mb.GlobalPosition);return;}if(@event is InputEventMouseMotion mm){if(_draggedCard!=null){_draggedCard.GlobalPosition=mm.GlobalPosition-_localMouseOffset;UpdateSlotHighlight(mm.GlobalPosition);AcceptEvent();}else if(_potentialCard!=null&&(mm.GlobalPosition-_dragStartMousePos).Length()>DragThreshold){StartDrag(_potentialCard);}}}private void OnLeftPressed(Vector2 mousePos){if(_draggedCard!=null)return;var card=FindValidCardAt(mousePos);if(card!=null){_potentialCard=card;_dragStartMousePos=mousePos;return;}var slot=FindSlotAt(mousePos);if(slot!=null){if(_selectedCard!=null)MoveToSlot(_selectedCard,slot);return;}if(_selectedCard!=null&& !IsOverPool(mousePos))ClearSelection();}private void OnLeftReleased(Vector2 mousePos){if(_draggedCard!=null){FinishDrag(mousePos);AcceptEvent();return;}if(_potentialCard==null)return;var card=_potentialCard;_potentialCard=null;HandleCardClick(card);}private void StartDrag(ActionCardBehaviour card){if(!GodotObject.IsInstanceValid(card))return;_potentialCard=null;_draggedCard=card;var sourceSlot=card.CurrentSlot;ClearSelection();_localMouseOffset=GetGlobalMousePosition()-card.GlobalPosition;var globalPos=card.GlobalPosition;var cardSize=card.Size;GameLogger.Log("DRAG",$"StartDrag: action='{card.ActionData?.ID}', "+$"source={(sourceSlot != null ? $"slot[{sourceSlot.SlotIndex}] char='{sourceSlot.CharacterId}'" : "pool")}");if(sourceSlot==null){_actionPool?.RemoveCard(card);RemovePoolId(card.ActionData?.ID);}card.GetParent()?.RemoveChild(card);_dragCanvas.AddChild(card);card.SetAnchorsPreset(LayoutPreset.TopLeft,false);card.Size=cardSize;card.GlobalPosition=globalPos;card.ZIndex=100;card.SetDragging(true);}private void FinishDrag(Vector2 mousePos){ClearHoverHighlight();var card=_draggedCard;_draggedCard=null;var targetSlot=FindSlotAt(mousePos);if(targetSlot!=null&&targetSlot!=card.CurrentSlot)MoveToSlot(card,targetSlot);else if(IsOverPool(mousePos))MoveToPool(card);else RestoreCard(card);if(GodotObject.IsInstanceValid(card))card.SetDragging(false);UpdateStatus();}private void HandleCardClick(ActionCardBehaviour card){if(!GodotObject.IsInstanceValid(card))return;if(_selectedCard==card){ClearSelection();return;}if(_selectedCard!=null&&card.CurrentSlot!=null){MoveToSlot(_selectedCard,card.CurrentSlot);return;}SelectCard(card);}private void SelectCard(ActionCardBehaviour card){ClearSelection();_selectedCard=card;card.SetSelected(true);}private void ClearSelection(){if(_selectedCard!=null&&GodotObject.IsInstanceValid(_selectedCard))_selectedCard.SetSelected(false);_selectedCard=null;}/// <summary> Moves (or swaps) a card into the target slot. A card dragged from another slot swaps with its displaced occupant; a card taken from the pool sends the displaced card back to the pool and consumes one pool-list entry (duplicates remain untouched). </summary>
private void MoveToSlot(ActionCardBehaviour card,ActionSlotBehaviour target){if(card==null||target==null|| !GodotObject.IsInstanceValid(card))return;var source=card.CurrentSlot;if(source==target){ClearSelection();return;}var displaced=target.CurrentCard;var poolChanged=source==null;if(source==null)_actionPool?.RemoveCard(card);target.PlaceCard(card);if(displaced!=null){if(source!=null)source.PlaceCard(displaced);else AddPoolId(displaced.ActionData?.ID);}if(poolChanged)RemovePoolId(card.ActionData?.ID);CommitCards();if(poolChanged)RefreshPool();ClearSelection();UpdateStatus();GameLogger.Log("DRAG",$"Move: action='{card.ActionData?.ID}' -> "+$"slot[{target.SlotIndex}] char='{target.CharacterId}'{(displaced != null ? $", displaced='{displaced.ActionData?.ID}'" : string.Empty)}");}private void MoveToPool(ActionCardBehaviour card){var source=card.CurrentSlot;source?.RemoveCard();card.SetCompact(true);_actionPool?.AcceptCard(card);AddPoolId(card.ActionData?.ID);if(source!=null)CommitCards();RefreshPool();UpdateStatus();}private void RestoreCard(ActionCardBehaviour card){if(card.CurrentSlot!=null){card.ReturnToOrigin();return;}card.QueueFree();AddPoolId(card.ActionData?.ID);RefreshPool();UpdateStatus();}private void UpdateSlotHighlight(Vector2 mousePos){var newHovered=FindSlotAt(mousePos);if(newHovered==_hoveredSlot)return;ClearHoverHighlight();_hoveredSlot=newHovered;_hoveredSlot?.SetHighlight(true);}private void ClearHoverHighlight(){_hoveredSlot?.SetHighlight(false);_hoveredSlot=null;}private void ReloadCharacters(){ClearCharacterCards();var characterData=GameData.Instance.Runtime.CharacterData;if(characterData?.BattleSet==null||characterData.Characters==null){RefreshPool();UpdateStatus();return;}foreach(var characterId in characterData.BattleSet){if(!characterData.Characters.TryGetValue(characterId,out var info))continue;var card=CharacterPanelPrefabs.CardInstance();if(card==null)continue;card.Name=$"Card_{characterId}";_charactersContainer.AddChild(card);card.SetCharacter(characterId,info);_characterCards.Add(card);}RefreshPool();UpdateStatus();}private void ClearCharacterCards(){foreach(var card in _characterCards)card.QueueFree();_characterCards.Clear();}private void RefreshPool(){_actionPool?.Refresh(_actionPoolData?.ActionIds);}private void CommitCards(){foreach(var card in _characterCards)card.CommitSlots();}private void AddPoolId(string id){if(!string.IsNullOrEmpty(id))_actionPoolData?.ActionIds.Add(id);}private void RemovePoolId(string id){if(!string.IsNullOrEmpty(id))_actionPoolData?.ActionIds.Remove(id);}private void UpdateStatus(){if(_statusLabel==null)return;var assigned=_characterCards.Sum(card=>card.Slots.Count(slot=>slot!=null&& !slot.IsEmpty));var total=_characterCards.Count*3;var pool=_actionPoolData?.ActionIds?.Count??0;_statusLabel.Text=string.Format(ActionCharacterUi.Translate("ui/character_panel/assigned"),assigned,total,pool)+" · "+ActionCharacterUi.Translate("ui/character_panel/hint_select");if(_partyCountLabel!=null)_partyCountLabel.Text=_characterCards.Count.ToString();}private ActionCardBehaviour FindValidCardAt(Vector2 globalPos){foreach(var characterCard in _characterCards){foreach(var slot in characterCard.Slots){var card=slot?.CurrentCard;if(card==null|| !GodotObject.IsInstanceValid(card))continue;if(card.GetGlobalRect().HasPoint(globalPos))return card;}}if(_actionPool!=null){foreach(var poolCard in _actionPool.VisibleCards){if(GodotObject.IsInstanceValid(poolCard)&&poolCard.GetGlobalRect().HasPoint(globalPos))return poolCard;}}return null;}private ActionSlotBehaviour FindSlotAt(Vector2 globalPos){foreach(var characterCard in _characterCards){foreach(var slot in characterCard.Slots){if(slot!=null&&slot.GetGlobalRect().HasPoint(globalPos))return slot;}}return null;}private bool IsOverPool(Vector2 globalPos){return _actionPool!=null&&_actionPool.GetGlobalRect().HasPoint(globalPos);}private void OnResetClick(){var dialog=new ConfirmationDialog();dialog.Title=ActionCharacterUi.Translate("ui/character_panel/reset_title");dialog.DialogText=ActionCharacterUi.Translate("ui/character_panel/reset_text");dialog.OkButtonText=ActionCharacterUi.Translate("ui/character_panel/reset_ok");dialog.CancelButtonText=ActionCharacterUi.Translate("ui/character_panel/reset_cancel");dialog.Exclusive=true;AddChild(dialog);dialog.Confirmed+=()=>{PerformReset();dialog.QueueFree();};dialog.Canceled+=()=>dialog.QueueFree();dialog.PopupCentered();}private void PerformReset(){var characterData=GameData.Instance.Runtime.CharacterData;if(characterData?.BattleSet==null||characterData.Characters==null)return;ActionAssignmentReset.Apply(characterData,_actionPoolData,CharacterFactory.Instance.Get);ReloadCharacters();GameLogger.Log("UI","CharacterPanel.PerformReset: assignments restored to templates, awarded cards returned to pool");}}}

===== FILE mods/characters_base/src/UI/CharacterPanel/CharacterPanelPrefabs.cs =====
using Cthangover.Core.Scenes;namespace Cthangover.Core.UI.CharacterPanel{/// <summary> Shared <see cref="ModScenePrefab"/> handles for the character panel's scene-built parts (cards, slots, the pool). Godot cannot load files inside <c>mods/</c> directly, so every instantiation of these UI pieces goes through the runtime tscn pipeline; the handles are created once and reused so repeated <see cref="ModScenePrefab.Instantiate{T}"/> calls stay cheap cache hits in <see cref="TscnScenes"/>. </summary>
public static class CharacterPanelPrefabs{private static ModScenePrefab _card;private static ModScenePrefab _slot;private static ModScenePrefab _pool;private static ModScenePrefab _characterCard;/// <summary>Prefab of the action card (<c>scenes/ui/action_card.tscn</c>).</summary>
public static ModScenePrefab Card=>_card??=new ModScenePrefab("scenes/ui/action_card.tscn");/// <summary>Prefab of the action slot (<c>scenes/ui/action_slot.tscn</c>).</summary>
public static ModScenePrefab Slot=>_slot??=new ModScenePrefab("scenes/ui/action_slot.tscn");/// <summary>Prefab of the action pool column (<c>scenes/ui/action_pool.tscn</c>).</summary>
public static ModScenePrefab Pool=>_pool??=new ModScenePrefab("scenes/ui/action_pool.tscn");/// <summary>Prefab of the character card (<c>scenes/ui/character_card.tscn</c>).</summary>
public static ModScenePrefab CharacterCard=>_characterCard??=new ModScenePrefab("scenes/ui/character_card.tscn");/// <summary>Instantiates a fresh action card from its scene; null when the scene cannot be resolved.</summary>
public static ActionCardBehaviour InstantiateCard()=>Card.Instantiate<ActionCardBehaviour>();/// <summary>Instantiates a fresh action slot from its scene; null when the scene cannot be resolved.</summary>
public static ActionSlotBehaviour InstantiateSlot()=>Slot.Instantiate<ActionSlotBehaviour>();/// <summary>Instantiates a fresh character card from its scene; null when the scene cannot be resolved.</summary>
public static CharacterCardBehaviour CardInstance()=>CharacterCard.Instantiate<CharacterCardBehaviour>();}}

===== FILE mods/cooking/src/CookingSettings.cs =====
using System.Collections.Generic;using Cthangover.Core.Mods;using Cthangover.Core.Settings;namespace Cthangover.Cooking{/// <summary> Exposes a user-configurable setting for the number of rations consumed per character per in-game day. Registered automatically via <see cref="ModSettingsRegistry"/> when the cooking mod assembly loads. The static <see cref="RationsPerCharacter"/> property is the canonical source of truth shared with <c>RationStatisticsPanel</c> and any future consumption logic. It is updated by the core on boot (from persisted JSON) and on every UI change. </summary>
public class CookingSettings:IModSettings{/// <summary> Current value for rations consumed per character per day. Default is 1. Updated by <see cref="ReadValues"/> on boot and whenever the player changes the setting in the menu. </summary>
public static int RationsPerCharacter{get;private set;}=1;public IReadOnlyList<ModSettingDefinition>GetDefinitions(){return new List<ModSettingDefinition>{new(){Key="rations_per_character",Name="mod_cooking/rations_per_character",Type=SettingType.Slider,DefaultValue="1",Min=1f,Max=10f,Step=1f,},};}public void WriteValues(DataBlob blob){blob.SetInt("rations_per_character",RationsPerCharacter);}public void ReadValues(DataBlob blob){RationsPerCharacter=blob.GetInt("rations_per_character",1);if(RationsPerCharacter<1)RationsPerCharacter=1;}}}

===== FILE mods/cooking/src/CookingSettingsForTests.cs =====
using Cthangover.Core.Settings;namespace Cthangover.Cooking{/// <summary> Test accessor over <see cref="CookingSettings"/>: the real class exposes <c>RationsPerCharacter</c> with a private setter because only the core settings pipeline may write it, and the getter is static — mod unit tests exercise the clamp through the public <c>ReadValues</c> contract and assert via this static passthrough, so no reflection into private members is needed. Stateless and harmless in production builds. </summary>
public static class CookingSettingsForTests{/// <summary>Current parsed value, for test assertions.</summary>
public static int RationsPerCharacter=>CookingSettings.RationsPerCharacter;}}

===== FILE mods/cooking/src/items/actions/WolfMeatToRation.cs =====
using Cthangover.Core.Settings;namespace Cthangover.Core.Items.Actions{/// <summary> Performs the "Wolf Meat to Ration" recipe-unlock action. When a player uses raw wolf meat, this action checks whether the <c>recipe/wolf_meat_to_ration</c> flag is already present in <see cref="RecipeData"/>; if not, it adds it, permanently unlocking the recipe in the <see cref="Mods.Cooking.Workbench.WorkbenchPanel"/>. Returns <c>false</c> when already unlocked (no-op) and <c>true</c> on first unlock. </summary>
public class WolfMeatToRation:IItemAction{/// <summary> Globally unique action identifier, matched by the item system to trigger this action when an item with this ID is used. </summary>
public string ID=>"action/recipe/wolf_meat_to_ration";/// <summary> Idempotently unlocks the wolf-meat-to-ration recipe. Adds <c>recipe/wolf_meat_to_ration</c> to <see cref="RecipeData"/> so that the cooking workbench can list it. </summary> <returns><c>true</c> if the recipe was newly unlocked; <c>false</c> if already known.</returns>
public bool UseAction(IItem item){var data=GameData.Instance.Runtime.RecipeData;if(data.Has("recipe/wolf_meat_to_ration"))return false;data.Add("recipe/wolf_meat_to_ration");return true;}}}

===== FILE mods/cooking/src/ui/RationStatisticsPanel.cs =====
using Cthangover.Cooking;using System;using Cthangover.Core.Settings;using Cthangover.Core.UI;using Godot;using Cthangover.Mods.Interface.Base.Lists;namespace Mods.Cooking.Rations{/// <summary> Displays live ration statistics on the dinner/ration UI screen. Shows the player the current ration count, how many rations are consumed daily (one per character), and the maximum number of days the party can survive on available rations. Listens to <see cref="Core.Items.InventoryBag.Change"/> events to refresh automatically whenever the inventory is modified. </summary>
public class RationStatisticsPanel:Widget{private RichTextLabel label;private Action<string,int>_onInventoryChange;protected override void OnceConstruct(){label=GetNode<RichTextLabel>("RationMargin/RationLabel");_onInventoryChange=(_,_)=>UpdateInfo();GameData.Instance.Runtime.Inventory.Change+=_onInventoryChange;}protected override void ShowConstruct(){UpdateInfo();}protected override void OnceDestruct(){GameData.Instance.Runtime.Inventory.Change-=_onInventoryChange;}/// <summary> Recomputes and refreshes the ration-statistics text. Reads current ration inventory via <see cref="Core.Items.InventoryBag.CheckCount"/> for <c>"food/ration"</c> and the total character count from <see cref="CharacterData"/>. Formats three localized lines: total rations, daily consumption, and remaining days (rations divided by character count, clamped to 1 minimum). </summary>
public void UpdateInfo(){if(label==null)return;var rationCount=GameData.Instance.Runtime.Inventory.CheckCount("food/ration");var characterCount=GameData.Instance.Runtime.CharacterData.Characters.Count;var perChar=CookingSettings.RationsPerCharacter;var dailyConsumption=characterCount*perChar;label.Text=string.Format(TranslationServer.Translate("ui/diner/rationcount"),rationCount)+"\n"+string.Format(TranslationServer.Translate("ui/diner/rationday"),dailyConsumption)+"\n"+string.Format(TranslationServer.Translate("ui/diner/rationmax"),rationCount/Mathf.Max(1,dailyConsumption));}}}

===== FILE mods/cooking/src/ui/workbench/RecipeIconItemBehaviour.cs =====
using Cthangover.Core.Items;using Cthangover.Core.Settings;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Base.Lists;namespace Mods.Cooking.Workbench{/// <summary> Renders a single ingredient row inside a <see cref="RecipeItemBehaviour"/> on the cooking workbench. Displays the ingredient's item icon and the required count. When <see cref="CheckAndUpdate"/> is called, compares inventory count against the recipe requirement and tints the icon and count text grey if the player lacks enough items. </summary>
public class RecipeIconItemBehaviour:Widget{private static readonly Color NormalColor=UiTokens.InkBright;private static readonly Color NotItemsColor=UiTokens.Muted;private TextureRect img;private Label txt;private IIngredient ingredient;protected override void OnceConstruct(){img=GetNode<TextureRect>("Img");txt=GetNode<Label>("Txt");}/// <summary> Binds this icon widget to a recipe ingredient definition. Sets the icon texture from <c>ingredient.Item.Sprite</c> (logs an error if the item reference is null) and displays the required count as text. The <paramref name="hasItems"/> flag is currently unused but reserved for future visual pre-initialization. </summary>
public void Init(IIngredient ingredient,bool hasItems){EnsureConstructed();this.ingredient=ingredient;if(ingredient.Item!=null){img.Texture=ingredient.Item.Sprite;}else{GameLogger.Log("MOD_TEST",$"RecipeIconItemBehaviour::Init ingredient.Item is NULL",LogLevel.Error);}txt.Text=ingredient.Count.ToString();}/// <summary> Evaluates whether the player's inventory contains enough of the required ingredient. Tints the icon and count text to <c>NormalColor</c> (bright ink) when satisfied or <c>NotItemsColor</c> (muted) when insufficient. Called by the parent <see cref="RecipeItemBehaviour"/> during state refresh. </summary> <returns><c>true</c> if inventory meets or exceeds the required count.</returns>
public bool CheckAndUpdate(){var result=GameData.Instance.Runtime.Inventory.CheckCount(ingredient.Item)>=ingredient.Count;txt.AddThemeColorOverride("font_color",result?NormalColor:NotItemsColor);img.Modulate=result?UiTokens.White:NotItemsColor;return result;}}}

===== FILE mods/cooking/src/ui/workbench/RecipeItemBehaviour.cs =====
using System.Collections.Generic;using Cthangover.Core.Items;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;namespace Mods.Cooking.Workbench{/// <summary> Represents a single recipe entry in the <see cref="WorkbenchPanel"/> list. Displays the recipe name, preparation time, and a horizontal row of ingredient icons. Supports click-to-select with visual highlight (tracked statically so only one recipe is selected at a time). Fires <see cref="RecipeClicked"/> to notify the parent panel of selection changes. Ingredient availability is checked via child <see cref="RecipeIconItemBehaviour"/> instances, greying out the entire row when any ingredient is missing. </summary>
public class RecipeItemBehaviour:Widget{private Control background;private Control recipeContent;private Label txtName;private Label txtTime;private HBoxContainer recipeContentHbox;private static readonly Color normalColor=UiTokens.White;private static readonly Color selectedColor=UiTokens.Alpha(UiTokens.White,0.8f);private static readonly Color disableColor=UiTokens.Muted;private IRecipe recipe;private Node panel;private static RecipeItemBehaviour selected;/// <summary> The recipe currently selected across all <see cref="RecipeItemBehaviour"/> instances. Only one recipe can be selected at a time because the selection is tracked via a static backing field. Returns <c>null</c> when no recipe is selected. </summary>
public static IRecipe SelectedRecipe=>selected?.recipe;private readonly List<Node>items=new();private readonly List<RecipeIconItemBehaviour>ingredients=new();/// <summary> Fired when this recipe entry is clicked, passing the recipe data and this widget so that <see cref="WorkbenchPanel"/> can update the description and output preview panel. </summary>
public event System.Action<IRecipe,RecipeItemBehaviour>RecipeClicked;protected override void OnceConstruct(){background=GetNode<Control>("Background");recipeContentHbox=GetNode<HBoxContainer>("HBox/RecipeContent/RecipeContentHbox");recipeContent=recipeContentHbox.GetParent()as Control;txtName=GetNode<Label>("HBox/TxtName");txtTime=GetNode<Label>("HBox/TxtTime");GuiInput+=OnGuiInput;}protected override void OnceDestruct(){GuiInput-=OnGuiInput;}private void OnGuiInput(InputEvent@event){if(@event is InputEventMouseButton mouseButton&&mouseButton.Pressed&&mouseButton.ButtonIndex==MouseButton.Left){OnClick();}}/// <summary> Refreshes the visual availability state of this recipe. Iterates all child <see cref="RecipeIconItemBehaviour"/> widgets, calling <c>CheckAndUpdate</c> on each. If any ingredient is missing, tints the name and time labels with <c>disableColor</c> (muted); otherwise restores the accent (gold) and danger (red) colour scheme. Call after inventory changes or cooking operations. </summary>
public void UpdateState(){var hasItems=true;foreach(var ingredient in ingredients)if(!ingredient.CheckAndUpdate())hasItems=false;if(!hasItems){txtName.AddThemeColorOverride("font_color",disableColor);txtTime.AddThemeColorOverride("font_color",disableColor);}else{txtName.AddThemeColorOverride("font_color",UiTokens.AccentBright);txtTime.AddThemeColorOverride("font_color",UiTokens.DangerText);}}/// <summary> Initialises the recipe row with recipe data and the parent panel reference. Spawns <see cref="RecipeIconItemBehaviour"/> children for each ingredient, with plus-sign separators between them. Sets the translated recipe name and time, then calls <see cref="UpdateState"/> to set initial colour state. </summary>
public void Init(IRecipe recipe,Node panel){EnsureConstructed();this.recipe=recipe;this.panel=panel;for(int i=0;i<recipe.Input.Count;i++){var ingredient=recipe.Input[i];AddIngredient(ingredient,i!=recipe.Input.Count-1);}txtName.Text=TranslationServer.Translate(recipe.Name);txtTime.Text=string.Format(TranslationServer.Translate("ui/cook/recipe_time"),recipe.Time);UpdateState();}private void AddIngredient(IIngredient ingredient,bool addPlus){var inventory=GameData.Instance.Runtime.Inventory;var item=(RecipeIconItemBehaviour)TscnScenes.LoadAndBuild("scenes/recipe_icon_item.tscn");recipeContentHbox.AddChild(item);var localHasItems=inventory.CheckCount(ingredient.Item)<ingredient.Count;item.Init(ingredient,localHasItems);items.Add(item);ingredients.Add(item);if(addPlus){var plus=new Label();plus.Text="+";plus.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);plus.VerticalAlignment=VerticalAlignment.Center;recipeContentHbox.AddChild(plus);items.Add(plus);}}/// <summary> Tears down this recipe row by queue-freeing all child nodes (ingredient icons, plus labels, and this widget itself), then clears the internal ingredient/item lists. Called by <see cref="WorkbenchPanel"/> when the panel is hidden. </summary>
public void Destroy(){foreach(var item in items)item.QueueFree();QueueFree();ingredients.Clear();items.Clear();}/// <summary> Handles left-click selection of this recipe entry. Deselects the previously-selected entry (if any) by restoring its background to normal colour, highlights the background of this entry, updates the static <c>selected</c> tracker, and invokes <see cref="RecipeClicked"/> so the parent can update the description and output panel. </summary>
public void OnClick(){if(selected!=null)selected.background.Modulate=normalColor;background.Modulate=selectedColor;selected=this;RecipeClicked?.Invoke(recipe,this);}}}

===== FILE mods/cooking/src/ui/workbench/ToggleCookingPanelAction.cs =====
using Cthangover.Core.Actions;using Cthangover.Core.Actions.Atomic;using Cthangover.Core.UI;using Cthangover.Core.Utils;namespace Mods.Cooking.Workbench{/// <summary> Scenario action registered under <c>toggle_cooking_workbench</c>. Delegates to <see cref="UiRegistry.Toggle"/>, which owns the workbench panel instance and its visibility state — the action no longer searches the tree or builds the scene itself, so the panel survives scene changes. </summary>
public class ToggleCookingPanelAction:IScenarioAction{/// <summary> Unique action name used in scenario script commands (e.g. <c>action toggle_cooking_workbench</c>). </summary>
public string Name=>"toggle_cooking_workbench";/// <summary> Toggles the <c>cooking_workbench</c> entry through the UI registry. </summary>
public void Run(IActionContext ctx){GameLogger.Log("COOKING","ToggleCookingPanelAction.Run: toggling 'cooking_workbench'");UiRegistry.Toggle("cooking_workbench");}}}

===== FILE mods/cooking/src/ui/workbench/WorkbenchPanel.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Audio;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Items;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Cthangover.Core.UI.Tool;using Cthangover.Mods.Time;using Cthangover.Core.UI.Inventory;using Godot;using Cthangover.Mods.Interface.Base.Lists;namespace Mods.Cooking.Workbench{/// <summary> Main cooking workbench UI panel that lists available recipes, shows descriptions and output previews, and performs the actual cooking operation. Recipes are loaded from <see cref="RecipeData"/> filtered by <see cref="WorkbenchType.Cooking"/> and sorted so that craftable recipes (all ingredients present) appear before un-craftable ones. Selecting a recipe displays its translated description and output items via an <see cref="InventoryBagBehaviour"/>. Cooking consumes ingredients, produces output items, advances in-game time, and plays a sound effect. </summary>
public class WorkbenchPanel:Widget{private Control content;private RichTextLabel txtDescription;private InventoryBagBehaviour outputContainer;private List<RecipeItemBehaviour>recipes=new();protected override void OnceConstruct(){MouseFilter=MouseFilterEnum.Ignore;var leftTitle=GetNode<Label>("HBox/LeftPanel/LeftTitle");leftTitle.Text=TranslationServer.Translate("ui/cook/title");leftTitle.AddThemeFontSizeOverride("font_size",UiTokens.FontLead);content=GetNode<VBoxContainer>("HBox/LeftPanel/Scroll/Content");txtDescription=GetNode<RichTextLabel>("HBox/RightPanel/TxtDescription");outputContainer=new InventoryBagBehaviour();outputContainer.SizeFlagsVertical=SizeFlags.ExpandFill;outputContainer.Visible=false;GetNode("HBox/RightPanel").AddChild(outputContainer);var btnCook=GetNode<Button>("HBox/RightPanel/BtnCook");btnCook.Text=TranslationServer.Translate("ui/cook/cook");btnCook.Pressed+=OnCookClick;var btnClose=GetNode<Button>("HBox/RightPanel/BtnClose");btnClose.Text=TranslationServer.Translate("ui/cook/close");btnClose.Pressed+=OnCloseClick;}protected override void ShowConstruct(){foreach(var recipe in GetSortedList())AddItem(recipe);}protected override void HideDestruct(){foreach(var recipeItem in recipes)if(recipeItem!=null)recipeItem.QueueFree();recipes.Clear();}private void AddItem(IRecipe recipe){var item=(RecipeItemBehaviour)TscnScenes.LoadAndBuild("scenes/recipe_item.tscn");if(item!=null&&content!=null){content.AddChild(item);item.Init(recipe,this);item.RecipeClicked+=ClickRecipe;recipes.Add(item);}}private List<IRecipe>GetSortedList(){var list=GameData.Instance.Runtime.RecipeData.GetRecipesByType(WorkbenchType.Cooking);var inventory=GameData.Instance.Runtime.Inventory;list.Sort((o1,o2)=>{var hasNotItems1=o1.Input.Any(o=>inventory.CheckCount(o.Item)<o.Count);var hasNotItems2=o2.Input.Any(o=>inventory.CheckCount(o.Item)<o.Count);if(hasNotItems1==hasNotItems2)return string.Compare(o1.ID,o2.ID,StringComparison.Ordinal);return hasNotItems1?1:-1;});return list;}/// <summary> Responds to a recipe being selected in the list. Updates the description area with the recipe's translated name (styled bold/yellow via BBCode) and description, and populates the output preview panel with items the recipe produces by wrapping each output into an <see cref="ItemContainer"/>. </summary>
public void ClickRecipe(IRecipe recipe,RecipeItemBehaviour recipeItemBehaviour){if(txtDescription!=null)txtDescription.Text="[b][color=#"+UiTokens.AccentBright.ToHtml(false)+"]"+TranslationServer.Translate(recipe.Name)+"[/color][/b]\n\n"+TranslationServer.Translate(recipe.Description);outputContainer.List=recipe.Output.Select(o=>(IItemContainer)new ItemContainer{Item=o.Item,Count=o.Count,}).ToList();}/// <summary> Hides the workbench panel and plays a UI close-click sound via the scene's <see cref="IAudioService"/> node. </summary>
public void OnCloseClick(){Hide();var audioService=SceneContextNode.FindNode<Node>("AudioService")as IAudioService;audioService?.PlaySound("ui/close_click",SoundType.UI);}/// <summary> Executes the cooking operation for the currently selected recipe. Validates that the player has enough ingredients in inventory (short-circuits if not). Deducts input items, adds output items, advances in-game time by the recipe's duration, updates the on-screen time display, plays the cooking sound effect, and refreshes all recipe rows so their availability colours reflect the new inventory state. </summary>
public void OnCookClick(){var inventory=GameData.Instance.Runtime.Inventory;var receipt=RecipeItemBehaviour.SelectedRecipe;if(receipt==null)return;var hasItems= !receipt.Input.Any(o=>inventory.CheckCount(o.Item)<o.Count);if(!hasItems)return;foreach(var item in receipt.Input)inventory.Remove(item.Item,item.Count);foreach(var item in receipt.Output)inventory.Add(item.Item,item.Count);GameData.Instance.Runtime.Time.AddTime(0,0,0,0,receipt.Time);var timer=SceneContextNode.FindNode<TimeController>("TimeController");timer?.UpdateRenderedTime();var audioService=SceneContextNode.FindNode<Node>("AudioService")as IAudioService;audioService?.PlaySound("ui/cook",SoundType.UI);foreach(var recipe in recipes)recipe.UpdateState();}}}

===== FILE mods/cooking/test_suites/cooking_walk/src/CookingWalkTestDriverNode.cs =====
using System.Collections.Generic;using Cthangover.Core.Bootstrap;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Items;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Core.UI;using Cthangover.Core.UI.Dialog;using Cthangover.Core.Utils;using Godot;namespace Mods.CookingWalkTest{/// <summary> Драйвер интеграционного теста «прогулка + готовка». Проводит игрока по маршруту теми же контрактами, что и человек: выборы локационных меню — честным кликом по кнопке варианта (<c>AnswerBox → Content → AnswerItem → Button.Pressed</code>), панель верстака — через <c>UiRegistry.Toggle</c> (тот же путь, что сценарная опция «Готовить»), клик по рецепту — эмуляцией <c>gui_input</c> строки рецепта, готовка и закрытие — кликами по <c>BtnCook</c>/<c>BtnClose</c>. Разблокировка рецепта выполняется публичным игровым путём: использование предмета-рецепта (<c>IItem.ItemAction.UseAction</c>) — как это делает инвентарь. Машина состояний: один шаг за тик таймера (0.4с), чтобы успевали применяться отложенные смены сцен и пересборки панелей. Каждый шаг — отдельная проверка: провал фиксируется, но не обрывает маршрут (кроме неожиданных исключений). Ожидание смены сцены — по <c>SceneManager.CurrentSceneName</c>, ожидание меню — по <c>IDialogBox.IsWaitingAnswer && IsAnswerBoxShowed</c>. Итог — контракт раннера <c>[TEST_RESULT] name=cooking_walk_test passed=N failed=M</code> и код выхода процесса; watchdog (<c>--timeout=</c>, дефолт 90с) гасит зависание с failed=1. </summary>
public partial class CookingWalkTestDriverNode:Node{private const string ResultName="cooking_walk_test";private const float TickSeconds=0.4f;private const float DefaultTimeoutSeconds=90f;private readonly float?timeoutSeconds;private readonly List<string>failures=new();private int passed;private int failed;private int step;private double elapsed;/// <summary>Ожидаемая логическая сцена для шагов-переходов (null — шаг не про переход).</summary>
private string expectedScene;private IDialogBox dialogBox;private Control workbench;private Control recipeRow;private int rationBefore;private int meatBefore;private int branchesBefore;/// <summary>Создаёт драйвер с watchdog-таймаутом (null — дефолт 90с) и ставит тикающий таймер-ребёнок.</summary>
public CookingWalkTestDriverNode(float?timeoutSeconds){this.timeoutSeconds=timeoutSeconds;var timer=new Timer{Name="TickTimer",WaitTime=TickSeconds,Autostart=true,OneShot=false};AddChild(timer);timer.Timeout+=Tick;}/// <summary>Один шаг машины состояний: ожидание готовности, затем маршрут по очереди.</summary>
private void Tick(){if(step<0)return;// тест уже завершён
elapsed+=TickSeconds;var limit=timeoutSeconds??DefaultTimeoutSeconds;if(elapsed>=limit){Finish($"timeout ({limit:0}s) reached at step {step}");return;}try{RunStep();}catch(System.Exception ex){failures.Add($"step {step}: unhandled {ex.GetType().Name}: {ex.Message}");failed++;Log($"[FAIL] step {step}: {ex.Message}");Finish(null);}}private void RunStep(){// Шаг 0: ждём готовности — бут завершён, сцена home_outside вошла в дерево, диалог-бокс доступен.
if(step==0){var sceneName=SceneContextNode.FindNode<SceneManager>("SceneManager")?.CurrentSceneName;var ready=Boot.IsReady&&GameData.Instance?.Runtime!=null&&sceneName=="home_outside"&&TryGetDialogBox(out dialogBox);if(!ready)return;Log("environment ready (home_outside entered, dialog box found)");Next();return;}// Шаг 1: стартовое меню home_outside показано; сеем предметы и предмет-рецепт через публичный API (эквивалент лута с волка).
if(step==1){if(!WaitSceneMenu())return;Check("home_outside menu shown; seeded wolf meat + branches + recipe item",()=>{var inv=GameData.Instance.Runtime.Inventory;if(inv==null)throw new CheckException("Runtime.Inventory is null (items mod factory missing?)");inv.Add("food/wolf_meat",1);inv.Add("resource/branches",2);inv.Add("recipe/wolf_meat_to_ration",1);if(inv.CheckCount("food/wolf_meat")<1||inv.CheckCount("resource/branches")<2||inv.CheckCount("recipe/wolf_meat_to_ration")<1)throw new CheckException("seeded items missing after Add");});Next();return;}// Шаг 2: используем предмет-рецепт (использование предмета из инвентаря вызывает его ItemAction) — рецепт должен разблокироваться.
if(step==2){Check("recipe unlocked via item use action",()=>{var item=ItemFactory.Instance.Get("recipe/wolf_meat_to_ration");if(item==null)throw new CheckException("recipe item not resolvable from factory");if(item.ItemAction==null)throw new CheckException("recipe item has no ItemAction (items/cooking mods not wired?)");var wasKnown=GameData.Instance.Runtime.RecipeData.Has("recipe/wolf_meat_to_ration");item.ItemAction.UseAction(item);if(!wasKnown&& !GameData.Instance.Runtime.RecipeData.Has("recipe/wolf_meat_to_ration"))throw new CheckException("UseAction did not register the recipe");});Next();return;}// Шаг 3: клик по кнопке «Зайти в дом» (первый вариант меню home_outside) → home_vestibule.
if(step==3){if(!WaitSceneMenu())return;ClickVariant(0,"home_outside: 'Зайти в дом'");expectedScene="home_vestibule";Next();return;}// Шаг 4: ждём прихода в коридор.
if(step==4){WaitArrival();return;}// Шаг 5: меню коридора; клик «В обеденную комнату» → home_kitchen.
if(step==5){if(!WaitSceneMenu())return;ClickVariant(1,"home_vestibule: 'В обеденную комнату'");expectedScene="home_kitchen";Next();return;}// Шаг 6: ждём кухню.
if(step==6){WaitArrival();return;}// Шаг 7: меню кухни (cooking-сценарий добавляет вариант «Готовить»); клик по нему открывает верстак через toggle_cooking_workbench.
if(step==7){if(!WaitSceneMenu())return;Check("kitchen menu has the cook option",()=>{// На кухне сначала показывается cooking-сценарий (priority 5 раньше default 10) с двумя вариантами: «Выйти», «Готовить».
if(dialogBox.AnswerVariantCount<2)throw new CheckException($"kitchen menu shows {dialogBox.AnswerVariantCount} variants, expected >= 2 (exit + cook)");});ClickVariant(1,"home_kitchen: 'Готовить'");Next();return;}// Шаг 8: ждём панель верстака (overlay открывается Toggle'ом).
if(step==8){Control panel=null;if(UiHost.Instance!=null&&UiHost.Instance.TryGetInstance("cooking_workbench",out var instance))panel=instance;if(panel==null|| !panel.Visible)return;workbench=panel;Check("workbench panel visible with recipe row",()=>{var content=workbench.FindChild("Content",true,false);if(content==null||content.GetChildCount()<1)throw new CheckException("workbench recipe list is empty (recipe not unlocked?)");});Next();return;}// Шаг 9: клик по строке рецепта (gui_input — тот же путь, что клик мышью: RecipeItemBehaviour подписан на GuiInput).
if(step==9){Check("recipe row click selects recipe",()=>{var content=workbench.FindChild("Content",true,false);recipeRow=content.GetChildCount()>0?content.GetChild(0)as Control:null;if(recipeRow==null)throw new CheckException("no recipe row to click");var click=new InputEventMouseButton{ButtonIndex=MouseButton.Left,Pressed=true};recipeRow.EmitSignal("gui_input",click);var description=workbench.FindChild("TxtDescription",true,false)as RichTextLabel;if(description==null||string.IsNullOrWhiteSpace(description.Text))throw new CheckException("recipe description empty after row click");});Next();return;}// Шаг 10: запоминаем запасы и жмём BtnCook; проверяем дельты — стартовая партия может уже держать те же предметы.
if(step==10){Check("cook click consumes inputs and yields ration",()=>{var inv=GameData.Instance.Runtime.Inventory;rationBefore=inv.CheckCount("food/ration");meatBefore=inv.CheckCount("food/wolf_meat");branchesBefore=inv.CheckCount("resource/branches");var btn=workbench.FindChild("BtnCook",true,false)as Button;if(btn==null)throw new CheckException("BtnCook not found");btn.EmitSignal("pressed");if(inv.CheckCount("food/wolf_meat")!=meatBefore-1)throw new CheckException($"wolf meat {inv.CheckCount("food/wolf_meat")}, expected {meatBefore - 1} (input consumed)");if(inv.CheckCount("resource/branches")!=branchesBefore-2)throw new CheckException($"branches {inv.CheckCount("resource/branches")}, expected {branchesBefore - 2} (input consumed)");if(inv.CheckCount("food/ration")!=rationBefore+1)throw new CheckException($"ration count {inv.CheckCount("food/ration")}, expected {rationBefore + 1}");});Next();return;}// Шаг 11: закрываем верстак кнопкой BtnClose.
if(step==11){Check("workbench closes via BtnClose",()=>{var btn=workbench.FindChild("BtnClose",true,false)as Button;if(btn==null)throw new CheckException("BtnClose not found");btn.EmitSignal("pressed");if(workbench.Visible)throw new CheckException("workbench still visible after BtnClose");});Next();return;}// Шаг 12: меню кухни снова показано; клик «Выйти» → коридор.
if(step==12){if(!WaitSceneMenu())return;ClickVariant(0,"home_kitchen: 'Выйти'");expectedScene="home_vestibule";Next();return;}// Шаг 13: ждём коридор.
if(step==13){WaitArrival();return;}// Шаг 14: меню коридора; клик «На улицу» → home_outside.
if(step==14){if(!WaitSceneMenu())return;ClickVariant(4,"home_vestibule: 'На улицу'");expectedScene="home_outside";Next();return;}// Шаг 15: ждём улицу.
if(step==15){WaitArrival();return;}// Шаг 16: меню улицы; клик «Зайти в дом» → коридор.
if(step==16){if(!WaitSceneMenu())return;ClickVariant(0,"home_outside: 'Зайти в дом' (2nd visit)");expectedScene="home_vestibule";Next();return;}// Шаг 17: ждём коридор (регрессия «кнопки пропали»: меню обязано быть — сценарий коридора не должен быть отфильтрован как завершённый после первого же перехода).
if(step==17){WaitArrival();return;}// Шаг 18: меню коридора; клик «В спальню» → home_bedroom.
if(step==18){if(!WaitSceneMenu())return;ClickVariant(2,"home_vestibule: 'В спальню'");expectedScene="home_bedroom";Next();return;}// Шаг 19: ждём спальню.
if(step==19){WaitArrival();return;}// Шаг 20: меню спальни; клик «Выйти» → коридор.
if(step==20){if(!WaitSceneMenu())return;ClickVariant(0,"home_bedroom: 'Выйти'");expectedScene="home_vestibule";Next();return;}// Шаг 21: ждём коридор.
if(step==21){WaitArrival();return;}// Шаг 22: меню коридора; клик «В рабочую комнату» → home_workroom.
if(step==22){if(!WaitSceneMenu())return;ClickVariant(0,"home_vestibule: 'В рабочую комнату'");expectedScene="home_workroom";Next();return;}// Шаг 23: ждём рабочую комнату — финальная точка маршрута.
if(step==23){WaitArrival();return;}// Шаг 24: меню рабочей комнаты показано → маршрут пройден целиком.
if(step==24){if(!WaitSceneMenu())return;Check("route complete: workroom menu shown",()=>{});Finish(null);}}/// <summary> Ждёт, что в текущей сцене показано меню выбора (диалог в режиме ожидания ответа и бокс вариантов на экране). Меню появляется через несколько тиков после смены сцены — шаг просто не проходит, пока условие не выполнено; watchdog страхует зависание. </summary>
private bool WaitSceneMenu(){return dialogBox!=null&&dialogBox.IsActive&&dialogBox.IsWaitingAnswer&&dialogBox.IsAnswerBoxShowed;}/// <summary> Шаг ожидания перехода: сцена уже сменилась на ожидаемую — проверка пройдена; иначе шаг молча повторяется на следующем тике. </summary>
private void WaitArrival(){var target=expectedScene;var current=SceneContextNode.FindNode<SceneManager>("SceneManager")?.CurrentSceneName;if(current!=target)return;Check($"arrived at '{target}'",()=>{if(current!=target)throw new CheckException($"current scene '{current}', expected '{target}'");});expectedScene=null;Next();}/// <summary> Честный клик по кнопке варианта: находит AnswerBox в дереве диалог-бокса, берёт строку варианта с индексом <paramref name="index"/> и дёргает её Button (тот же сигнал, что нажатие мышью — AnswerItem подписан на Pressed). Падает, если структура бокса не соответствует ожидаемой. </summary>
private void ClickVariant(int index,string what){Check($"click {what}",()=>{// IDialogBox — контракт ядра без Node-членов; сам бокс живёт под concrete-узлом DialogBox в дереве.
var box=FindAnswerBox(dialogBox as Node);if(box==null)throw new CheckException("AnswerBox node not found under dialog box");var content=box.FindChild("Content",true,false);if(content==null||content.GetChildCount()<=index)throw new CheckException($"answer content missing or has fewer than {index + 1} items");var item=content.GetChild(index);var button=item?.FindChild("Button",true,false)as Button;if(button==null)throw new CheckException("variant Button node not found in answer item");button.EmitSignal("pressed");});}/// <summary> Ищет показанный AnswerBox по типу, а не по имени узла: прошлый бокс освобождается через QueueFree при скрытии, и если следующий вопрос открывается в том же кадре, новый бокс получает автоимя (<c>@Control@N</c>), а старый с именем «AnswerBox» уже помечен на удаление. </summary>
private static Cthangover.Mods.Dialog.AnswerBox FindAnswerBox(Node node){if(node==null)return null;foreach(var child in node.GetChildren()){if(child is Cthangover.Mods.Dialog.AnswerBox box&& !box.IsQueuedForDeletion()&&box.Visible)return box;var found=FindAnswerBox(child);if(found!=null)return found;}return null;}private bool TryGetDialogBox(out IDialogBox box){var node=SceneContextNode.FindNode<Node>("DialogBox");box=node as IDialogBox;return box!=null;}private void Next(){step++;}private void Check(string title,System.Action body){var sw=Godot.Time.GetTicksUsec();try{body();passed++;Log($"[PASS] {title} ({(Godot.Time.GetTicksUsec() - sw) / 1000.0:0.0} ms)");}catch(CheckException ex){failed++;failures.Add($"{title}: {ex.Message}");Log($"[FAIL] {title}: {ex.Message}");}}/// <summary>Печатает итог и завершает процесс; после вызова шаги не выполняются.</summary>
private void Finish(string reason){if(step<0)return;step= -1;if(reason!=null){failed++;Log($"[FAIL] {reason}");}foreach(var f in failures)Log($"       failed: {f}");Log($"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");GetTree().Quit(failed==0?0:1);}private void Log(string message){GameLogger.Log("TEST",$"CookingWalkTest: {message}");}/// <summary>Отличает ожидаемую проверку от неожиданного исключения шага.</summary>
private sealed class CheckException:System.Exception{public CheckException(string message):base(message){}}}}

===== FILE mods/cooking/test_suites/cooking_walk/src/CookingWalkTestInitializer.cs =====
using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Mods.CookingWalkTest{/// <summary> Точка входа интеграционного теста «прогулка + готовка» (прецедент inventory_test: мод сам везёт драйвер и сам печатает [TEST_RESULT]). Активация — только по явному пользовательскому флагу <c>--itest=cooking_walk</c> (user-args после --), чтобы мод никогда не вмешивался в обычную игру. Стартовая сцена задаётся ядром (<c>--scene=home_outside</c>); драйверу остаётся дождаться готового шелла и провести игрока по маршруту кликами по настоящим кнопкам. </summary>
public class CookingWalkTestInitializer:IModInitializer{private static bool driverQueued;/// <summary>Per-mod инициализация не требуется — драйвер ставится на resources-ready.</summary>
public void OnModLoaded(string modId){}/// <summary> Разбирает user-args; при активационном флаге добавляет драйвер теста в корень дерева. Повторный <c>ModManager.Reload()</c> не должен плодить драйверы — защищено статическим флагом. </summary>
public void OnModResourcesReady(){if(driverQueued)return;var args=OS.GetCmdlineUserArgs();if(!args.Any(a=>a=="--itest=cooking_walk"))return;var timeout=ParseTimeout(args);driverQueued=true;GameLogger.Log("TEST",$"CookingWalkTestInitializer: activating cooking walk test, timeout={timeout?.ToString() ?? "none"}s");// Deferred: сцена ещё не готова; драйвер сам ждёт Boot.IsReady и появления CurrentScene, поэтому порядок с загрузкой сцены не важен.
(Godot.Engine.GetMainLoop()as SceneTree)?.Root?.CallDeferred("add_child",new CookingWalkTestDriverNode(timeout));}private static float?ParseTimeout(string[]args){var raw=args.Where(a=>a.StartsWith("--timeout=",System.StringComparison.Ordinal)).Select(a=>a.Substring("--timeout=".Length)).FirstOrDefault();return float.TryParse(raw,System.Globalization.CultureInfo.InvariantCulture,out var t)&&t>0?t:null;}}}

===== FILE mods/cooking/tests/CookingModTests.cs =====
using System.Text.Json;using System.Threading.Tasks;using Cthangover.Core.Items;using Cthangover.Core.Mods.Testing;using Cthangover.Core.Settings;namespace Cthangover.Cooking.Tests{/// <summary> Unit tests of the cooking mod's pure logic (plan-mod-testing.md §4.2). Compiled by ModTestEngine into a separate {modId}__tests assembly, only in the explicit test mode. No scene-tree interaction: anything requiring a live scene belongs to the integration contour. </summary>
public class CookingModTests{[ModTest("recipe/wolf_meat_data_shape")]static void WolfMeatRecipeDataShape(ModTestContext ctx){// The recipe JSON declared by the mod must keep the exact shape RecipeFactory consumes: single recipe, cooking type, inputs/outputs.
var raw=ctx.ReadFileText("recipes/cooking.json");ModAssert.NotNull(raw,"recipes/cooking.json exists");using var doc=JsonDocument.Parse(raw);var items=doc.RootElement.GetProperty("Items");ModAssert.Equal(1,items.GetArrayLength(),"recipe count");var recipe=items[0];ModAssert.Equal("recipe/wolf_meat_to_ration",recipe.GetProperty("Id").GetString(),"recipe id");ModAssert.Equal("Cooking",recipe.GetProperty("Type").GetString(),"workbench type");ModAssert.Equal(15,recipe.GetProperty("Time").GetInt32(),"crafting time");ModAssert.Equal(2,recipe.GetProperty("Input").GetArrayLength(),"input ingredient count");ModAssert.Equal(1,recipe.GetProperty("Output").GetArrayLength(),"output ingredient count");var meat=recipe.GetProperty("Input")[0];ModAssert.Equal("food/wolf_meat",meat.GetProperty("Id").GetString(),"input meat id");ModAssert.Equal(1,meat.GetProperty("Count").GetInt32(),"input meat count");var branches=recipe.GetProperty("Input")[1];ModAssert.Equal("resource/branches",branches.GetProperty("Id").GetString(),"input branches id");ModAssert.Equal(2,branches.GetProperty("Count").GetInt32(),"input branches count");}[ModTest("recipe/recipe_data_unlock_flow")]static void RecipeDataUnlockFlow(ModTestContext ctx){// The unlock contract WolfMeatToRation relies on: Add is idempotent and Has mirrors membership. Exercises the same flow the item action performs, through the public RecipeData API.
var data=new RecipeData();ModAssert.False(data.Has("recipe/wolf_meat_to_ration"),"fresh state does not know the recipe");ModAssert.True(data.Add("recipe/wolf_meat_to_ration"),"first Add unlocks");ModAssert.False(data.Add("recipe/wolf_meat_to_ration"),"second Add is a no-op");ModAssert.True(data.Has("recipe/wolf_meat_to_ration"),"recipe stays unlocked");// Round-trip through JSON: Data is a public property, the save system serializes RecipeData as a whole.
var json=JsonSerializer.Serialize(data);var restored=JsonSerializer.Deserialize<RecipeData>(json);ModAssert.True(restored.Has("recipe/wolf_meat_to_ration"),"unlock survives JSON round-trip");}[ModTest("settings/rations_per_character_clamp")]static void SettingsClamp(ModTestContext ctx){// CookingSettings.ReadValues clamps below-minimum input to 1 — verify through a real DataBlob key-value round-trip.
var blob=new DataBlob();blob.SetInt("rations_per_character",-5);new global::Cthangover.Cooking.CookingSettings().ReadValues(blob);ModAssert.Equal(1,global::Cthangover.Cooking.CookingSettingsForTests.RationsPerCharacter,"negative value clamps to 1");var blob2=new DataBlob();blob2.SetInt("rations_per_character",4);new global::Cthangover.Cooking.CookingSettings().ReadValues(blob2);ModAssert.Equal(4,global::Cthangover.Cooking.CookingSettingsForTests.RationsPerCharacter,"valid value passes through");// Write/read round-trip through the blob's own serialization.
new global::Cthangover.Cooking.CookingSettings().WriteValues(blob2);ModAssert.Equal(4,blob2.GetInt("rations_per_character",0),"WriteValues stores the value");}[ModTest("manifest/declares_required_dependencies")]static void ManifestDependencies(ModTestContext ctx){// The cooking UI binds to interface widget types and item/time subsystems: the manifest must keep these declared, otherwise compilation against the dependencies breaks silently.
var raw=ctx.ReadFileText("manifest.json");ModAssert.NotNull(raw,"manifest.json exists");using var doc=JsonDocument.Parse(raw);var depends=doc.RootElement.GetProperty("depends");var found=new[]{"cthangover_game","ui_base","items_base","time_base"};foreach(var dep in found){var ok=false;foreach(var d in depends.EnumerateArray()){var s=d.GetString()??"";var id=s.Split('>','<','=','?')[0];if(id==dep){ok=true;break;}}ModAssert.True(ok,$"dependency '{dep}' declared");}}[ModTest("frames/async_test_spans_frames",TimeoutSeconds=10)]static async Task AsyncTestSpansFrames(ModTestContext ctx){// Async tests may wait for engine frames (deferred UI rebuilds, signals). The recovery cycle between tests must keep the scene tree intact across those frames: core autoloads and the running scene survive, and the run still finishes with a report.
var tree=(Godot.SceneTree)Godot.Engine.GetMainLoop();for(var i=0;i<3;i++)await tree.ToSignal(tree,Godot.SceneTree.SignalName.ProcessFrame);ModAssert.True(tree.Root.HasNode("GameData"),"/root/GameData survives frames between tests");ModAssert.NotNull(tree.CurrentScene,"current scene survives frames between tests");ModAssert.NotNull(ctx.ReadFileText("recipes/cooking.json"),"mod files stay readable after awaiting frames");}}}

===== FILE mods/cthangover_party/src/StarterPartyInitializer.cs =====
using Cthangover.Core.Events;using Cthangover.Core.Mods;using Cthangover.Core.Settings;using Cthangover.Mods.Skills;namespace Cthangover.Mods.StarterParty{/// <summary> Populates the initial player party with the default starter character <c>"Marao"</c> when the cthangover_party mod finishes loading. The party can only be populated during <c>OnModResourcesReady</c>, not earlier. <c>OnModLoaded</c> and the constructor fire inside <c>ModRegistry.Initialize</c> → <c>ModCompiler.LoadModCode</c>, where mod JSON resources are not yet guaranteed to be accessible. <c>AddCharacterToParty</c> chains into <c>CharacterFactory.Get</c>, which triggers the shared entity resolver's first scan — this requires file providers to be fully set up, which only happens after the entire <c>ModRegistry.Initialize</c> pipeline completes. <c>OnModResourcesReady</c> is invoked by <c>ModInitializerRegistry .NotifyResourcesReady</c> at the end of <c>SceneManager.Initialize</c>, after <c>ModRegistry</c> finishes discovery, <c>SceneEventRegistry</c> is primed, and scene definitions are collected. At that point all factories can safely read mod JSON data. Mods that introduce additional starter characters should implement their own <c>IModInitializer</c> and call <c>AddCharacterToParty</c> from <c>OnModResourcesReady</c> for their character IDs. </summary>
public class StarterPartyInitializer:IModInitializer{private static bool newGameHookInstalled;/// <summary> No-op: cross-mod lifecycle notifications are not needed for starter party setup. Each mod's <c>OnModResourcesReady</c> handles its own default characters independently. </summary>
public void OnModLoaded(string modId){}/// <summary> Adds the cthangover_party mod's default starter character <c>"Marao"</c> to both <c>Characters</c> and <c>BattleSet</c> via <c>CharacterData.AddCharacterToParty</c>. Called once, after all mod file providers and JSON resources are guaranteed to be accessible. Also installs the one-time <see cref="NewGameStartedEvent"/> hook so the starter party is restored after <c>ResetRuntime</c> wipes it at the start of a new game. Both operations are idempotent. </summary>
public void OnModResourcesReady(){SeedStarterParty();if(newGameHookInstalled)return;newGameHookInstalled=true;ModEventBus.Subscribe<NewGameStartedEvent>(_=>SeedStarterParty());}private static void SeedStarterParty(){GameData.Instance.Runtime.CharacterData.AddCharacterToParty("Marao");SkillService.GrantSkill("slave_master");}}}

===== FILE mods/dialog_base/src/Actions/ScenarioActionContext.cs =====
using Cthangover.Core.Actions;using Cthangover.Core.Items;using Cthangover.Core.Mods;using Cthangover.Core.Quests;using Cthangover.Core.Settings;using Cthangover.Mods.Dialog;using Cthangover.Core.Utils;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;using Cthangover.Mods.Map;using Cthangover.Mods.Time;namespace Cthangover.Mods.Dialog{/// <summary> Concrete IActionContext wired to a DialogRuntime. Each service property is instantiated inline (no DI container) — this is intentional: scenario actions are short-lived commands, so creating new service instances per context is cheap and avoids shared state. GetParam delegates to the dialog runtime's variable store, bridging the dialog DSL's "set" variables to the atomic action system. The Inventory reference is try-caught because it may not exist at the moment of context creation (e.g. before game data is fully loaded). </summary>
internal class ScenarioActionContext:IActionContext{private readonly DialogRuntime runtime;/// <summary> Service accessor for quest operations. Resolved from the kernel quest registry — the implementation (QuestServiceImpl) lives in the quests_base engine mod and is published there during mod load, so contexts created after boot always see a live service. </summary>
public IQuestService Quests=>QuestServiceRegistry.Provider;/// <summary> Service accessor for character party operations. Instantiated per-context. Routes to CharacterData via strict enum parsing. </summary>
public ICharacterService Character{get;}=new CharacterServiceImpl();/// <summary> Service accessor for lighting control. Instantiated per-context. Thin wrapper around UiLightController singleton. </summary>
public ILightingService Lighting{get;}=new LightingServiceImpl();/// <summary> Service accessor for runtime scene node manipulation. Instantiated per-context. All operations route through SceneContextNode.Instance as the scene root. </summary>
public ISceneNodeService Scene{get;}=new SceneNodeServiceImpl();/// <summary> Singleton reference to the mod registry. Shared across all contexts because mod registration is global and mod data doesn't change during a single dialog execution. </summary>
public IModRegistry ModRegistry{get;}=Cthangover.Core.Mods.ModRegistry.Instance;/// <summary> Player inventory reference, captured once at context construction time. Try-caught because GameData may not be fully loaded when the context is created (e.g. during early dialog initialization before the game scene is ready). Consumers must tolerate a null inventory. </summary>
public IInventory Inventory{get;}/// <summary> Map zone control. Stateless adapter over the <c>MapZoneService</c> singleton, which owns the actual state; safe to instantiate per-context like the quest service, because the state does not live in the adapter. </summary>
public IMapZoneService Map{get;}=new MapZoneServiceImpl();/// <summary> Creates a context wired to the given DialogRuntime. The runtime provides the variable store for GetParam — without it, actions would have no way to receive parameters from the scenario DSL. Inventory is captured eagerly (not lazily) because it's fixed for the lifetime of this context. </summary>
public ScenarioActionContext(DialogRuntime runtime){this.runtime=runtime;try{Inventory=GameData.Instance?.Runtime?.Inventory;}catch{Inventory=null;}}/// <summary> Reads a named variable from the dialog runtime's variable store. This is the bridge between the scenario DSL's "set" command and the atomic action system — variables set in the script are consumed here. Returns null for undefined variables. </summary>
public string GetParam(string name)=>runtime.GetVariable(name);/// <summary> Emits a debug-level log entry via GameLogger. The category groups related entries for filtering; use consistent categories (e.g. "EVENT", "QUEST", "BATTLE") across actions. </summary>
public void Log(string category,string message)=>GameLogger.Log(category,message);/// <summary> Emits a warning-level log entry. Automatically prepends "[WARN]" to the message and logs at LogLevel.Warning. </summary>
public void LogWarning(string category,string message)=>GameLogger.Log(category,$"[WARN] {message}",LogLevel.Warning);}}

===== FILE mods/dialog_base/src/DialogModInitializer.cs =====
using Cthangover.Core.Bootstrap;using Cthangover.Core.Events;using Cthangover.Core.Mods;using Cthangover.Core.Scenarios;using Cthangover.Core.UI;using Cthangover.Core.Utils;using Cthangover.Mods.Dialog.Scenarios;using Godot;namespace Cthangover.Mods.Dialog{/// <summary> Engine-mod initializer for the dialog subsystem. On resources-ready it publishes the scenario event factory used by the core SceneManager and parks the <c>/root/DialogRunner</c> node implementing the core <c>IInteractiveDialogRunner</c> contract. All operations are idempotent: <c>ModManager.Reload()</c> re-runs <c>OnModResourcesReady</c>, so node creation must tolerate repeat invocations. Scenario command strategies of every mod reach their factory through <see cref="ScenarioCommandStrategyConsumer"/>, not through this initializer. </summary>
public class DialogModInitializer:IModInitializer{private static bool initialized;/// <summary>No per-mod setup is required; registration happens on resources-ready.</summary>
public void OnModLoaded(string modId){}/// <summary> Publishes the scenario event factory and queues the dialog runner node. Safe to call again after a mod reload. </summary>
public void OnModResourcesReady(){if(initialized)return;initialized=true;// Scenario-backed events (scene definitions and test-play) are built by the core SceneManager through this factory.
AppServices.Current.SceneEvents.ScenarioEventFactory=new ScenarioEventFactory();EnsureDialogRunner();}private static void EnsureDialogRunner(){var root=ModEventBus.Instance?.GetTree()?.Root;if(root==null){GameLogger.Log("DIALOG","DialogModInitializer: tree root not available, skipping runner setup",LogLevel.Error);return;}if(root.GetNodeOrNull("DialogRunner")!=null)return;// Deferred: OnModResourcesReady fires while the root is still setting up autoload children, so a direct AddChild is rejected.
root.CallDeferred("add_child",new DialogScriptRunner{Name="DialogRunner"});}}}

===== FILE mods/dialog_base/src/DialogScriptRunner.cs =====
using Cthangover.Core.Factories.Impls;using Cthangover.Core.Interactive;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Scenarios;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;using Cthangover.Core.UI;using Cthangover.Core.Utils;using Cthangover.Mods.Dialog.Scenarios;using Cthangover.Mods.Interface.Base;namespace Cthangover.Mods.Dialog{/// <summary> Dialog execution entry point of the dialog_base engine mod, parked at /root/DialogRunner. Implements the core <c>IInteractiveDialogRunner</c> contract: interactive click-dialogs (a scenario file combined with inline DSL commands) and standalone inline DSL commands are parsed here and executed through the mod-owned dialog runtime and the shared <see cref="DialogBox"/>. </summary>
public partial class DialogScriptRunner:ModNode,IInteractiveDialogRunner{/// <summary>Runs the click action of the interactive definition with the given id through the shared DialogBox. The definition is resolved through the interactive_base factory. Returns false when the definition is unknown, the box is missing, busy, or the definition carries no runnable commands.</summary>
public bool TryExecuteClickDialog(string definitionId){var definition=InteractiveFactory.Instance.Get(definitionId);if(definition==null){GameLogger.Log("INTERACTIVE",$"TryExecuteClickDialog: definition '{definitionId}' not found",LogLevel.Error);return false;}var dialogBox=SceneContextNode.FindNode<DialogBox>("DialogBox");if(dialogBox==null){GameLogger.Log("INTERACTIVE","TryExecuteClickDialog: DialogBox not found",LogLevel.Error);return false;}if(dialogBox.IsRunning){GameLogger.Log("INTERACTIVE","TryExecuteClickDialog: dialog already running, skipping");return false;}var dlg=BuildClickDialog(definition);if(dlg==null||dlg.Queue.Count==0)return false;dialogBox.SetDialogQueueAndRun(dlg,null,0,null);return true;}/// <summary>Runs inline DSL commands (hover effects and similar) through a standalone dialog runtime.</summary>
public void ExecuteInlineCommands(string dsl){if(string.IsNullOrWhiteSpace(dsl))return;try{var dlg=ScenarioParser.Parse(dsl);if(dlg?.Queue==null||dlg.Queue.Count==0)return;var runtime=new DialogRuntime();runtime.SetDialogQueueAndRun(null,dlg.Queue,null,0);runtime.Run();}catch(System.Exception ex){GameLogger.Log("INTERACTIVE",$"ExecuteInlineCommands failed: {ex.Message}",LogLevel.Error);}}private DialogQueue BuildClickDialog(InteractiveDefinition def){if(def?.Actions?.OnClick==null)return null;var clickAction=def.Actions.OnClick;var combinedText="";if(!string.IsNullOrEmpty(clickAction.Scenario)){var modId=def.ModId;if(string.IsNullOrEmpty(modId))modId=ResolveModIdForScenario(clickAction.Scenario);if(!string.IsNullOrEmpty(modId)){var scenarioText=ModManager.Instance.ReadFileText(modId,clickAction.Scenario);if(!string.IsNullOrEmpty(scenarioText)){var(metadata,body)=ScenarioMetadata.ParseMetadata(scenarioText);combinedText+=body+"\n";}}}if(clickAction.Commands!=null&&clickAction.Commands.Length>0){combinedText+=string.Join("\n",clickAction.Commands);}if(string.IsNullOrWhiteSpace(combinedText))return null;return ScenarioParser.Parse(combinedText);}private string ResolveModIdForScenario(string scenarioPath){var defs=ScenarioDefs.CollectDefinitions();var fileName=System.IO.Path.GetFileNameWithoutExtension(scenarioPath.Replace('\\','/').Split('/')[^1]);foreach(var kv in defs){foreach(var sd in kv.Value){if(sd.Name==fileName)return sd.ModId;}}return null;}}}

===== FILE mods/dialog_base/src/Factories/ScenarioCommandStrategyFactory.cs =====
using System.Collections.Generic;using Cthangover.Mods.Dialog.Scenarios;using Cthangover.Core.Utils;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Registry of <see cref="IScenarioCommandStrategy"/> implementations, indexed by their <see cref="IScenarioCommandStrategy.Command"/> name. It discovers nothing itself: <see cref="ScenarioCommandStrategyConsumer"/> delivers the strategies of every mod as the kernel registers its assembly and takes them back when the mod unloads, so the table never holds a strategy of a dead assembly (which would pin it and keep running its old code after a hot reload). </summary>
public static class ScenarioCommandStrategyFactory{private static readonly Dictionary<string,IScenarioCommandStrategy>Strategies=new();/// <summary> Looks up the <see cref="IScenarioCommandStrategy"/> registered for <paramref name="command"/>. Returns <c>null</c> if no strategy exists for the given command name. </summary>
public static IScenarioCommandStrategy Get(string command){Strategies.TryGetValue(command,out var strategy);return strategy;}/// <summary> Enumerates all registered DSL command names (lowercase). </summary>
public static IEnumerable<string>GetAllCommandNames(){return Strategies.Keys;}/// <summary> Returns the <see cref="ICommandReferenceMetadata"/> for a command if its strategy implements that interface, or <c>null</c> otherwise. Used by the build pipeline to collect asset dependencies from scenario scripts. </summary>
public static ICommandReferenceMetadata GetReferenceMetadata(string command){if(Strategies.TryGetValue(command,out var strategy)&&strategy is ICommandReferenceMetadata meta)return meta;return null;}/// <summary> Registers a strategy under its command name. The first strategy of a command wins: a mod cannot silently take over a built-in command. </summary>
public static void Register(IScenarioCommandStrategy strategy){if(strategy!=null&& !Strategies.ContainsKey(strategy.Command))Strategies[strategy.Command]=strategy;}/// <summary> Removes <paramref name="strategy"/> (its mod is being unloaded). A command owned by another strategy instance is left alone. </summary>
public static void Unregister(IScenarioCommandStrategy strategy){if(strategy!=null&&Strategies.TryGetValue(strategy.Command,out var registered)&&registered==strategy)Strategies.Remove(strategy.Command);}}}

===== FILE mods/dialog_base/src/ScenarioEventFactory.cs =====
using Cthangover.Core.UI.Executable;using Cthangover.Mods.Dialog.Executable;namespace Cthangover.Mods.Dialog{/// <summary> Builds scenario-backed executable events for the core SceneManager. The returned <see cref="ScenarioEvent"/> is parented to the requesting event chain by <c>IExecutableEventChain.AddEvent</c>, so the factory only needs to configure it. </summary>
public class ScenarioEventFactory:IScenarioEventFactory{/// <summary>Builds a one-shot event that executes the inline scenario text (test-play path).</summary>
public IExecutableEvent CreateFromText(string scenarioText){return new ScenarioEvent{ScenarioText=scenarioText};}/// <summary>Builds an event from a scene event definition's scenario fields, remembering the source file.</summary>
public IExecutableEvent CreateFromDefinition(string scenarioText,string scenarioId,string condition,bool?lightUseTime,bool isOneRun,string modId,string filePath){return new ScenarioEvent{ScenarioText=scenarioText,ScenarioId=scenarioId,Condition=condition,LightUseTime=lightUseTime,IsOneRun=isOneRun,SourceModId=modId,SourcePath=filePath,};}}}

===== FILE mods/dialog_base/src/Scenarios/ICommandReferenceMetadata.cs =====
namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Classifies the type of resource that the first positional argument of a scenario command refers to. Used for dependency tracking — the build tool can scan commands by their <see cref="ICommandReferenceMetadata"/> to discover which assets (backgrounds, scenes, music, etc.) are referenced and need to be packed into the final build. </summary>
public enum PositionalReferenceKind{/// <summary>No resource reference in the first positional argument.</summary>
None,/// <summary>A background image resource path.</summary>
Background,/// <summary>A scene resource path (for scene switching).</summary>
Scene,/// <summary>A music/audio resource path.</summary>
Music,/// <summary>A sound effect resource path.</summary>
Sound,/// <summary>An effect/shader resource path.</summary>
Effect,/// <summary>An interactive action definition ID.</summary>
Action}/// <summary> Optional interface for command strategies whose first positional argument references an external resource. Strategies implementing this interface are inspected by the build pipeline to collect asset dependencies. </summary>
public interface ICommandReferenceMetadata{/// <summary> The kind of resource referenced by the first positional argument of this command. </summary>
PositionalReferenceKind Positional0Kind{get;}}}

===== FILE mods/dialog_base/src/Scenarios/IScenarioCommandSpec.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Optional declarative contract of a scenario command, implemented by <see cref="IScenarioCommandStrategy"/> classes that want their arguments checked without the checker knowing the command name. The default members mean a strategy overrides only what differs from "accepts anything". The contract belongs to the command itself, so including a new command from a mod (or Core) automatically teaches the validator its rules; a command that does not implement this interface is still recognised as a known command but its arguments are not constrained. </summary>
public interface IScenarioCommandSpec{/// <summary>Minimum number of positional arguments. Default: 0.</summary>
int MinPositional=>0;/// <summary>Maximum number of positional arguments; -1 means unlimited. Default: -1.</summary>
int MaxPositional=> -1;/// <summary> Minimum number of arguments in total (positional + named). Used by commands whose required input may arrive either way, e.g. text that is either literal or a <c>key=</c> lookup. </summary>
int MinArguments=>0;/// <summary>Named arguments that must be present. Default: none.</summary>
IReadOnlyList<string>RequiredNamed=>Array.Empty<string>();/// <summary> Named arguments the command understands. Empty/null means the command accepts arbitrary named arguments (e.g. <c>action</c> forwards them as dialog variables), so unknown-name checks are skipped. </summary>
IReadOnlyList<string>AllowedNamed=>null;/// <summary> Named argument that carries a <c>:label</c> jump target, or null. Lets the checker validate that the target exists without knowing the command; <c>goto</c> declares <c>"target"</c> here. </summary>
string JumpTargetNamedKey=>null;}}

===== FILE mods/dialog_base/src/Scenarios/IScenarioCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Strategy interface for handling a single DSL command type in scenario scripts. Each command word (e.g. <c>text</c>, <c>music</c>, <c>goto</c>) maps to one strategy implementation. The <see cref="ScenarioCommandStrategyFactory"/> auto-discovers all implementations via reflection and dispatches commands to the matching strategy during parsing. </summary>
public interface IScenarioCommandStrategy{/// <summary> The lowercase DSL command word this strategy handles (e.g. <c>"text"</c>, <c>"music"</c>). </summary>
string Command{get;}/// <summary> Executes the command by appending one or more dialog actions to <paramref name="dlg"/>. </summary> <param name="dlg">The dialog queue to push actions onto.</param> <param name="positional">Unnamed tokens following the command word in order.</param> <param name="named">Key-value pairs parsed from <c>name=value</c> tokens (keys are lowercased).</param> <param name="arrowTarget">The target label following a <c>-&gt;</c> arrow token, if present.</param> <param name="locale">Optional localization provider for resolving <c>key=</c> lookups.</param>
void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale);}}

===== FILE mods/dialog_base/src/Scenarios/Impls/ActionCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>action</c> DSL command. Triggers a named interactive action (defined in the action system) and forwards any extra named parameters as context variables via <see cref="DialogQueue.Set"/>. Implements <see cref="ICommandReferenceMetadata"/> to signal that the first positional argument is an <see cref="PositionalReferenceKind.Action"/> reference. </summary>
public class ActionCommandStrategy:IScenarioCommandStrategy,ICommandReferenceMetadata,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"action";/// <inheritdoc/>
public PositionalReferenceKind Positional0Kind=>PositionalReferenceKind.Action;/// <inheritdoc/>
public int MinPositional=>1;/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){var actionName=positional.Count>0?positional[0]:"";foreach(var kv in named){if(kv.Key is"name"or"key")continue;dlg.Set(kv.Key,kv.Value);}dlg.Action(actionName);}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/AnimationCommandStrategy.cs =====
using System.Collections.Generic;using System.Globalization;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>animation</c> DSL command. Supports two modes: (a) multiple sprites via <c>sprites=</c> named parameter, or (b) a single sprite base with <c>start</c>, <c>count</c>, <c>speed</c>, and <c>loop</c> controls. Delegates to <see cref="DialogQueue.Animation"/>. </summary>
public class AnimationCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"animation";/// <inheritdoc/>
public int MinArguments=>1;/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public IReadOnlyList<string>AllowedNamed=>new[]{"sprites","speed","loop","start","count"};/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){if(named.TryGetValue("sprites",out var spritesStr)){var sprites=new List<string>(spritesStr.Split(','));var speed=named.TryGetValue("speed",out var sp)?ParseFloat(sp):1f;var loop= !named.TryGetValue("loop",out var lp)||lp=="true";dlg.Animation(sprites,speed,1f,loop);}else if(positional.Count>0){var sprite=positional[0];var start=named.TryGetValue("start",out var st)?ParseInt(st):0;var count=named.TryGetValue("count",out var cn)?ParseInt(cn):1;var speed=named.TryGetValue("speed",out var sp)?ParseFloat(sp):1f;var loop= !named.TryGetValue("loop",out var lp)||lp=="true";dlg.Animation(sprite,start,count,speed,1f,loop);}}private static float ParseFloat(string s){return float.TryParse(s,NumberStyles.Float,CultureInfo.InvariantCulture,out var v)?v:0f;}private static int ParseInt(string s){return int.TryParse(s,NumberStyles.Integer,CultureInfo.InvariantCulture,out var v)?v:0;}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/BackgroundColorCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>background_color</c> DSL command. Sets the dialog scene's background color from a named color string (e.g. <c>black</c>, <c>red</c>) or an HTML hex color (e.g. <c>#FF0000</c>). Delegates to <see cref="DialogQueue.BackgroundColor"/>. </summary>
public class BackgroundColorCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"background_color";/// <inheritdoc/>
public int MinPositional=>1;/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){if(positional.Count>0)dlg.BackgroundColor(ColorFromString(positional[0]));}private static Color ColorFromString(string s){if(s.StartsWith('#'))return Color.FromHtml(s.Substring(1));var named=s.ToLowerInvariant()switch{"black"=>Colors.Black,"white"=>Colors.White,"red"=>Colors.Red,"green"=>Colors.Green,"blue"=>Colors.Blue,"yellow"=>Colors.Yellow,"gray"or"grey"=>Colors.Gray,_=>Colors.Black,};return named;}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/BackgroundCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>background</c> DSL command. Sets the dialog scene background image from a resource path. Implements <see cref="ICommandReferenceMetadata"/> with <see cref="PositionalReferenceKind.Background"/> so the build pipeline can track referenced background assets. </summary>
public class BackgroundCommandStrategy:IScenarioCommandStrategy,ICommandReferenceMetadata,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"background";/// <inheritdoc/>
public PositionalReferenceKind Positional0Kind=>PositionalReferenceKind.Background;/// <inheritdoc/>
public int MinPositional=>1;/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){dlg.Background(positional.Count>0?positional[0]:"");}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/BackgroundShowHideCommandStrategy.cs =====
using System.Collections.Generic;using System.Globalization;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Mods.Dialog.Action.Impls;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>background_show_hide</c> DSL command. Animates the background in (<c>show</c>) or out (<c>hide</c>) with configurable <c>duration</c> and <c>wait</c> flag. Delegates to <see cref="DialogQueue.BackgroundShowHide"/>. </summary>
public class BackgroundShowHideCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"background_show_hide";/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public IReadOnlyList<string>AllowedNamed=>new[]{"duration","wait"};/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){var typeStr=positional.Count>0?positional[0]:"show";var type=typeStr.ToLowerInvariant()=="show"?BackgroundActionType.Show:BackgroundActionType.Hide;var duration=named.TryGetValue("duration",out var dur)?ParseFloat(dur):1f;var wait=named.TryGetValue("wait",out var w)&&w=="true";dlg.BackgroundShowHide(type,duration,wait);}private static float ParseFloat(string s){return float.TryParse(s,NumberStyles.Float,CultureInfo.InvariantCulture,out var v)?v:0f;}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/DelayCommandStrategy.cs =====
using System.Collections.Generic;using System.Globalization;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>delay</c> DSL command. Pauses dialog execution for a number of seconds, optionally displaying a text label during the pause. If the <c>hidden</c> flag is set, the delay runs without visible text. Supports localization via the <c>key=</c> named parameter. </summary>
public class DelayCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"delay";/// <inheritdoc/>
public int MaxPositional=>2;/// <inheritdoc/>
public IReadOnlyList<string>AllowedNamed=>new[]{"key","hidden"};/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){var time=positional.Count>0?ParseFloat(positional[0]):1f;var hidden=positional.Count>1&&positional[1]=="hidden"||named.TryGetValue("hidden",out var h)&&h=="true";var showText=hidden?null:positional.Count>1?positional[1]:null;if(named.TryGetValue("key",out var key)&&locale!=null&&showText!=null){var loc=locale.Get(key);if(loc!=null)showText=loc;}if(showText!=null)dlg.Delay(time,showText);else dlg.Delay(time,hidden);}private static float ParseFloat(string s){return float.TryParse(s,NumberStyles.Float,CultureInfo.InvariantCulture,out var v)?v:0f;}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/EffectCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>effect</c> DSL command. Applies a named visual effect/shader to the scene. Implements <see cref="ICommandReferenceMetadata"/> with <see cref="PositionalReferenceKind.Effect"/> for build-time dependency tracking. </summary>
public class EffectCommandStrategy:IScenarioCommandStrategy,ICommandReferenceMetadata,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"effect";/// <inheritdoc/>
public PositionalReferenceKind Positional0Kind=>PositionalReferenceKind.Effect;/// <inheritdoc/>
public int MinPositional=>1;/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){if(positional.Count>0)dlg.Effect(positional[0]);}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/EmptyCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>empty</c> DSL command. Queues a no-op placeholder action (<see cref="ActionEmpty"/>) that is skipped instantly — the DSL-level label mechanism (<c>:label</c> via <see cref="DialogQueue.Point"/>) is built on the same action type, and a bare <c>empty</c> line works as a placeholder marker. </summary>
public class EmptyCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"empty";/// <inheritdoc/>
public int MaxPositional=>0;/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){dlg.Empty();}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/EndCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>end</c> DSL command. Marks the end of the dialog sequence, terminating execution of the current scenario. Typically used at the final line of a scenario script to signal the dialog engine to close or transition. </summary>
public class EndCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"end";/// <inheritdoc/>
public int MaxPositional=>0;/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){dlg.End();}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/ForegroundCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>foreground</c> DSL command. Places a foreground overlay image on top of the scene (above background and characters). The first positional argument specifies the resource path to the image asset. </summary>
public class ForegroundCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"foreground";/// <inheritdoc/>
public int MinPositional=>1;/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){dlg.Foreground(positional.Count>0?positional[0]:"");}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/GotoCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>goto</c> DSL command. Jumps execution to a labeled anchor point (<c>:labelname</c>) in the scenario script. Supports two forms: <c>goto target_name</c> via <c>target=</c> named parameter, or <c>goto -&gt; target_name</c> via the arrow target syntax. </summary>
public class GotoCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"goto";/// <inheritdoc/>
public int MinArguments=>1;/// <inheritdoc/>
public int MaxPositional=>0;/// <inheritdoc/>
public IReadOnlyList<string>AllowedNamed=>new[]{"target"};/// <inheritdoc/>
public string JumpTargetNamedKey=>"target";/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){if(!string.IsNullOrEmpty(arrowTarget))dlg.GoTo(arrowTarget);else if(named.TryGetValue("target",out var target))dlg.GoTo(target);}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/HideDialogCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>hide_dialog</c> DSL command. Hides the dialog UI panel (text box, avatars, and UI chrome) from the screen. The inverse of <see cref="ShowDialogCommandStrategy"/>. </summary>
public class HideDialogCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"hide_dialog";/// <inheritdoc/>
public int MaxPositional=>0;/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){dlg.HideDialog();}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/InteractiveAddCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Scenario DSL command <c>interactive_add</c>: instantiates an interactive object from its definition ID. Optional <c>layer</c> parameter overrides the layer from the definition. Usage: <c>interactive_add door_to_kitchen</c> <c>interactive_add door_to_kitchen layer=foreground</c> </summary>
public class InteractiveAddCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <summary>The lowercase DSL keyword handled by this strategy: <c>interactive_add</c>.</summary>
public string Command=>"interactive_add";/// <inheritdoc/>
public int MinPositional=>1;/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public IReadOnlyList<string>AllowedNamed=>new[]{"layer"};/// <summary>Queues an <see cref="ActionInteractiveAdd"/> for the definition id in <c>positional[0]</c>; lines without an id are skipped.</summary>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){if(positional.Count==0)return;var id=positional[0];dlg.InteractiveAdd(id);}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/InteractiveClearCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Scenario DSL command <c>interactive_clear</c>: removes all active interactive objects from the scene. Usage: <c>interactive_clear</c> </summary>
public class InteractiveClearCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <summary>The lowercase DSL keyword handled by this strategy: <c>interactive_clear</c>.</summary>
public string Command=>"interactive_clear";/// <inheritdoc/>
public int MaxPositional=>0;/// <summary>Queues an <see cref="ActionInteractiveClear"/> that removes every active interactive object from the scene.</summary>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){dlg.InteractiveClear();}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/InteractiveRemoveCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Scenario DSL command <c>interactive_remove</c>: removes and destroys an interactive object by its definition ID. Usage: <c>interactive_remove door_to_kitchen</c> </summary>
public class InteractiveRemoveCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <summary>The lowercase DSL keyword handled by this strategy: <c>interactive_remove</c>.</summary>
public string Command=>"interactive_remove";/// <inheritdoc/>
public int MinPositional=>1;/// <inheritdoc/>
public int MaxPositional=>1;/// <summary>Queues an <see cref="ActionInteractiveRemove"/> for the definition id in <c>positional[0]</c>; lines without an id are skipped.</summary>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){if(positional.Count==0)return;var id=positional[0];dlg.InteractiveRemove(id);}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/InteractiveSetCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Scenario DSL command <c>interactive_set</c>: modifies a property of an active interactive object. Supported properties: <c>enabled</c>, <c>visible</c>. Usage: <c>interactive_set door_to_kitchen enabled=false</c> <c>interactive_set door_to_kitchen visible=true</c> </summary>
public class InteractiveSetCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <summary>The lowercase DSL keyword handled by this strategy: <c>interactive_set</c>.</summary>
public string Command=>"interactive_set";/// <inheritdoc/>
public int MinPositional=>1;/// <inheritdoc/>
public int MinArguments=>2;/// <inheritdoc/>
public IReadOnlyList<string>AllowedNamed=>new[]{"enabled","visible"};/// <summary>Queues an <see cref="ActionInteractiveSet"/> for <c>positional[0]</c>; <c>enabled=</c> wins over <c>visible=</c> when both are given, and lines without both an id and a property are skipped.</summary>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){if(positional.Count<2)return;var id=positional[0];if(named.TryGetValue("enabled",out var enabled))dlg.InteractiveSet(id,"enabled",enabled);else if(named.TryGetValue("visible",out var visible))dlg.InteractiveSet(id,"visible",visible);}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/LightSetCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios.Impls{/// <summary> Handles the <c>light_set</c> DSL command. Passes a JSON string that describes lighting configuration to the dialog engine via <see cref="DialogQueue.LightSet"/>. The JSON structure is forwarded as-is to the rendering layer for point-light setup. </summary>
public class LightSetCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"light_set";/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){var json=positional.Count>0?positional[0]:null;dlg.LightSet(json);}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/LightUseTimeCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>light_use_time</c> DSL command. Toggles whether the scene lighting should be driven by the simulated time-of-day system. Passes a boolean flag to <see cref="DialogQueue.LightUseTime"/>. </summary>
public class LightUseTimeCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"light_use_time";/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){var val=positional.Count>0?positional[0]:"true";dlg.LightUseTime(val=="true");}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/MusicCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>music</c> DSL command. Starts playing a background music track from the given resource path. Implements <see cref="ICommandReferenceMetadata"/> with <see cref="PositionalReferenceKind.Music"/> so the build pipeline can track referenced audio assets. </summary>
public class MusicCommandStrategy:IScenarioCommandStrategy,ICommandReferenceMetadata,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"music";/// <inheritdoc/>
public PositionalReferenceKind Positional0Kind=>PositionalReferenceKind.Music;/// <inheritdoc/>
public int MinPositional=>1;/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){if(positional.Count>0)dlg.Music(positional[0]);}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/MusicPauseCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>music_pause</c> DSL command. Pauses the currently playing background music stream. Playback can be resumed later with <see cref="MusicPlayCommandStrategy"/>. </summary>
public class MusicPauseCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"music_pause";/// <inheritdoc/>
public int MaxPositional=>0;/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){dlg.MusicPause();}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/MusicPlayCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>music_play</c> DSL command. Resumes the currently paused background music stream. Has no effect if music is already playing. </summary>
public class MusicPlayCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"music_play";/// <inheritdoc/>
public int MaxPositional=>0;/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){dlg.MusicPlay();}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/NotifyCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI.Messages;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>notify</c> DSL command. Queues a notification text into <see cref="NotificationBuffer"/> for display in the top-left notification panel. This is a fire-and-forget command — it does not block dialog execution. </summary>
public class NotifyCommandStrategy:IScenarioCommandStrategy{/// <summary> The lowercase DSL command word. </summary>
public string Command=>"notify";/// <inheritdoc/>
public int MinArguments=>1;/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public IReadOnlyList<string>AllowedNamed=>new[]{"key"};/// <summary> Extracts the notification text from the first positional argument and enqueues it into <see cref="NotificationBuffer"/>. Supports localization via the <c>key=</c> named parameter. </summary> <param name="dlg">The dialog queue (unused — notifications bypass dialog).</param> <param name="positional">Positional arguments; <c>positional[0]</c> is the notification text.</param> <param name="named">Named arguments; <c>"key"</c> is a locale lookup key.</param> <param name="arrowTarget">Unused by this command.</param> <param name="locale">Optional localization provider for <c>key=</c> resolution.</param>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){var text=positional.Count>0?positional[0]:null;if(named.TryGetValue("key",out var key)&&locale!=null){var loc=locale.Get(key);if(loc!=null)text=loc;}if(text!=null)NotificationBuffer.Add(text);}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/PTextCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Mods.Dialog.Action;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>ptext</c> DSL command. A "plain text" variant of <see cref="TextCommandStrategy"/> that skips speaker attribution but still supports <c>first</c>/<c>second</c> avatar faces and <c>hide_color</c> and <c>wait</c> behavior flags. Text content can be provided directly or resolved via a <c>key=</c> localization key. </summary>
public class PTextCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"ptext";/// <inheritdoc/>
public int MinArguments=>1;/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public IReadOnlyList<string>AllowedNamed=>new[]{"key","first","second","hide_color","wait"};/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){var textVal=positional.Count>0?positional[0]:"";if(named.TryGetValue("key",out var key)&&locale!=null){var loc=locale.Get(key);if(loc!=null)textVal=loc;}var first=named.TryGetValue("first",out var f)?f:null;var second=named.TryGetValue("second",out var s)?s:null;var hideColor=named.TryGetValue("hide_color",out var hc)&&hc=="true";var action=dlg.PText(textVal,first,second,hideColor);if(named.TryGetValue("wait",out var wait)&&wait=="false")action.WaitType=WaitType.NoWait;}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/SetCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>set</c> DSL command. Stores an arbitrary key-value pair into the dialog session's variable state via <see cref="DialogQueue.Set"/>. Values are read back later through <c>${var}</c> substitution in <c>ptext</c> lines and via <see cref="Cthangover.Core.Actions.IActionContext.GetParam"/> in scenario actions. </summary>
public class SetCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"set";/// <inheritdoc/>
public int MinPositional=>2;/// <inheritdoc/>
public int MaxPositional=>2;/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){var name=positional.Count>0?positional[0]:"";var value=positional.Count>1?positional[1]:"";dlg.Set(name,value);}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/ShowDialogCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>show_dialog</c> DSL command. Makes the dialog UI panel (text box, avatars, and UI chrome) visible on screen. The inverse of <see cref="HideDialogCommandStrategy"/>. </summary>
public class ShowDialogCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"show_dialog";/// <inheritdoc/>
public int MaxPositional=>0;/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){dlg.ShowDialog();}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/SoundCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>sound</c> DSL command. Plays a one-shot sound effect from the given resource path. Implements <see cref="ICommandReferenceMetadata"/> with <see cref="PositionalReferenceKind.Sound"/> so the build pipeline can track referenced audio assets. </summary>
public class SoundCommandStrategy:IScenarioCommandStrategy,ICommandReferenceMetadata,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"sound";/// <inheritdoc/>
public PositionalReferenceKind Positional0Kind=>PositionalReferenceKind.Sound;/// <inheritdoc/>
public int MinPositional=>1;/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){if(positional.Count>0)dlg.Sound(positional[0]);}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/SwitchSceneCommandStrategy.cs =====
using System.Collections.Generic;using System.Globalization;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>switch_scene</c> DSL command. Transitions to a new Godot scene (PackedScene) with configurable transition <c>speed</c> and an optional <c>hidden</c> mode that shows only the scene background during the transition. Implements <see cref="ICommandReferenceMetadata"/> with <see cref="PositionalReferenceKind.Scene"/> for build-time dependency tracking. </summary>
public class SwitchSceneCommandStrategy:IScenarioCommandStrategy,ICommandReferenceMetadata,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"switch_scene";/// <inheritdoc/>
public PositionalReferenceKind Positional0Kind=>PositionalReferenceKind.Scene;/// <inheritdoc/>
public int MinPositional=>1;/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public IReadOnlyList<string>AllowedNamed=>new[]{"speed","hidden"};/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){var scene=positional.Count>0?positional[0]:"";var speed=named.TryGetValue("speed",out var sp)?ParseFloat(sp):4f;var hidden=named.TryGetValue("hidden",out var h)&&h=="true";dlg.SwitchScene(scene,speed,hidden);}private static float ParseFloat(string s){return float.TryParse(s,NumberStyles.Float,CultureInfo.InvariantCulture,out var v)?v:0f;}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/TextCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Mods.Dialog.Action;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>text</c> DSL command, the primary dialog display instruction. Shows character text with optional <c>first</c>/<c>second</c> speaker avatar faces, <c>hide_color</c> tint suppression, and a <c>wait</c> flag that controls whether the dialog waits for user input before continuing. Text can be literal or resolved from a <c>key=</c> localization entry. </summary>
public class TextCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"text";/// <inheritdoc/>
public int MinArguments=>1;/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public IReadOnlyList<string>AllowedNamed=>new[]{"key","first","second","hide_color","wait"};/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){var textVal=positional.Count>0?positional[0]:"";if(named.TryGetValue("key",out var key)&&locale!=null){var loc=locale.Get(key);if(loc!=null)textVal=loc;}var first=named.TryGetValue("first",out var f)?f:null;var second=named.TryGetValue("second",out var s)?s:null;var hideColor=named.TryGetValue("hide_color",out var hc)&&hc=="true";var action=dlg.Text(textVal,first,second,hideColor);if(named.TryGetValue("wait",out var wait)&&wait=="false")action.WaitType=WaitType.NoWait;}}}

===== FILE mods/dialog_base/src/Scenarios/Impls/TitleCommandStrategy.cs =====
using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Handles the <c>title</c> DSL command. Displays a centered title/caption text on screen, typically used for chapter headings or location labels. Text can be literal or resolved from a <c>key=</c> localization entry. Silently does nothing if no text value is provided or resolved. </summary>
public class TitleCommandStrategy:IScenarioCommandStrategy,IScenarioCommandSpec{/// <inheritdoc/>
public string Command=>"title";/// <inheritdoc/>
public int MinArguments=>1;/// <inheritdoc/>
public int MaxPositional=>1;/// <inheritdoc/>
public IReadOnlyList<string>AllowedNamed=>new[]{"key"};/// <inheritdoc/>
public void Execute(DialogQueue dlg,List<string>positional,Dictionary<string,string>named,string arrowTarget,ILocalizationProvider locale){var textVal=positional.Count>0?positional[0]:null;if(named.TryGetValue("key",out var key)&&locale!=null){var loc=locale.Get(key);if(loc!=null)textVal=loc;}if(textVal!=null)dlg.Title(textVal);}}}

===== FILE mods/dialog_base/src/Scenarios/ScenarioCommandStrategyConsumer.cs =====
using Cthangover.Core.Mods.Consumers;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Feeds <see cref="ScenarioCommandStrategyFactory"/> with every mod's <see cref="IScenarioCommandStrategy"/> implementations as the mods load (dialog_base's own built-in commands included, through the join-time catch-up) and removes them when their mod unloads, so a hot-reloaded mod's commands run its fresh code and the dying assembly is not pinned. </summary>
public class ScenarioCommandStrategyConsumer:ModTypeConsumer<IScenarioCommandStrategy>{/// <inheritdoc />
public override string Id=>"dialog.command_strategies";/// <inheritdoc />
protected override void Add(IScenarioCommandStrategy instance,string modId)=>ScenarioCommandStrategyFactory.Register(instance);/// <inheritdoc />
protected override void Remove(IScenarioCommandStrategy instance,string modId)=>ScenarioCommandStrategyFactory.Unregister(instance);}}

===== FILE mods/dialog_base/src/Scenarios/ScenarioConverter.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Core.Mods;using Cthangover.Mods.Dialog;using Cthangover.Mods.Dialog.Action;using Cthangover.Mods.Dialog.Action.Impls;using Cthangover.Core.Utils;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Converts scenario DSL text or files into <see cref="DialogQueue"/> instances. Wraps <see cref="ScenarioParser"/> with a configurable <see cref="ILocalizationProvider"/> that defaults to <see cref="GodotTranslationProvider.Instance"/> when none is supplied. </summary>
public class ScenarioConverter{private readonly ILocalizationProvider locale;/// <summary> Creates a converter with an optional localization provider. </summary> <param name="locale">Localization provider for <c>key=</c> text lookups. Defaults to Godot translations.</param>
public ScenarioConverter(ILocalizationProvider locale=null){this.locale=locale??GodotTranslationProvider.Instance;}/// <summary> Parses raw scenario script text into a dialog queue using the configured locale. </summary>
public DialogQueue Convert(string text){return ScenarioParser.Parse(text,locale);}/// <summary> Reads a scenario file from disk and parses it into a dialog queue. </summary> <param name="path">Filesystem path to the <c>.scenario</c> file.</param> <returns>The parsed <see cref="DialogQueue"/>, or <c>null</c> if the file could not be opened.</returns>
public DialogQueue ConvertFile(string path){foreach(var kvp in ModRegistry.Instance.Mods){var text=kvp.Value.FileProvider.ReadFileText(path);if(text!=null)return Convert(text);}var file=FileAccess.Open(path,FileAccess.ModeFlags.Read);if(file==null){GameLogger.Log("CONVERTER",$"Failed to open '{path}'",LogLevel.Error);return null;}var fileText=file.GetAsText();file.Close();return Convert(fileText);}}}

===== FILE mods/dialog_base/src/Scenarios/ScenarioLoader.cs =====
using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Core.Utils;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Loads a <c>.scenario</c> file from disk, reads its text content, and parses it via <see cref="ScenarioParser.Parse"/> into a <see cref="DialogQueue"/>. Uses <see cref="GodotTranslationProvider.Instance"/> as the default localization source. </summary>
public static class ScenarioLoader{/// <summary> Loads and parses a scenario file using the default Godot translation provider. </summary> <param name="path">Filesystem path to the <c>.scenario</c> file.</param> <returns>The parsed <see cref="DialogQueue"/>, or <c>null</c> if the file could not be opened.</returns>
public static DialogQueue Load(string path){return Load(path,GodotTranslationProvider.Instance);}/// <summary> Loads and parses a scenario file with a custom localization provider for text lookups. </summary> <param name="path">Filesystem path to the <c>.scenario</c> file.</param> <param name="provider">Localization provider to resolve <c>key=</c> references during parsing.</param> <returns>The parsed <see cref="DialogQueue"/>, or <c>null</c> if the file could not be opened.</returns>
public static DialogQueue Load(string path,ILocalizationProvider provider){var file=FileAccess.Open(path,FileAccess.ModeFlags.Read);if(file==null){GameLogger.Log("SCENE",$"ScenarioLoader: failed to open '{path}'",LogLevel.Error);return null;}var text=file.GetAsText();file.Close();return ScenarioParser.Parse(text,provider);}}}

===== FILE mods/dialog_base/src/Scenarios/ScenarioParser.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Localization;using Cthangover.Mods.Dialog;using Cthangover.Mods.Dialog.Action.Impls;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Scenarios{/// <summary> Parses scenario DSL script text into a <see cref="DialogQueue"/> of dialog actions. The pipeline works in two phases: first the text is split into lines, then each non-empty, non-comment line is tokenized and dispatched to the matching <see cref="IScenarioCommandStrategy"/> via <see cref="ScenarioCommandStrategyFactory"/>. Special handling exists for <c>:label</c> anchor points, <c>select/option</c> blocks (accumulated into <see cref="ActionSelect"/>), and <c>-&gt;</c> arrow targets. </summary>
public static class ScenarioParser{/// <summary> Parses a complete scenario script text and returns the resulting dialog queue. </summary> <param name="text">Raw scenario DSL content.</param> <param name="locale">Optional localization provider for <c>key=</c> text lookups.</param>
public static DialogQueue Parse(string text,ILocalizationProvider locale=null){var dlg=new DialogQueue();var lines=text.Split('\n');ActionSelect pendingSelect=null;for(var lineIndex=0;lineIndex<lines.Length;lineIndex++){var lineNumber=lineIndex+1;var line=lines[lineIndex].Trim();if(line.Length==0||line.StartsWith('#')){FinalizePendingSelect(dlg,ref pendingSelect);continue;}if(line.StartsWith(':')){FinalizePendingSelect(dlg,ref pendingSelect);dlg.Point(line.Substring(1)).SourceLine=lineNumber;continue;}var positional=new List<string>();var named=new Dictionary<string,string>();var cmd=SplitLine(line,positional,named,out var arrowTarget);if(cmd==null)continue;if(cmd=="select"){FinalizePendingSelect(dlg,ref pendingSelect);pendingSelect=new ActionSelect{SourceLine=lineNumber};var promptText=positional.Count>0?positional[0]:null;if(named.TryGetValue("key",out var key)&&locale!=null){var loc=locale.Get(key);if(loc!=null)promptText=loc;}if(promptText!=null)pendingSelect.Text=promptText;if(named.TryGetValue("first",out var first))pendingSelect.FirstAvatar=first;if(named.TryGetValue("second",out var second))pendingSelect.SecondAvatar=second;continue;}if(cmd=="option"){if(pendingSelect==null)continue;var optText=positional.Count>0?positional[0]:"";if(named.TryGetValue("key",out var key)&&locale!=null){var loc=locale.Get(key);if(loc!=null)optText=loc;}var goTo=arrowTarget;if(string.IsNullOrEmpty(goTo)){if(named.TryGetValue("goto",out var gt))goTo=gt;else if(positional.Count>1&&positional[1].StartsWith(':'))goTo=positional[1].TrimStart(':');}if(pendingSelect.Variants==null)pendingSelect.Variants=new List<SelectVariant>();pendingSelect.Variants.Add(SelectVariant.New(optText,goTo));continue;}FinalizePendingSelect(dlg,ref pendingSelect);var strategy=ScenarioCommandStrategyFactory.Get(cmd);if(strategy==null)continue;var firstNew=dlg.Queue.Count;strategy.Execute(dlg,positional,named,arrowTarget,locale);for(var i=firstNew;i<dlg.Queue.Count;i++){if(dlg.Queue[i]!=null&&dlg.Queue[i].SourceLine==0)dlg.Queue[i].SourceLine=lineNumber;}}FinalizePendingSelect(dlg,ref pendingSelect);return dlg;}/// <summary> Splits one trimmed, non-empty DSL line the way <see cref="Parse"/> does: returns the lowercase command word (null for an empty line) and fills the positional tokens, the <c>name=value</c> pairs (names lowercased) and the <c>-&gt; :label</c> target. Tools that read scenarios without running them (the scenario studio) use it to agree with the parser. </summary>
public static string SplitLine(string line,List<string>positional,Dictionary<string,string>named,out string arrowTarget){arrowTarget="";var tokens=Tokenize(line);if(tokens.Count==0)return null;var cmd=tokens[0].ToLowerInvariant();for(var i=1;i<tokens.Count;i++){var t=tokens[i];if(t=="->"){if(i+1<tokens.Count){arrowTarget=tokens[i+1].TrimStart(':');i++;}continue;}var eqIdx=t.IndexOf('=');if(eqIdx>0){var name=t.Substring(0,eqIdx);if(IsValidIdent(name)){named[name.ToLowerInvariant()]=t.Substring(eqIdx+1);continue;}}positional.Add(t);}return cmd;}/// <summary> Extracts YAML-like front-matter metadata from script text. Lines before a <c>---</c> separator or the first non-key:value line are parsed as <c>key: value</c> pairs. Returns both the metadata dictionary and the remaining script content after the metadata block. </summary>
public static(Dictionary<string,string>metadata,string remainingText)ParseMetadata(string text){var metadata=new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);var lines=text.Split('\n');var contentStart=0;var inMetadata=true;for(var i=0;i<lines.Length;i++){var line=lines[i].Trim();if(!inMetadata){continue;}if(line=="---"){contentStart=i+1;break;}if(string.IsNullOrEmpty(line)){contentStart=i;break;}var colonIdx=line.IndexOf(':');if(colonIdx>0){var key=line.Substring(0,colonIdx).Trim();var val=line.Substring(colonIdx+1).Trim();metadata[key]=val;}else{contentStart=i;break;}}var remaining=string.Join("\n",lines,contentStart,lines.Length-contentStart);return(metadata,remaining);}private static void FinalizePendingSelect(DialogQueue dlg,ref ActionSelect pending){if(pending==null)return;if(pending.Variants==null||pending.Variants.Count==0){pending=null;return;}dlg.Queue.Add(pending);pending=null;}private static List<string>Tokenize(string line){var result=new List<string>();var i=0;while(i<line.Length){if(char.IsWhiteSpace(line[i])){i++;continue;}if(line[i]=='"'){i++;var start=i;while(i<line.Length&&line[i]!='"'){if(line[i]=='\\'&&i+1<line.Length)i++;i++;}result.Add(line[start..i].Replace("\\\"","\""));if(i<line.Length)i++;continue;}var tokenStart=i;while(i<line.Length&& !char.IsWhiteSpace(line[i]))i++;result.Add(line[tokenStart..i]);}return result;}private static bool IsValidIdent(string s){if(string.IsNullOrEmpty(s))return false;for(var i=0;i<s.Length;i++){var c=s[i];if(!char.IsLetterOrDigit(c)&&c!='_')return false;}return true;}}}

===== FILE mods/dialog_base/src/SubsystemRegistration.cs =====
namespace Cthangover.Mods.Dialog{/// <summary> Migration marker for the coverage pipeline (<c>SubsystemsBridge</c> reflection scan): declares the dialog subsystem fully moved out of the core assembly into this engine mod. The id must match the <c>DeclaredTests</c> entry in <c>SubsystemsCoverageTestSetup</c>. </summary>
public static class SubsystemRegistration{public static string SubsystemId=>"dialog";}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/ActionCommand.cs =====
using System;using System.Collections.Generic;using Godot;using Cthangover.Core.Actions;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action{/// <summary> Base for all dialog actions with a lifecycle of Construct → Run → Destruct. Each action gets an auto-generated GUID for targeting by GoTo/If commands. The three timing enums control when Construct and Destruct fire relative to the action's lifecycle and queue life, enabling both immediate teardown (OnEndAction) and deferred cleanup (OnEndQueue). WaitType determines whether the runtime pauses here (WaitClick/Select/Time/Event) or advances immediately (NoWait). DelayedDestruct is a set of action IDs whose Destruct should be called after this action's own destruct — used for cross-action cleanup dependencies where one action's visual state must outlast another. </summary>
public abstract class ActionCommand:IActionCommand{/// <summary>Unique identifier for this action. Auto-generated GUID by default; can be overridden via <see cref="SetID"/> for GoTo targeting.</summary>
public string ID{get;set;}/// <summary>Whether <see cref="Construct"/> has been called and <see cref="DoConstruct"/> has completed.</summary>
public bool IsConstructed{get;private set;}/// <summary>Whether <see cref="Destruct"/> has been called and this action is finished.</summary>
public bool IsDestructed{get;private set;}/// <summary>When to call <see cref="Construct"/>: OnStartQueue (preload) or OnStartAction (lazy, just before run). Default is OnStartAction.</summary>
public virtual ConstructType ConstructType{get;set;}=ConstructType.OnStartAction;/// <summary>When to call <see cref="Destruct"/>: OnEndAction (immediate), OnDelayed (manual cleanup), or OnEndQueue (when dialog finishes). Default is OnEndAction.</summary>
public virtual DestructType DestructType{get;set;}=DestructType.OnEndAction;/// <summary>Controls how the runtime advances past this action. Default is WaitClick (pause for user input).</summary>
public virtual WaitType WaitType{get;set;}=WaitType.WaitClick;/// <summary>Duration in seconds for WaitTime. Used only when <see cref="WaitType"/> is WaitTime.</summary>
public virtual float WaitTime{get;set;}/// <summary>Timestamp when <see cref="Run"/> was called. Used by WaitTime actions to measure elapsed time.</summary>
public float StartTime{get;private set;}/// <summary>Action IDs whose <see cref="Destruct"/> will be triggered when this action destructs. Used for cross-action cleanup dependencies.</summary>
public ISet<string>DelayedDestruct{get;}=new HashSet<string>();/// <summary>1-based scenario line the action was parsed from; 0 when built in code.</summary>
public int SourceLine{get;set;}/// <summary> Scene node lookup seam for subclasses. Replaces direct SceneContextNode.FindNode calls so action behavior can be exercised against a fake lookup in unit tests instead of the global scene-tree singleton. Defaults to the runtime implementation (<see cref="SceneNodeServiceImpl"/>) and is instantiated per action instance — the same no-container style as ScenarioActionContext. </summary>
protected ISceneNodeService Scene{get;set;}=new SceneNodeServiceImpl();/// <summary>Generates a unique ID so every action is addressable by GoTo/If even without an explicit <see cref="SetID"/> call.</summary>
public ActionCommand(){ID=Guid.NewGuid().ToString();}/// <summary>Idempotent construction: calls <see cref="DoConstruct"/> once and sets <see cref="IsConstructed"/> to true.</summary>
public void Construct(){if(IsConstructed)return;DoConstruct();IsConstructed=true;}/// <summary>Idempotent desctruction: calls <see cref="DoDestruct"/> once, then sets <see cref="IsConstructed"/> false and <see cref="IsDestructed"/> true.</summary>
public void Destruct(){if(IsDestructed)return;DoDestruct();IsConstructed=false;IsDestructed=true;}/// <summary>Constructs if not already done, records start time, and delegates execution to <see cref="DoRun"/>.</summary>
public void Run(DialogRuntime runtime){if(!IsConstructed)DoConstruct();StartTime=(float)(Godot.Time.GetTicksUsec()/1_000_000.0);DoRun(runtime);}/// <summary>Override in subclasses to implement action-specific behavior. Receives the runtime for text processing and dialog box access.</summary>
public abstract void DoRun(DialogRuntime runtime);/// <summary>Override to preload resources or register listeners. Called by <see cref="Construct"/>.</summary>
public virtual void DoConstruct(){}/// <summary>Override to release resources or disconnect listeners. Called by <see cref="Destruct"/>.</summary>
public virtual void DoDestruct(){}/// <summary>Fluent setter for <see cref="ID"/>. Use to assign a named target for GoTo/If commands.</summary>
public ActionCommand SetID(string id){ID=id;return this;}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/ActionType.cs =====
namespace Cthangover.Mods.Dialog.Action{/// <summary> High-level action category discriminator. Currently minimal (Text, End) — may expand as additional action families are added. </summary>
public enum ActionType{/// <summary>A dialog text display action that pauses for click input.</summary>
Text,/// <summary>Terminates the dialog queue.</summary>
End}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/ConstructType.cs =====
namespace Cthangover.Mods.Dialog.Action{/// <summary> Controls when an action's Construct() fires. OnStartQueue — construct when the dialog queue is first loaded (good for preloading); OnStartAction — lazy-construct just before the action runs (saves resources for unreached branches). Default for most actions is OnStartAction. </summary>
public enum ConstructType{/// <summary>Construct when the dialog queue is first loaded. Useful for actions that need to preload resources.</summary>
OnStartQueue,/// <summary>Construct lazily just before the action runs. Default for most actions — saves resources for unreached branches.</summary>
OnStartAction}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/DestructType.cs =====
namespace Cthangover.Mods.Dialog.Action{/// <summary> Controls when an action's Destruct() fires. OnEndAction — immediately after the action advances (typical for most commands). OnDelayed — deferred until specifically cleaned up (for visual state that must persist across actions). OnEndQueue — destruct when the entire dialog queue finishes (for resources that must survive the whole dialog but not beyond). </summary>
public enum DestructType{/// <summary>Destruct immediately when the action is advanced past. Default for most commands.</summary>
OnEndAction,/// <summary>Destruct is deferred — must be manually triggered or triggered via <see cref="ActionCommand.DelayedDestruct"/>.</summary>
OnDelayed,/// <summary>Destruct when the entire dialog queue finishes. Use for resources that must survive the whole dialog but not beyond.</summary>
OnEndQueue,}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/IActionCommand.cs =====
using System.Collections.Generic;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action{/// <summary> Full dialog action contract: extends IActionDestruct with construction, execution (Run), and queryable state (WaitType, timing enums, delayed-destruct dependencies). The ID property enables cross-action references (GoTo, If). </summary>
public interface IActionCommand:IActionDestruct{
#region Properties
/// <summary>Unique action identifier, auto-generated GUID. Can be overridden for named GoTo targets.</summary>
string ID{get;set;}/// <summary>Whether <see cref="Construct"/> has completed.</summary>
bool IsConstructed{get;}/// <summary>Whether <see cref="Destruct"/> has been called and the action is finished.</summary>
bool IsDestructed{get;}/// <summary>Controls when construction fires relative to the action's lifecycle.</summary>
ConstructType ConstructType{get;}/// <summary>Controls when destruction fires relative to the action's lifecycle.</summary>
DestructType DestructType{get;}/// <summary>How the runtime should handle advancement past this action.</summary>
WaitType WaitType{get;}/// <summary>IDs of actions whose Destruct should be called when this action destructs.</summary>
ISet<string>DelayedDestruct{get;}/// <summary>Duration in seconds when WaitType is WaitTime.</summary>
float WaitTime{get;}/// <summary>Timestamp of the last <see cref="Run"/> call, in seconds since epoch.</summary>
float StartTime{get;}/// <summary> 1-based line of the scenario text this action was parsed from, 0 for actions built in code. Set by <see cref="Scenarios.ScenarioParser"/>; debugging tools map the running action back to the script with it. </summary>
int SourceLine{get;set;}
#endregion
#region Methods
/// <summary>Called by the runtime to initialize the action before execution.</summary>
void Construct();/// <summary>Called by the runtime to execute the action. Receives the runtime for context.</summary>
void Run(DialogRuntime runtime);
#endregion
}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/IActionDestruct.cs =====
namespace Cthangover.Mods.Dialog.Action{/// <summary> Minimal destructible contract — anything in the dialog runtime's object list that must be cleaned up when a dialog ends. </summary>
public interface IActionDestruct{/// <summary>Called to release resources, disconnect signals, and mark the object as finished. Must be idempotent.</summary>
void Destruct();}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionAnimatedForeground.cs =====
using System.Collections.Generic;using Cthangover.Mods.Effects.Animation;using Cthangover.Mods.Dialog.Action;using Cthangover.Core.UI.Event;using Cthangover.Mods.Interface.View;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Frame-animates the scene foreground by swapping ViewBox textures at a configurable speed. Loads frames from SpriteIDs on construction (OnStartQueue). Unlike AnimationController which crossfades, this does hard cuts — simpler but acceptable for foreground effects. The speed switches to NextFrameSpeed after the first frame so first-frame delay can differ from subsequent frames. Registers as IOnUpdateEvent for per-frame advancement. Non-looping mode auto-stops at last frame. </summary>
public class ActionAnimatedForeground:ActionCommand,IOnUpdateEvent{/// <summary>Update priority. Runs after higher-priority listeners. Value 0.</summary>
public int Priority=>0;/// <summary>List of sprite resource paths to display as animation frames. Loaded in <see cref="DoConstruct"/>.</summary>
public List<string>SpriteIDs{get;set;}/// <summary>Initial frame rate multiplier for the first frame.</summary>
public float Speed{get;set;}=1f;/// <summary>Frame rate multiplier for subsequent frames after the first. Allows different pacing for the initial frame.</summary>
public float NextFrameSpeed{get;set;}=1f;/// <summary>Whether the animation wraps after the last frame. When false, playback stops at the final frame.</summary>
public bool IsLoop{get;set;}=true;private int currentFrame;private float elapsed;private bool isAnimating;private List<Texture2D>frames;/// <summary>Preloads frames when the dialog queue is first loaded, so textures are ready before playback.</summary>
public override ConstructType ConstructType{get;set;}=ConstructType.OnStartQueue;/// <summary>Animation runs in the background — the dialog queue continues immediately.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Preloads every sprite in <see cref="SpriteIDs"/> into memory so playback never stalls on I/O.</summary>
public override void DoConstruct(){base.DoConstruct();LoadFrames();}/// <summary>Stops the animation loop; the last shown frame stays on screen.</summary>
public override void DoDestruct(){base.DoDestruct();isAnimating=false;}private void LoadFrames(){frames=new List<Texture2D>();if(SpriteIDs==null)return;foreach(var id in SpriteIDs){var tex=GD.Load<Texture2D>(id);if(tex!=null)frames.Add(tex);}}/// <summary>Resets playback to the first frame and shows it on the ViewBox; per-frame stepping happens through <see cref="IOnUpdateEvent.OnUpdate"/>.</summary>
public override void DoRun(DialogRuntime runtime){currentFrame=0;elapsed=0f;isAnimating=frames!=null&&frames.Count>0;if(isAnimating){var viewBox=Scene.Find<ViewBox>("ViewBox");viewBox?.SetForegroundTexture(frames[0]);}}void IOnUpdateEvent.OnUpdate(){if(!isAnimating||frames==null||frames.Count==0)return;var viewBox=Scene.Find<ViewBox>("ViewBox");if(viewBox==null)return;elapsed+=1f/60f;var frameDuration=1f/Mathf.Max(Speed,0.01f);if(elapsed>=frameDuration){elapsed-=frameDuration;currentFrame++;if(currentFrame>=frames.Count){if(IsLoop)currentFrame=0;else{isAnimating=false;return;}}viewBox.SetForegroundTexture(frames[currentFrame]);Speed=NextFrameSpeed;}}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionBackground.cs =====
using Cthangover.Core.Factories.Impls;using Cthangover.Core.Scenes;using Cthangover.Mods.Dialog.Action;using Cthangover.Mods.Time;using Cthangover.Mods.Interface.View;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Sets the scene background on the ViewBox, including depth and albedo maps for the lighting system. Resolves textures through BackgroundFactory on construction (OnStartQueue) — depth and albedo are loaded from the same sprite ID with "_depth" / "_albedo" suffixes. Kills any in-progress background transition before setting the new texture. Textures are nulled after application to prevent stale reuse across dialog restarts. Sets SceneContextNode.LastBackgroundID so the scene manager can restore the correct background on scene revisit. </summary>
public class ActionBackground:ActionCommand{/// <summary>Sprite resource ID resolved through <see cref="BackgroundFactory"/> on construction. Also loads depth (_depth) and albedo (_albedo) variants.</summary>
public string SpriteID{get;set;}/// <summary>Direct texture override. When non-null, bypasses factory resolution. Set to null after application to prevent stale reuse.</summary>
public Texture2D Texture{get;set;}/// <summary>Depth map for the lighting system, loaded from <see cref="SpriteID"/> + "_depth".</summary>
public Texture2D DepthTexture{get;set;}/// <summary>Albedo color map for the lighting system, loaded from <see cref="SpriteID"/> + "_albedo".</summary>
public Texture2D AlbedoTexture{get;set;}/// <summary>Preloads textures when the dialog queue is first created, resolving SpriteID through the factory.</summary>
public override ConstructType ConstructType{get;set;}=ConstructType.OnStartQueue;/// <summary>Background is set imperatively — the dialog continues immediately.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Resolves <see cref="SpriteID"/> plus its <c>_depth</c>/<c>_albedo</c> variants through the background factory.</summary>
public override void DoConstruct(){base.DoConstruct();if(Texture==null&& !string.IsNullOrEmpty(SpriteID))Texture=BackgroundFactory.Instance.Get(SpriteID);if(!string.IsNullOrEmpty(SpriteID)){DepthTexture=BackgroundFactory.Instance.Get(SpriteID+"_depth");AlbedoTexture=BackgroundFactory.Instance.Get(SpriteID+"_albedo");}}/// <summary> Applies the background to the ViewBox, kills any in-flight background transition and feeds the depth/albedo maps to the light controller. Consumed textures are nulled so a reused action cannot re-apply stale data. </summary>
public override void DoRun(DialogRuntime runtime){if(Texture==null)return;SceneContextNode.LastBackgroundID=SpriteID;var viewBox=Scene.Find<ViewBox>("ViewBox");viewBox?.KillTransitionTween();viewBox?.SetBackgroundTexture(Texture);var controller=UiLightController.Instance;controller?.SetupDepthMap(DepthTexture);controller?.SetupAlbedoMap(AlbedoTexture);Texture=null;DepthTexture=null;AlbedoTexture=null;}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionBackgroundColor.cs =====
using Cthangover.Mods.Dialog.Action;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Sets the background tint color. DoRun is currently a no-op — the Color property is stored for external systems to read. Likely intended as a declarative instruction consumed by a background-rendering node rather than applied imperatively. </summary>
public class ActionBackgroundColor:ActionCommand{/// <summary>Target tint color. Stored declaratively — consumed by background-rendering nodes rather than applied imperatively here.</summary>
public Color Color{get;set;}=Colors.Black;/// <summary>Imperative — the dialog continues immediately after this action is processed.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Deliberate no-op: the tint is declarative data for background-rendering nodes, not applied here.</summary>
public override void DoRun(DialogRuntime runtime){}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionBackgroundShowHide.cs =====
using Cthangover.Mods.Dialog.Action;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Tween-driven background fade in/out. Animates the modulate alpha of the dialog box's parent Control over Duration seconds. Creates and manages its own Tween internally, killing any previous tween before starting a new one to prevent overlap. Can optionally set WaitType to WaitTime with a matching duration so the dialog pauses during the transition. </summary>
public class ActionBackgroundShowHide:ActionCommand{/// <summary>Whether to fade the background in (Show) or out (Hide).</summary>
public BackgroundActionType ActionType{get;set;}=BackgroundActionType.Show;/// <summary>Duration of the fade tween in seconds.</summary>
public float Duration{get;set;}=1f;private Tween activeTween;/// <summary>Default NoWait — overridden by the DialogQueue builder if the wait flag is set, changing to WaitTime to pause during transition.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Starts an alpha tween of the dialog box parent's <c>modulate:a</c> towards fully visible (Show) or fully transparent (Hide).</summary>
public override void DoRun(DialogRuntime runtime){var dialogBox=runtime.DialogBox;if(dialogBox==null)return;var targetAlpha=ActionType==BackgroundActionType.Show?1f:0f;if(dialogBox.GetParent()is Control backgroundNode){StopTween();activeTween=backgroundNode.CreateTween();activeTween.TweenProperty(backgroundNode,"modulate:a",targetAlpha,Duration);}}/// <summary>Kills the fade tween so a destructed action cannot keep animating.</summary>
public override void DoDestruct(){base.DoDestruct();StopTween();}private void StopTween(){if(activeTween!=null){activeTween.Kill();activeTween=null;}}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionDelay.cs =====
using Cthangover.Mods.Dialog.Action;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Waits for a fixed duration. When HiddenMode is true, hides the dialog body for the duration (for dramatic pauses or cutscene beats). ShowText, if set, displays a temporary message during the wait. DoDestruct is a no-op because body visibility is implicitly restored on next Show. </summary>
public class ActionDelay:ActionCommand{/// <summary>When true, hides the dialog body for the duration of the wait (dramatic pauses, cutscene beats).</summary>
public bool HiddenMode{get;set;}/// <summary>Optional temporary text displayed while waiting. Set to null to keep the current dialog text.</summary>
public string ShowText{get;set;}/// <summary>Pauses the dialog until <see cref="WaitTime"/> elapses.</summary>
public override WaitType WaitType{get;set;}=WaitType.WaitTime;/// <summary>Duration of the pause in seconds. Default 1.0.</summary>
public override float WaitTime{get;set;}=1f;/// <summary>Shows the optional <see cref="ShowText"/> and, in <see cref="HiddenMode"/>, hides the dialog body for the pause duration.</summary>
public override void DoRun(DialogRuntime runtime){var dialogBox=runtime.DialogBox;if(dialogBox==null)return;if(ShowText!=null)dialogBox.SetText(ShowText);if(HiddenMode)dialogBox.Body.SetDeferred("visible",false);}/// <summary>No-op: the dialog body hidden by <see cref="HiddenMode"/> is restored implicitly by the next show.</summary>
public override void DoDestruct(){base.DoDestruct();if(HiddenMode){// Body restored on next Show
}}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionEffect.cs =====
using Cthangover.Mods.Effects;using Cthangover.Core.Utils;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Spawns a visual effect from the EffectFactory by ID. The spawned node persists until the action is destructed. Uses DestructType.OnDelayed because the effect must outlive the action's run — the effect node's own animation or lifetime controls when it disappears. DoRun is a no-op because construction handles spawning. </summary>
public class ActionEffect:ActionCommand{/// <summary>Identifier resolved through <see cref="EffectFactory"/> to a PackedScene. The spawned node persists until destructed.</summary>
public string EffectID{get;set;}private Node spawnedEffect;/// <summary>The dialog continues immediately — the spawned effect plays independently.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>OnDelayed ensures the effect node outlives the action's run phase. Destruct is called manually when the effect finishes.</summary>
public override DestructType DestructType{get;set;}=DestructType.OnDelayed;/// <summary>Resolves <see cref="EffectID"/> through <see cref="EffectFactory"/> and instantiates the effect scene.</summary>
public override void DoConstruct(){base.DoConstruct();if(string.IsNullOrEmpty(EffectID))return;var packedScene=EffectFactory.Instance.Get(EffectID);if(packedScene==null){GameLogger.Log("DIALOG",$"ActionEffect: effect '{EffectID}' not found",LogLevel.Error);return;}spawnedEffect=packedScene.Instantiate();}/// <summary>No-op: spawning happens during construction so the effect is ready before the queue reaches this point.</summary>
public override void DoRun(DialogRuntime runtime){}/// <summary>Frees the spawned effect node; paired with the OnDelayed destruct so effects outlive the action's run.</summary>
public override void DoDestruct(){base.DoDestruct();if(spawnedEffect!=null){spawnedEffect.QueueFree();spawnedEffect=null;}}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionEmpty.cs =====
using Cthangover.Mods.Dialog.Action;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> No-op placeholder action. Used as a labeled jump target (Point) for GoTo commands — provides an addressable marker in the dialog sequence without any visible effect. NoWait so the dialog skips through labels instantly. </summary>
public class ActionEmpty:ActionCommand{/// <summary>Label actions skip immediately so the dialog doesn't pause on markers.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>No-op: the action exists only to carry the jump-target ID.</summary>
public override void DoRun(DialogRuntime runtime){}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionEnd.cs =====
using Cthangover.Mods.Dialog.Action;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Terminates the dialog queue immediately. Calls Runtime.End() which destructs all remaining actions, processes the cleanup queue, hides the dialog box, and fires the dialog-end event. </summary>
public class ActionEnd:ActionCommand{/// <summary>Ends the dialog queue immediately — no pause, no return.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Terminates the queue via <see cref="DialogRuntime.End"/> — destructs remainders, runs cleanup, hides the box, fires the end event.</summary>
public override void DoRun(DialogRuntime runtime){runtime.End();}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionForeground.cs =====
using Cthangover.Core.Factories.Impls;using Cthangover.Mods.Interface.View;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Sets the scene foreground texture on the ViewBox. Resolves SpriteID through BackgroundFactory on construction (OnStartQueue) so the texture is loaded before the dialog starts. Sets the texture to null after applying it — ActionCommand instances may be reused by the runtime, and nulling prevents double-applying stale references. </summary>
public class ActionForeground:ActionCommand{/// <summary>Sprite resource ID resolved through <see cref="BackgroundFactory"/> on construction.</summary>
public string SpriteID{get;set;}/// <summary>Direct texture override. Bypasses factory resolution. Nulled after application to prevent stale reuse across dialog cycles.</summary>
public Texture2D Texture{get;set;}/// <summary>Preloads the texture when the dialog queue is first created.</summary>
public override ConstructType ConstructType{get;set;}=ConstructType.OnStartQueue;/// <summary>Foreground is set imperatively — the dialog continues immediately.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Resolves <see cref="SpriteID"/> through the background factory unless a direct texture override is set.</summary>
public override void DoConstruct(){base.DoConstruct();if(Texture==null&& !string.IsNullOrEmpty(SpriteID))Texture=BackgroundFactory.Instance.Get(SpriteID);}/// <summary>Applies the preloaded texture to the ViewBox foreground, then nulls it so a reused action cannot re-apply stale data.</summary>
public override void DoRun(DialogRuntime runtime){var viewBox=Scene.Find<ViewBox>("ViewBox");viewBox?.SetForegroundTexture(Texture);Texture=null;}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionGoTo.cs =====
using Cthangover.Mods.Dialog.Action;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Unconditional jump to a labeled action in the dialog queue. NoWait so the jump happens transparently. The target is resolved by action ID (set via ActionCommand.SetID or the Point()/Empty() fluent methods). </summary>
public class ActionGoTo:ActionCommand{/// <summary>Target action ID to jump to. Resolved by linear search through the dialog queue.</summary>
public string GoTo{get;set;}/// <summary>Jump happens transparently — the dialog continues from the target without pausing.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Jumps the queue index to the action whose ID matches <see cref="GoTo"/>.</summary>
public override void DoRun(DialogRuntime runtime){runtime.TryGoTo(GoTo);}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionHideDialog.cs =====
namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Hides the dialog box. Used in cutscenes where UI chrome should be temporarily hidden while animations or background transitions play. Paired with ActionShowDialog to restore visibility. </summary>
public class ActionHideDialog:ActionCommand{/// <summary>Hides the dialog immediately — the queue continues without pause.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Hides the dialog box chrome while keeping the queue running.</summary>
public override void DoRun(DialogRuntime runtime){var dialogBox=runtime.DialogBox;if(dialogBox==null)return;dialogBox.Visible=false;}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionIf.cs =====
using System;using Cthangover.Core.Utils;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Conditional branch: evaluates a Func&lt;bool&gt; and jumps to TrueGoTo or FalseGoTo based on the result. If the chosen target is null/empty, falls through to Next(). NoWait so branching is transparent to the player. </summary>
public class ActionIf:ActionCommand{/// <summary>Delegate evaluated at runtime. Must return true/false to select the branch target.</summary>
public Func<bool>Condition{get;set;}/// <summary>Target action ID if <see cref="Condition"/> returns true. Empty or null causes fall-through.</summary>
public string TrueGoTo{get;set;}/// <summary>Target action ID if <see cref="Condition"/> returns false. Empty or null causes fall-through.</summary>
public string FalseGoTo{get;set;}/// <summary>Branch evaluation is transparent — the dialog jumps to the target without user input.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Evaluates <see cref="Condition"/> and jumps to <see cref="TrueGoTo"/> or <see cref="FalseGoTo"/>; an empty target falls through to the next action.</summary>
public override void DoRun(DialogRuntime runtime){if(Condition==null){GameLogger.Log("DIALOG","ActionIf: Condition delegate is null",LogLevel.Error);runtime.Next();return;}var result=Condition();var goTo=result?TrueGoTo:FalseGoTo;if(!string.IsNullOrEmpty(goTo))runtime.TryGoTo(goTo);else runtime.Next();}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionInteractiveAdd.cs =====
using Cthangover.Core.Interactive;using Cthangover.Core.Utils;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Dialog action that instantiates an interactive object from its definition and places it on the scene. Executes immediately (NoWait). </summary>
public class ActionInteractiveAdd:ActionCommand{/// <summary>The interactive definition ID to load and instantiate.</summary>
public string DefinitionId{get;set;}/// <summary>Placement happens immediately — the dialog does not pause.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Instantiates the interactive object named by <see cref="DefinitionId"/> through <see cref="InteractiveManager"/>.</summary>
public override void DoRun(DialogRuntime runtime){if(string.IsNullOrEmpty(DefinitionId)){GameLogger.Log("INTERACTIVE","ActionInteractiveAdd: DefinitionId is empty",LogLevel.Error);return;}var mgr=InteractiveManager.Instance;if(mgr==null){GameLogger.Log("INTERACTIVE","ActionInteractiveAdd: InteractiveManager not initialized yet",LogLevel.Error);return;}mgr.Add(DefinitionId);}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionInteractiveClear.cs =====
using Cthangover.Core.Interactive;using Cthangover.Core.Utils;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Dialog action that removes and destroys all active interactive objects. Typically used on scene exit to ensure clean state. </summary>
public class ActionInteractiveClear:ActionCommand{/// <summary>Clearing happens immediately — the dialog does not pause.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Destroys every active interactive object via <see cref="InteractiveManager"/>.</summary>
public override void DoRun(DialogRuntime runtime){GameLogger.Log("INTERACTIVE","ActionInteractiveClear: clearing all interactives");InteractiveManager.Instance?.ClearAll();}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionInteractiveRemove.cs =====
using Cthangover.Core.Interactive;using Cthangover.Core.Utils;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Dialog action that removes and destroys an interactive object by its definition ID. </summary>
public class ActionInteractiveRemove:ActionCommand{/// <summary>The definition ID of the interactive object to remove.</summary>
public string DefinitionId{get;set;}/// <summary>Removal happens immediately — the dialog does not pause.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Destroys the interactive object named by <see cref="DefinitionId"/>.</summary>
public override void DoRun(DialogRuntime runtime){if(string.IsNullOrEmpty(DefinitionId)){GameLogger.Log("INTERACTIVE","ActionInteractiveRemove: DefinitionId is empty",LogLevel.Error);return;}InteractiveManager.Instance?.Remove(DefinitionId);}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionInteractiveSet.cs =====
using Cthangover.Core.Interactive;using Cthangover.Core.Utils;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Dialog action that modifies a property of an active interactive object. Supported properties: <c>enabled</c> (bool), <c>visible</c> (bool). </summary>
public class ActionInteractiveSet:ActionCommand{/// <summary>The definition ID of the interactive object to modify.</summary>
public string DefinitionId{get;set;}/// <summary>Property name: "enabled" or "visible".</summary>
public string Property{get;set;}/// <summary>String value to parse: "true" or "false".</summary>
public string Value{get;set;}/// <summary>Property changes happen immediately — the dialog does not pause.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Parses <see cref="Value"/> as a bool and applies it to the <c>enabled</c> or <c>visible</c> property of the object named by <see cref="DefinitionId"/>.</summary>
public override void DoRun(DialogRuntime runtime){if(string.IsNullOrEmpty(DefinitionId)||string.IsNullOrEmpty(Property)||string.IsNullOrEmpty(Value)){GameLogger.Log("INTERACTIVE","ActionInteractiveSet: missing parameters",LogLevel.Error);return;}var mgr=InteractiveManager.Instance;if(mgr==null)return;if(!bool.TryParse(Value,out var boolVal)){GameLogger.Log("INTERACTIVE",$"ActionInteractiveSet: invalid bool value '{Value}'",LogLevel.Error);return;}switch(Property.ToLowerInvariant()){case"enabled":mgr.SetEnabled(DefinitionId,boolVal);break;case"visible":mgr.SetVisible(DefinitionId,boolVal);break;default:GameLogger.Log("INTERACTIVE",$"ActionInteractiveSet: unknown property '{Property}'",LogLevel.Error);break;}}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionLightSet.cs =====
using Cthangover.Mods.Time;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Applies static lights from a JSON string to the UiLightController. An empty or null string clears all static lights. The JSON is deserialized into LightDef objects with viewport-relative coordinates, which are then converted to pixel positions. Supports up to 10 static lights plus the player's dynamic lamp. </summary>
public class ActionLightSet:ActionCommand{/// <summary>JSON string defining static light positions and properties. Empty or null clears all static lights.</summary>
public string LightsJson{get;set;}/// <summary>Lights are set imperatively — the dialog continues immediately.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Clears static lights when <see cref="LightsJson"/> is empty, otherwise parses and applies the light definitions to the scene's <see cref="UiLightController"/>.</summary>
public override void DoRun(DialogRuntime runtime){var controller=Scene.Find<UiLightController>("Lights");if(controller==null)return;if(string.IsNullOrEmpty(LightsJson))controller.ClearStaticLights();else controller.SetStaticLights(LightsJson);}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionLightUseTime.cs =====
using Cthangover.Mods.Dialog.Action;using Cthangover.Mods.Time;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Toggles the light controller's time-of-day mode. When UseTime is false, static lighting is used; when true, lighting responds to the in-game clock. </summary>
public class ActionLightUseTime:ActionCommand{/// <summary>True = lights respond to in-game time of day; false = static lighting mode.</summary>
public bool UseTime{get;set;}=true;/// <summary>Time mode toggle is instant — the dialog continues without pause.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Switches the scene's <see cref="UiLightController"/> between time-of-day lighting and static lights.</summary>
public override void DoRun(DialogRuntime runtime){var controller=Scene.Find<UiLightController>("Lights");if(controller!=null)controller.IsUseLight=UseTime;}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionMusic.cs =====
using Cthangover.Core.Audio;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Starts background music playback via IAudioService. Uses MusicType.Ambient so the music layer is treated as atmospheric background rather than UI feedback. If the same music track is already playing, the audio service handles deduplication. </summary>
public class ActionMusic:ActionCommand{/// <summary>Music track identifier resolved through the audio system. Empty strings are silently ignored.</summary>
public string Music{get;set;}/// <summary>Music starts playing in the background — the dialog continues immediately.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Starts the named track as ambient background music through <see cref="IAudioService"/>; empty names are ignored.</summary>
public override void DoRun(DialogRuntime runtime){if(string.IsNullOrEmpty(Music))return;var audioService=Scene.Find<Node>("AudioService")as IAudioService;audioService?.PlayMusic(Music,MusicType.Ambient);}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionMusicPause.cs =====
using Cthangover.Core.Audio;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Pauses background music playback via IAudioService without stopping the track. Can be resumed with ActionMusicPlay. </summary>
public class ActionMusicPause:ActionCommand{/// <summary>Pause is instant — the dialog continues without waiting for audio acknowledgment.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Pauses the current music track without stopping it, so <see cref="ActionMusicPlay"/> can resume it.</summary>
public override void DoRun(DialogRuntime runtime){var audioService=Scene.Find<Node>("AudioService")as IAudioService;audioService?.PauseMusic(true);}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionMusicPlay.cs =====
using Cthangover.Core.Audio;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Resumes paused background music. Paired with ActionMusicPause. </summary>
public class ActionMusicPlay:ActionCommand{/// <summary>Resume is instant — the dialog continues without waiting for audio.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Resumes music paused by <see cref="ActionMusicPause"/>.</summary>
public override void DoRun(DialogRuntime runtime){var audioService=Scene.Find<Node>("AudioService")as IAudioService;audioService?.PauseMusic(false);}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionRun.cs =====
using Cthangover.Core.Utils;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Executes an arbitrary Action delegate inside the dialog queue. Allows imperative code injection (e.g. quest state changes, UI toggles) without creating a dedicated action class. NoWait so the queue advances immediately after the delegate returns. </summary>
public class ActionRun:ActionCommand{/// <summary>Arbitrary delegate executed during dialog sequencing. Null delegates are logged and skipped.</summary>
public System.Action Executable{get;set;}/// <summary>The delegate runs and the dialog advances — no pausing.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Invokes <see cref="Executable"/>; a null delegate is logged and skipped so the queue keeps flowing.</summary>
public override void DoRun(DialogRuntime runtime){if(Executable==null){GameLogger.Log("DIALOG","ActionRun: Executable delegate is null",LogLevel.Error);return;}Executable();}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionScenario.cs =====
using Cthangover.Core.Actions;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Utils;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Bridges the dialog system to the broader scenario action system (ScenarioActionFactory). Resolves an action by name and runs it with a ScenarioActionContext wrapping the dialog runtime. Wraps execution in try/catch so a faulty scenario action doesn't crash the dialog queue. Every error log carries the owning scenario identity (see <see cref="DescribeScenario"/>): the runtime cannot report the source file and line — the parser drops them — but it can name the scenario definition, which together with the validator's file:line report pinpoints the broken command. </summary>
public class ActionScenario:ActionCommand{/// <summary>Scenario action name resolved through <see cref="ScenarioActionFactory"/>. Empty names are logged and skipped.</summary>
public string ActionName{get;set;}/// <summary>The scenario action runs and the dialog advances — no synchronization.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Resolves <see cref="ActionName"/> through <see cref="ScenarioActionFactory"/> and runs it with a dialog-backed context; exceptions are logged, never propagated.</summary>
public override void DoRun(DialogRuntime runtime){if(string.IsNullOrEmpty(ActionName)){GameLogger.Log("DIALOG","ActionScenario: ActionName is null or empty"+DescribeScenario(runtime),LogLevel.Error);return;}var action=ScenarioActionFactory.Instance.Get(ActionName);if(action==null){GameLogger.Log("DIALOG",$"ActionScenario: scenario action '{ActionName}' is not registered — the action is skipped, check the spelling and that the defining mod is loaded"+DescribeScenario(runtime),LogLevel.Error);return;}var ctx=new ScenarioActionContext(runtime);try{action.Run(ctx);}catch(System.Exception ex){GameLogger.Log("DIALOG",$"ActionScenario: {ex.Message}\n{ex.StackTrace}"+DescribeScenario(runtime),LogLevel.Error);}}/// <summary> Formats the scenario suffix for error logs. The locker that started the dialog is the event owning the scenario; its ID is the scenario definition name (ScenarioId) or, for code-driven lockers, the event type name. Returns a human-readable suffix either way, so a missing action in a shipped game can be traced to its scenario from user://logs/cthangover.log alone. </summary>
private static string DescribeScenario(DialogRuntime runtime){var id=runtime?.DialogBox?.Locker?.ID;return string.IsNullOrEmpty(id)?" (scenario unknown)":$" (scenario '{id}')";}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionSelect.cs =====
using System.Collections.Generic;using Cthangover.Mods.Dialog.Action;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Presents player choices by spawning answer variants in the DialogBox. WaitType is WaitSelect so the dialog pauses until the player picks an option (which triggers DialogBox.SelectVariant → Runtime.TryGoTo). Each variant's text is processed through variable substitution before display. Also sets an accompanying text message to contextualize the choice (e.g. "What will you do?"). </summary>
public class ActionSelect:ActionCommand{/// <summary>The list of selectable choice variants. Each carries display text and a GoTo target.</summary>
public List<SelectVariant>Variants{get;set;}/// <summary>Context text shown alongside the choices (e.g. "What will you do?"). Supports variable substitution.</summary>
public string Text{get;set;}/// <summary>Left avatar sprite ID shown during the choice. Null hides the slot.</summary>
public string FirstAvatar{get;set;}/// <summary>Right avatar sprite ID shown during the choice. Null hides the slot.</summary>
public string SecondAvatar{get;set;}/// <summary>Dialog pauses until the player selects a variant — then the runtime jumps to the chosen GoTo target.</summary>
public override WaitType WaitType{get;set;}=WaitType.WaitSelect;/// <summary>Substitutes variables in the variants and context text, then shows the answer box; the runtime stays paused until a variant is picked.</summary>
public override void DoRun(DialogRuntime runtime){var dialogBox=runtime.DialogBox;if(dialogBox==null)return;foreach(var variant in Variants)variant.Text=runtime.ProcessText(variant.Text);dialogBox.SetVariants(Variants);dialogBox.SetText(runtime.ProcessText(Text));}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionSet.cs =====
using System;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Sets a dialog runtime variable (Name=Value) for use by subsequent actions via the ${var} substitution syntax. The optional Callback Func&lt;string&gt; allows lazy evaluation — if set, Value is ignored and Callback is invoked at runtime. This enables dynamic values (e.g. quest state, character names) that can't be known when the dialog script is authored. </summary>
public class ActionSet:ActionCommand{/// <summary>Variable name. Referenced by subsequent actions via ${name} syntax.</summary>
public string Name{get;set;}/// <summary>Literal value to store. Ignored if <see cref="Callback"/> is set.</summary>
public string Value{get;set;}/// <summary>Lazy evaluation delegate. When non-null, invoked at runtime to compute the value dynamically.</summary>
public Func<string>Callback{get;set;}/// <summary>Variable assignment is instant — the dialog continues without pause.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Stores <see cref="Value"/> — or the result of <see cref="Callback"/> when set — as the runtime variable <see cref="Name"/>.</summary>
public override void DoRun(DialogRuntime runtime){var val=Callback!=null?Callback():Value;runtime.SetVariable(Name,val??string.Empty);}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionShowDialog.cs =====
namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Makes the dialog box visible. Typically paired with ActionHideDialog for cutscenes that temporarily hide the dialog UI while animations play. </summary>
public class ActionShowDialog:ActionCommand{/// <summary>Dialog visibility change is instant — the queue continues without pause.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Makes the dialog box chrome visible again after <see cref="ActionHideDialog"/>.</summary>
public override void DoRun(DialogRuntime runtime){var dialogBox=runtime.DialogBox;if(dialogBox==null)return;dialogBox.Visible=true;}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionSound.cs =====
using Cthangover.Core.Audio;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Plays a sound through the audio service using the UI sound channel (SoundType.UI). NoWait so the sound plays and the dialog continues immediately — no synchronization with audio completion. </summary>
public class ActionSound:ActionCommand{/// <summary>Sound effect identifier resolved through <see cref="IAudioService"/>. Played on the UI channel.</summary>
public string Sound{get;set;}/// <summary>Sound is fire-and-forget — the dialog continues immediately without synchronization.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Plays <see cref="Sound"/> on the UI sound channel through <see cref="IAudioService"/>; empty names are ignored.</summary>
public override void DoRun(DialogRuntime runtime){if(string.IsNullOrEmpty(Sound))return;var audioService=Scene.Find<Node>("AudioService")as IAudioService;audioService?.PlaySound(Sound,SoundType.UI);}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionSwitchScene.cs =====
using Cthangover.Core.Scenes;using Cthangover.Core.Utils;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Deferred scene switch: calls SceneManager.SwitchScene via CallDeferred to avoid mid-queue scene tree mutations. NoWait ensures the dialog ends immediately — there's no return from a scene switch. <para/> Does NOT force the owning scenario into OneRun: re-runs are the scenario author's decision (is_one_run metadata) plus per-event conditions. Navigation menus (select + switch_scene) must survive into the next visit, and forcing OneRun recorded them in CompletedScenarioIds — after which FilterCompletedScenarios silently dropped the whole location menu on re-entry. </summary>
public class ActionSwitchScene:ActionCommand{/// <summary>Target scene name passed to <see cref="SceneManager.SwitchScene"/>.</summary>
public string SceneName{get;set;}/// <summary>Whether to transition with the dialog hidden. Default true.</summary>
public bool HiddenMode{get;set;}=true;/// <summary>Transition speed parameter passed to the scene manager. Default 4.0.</summary>
public float Speed{get;set;}=4f;/// <summary>Scene switch is deferred — the dialog ends immediately with no return.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Marks the dialog locker as OneRun and defers <c>SceneManager.SwitchScene</c> so the tree is not mutated mid-queue.</summary>
public override void DoRun(DialogRuntime runtime){if(string.IsNullOrEmpty(SceneName)){GameLogger.Log("DIALOG","ActionSwitchScene: SceneName is null or empty",LogLevel.Error);return;}// No forced IsOneRun here: location navigation scenarios are meant to re-run on every visit. Forcing it made DialogRuntime record the scenario as completed, and the next visit to the same scene started without its menu. One-time story scenes declare is_one_run: true (or a condition:) in their metadata instead.
GameLogger.Log("DIALOG",$"ActionSwitchScene: deferring switch to '{SceneName}'");var sceneManager=Scene.Find<SceneManager>("SceneManager");if(sceneManager!=null){Callable.From(()=>sceneManager.SwitchScene(SceneName)).CallDeferred();}else{GameLogger.Log("DIALOG",$"ActionSwitchScene: SceneManager not found for '{SceneName}'",LogLevel.Error);}}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionText.cs =====
using Cthangover.Core.Factories;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> The primary dialog text display action. Sets the text and both avatar slots (left/right) on the DialogBox. WaitType is WaitClick so the dialog pauses for player input before advancing. Avatars are resolved through IAvatarProvider by string ID; a null/empty avatar hides that slot. When UseProcessText is true, runtime variables (${var}) are substituted before display. </summary>
public class ActionText:ActionCommand{/// <summary>The dialog text to display. Supports ${var} substitution when <see cref="UseProcessText"/> is true.</summary>
public string Text{get;set;}/// <summary>Left avatar sprite ID resolved through <see cref="IAvatarProvider"/>. Null or empty hides the slot.</summary>
public string FirstAvatar{get;set;}/// <summary>Right avatar sprite ID resolved through <see cref="IAvatarProvider"/>. Null or empty hides the slot.</summary>
public string SecondAvatar{get;set;}/// <summary>When true, the avatar is rendered in shader-based silhouette/hidden-color mode.</summary>
public bool HideColor{get;set;}/// <summary>When true, runtime variable substitution (${var}) is applied to <see cref="Text"/> before display.</summary>
public bool UseProcessText{get;set;}/// <summary>Pauses for player click/key input — the dialog waits for user acknowledgment.</summary>
public override WaitType WaitType{get;set;}=WaitType.WaitClick;/// <summary>Sets the dialog text (optionally variable-substituted) and both avatar slots, then waits for the player's click.</summary>
public override void DoRun(DialogRuntime runtime){var dialogBox=runtime.DialogBox;if(dialogBox==null)return;var processedText=UseProcessText?runtime.ProcessText(Text):Text;dialogBox.SetText(processedText);dialogBox.SetFirstAvatar(AvatarProviderRegistry.Get(FirstAvatar),HideColor);dialogBox.SetSecondAvatar(AvatarProviderRegistry.Get(SecondAvatar),HideColor);}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionTimedUpdate.cs =====
using Cthangover.Core.UI.Event;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Timeline-based update action: registers itself as an IOnUpdateEvent subscriber and fires OnUpdateCallback(float t) each frame with a normalized progress value (0→1) over WaitTime seconds. WaitType is WaitEvent so the dialog runtime pauses until elapsed >= WaitTime, at which point the action advances the runtime directly (event-driven — no DialogBox poll) and destructs itself. OnStartCallback/OnFinishCallback bracket the animation for setup/teardown. Fixed 60fps step for deterministic progress regardless of actual frame rate. </summary>
public class ActionTimedUpdate:ActionCommand,IOnUpdateEvent{/// <summary>Update priority. Value 0 — runs before most other listeners.</summary>
public int Priority=>0;/// <summary>Fires once when <see cref="DoRun"/> is called, before the first update frame.</summary>
public System.Action OnStartCallback{get;set;}/// <summary>Fires each frame during the active period with a normalized progress value t (0→1) over <see cref="WaitTime"/> seconds.</summary>
public System.Action<float>OnUpdateCallback{get;set;}/// <summary>Fires once when the elapsed time exceeds <see cref="WaitTime"/> or when destructed.</summary>
public System.Action OnFinishCallback{get;set;}/// <summary>Pauses the dialog until the elapsed time meets WaitTime — the action then advances the runtime itself.</summary>
public override WaitType WaitType{get;set;}=WaitType.WaitEvent;/// <summary>Total duration in seconds over which <see cref="OnUpdateCallback"/> receives normalized progress.</summary>
public override float WaitTime{get;set;}=1f;private float elapsed;private bool isRunning;private DialogRuntime runtime;/// <summary>Registers this action as an <see cref="IOnUpdateEvent"/> listener on the scene's event controller.</summary>
public override void DoConstruct(){base.DoConstruct();var controller=Scene.Find<SceneEventController>("EventController");controller?.AddUpdateEventListener(this);}/// <summary>Starts the timeline: resets elapsed time and fires <see cref="OnStartCallback"/> before the first frame.</summary>
public override void DoRun(DialogRuntime runtime){this.runtime=runtime;elapsed=0f;isRunning=true;OnStartCallback?.Invoke();}/// <summary>Detaches from the update controller, stops ticking and fires <see cref="OnFinishCallback"/>.</summary>
public override void DoDestruct(){base.DoDestruct();isRunning=false;runtime=null;var controller=Scene.Find<SceneEventController>("EventController");controller?.RemoveUpdateEventListener(this);OnFinishCallback?.Invoke();}void IOnUpdateEvent.OnUpdate(){if(!isRunning)return;elapsed+=1f/60f;var t=WaitTime>0f?Mathf.Min(elapsed/WaitTime,1f):1f;OnUpdateCallback?.Invoke(t);if(elapsed>=WaitTime){// Advances the runtime directly instead of relying on a DialogBox poll. Next() destructs this action (firing OnFinishCallback and detaching the update listener), then Run() continues the queue past the WaitEvent point.
isRunning=false;runtime?.Next();runtime?.Run();}}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/ActionTitle.cs =====
namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Sets the dialog box title text. A null title hides the title bar. NoWait — title updates are instant visual changes. </summary>
public class ActionTitle:ActionCommand{/// <summary>Text to display in the title bar. Null hides the title bar.</summary>
public string TitleText{get;set;}/// <summary>Title changes are instant visual updates — the dialog continues immediately.</summary>
public override WaitType WaitType{get;set;}=WaitType.NoWait;/// <summary>Applies <see cref="TitleText"/> to the dialog title bar; null hides the bar.</summary>
public override void DoRun(DialogRuntime runtime){var dialogBox=runtime.DialogBox;if(dialogBox==null)return;dialogBox.SetTitle(TitleText);}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/BackgroundActionType.cs =====
namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Discriminator for background show/hide transitions. </summary>
public enum BackgroundActionType{/// <summary>Fade the dialog background visible (target alpha = 1.0).</summary>
Show,/// <summary>Fade the dialog background transparent (target alpha = 0.0).</summary>
Hide}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/Impls/SelectVariant.cs =====
using System;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Action.Impls{/// <summary> Data model for a dialog choice option: display Text and a GoTo target action ID. The static Point property returns a fresh GUID for runtime goto targets. The New factory provides concise C# construction without object-initializer syntax. </summary>
public class SelectVariant{/// <summary>The text displayed on the choice button. Supports runtime variable substitution.</summary>
public string Text{get;set;}/// <summary>Target action ID to jump to via <see cref="DialogRuntime.TryGoTo"/> when this variant is selected.</summary>
public string GoTo{get;set;}/// <summary>Generates a new unique action ID for use as a GoTo target (e.g. with <see cref="DialogQueue.Point"/>).</summary>
public static string Point=>Guid.NewGuid().ToString();/// <summary>Factory for concise C# construction without object-initializer syntax.</summary>
public static SelectVariant New(string text,string goTo){return new SelectVariant{Text=text,GoTo=goTo};}}}

===== FILE mods/dialog_base/src/UI/Dialog/Action/WaitType.cs =====
namespace Cthangover.Mods.Dialog.Action{/// <summary> Determines how the DialogRuntime advances past this action. NoWait chains immediately to the next action (for imperative commands). WaitClick pauses until user input (click/key). WaitSelect pauses for variant selection. WaitTime pauses for a fixed duration. WaitEvent pauses until an external event marks the action as destructed. </summary>
public enum WaitType{/// <summary>Don't wait — the runtime advances to the next action immediately in the same Run call.</summary>
NoWait,/// <summary>Pause until the player clicks or presses the accept key. Used by dialog text actions.</summary>
WaitClick,/// <summary>Pause until the player selects a choice variant. Used by choice menus.</summary>
WaitSelect,/// <summary>Pause for a fixed duration in seconds (<see cref="ActionCommand.WaitTime"/>).</summary>
WaitTime,/// <summary>Pause until an external event marks the action as destructed (<see cref="ActionCommand.IsDestructed"/>).</summary>
WaitEvent,}}

===== FILE mods/dialog_base/src/UI/Dialog/AnswerBox.cs =====
using System.Collections.Generic;using Cthangover.Mods.Interface.Base.Lists.Impls;using Cthangover.Mods.Dialog.Action.Impls;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog{/// <summary> Answer choice container: extends VerticalListWidget but deliberately overrides PutToLayout to do nothing — the prefab-based layout system handles positioning internally (answer_box.tscn: Content VBoxContainer lays out the items as a container), and the base ColumnCellListWidget layout would conflict. Content size is computed as count * 60px height, creating a fixed-height per-option layout. Self-destructs on hide. </summary>
public partial class AnswerBox:VerticalListWidget<AnswerItem,SelectVariant>{private ICollection<SelectVariant>variants;/// <summary>answer_box.tscn binds Content via the content export; the lookup covers instances built without the binding.</summary>
protected override void OnReady(){if(Content==null){var found=FindChild("Content",true,false);if(found!=null)Set("content",found);}}/// <summary>Stores the variants and triggers <see cref="Widget.Show"/> to build and display the answer list.</summary>
public void CreateVariantsUI(ICollection<SelectVariant>variants){this.variants=variants;Show();}protected override void HideDestruct(){base.HideDestruct();QueueFree();}/// <summary>Returns the stored variants collection. Called by the base <see cref="ListWidget{T, M}.ConstructUI"/> pipeline.</summary>
public override ICollection<SelectVariant>CreateModels(){return variants;}/// <summary>Deliberate no-op — answer items are positioned by their own layout system, not by the list widget's PutToLayout.</summary>
protected override void PutToLayout(AnswerItem item,int index,Control container,Vector2 contentSize){}/// <summary>Fixed-height layout: content height = <paramref name="count"/> * 60px. Width is inherited from the Content container.</summary>
public override Vector2 GetContentSize(int count){return new Vector2(Content.Size.X,count*60);}}}

===== FILE mods/dialog_base/src/UI/Dialog/AnswerItem.cs =====
using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Dialog.Action.Impls;using Godot;using Cthangover.Core.UI;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog{/// <summary> Renders a single dialog choice option. Walks up the scene tree in OnReady to find the nearest IDialogBox parent — this avoids coupling to a specific dialog implementation and lets answer items work regardless of nesting depth. On click, calls SelectVariant on the found dialog box, passing the bound SelectVariant model (which carries the GoTo target and display text). </summary>
public partial class AnswerItem:ListItem<SelectVariant>{[Export]private Label textField;private DialogBox dialogBox;/// <summary>Binds the choice button from the answer_item scene prefab and finds the owning dialog box by walking up the tree, so items work at any nesting depth.</summary>
protected override void OnReady(){textField??=GetNode<Label>("Button/TextLabel");var btn=GetNode<Button>("Button");if(btn!=null)btn.Pressed+=OnClick;if(dialogBox==null){var parent=GetParent();while(parent!=null){if(parent is DialogBox db){dialogBox=db;break;}parent=parent.GetParent();}}}/// <summary>Binds the variant model and sets the button label text to <see cref="SelectVariant.Text"/>.</summary>
public override void Construct(SelectVariant variant){base.Construct(variant);textField.Text=variant.Text;}private void OnClick(){dialogBox?.SelectVariant(Model);}/// <summary>Required by <see cref="IListItem{TModel}"/>. Intentionally empty — the parent AnswerBox handles cleanup.</summary>
public override void Destruct(){}}}

===== FILE mods/dialog_base/src/UI/Dialog/DialogBox.cs =====
using System.Collections.Generic;using Cthangover.Core.Audio;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Mods.Dialog.Action;using Cthangover.Mods.Dialog.Action.Impls;using Cthangover.Mods.Dialog.Executable;using Cthangover.Core.Utils;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog{/// <summary> The visible dialog UI: dual avatars with shader-based hide-color mode, answer box spawning, and click/keyboard advance. Validates that no existing dialog locker is running before accepting a new queue, ensuring only one dialog can occupy the box at a time. Integrates with the audio system for skip sounds on mouse click. Uses the same Widget lifecycle but overrides HideDestruct to reset avatar textures and release the locker. The shader material for avatars is resolved through the mod system on _Ready, supporting mod-provided avatar effects. </summary>
public partial class DialogBox:Widget,IDialogBox{[Export]private PackedScene answerBoxScene;[Export]private Label textField;[Export]private Label titleField;[Export]private Control titleBody;[Export]private TextureRect firstAvatar;[Export]private TextureRect secondAvatar;[Export]private Texture2D emptyAvatar;private IAudioService audioService;private AnswerBox answerBox;private SceneTreeTimer waitTimer;/// <summary>Resolves the body, text and title labels, avatars and the audio service, and applies the mod-provided avatar shader.</summary>
protected override void OnReady(){MouseFilter=MouseFilterEnum.Ignore;if(Body==null){var found=FindChild("DialogBody",true,false);if(found!=null)Set("body",found);}audioService=GetNodeOrNull<Node>("/root/AudioService")as IAudioService;textField??=GetNode<Label>("DialogBody/TextField");titleField??=GetNode<Label>("TitleBody/TitleField");titleBody??=GetNode<Control>("TitleBody");firstAvatar??=GetNode<TextureRect>("FirstAvatar");secondAvatar??=GetNode<TextureRect>("SecondAvatar");var avatarShader=Shaders.Resolve("avatar");if(avatarShader!=null){if(firstAvatar!=null&&firstAvatar.Material==null)firstAvatar.Material=new ShaderMaterial{Shader=avatarShader};if(secondAvatar!=null&&secondAvatar.Material==null)secondAvatar.Material=new ShaderMaterial{Shader=avatarShader};}}/// <summary>Mutable lock object that prevents concurrent dialogs. Set when a queue starts, cleared when the dialog ends.</summary>
public ExecutableEvent Locker{get;set;}/// <summary>The execution engine. Created once at construction and reused for all dialog queues.</summary>
public DialogRuntime Runtime{get;}=new();/// <summary>Whether the answer/choice box is currently visible.</summary>
public bool IsAnswerBoxShowed=>answerBox!=null;/// <summary>If false, new dialog queues are rejected. Set externally to prevent dialog during non-dialog scenes.</summary>
public bool IsActive{get;set;}=true;/// <summary>Called by answer items when the player picks a choice. Hides the answer box and jumps the runtime to the variant's GoTo target.</summary>
public void SelectVariant(SelectVariant variant){if(answerBox!=null){answerBox.Hide();answerBox=null;}Runtime.TryGoTo(variant.GoTo);Runtime.Run();ScheduleWaitTimer();}/// <summary>Sets the title bar text. Null or empty hides the title bar.</summary>
public void SetTitle(string title){if(title==null)title=string.Empty;titleField.Text=title;titleBody.Visible=title!=string.Empty;}/// <summary>Instantiates the answer_box scene prefab (bound in dialog_box.tscn), adds it as a child, and populates it with the given choice variants.</summary>
public void SetVariants(ICollection<SelectVariant>variants){if(answerBoxScene==null){GameLogger.Log("DIALOG","SetVariants: answerBoxScene is not bound in the dialog_box scene",LogLevel.Error);return;}var box=answerBoxScene.Instantiate<AnswerBox>();AddChild(box);answerBox=box;answerBox.CreateVariantsUI(variants);}/// <summary>Sets the main dialog text. Hides the Body canvas item when text is null or whitespace.</summary>
public void SetText(string text){if(text==null)text=string.Empty;textField.Text=text;var body=Body as CanvasItem;if(body!=null)body.Visible= !string.IsNullOrWhiteSpace(text);}/// <summary>Sets the left avatar texture. Null hides the slot. <paramref name="hideColor"/> enables shader silhouette mode when true.</summary>
public void SetFirstAvatar(Texture2D avatar,bool hideColor=false){if(avatar==null){firstAvatar.Visible=false;}else{firstAvatar.Texture=avatar;firstAvatar.Visible=true;if(firstAvatar.Material is ShaderMaterial mat)mat.SetShaderParameter("hide_mode",hideColor?1:0);}}/// <summary>Sets the right avatar texture. Null hides the slot. <paramref name="hideColor"/> enables shader silhouette mode when true.</summary>
public void SetSecondAvatar(Texture2D avatar,bool hideColor=false){if(avatar==null){secondAvatar.Visible=false;}else{secondAvatar.Texture=avatar;secondAvatar.Visible=true;if(secondAvatar.Material is ShaderMaterial mat)mat.SetShaderParameter("hide_mode",hideColor?1:0);}}/// <summary>Resets avatars to empty and releases the dialog locker, preparing for the next dialog session.</summary>
protected override void HideDestruct(){ClearWaitTimer();firstAvatar.Texture=emptyAvatar;firstAvatar.Visible=false;secondAvatar.Texture=emptyAvatar;secondAvatar.Visible=false;Locker=null;}/// <summary> Loads and starts a dialog sequence. Validates that: (1) dialog is not null, (2) this DialogBox is active, (3) no existing dialog locker is running. On success, shows the box and delegates to <see cref="DialogRuntime.SetDialogQueueAndRun"/>. </summary>
public void SetDialogQueueAndRun(DialogQueue dialog,IEnumerable<IActionCommand>endDialogQueue,int startIndex,ExecutableEvent locker){if(dialog==null){GameLogger.Log("DIALOG",$"can't run '{locker?.GetType().FullName}', dialog is null",LogLevel.Error);return;}if(!IsActive){GameLogger.Log("DIALOG",$"can't run '{locker?.GetType().FullName}', DialogBox is not active",LogLevel.Error);return;}if(Locker!=null){GameLogger.Log("DIALOG",$"can't run '{locker?.GetType().FullName}', locker already executing!",LogLevel.Error);return;}GameLogger.Log("DIALOG",$"SetDialogQueueAndRun: locker='{locker?.GetType().Name}', queue size={dialog?.Queue.Count}");Locker=locker;Show();Runtime.SetDialogQueueAndRun(this,dialog.Queue,endDialogQueue,startIndex);Runtime.Dialog=dialog;Runtime.Run();ScheduleWaitTimer();}/// <summary> Advances the dialog to the next action. Skips if the runtime is waiting for an answer or has ended. Respects <see cref="WaitType.WaitEvent"/> (waits for destruct) and <see cref="WaitType.WaitTime"/> (waits for elapsed duration). Called by click/keyboard input, the wait timer, and self-completing WaitEvent actions. </summary>
public void NextAction(){if(Runtime.IsHeld||Runtime.IsWaitAnswer)return;if(Runtime.IsEnd){GameLogger.Log("DIALOG","NextAction: dialog ended, hiding");Hide();return;}var action=Runtime.CurrentAction;if(action==null){GameLogger.Log("DIALOG","NextAction: no current action, hiding");Hide();return;}double now=Godot.Time.GetTicksUsec()/1_000_000.0;if(action.WaitType==WaitType.WaitEvent&& !action.IsDestructed)return;if(action.WaitType==WaitType.WaitTime&&now-action.StartTime<action.WaitTime)return;GameLogger.Log("DIALOG",$"NextAction: advancing past '{action.GetType().Name}' (idx={Runtime.CurrentAction?.GetType().Name})");Runtime.Next();Runtime.Run();ScheduleWaitTimer();}/// <summary>Advances the dialog on <c>ui_accept</c> or on a click outside the body, unless a choice is pending or the queue has ended.</summary>
protected override void OnInput(InputEvent@event){if(Runtime.IsHeld||Runtime.IsEnd||Runtime.IsWaitAnswer)return;if(@event.IsActionPressed("ui_accept")){if(IsTyping())return;GameLogger.Log("DIALOG","input: advancing dialog");NextAction();}else if(@event is InputEventMouseButton btn&&btn.ButtonIndex==MouseButton.Left&&btn.Pressed){var action=Runtime.CurrentAction;if(action==null||action.WaitType==WaitType.WaitTime||action.WaitType==WaitType.WaitEvent)return;if(IsOverOtherControl())return;if(Body is Control bodyControl){var bodyRect=bodyControl.GetGlobalRect();if(bodyRect.HasPoint(btn.Position)){GameLogger.Log("DIALOG","input: advancing dialog");audioService.PlaySound("ui/skip",SoundType.UI);NextAction();}}}}/// <summary> True while a text field has keyboard focus: Enter and Space belong to it, not to the dialog (the dialog reads <c>ui_accept</c> in <c>_Input</c>, before the GUI). </summary>
private bool IsTyping()=>GetViewport()?.GuiGetFocusOwner()is LineEdit or TextEdit;/// <summary> True when the mouse is over a control that is not part of this box and stops the mouse (a tool panel, a button drawn over the dialog area): the click is meant for that control. </summary>
private bool IsOverOtherControl(){var hovered=GetViewport()?.GuiGetHoveredControl();return hovered!=null&&hovered!=this&& !IsAncestorOf(hovered)&&hovered.MouseFilter==MouseFilterEnum.Stop;}/// <summary> Continues a dialog the debugger held (<see cref="DialogRuntime.IsHeld"/>): runs from the held action on, exactly as if it had never stopped. Does nothing when the runtime is not held. </summary>
public void Resume(){if(!Runtime.IsHeld)return;Runtime.IsHeld=false;Runtime.Run();ScheduleWaitTimer();}/// <summary> Stops the running dialog without finishing it (<see cref="DialogRuntime.Abort"/>) and frees the box for another queue: closes the answer box, hides and releases the locker. The scenario does not count as completed. </summary>
public void Abort(){ClearWaitTimer();if(answerBox!=null){answerBox.Hide();answerBox=null;}Runtime.Abort();Hide();Locker=null;}/// <summary>Whether a dialog is currently running (Locker is not null).</summary>
public bool IsRunning=>Locker!=null;/// <summary>Whether the current dialog queue reached its end (also true before the first queue loads).</summary>
public bool IsDialogEnded=>Runtime.IsEnd;/// <summary>Whether the dialog is waiting for the player to pick a variant.</summary>
public bool IsWaitingAnswer=>Runtime.IsWaitAnswer;/// <summary>Number of answer variants currently shown (0 when the answer box is hidden).</summary>
public int AnswerVariantCount=>answerBox?.CreateModels()?.Count??0;/// <summary>Selects the shown variant at <paramref name="index"/>. Returns false when nothing is shown or the index is out of range.</summary>
public bool TrySelectVariant(int index){var models=answerBox?.CreateModels();if(models==null||models.Count==0||index<0||index>=models.Count)return false;var list=new List<SelectVariant>(models);SelectVariant(list[index]);return true;}/// <summary> Schedules a one-shot <see cref="SceneTreeTimer"/> that advances the dialog past the current <see cref="WaitType.WaitTime"/> action once its <see cref="ActionCommand.WaitTime"/> elapses. Replaces the former per-frame polling: the runtime now yields until the timer fires exactly once, so no Update work happens between delays. </summary>
private void ScheduleWaitTimer(){ClearWaitTimer();if(!Runtime.IsWaitTime)return;var wait=Runtime.CurrentAction?.WaitTime??0f;if(wait<=0f){NextAction();return;}waitTimer=GetTree().CreateTimer(wait);waitTimer.Timeout+=OnWaitTimerTimeout;}/// <summary> Fires when the delay timer elapses. The timer already guarantees the wait has elapsed, so the runtime is advanced directly — no wall-clock re-check. The <see cref="Runtime.IsWaitTime"/> guard skips a stale timer that fired after the dialog already moved on. </summary>
private void OnWaitTimerTimeout(){waitTimer=null;if(!Runtime.IsWaitTime)return;Runtime.Next();Runtime.Run();ScheduleWaitTimer();}/// <summary> Cancels a pending delay timer. Called before scheduling a new one and when the dialog hides, so a stale timer can never advance a later, unrelated dialog session. </summary>
private void ClearWaitTimer(){if(waitTimer==null)return;waitTimer.Timeout-=OnWaitTimerTimeout;waitTimer=null;}}}

===== FILE mods/dialog_base/src/UI/Dialog/DialogCanvas.cs =====
using Cthangover.Core.Scenes;using Cthangover.Core.Utils;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog{/// <summary> Thin wrapper node that hosts the dialog UI. The canvas instantiates its <see cref="DialogBox"/> child at runtime from the core mod through <see cref="ModScenePrefab"/> and keeps the well-known node name <c>DialogBox</c>. The rest of the game locates it by that name via <see cref="SceneContextNode.FindNode{T}"/>, not by hardcoded paths. </summary>
public partial class DialogCanvas:Control{private static readonly ModScenePrefab DialogBoxPrefab=new("scenes/ui/dialog_box.tscn");private DialogBox dialogBox;/// <summary>The active DialogBox child, resolved in _Ready. Used by the scene tree root to access dialog without path traversal.</summary>
public DialogBox DialogBox=>dialogBox;/// <summary>Instantiates the <see cref="DialogBox"/> child from the core prefab when the scene does not ship one, keeping the well-known node name for path-free lookup.</summary>
public override void _Ready(){dialogBox??=GetNodeOrNull<DialogBox>("DialogBox");if(dialogBox==null){dialogBox=DialogBoxPrefab.Instantiate<DialogBox>();if(dialogBox!=null){dialogBox.Name="DialogBox";AddChild(dialogBox);}else{GameLogger.Log("DIALOG","DialogCanvas._Ready: failed to build 'scenes/ui/dialog_box.tscn'",LogLevel.Error);}}MouseFilter=MouseFilterEnum.Ignore;}}}

===== FILE mods/dialog_base/src/UI/Dialog/DialogQueue.cs =====
using System;using System.Collections.Generic;using Cthangover.Mods.Dialog.Action;using Cthangover.Mods.Dialog.Action.Impls;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Mods.Effects;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog{/// <summary> Fluent builder for dialog sequences. Each method creates an action of the corresponding type, appends it to the Queue, and returns the action for optional further configuration. The API vocabulary mirrors the scenario DSL commands (Text, Title, Background, Music, Sound, Delay, GoTo, If, Select, SwitchScene, etc.), making the C# API feel like a script. RuntimeObjectList is a separate list of IActionDestruct objects that must be cleaned up when the dialog ends but are not part of the sequential queue (e.g. spawned resources, scheduled callbacks). </summary>
public class DialogQueue{/// <summary>Objects that need <see cref="IActionDestruct.Destruct"/> cleanup when the dialog ends but are not part of the sequential queue (e.g. spawned resources).</summary>
public List<IActionDestruct>RuntimeObjectList{get;}=new();/// <summary>The ordered sequence of dialog actions. Appended via fluent builder methods; consumed by <see cref="DialogRuntime"/>.</summary>
public List<IActionCommand>Queue{get;}=new();/// <summary>Spawns a visual effect identified by <paramref name="effectID"/> through <see cref="EffectFactory"/>. Returns the action for further configuration.</summary>
public ActionEffect Effect(string effectID){var action=new ActionEffect{EffectID=effectID};Queue.Add(action);return action;}/// <summary>Creates a frame-animation foreground from a base sprite path, generating frame IDs from <paramref name="start"/> to <paramref name="start"/> + <paramref name="count"/>.</summary>
public ActionAnimatedForeground Animation(string sprite,int start,int count,float speed=1,float nextFrameSpeed=1,bool isLoop=true){var list=new List<string>();for(int i=start;i<=start+count;i++)list.Add(sprite+$"{i:00}");return Animation(list,speed,nextFrameSpeed,isLoop);}/// <summary>Creates a frame-animation foreground from a pre-built list of sprite resource paths.</summary>
public ActionAnimatedForeground Animation(List<string>sprites,float speed=1,float nextFrameSpeed=1,bool isLoop=true){var action=new ActionAnimatedForeground{SpriteIDs=sprites,Speed=speed,NextFrameSpeed=nextFrameSpeed,IsLoop=isLoop};Queue.Add(action);return action;}/// <summary>Sets the scene background from a sprite ID resolved through <see cref="BackgroundFactory"/>. Also loads depth and albedo maps for lighting.</summary>
public ActionBackground Background(string spriteId){var action=new ActionBackground{SpriteID=spriteId};Queue.Add(action);return action;}/// <summary>Sets the scene background directly from a pre-loaded <see cref="Texture2D"/>. Bypasses factory resolution.</summary>
public ActionBackground BackgroundTexture(Texture2D texture){var action=new ActionBackground{Texture=texture};Queue.Add(action);return action;}/// <summary>Sets the scene foreground from a sprite ID resolved through <see cref="BackgroundFactory"/>.</summary>
public ActionForeground Foreground(string spriteId){var action=new ActionForeground{SpriteID=spriteId};Queue.Add(action);return action;}/// <summary>Sets the scene foreground directly from a pre-loaded <see cref="Texture2D"/>.</summary>
public ActionForeground ForegroundTexture(Texture2D texture){var action=new ActionForeground{Texture=texture};Queue.Add(action);return action;}/// <summary>Displays dialog text with optional avatars. Pauses for click before advancing.</summary>
public ActionText Text(string text,string first=null,string second=null,bool hideColor=false){var action=new ActionText{Text=text,FirstAvatar=first,SecondAvatar=second,HideColor=hideColor};Queue.Add(action);return action;}/// <summary>Like <see cref="Text"/> but enables runtime variable substitution (${var}) on the text content.</summary>
public ActionText PText(string text,string first=null,string second=null,bool hideColor=false){var action=new ActionText{Text=text,FirstAvatar=first,SecondAvatar=second,HideColor=hideColor,UseProcessText=true};Queue.Add(action);return action;}/// <summary>Sets the dialog title bar. Non-null shows it; null hides.</summary>
public ActionTitle Title(string title){var action=new ActionTitle{TitleText=title};Queue.Add(action);return action;}/// <summary>Explicitly hides the title bar by setting TitleText to null.</summary>
public ActionTitle HideTitle(){var action=new ActionTitle{TitleText=null};Queue.Add(action);return action;}/// <summary>Sets a runtime variable by name and literal value. Available to subsequent actions via ${name} syntax.</summary>
public ActionSet Set(string name,string value){var action=new ActionSet{Name=name,Value=value};Queue.Add(action);return action;}/// <summary>Sets a runtime variable by name, using a lazy-evaluated callback for dynamic values not known at script authoring time.</summary>
public ActionSet Set(string name,Func<string>callback){var action=new ActionSet{Name=name,Callback=callback};Queue.Add(action);return action;}/// <summary>Presents player choices. Each <see cref="SelectVariant"/> has display text and a GoTo target. Dialog pauses until a choice is picked.</summary>
public ActionSelect Select(List<SelectVariant>variants,string text=null,string first=null,string second=null){var action=new ActionSelect{Variants=variants,Text=text,FirstAvatar=first,SecondAvatar=second};Queue.Add(action);return action;}/// <summary>Terminates the dialog queue, calling <see cref="DialogRuntime.End"/>.</summary>
public ActionEnd End(){var action=new ActionEnd();Queue.Add(action);return action;}/// <summary>A labeled no-op. Used as a named jump target for GoTo/If commands.</summary>
public ActionEmpty Empty(){var action=new ActionEmpty();Queue.Add(action);return action;}/// <summary>Creates a named empty action (a "point") identified by <paramref name="id"/> for GoTo targeting.</summary>
public ActionEmpty Point(string id){return(ActionEmpty)Empty().SetID(id);}/// <summary>Starts background music playback via <see cref="IAudioService"/> using <see cref="MusicType.Ambient"/> channel.</summary>
public ActionMusic Music(string music){var action=new ActionMusic{Music=music};Queue.Add(action);return action;}/// <summary>Pauses background music without stopping the track. Resume with <see cref="MusicPlay"/>.</summary>
public ActionMusicPause MusicPause(){var action=new ActionMusicPause();Queue.Add(action);return action;}/// <summary>Resumes previously paused background music.</summary>
public ActionMusicPlay MusicPlay(){var action=new ActionMusicPlay();Queue.Add(action);return action;}/// <summary>Waits for <paramref name="waitTime"/> seconds while optionally displaying a text message via <paramref name="showText"/>.</summary>
public ActionDelay Delay(float waitTime,string showText){var action=new ActionDelay{WaitTime=waitTime,HiddenMode=false,ShowText=showText};Queue.Add(action);return action;}/// <summary>Waits for <paramref name="waitTime"/> seconds. When <paramref name="hiddenMode"/> is true, hides the dialog body for the duration (dramatic pauses, cutscene beats).</summary>
public ActionDelay Delay(float waitTime,bool hiddenMode=false){var action=new ActionDelay{WaitTime=waitTime,HiddenMode=hiddenMode,ShowText=null};Queue.Add(action);return action;}/// <summary>Plays a UI sound effect through <see cref="IAudioService"/>. Non-blocking — the dialog continues immediately.</summary>
public ActionSound Sound(string sound){var action=new ActionSound{Sound=sound};Queue.Add(action);return action;}/// <summary>Invokes an external scenario action by name, resolved through <see cref="ScenarioActionFactory"/>.</summary>
public ActionScenario Action(string actionName){var action=new ActionScenario{ActionName=actionName};Queue.Add(action);return action;}/// <summary>Runs arbitrary C# code as a dialog action. Useful for quest state changes or UI toggles without a dedicated action class.</summary>
public ActionRun Run(System.Action executable){var action=new ActionRun{Executable=executable};Queue.Add(action);return action;}/// <summary>Evaluates a condition and jumps to <paramref name="trueGoTo"/> or <paramref name="falseGoTo"/> based on the result. Falls through if the chosen target is empty.</summary>
public ActionIf IfGoTo(Func<bool>condition,string trueGoTo,string falseGoTo=null){var action=new ActionIf{Condition=condition,TrueGoTo=trueGoTo,FalseGoTo=falseGoTo};Queue.Add(action);return action;}/// <summary>Unconditionally jumps to the action identified by <paramref name="goTo"/> in the queue.</summary>
public ActionGoTo GoTo(string goTo){var action=new ActionGoTo{GoTo=goTo};Queue.Add(action);return action;}/// <summary>Deferred scene switch via <see cref="SceneManager"/>. Sets the locker as OneRun to prevent re-triggering after scene reload.</summary>
public ActionSwitchScene SwitchScene(string targetScene,float speed=4f,bool hiddenMode=true){var action=new ActionSwitchScene{SceneName=targetScene,HiddenMode=hiddenMode,Speed=speed};Queue.Add(action);return action;}/// <summary>Declares a background tint color to be consumed by background-rendering nodes.</summary>
public ActionBackgroundColor BackgroundColor(Color color){var action=new ActionBackgroundColor{Color=color};Queue.Add(action);return action;}/// <summary>Makes the dialog box visible. Paired with <see cref="HideDialog"/> for cutscenes that temporarily hide dialog chrome.</summary>
public ActionShowDialog ShowDialog(){var action=new ActionShowDialog();Queue.Add(action);return action;}/// <summary>Hides the dialog box. Paired with <see cref="ShowDialog"/> for cutscene transitions.</summary>
public ActionHideDialog HideDialog(){var action=new ActionHideDialog();Queue.Add(action);return action;}/// <summary>Toggles whether lighting responds to in-game time of day (true) or uses static lights (false).</summary>
public ActionLightUseTime LightUseTime(bool useTime){var action=new ActionLightUseTime{UseTime=useTime};Queue.Add(action);return action;}/// <summary>Applies static light definitions from a JSON string to the light controller. Empty string clears all static lights.</summary>
public ActionLightSet LightSet(string json){var action=new ActionLightSet{LightsJson=json};Queue.Add(action);return action;}/// <summary>Fade the dialog background in (<see cref="BackgroundActionType.Show"/>) or out (<see cref="BackgroundActionType.Hide"/>). Optionally set <paramref name="wait"/> to pause the dialog during the transition.</summary>
public ActionBackgroundShowHide BackgroundShowHide(BackgroundActionType type,float duration,bool wait=false){var action=new ActionBackgroundShowHide{ActionType=type,Duration=duration};if(wait){action.WaitType=WaitType.WaitTime;action.WaitTime=duration;}Queue.Add(action);return action;}/// <summary>Adds an interactive element to the scene by definition ID.</summary>
public ActionInteractiveAdd InteractiveAdd(string definitionId){var action=new ActionInteractiveAdd{DefinitionId=definitionId};Queue.Add(action);return action;}/// <summary>Removes an interactive element from the scene by definition ID.</summary>
public ActionInteractiveRemove InteractiveRemove(string definitionId){var action=new ActionInteractiveRemove{DefinitionId=definitionId};Queue.Add(action);return action;}/// <summary>Clears all interactive elements from the scene.</summary>
public ActionInteractiveClear InteractiveClear(){var action=new ActionInteractiveClear();Queue.Add(action);return action;}/// <summary>Sets a property on an interactive element identified by definition ID.</summary>
public ActionInteractiveSet InteractiveSet(string definitionId,string property,string value){var action=new ActionInteractiveSet{DefinitionId=definitionId,Property=property,Value=value};Queue.Add(action);return action;}}}

===== FILE mods/dialog_base/src/UI/Dialog/DialogRuntime.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Events;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Mods.Dialog.Action;using Cthangover.Core.UI.Event;using Cthangover.Mods.Dialog.Executable;using Cthangover.Core.Utils;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog{/// <summary> Dialog execution engine: manages the action queue with goto-based branching and variable substitution. Run() iterates actions in a tight loop, breaking only on wait points (WaitClick/Select/Time/Event). Text variables use the "${name}" syntax and are resolved via GetVariable/SetVariable — values are stored in a simple Dictionary, not a cascading scope, keeping lookups O(1). GoTo searches the queue linearly by action ID and jumps the index; the source action's destruct fires on jump. The isLastGoTo flag prevents double advancement when a GoTo is the last action in its branch. End() processes an optional cleanup queue (endDialogQueue) before tearing down, and raises the dialog-end event via the SceneEventController for chaining. Infinite loop protection caps at 100k iterations per Run call. </summary>
public class DialogRuntime{private List<IActionCommand>dialogQueue;private List<IActionCommand>endDialogQueue;private int goToActionIndex;private int index;private bool isLastGoTo;private IDictionary<string,string>variables=new Dictionary<string,string>();/// <summary>The dialog box instance this runtime drives. Set when a new queue is loaded.</summary>
public DialogBox DialogBox{get;private set;}/// <summary>The currently active dialog queue. Set by <see cref="SetDialogQueueAndRun"/>.</summary>
public DialogQueue Dialog{get;set;}/// <summary>The action at the current queue index. Automatically constructs it if <see cref="ConstructType"/> is OnStartAction and it hasn't been constructed yet.</summary>
public IActionCommand CurrentAction=>GetByIndex(index);/// <summary>True when the current action is waiting for a player choice (<see cref="WaitType.WaitSelect"/>).</summary>
public bool IsWaitAnswer=>IsWaitType(WaitType.WaitSelect);/// <summary>True when the current action is waiting for a timer (<see cref="WaitType.WaitTime"/>).</summary>
public bool IsWaitTime=>IsWaitType(WaitType.WaitTime);/// <summary>True when the current action is waiting for an external event (<see cref="WaitType.WaitEvent"/>).</summary>
public bool IsWaitEvent=>IsWaitType(WaitType.WaitEvent);/// <summary>True when the dialog queue is exhausted (index past end or queue is empty).</summary>
public bool IsEnd=>Lists.IsEmpty(dialogQueue)||index>=dialogQueue.Count;/// <summary> Process-wide debugging hook asked before every action (see <see cref="IDialogDebugger"/>); null in normal play, which costs one null check per action. </summary>
public static IDialogDebugger Debugger{get;set;}/// <summary> True while the debugger holds the runtime before <see cref="CurrentAction"/>: that action has not run yet, so advancing must wait for <see cref="DialogBox.Resume"/> instead of skipping it. </summary>
public bool IsHeld{get;set;}/// <summary>Queue index of <see cref="CurrentAction"/>.</summary>
public int Index=>index;/// <summary>Current <c>${name}</c> variables of the dialog, for inspection.</summary>
public IReadOnlyDictionary<string,string>Variables=>(IReadOnlyDictionary<string,string>)variables;/// <summary>Retrieves a stored variable value by name. Returns null if not set.</summary>
public string GetVariable(string name){variables.TryGetValue(name,out var val);return val;}/// <summary>Stores a variable for later substitution via ${name} syntax. Overwrites existing values.</summary>
public void SetVariable(string name,string value){variables[name]=value;}/// <summary>Scans <paramref name="text"/> for ${key} patterns and replaces them with stored variable values. Unresolved keys become "?".</summary>
public string ProcessText(string text){if(string.IsNullOrWhiteSpace(text))return null;var vars=text.GetIncludesInQuotes("${","}");if(Lists.IsNotEmpty(vars))foreach(var variable in vars){if(!variables.TryGetValue(variable,out var val))val="?";text=text.Replace("${"+variable+"}",val);}return text;}private bool IsWaitType(WaitType type){if(Lists.IsEmpty(dialogQueue))return false;if(index<0||index>=dialogQueue.Count)return false;var action=dialogQueue[index];return action!=null&&action.WaitType==type;}private IActionCommand GetByIndex(int idx){if(Lists.IsEmpty(dialogQueue))return null;if(idx<0||idx>=dialogQueue.Count)return null;var action=dialogQueue[idx];if(action!=null&&action.ConstructType==ConstructType.OnStartAction&& !action.IsConstructed)action.Construct();return action;}/// <summary> Loads a new dialog queue and prepares for execution. Resets variables, sets the dialog box reference, ends any previous queue (destructing its actions), copies the new queue, and constructs OnStartQueue actions. Note: does NOT call Run() — the caller must call Run() separately. </summary>
public void SetDialogQueueAndRun(DialogBox dialogBox,IEnumerable<IActionCommand>queue,IEnumerable<IActionCommand>endQueue,int startIndex=0){variables=new Dictionary<string,string>();DialogBox=dialogBox;IsHeld=false;this.endDialogQueue=endQueue==null?null:endQueue.ToList();if(Lists.IsNotEmpty(this.dialogQueue))End();this.dialogQueue=queue.ToList();index=startIndex;foreach(var action in this.dialogQueue)if(action!=null&&action.ConstructType==ConstructType.OnStartQueue&& !action.IsConstructed)action.Construct();}/// <summary> Searches the queue linearly for an action with ID == <paramref name="actionID"/> and jumps the index there. Destructs the source action before jumping. If not found, calls <see cref="End"/>. <paramref name="lastGoTo"/> prevents the runtime from auto-advancing past the target when the GoTo is the last action in its branch. </summary>
public void TryGoTo(string actionID,bool lastGoTo=false){goToActionIndex=index;if(string.IsNullOrEmpty(actionID))return;for(var i=0;i<dialogQueue.Count;i++){var action=dialogQueue[i];if(action?.ID==actionID){index=i;var actionGoTo=GetByIndex(goToActionIndex);if(actionGoTo!=null&&actionGoTo.IsConstructed&&actionGoTo.DestructType==DestructType.OnEndAction)actionGoTo.Destruct();this.isLastGoTo=lastGoTo;return;}}GameLogger.Log("DIALOG","Dialog goto action not found!",LogLevel.Error);End();}/// <summary> Main execution loop. Iterates through actions, calling <see cref="IActionCommand.Run"/> on each and advancing automatically for <see cref="WaitType.NoWait"/> actions. Stops the loop on any wait-type action (WaitClick, WaitSelect, WaitTime, WaitEvent) so the runtime yields control until the condition is satisfied. Protected against infinite loops with a 100,000-iteration cap. </summary>
public void Run(){var iteration=0;for(;;){var action=CurrentAction;if(action==null){GameLogger.Log("DIALOG","Run: action is null, ending",LogLevel.Debug);End();return;}GameLogger.Log("DIALOG",$"Run: idx={index}/{dialogQueue.Count-1} type={action.GetType().Name} wait={action.WaitType}",LogLevel.Debug);if(Debugger!=null&&Debugger.ShouldHold(this,action)){IsHeld=true;return;}action.Run(this);if(iteration++ >100000){GameLogger.Log("DIALOG","Run: infinite loop protection (100k iterations)",LogLevel.Warning);break;}switch(action.WaitType){case WaitType.WaitClick:case WaitType.WaitSelect:case WaitType.WaitTime:case WaitType.WaitEvent:break;case WaitType.NoWait:Next();continue;}break;}}/// <summary> Advances the queue index by one. Destructs the current action if its <see cref="DestructType"/> is OnEndAction. Processes any delayed-destruct targets registered by the current action. Calls <see cref="End"/> if the queue is exhausted. Respects <see cref="isLastGoTo"/> — if a GoTo was the final action, the index is not incremented. </summary>
public void Next(){if(isLastGoTo){GameLogger.Log("DIALOG","Next: last goto, returning");isLastGoTo=false;return;}var action=CurrentAction;if(action!=null&&action.IsConstructed&&action.DestructType==DestructType.OnEndAction)action.Destruct();if(action!=null&&Lists.IsNotEmpty(dialogQueue)&&Lists.IsNotEmpty(action.DelayedDestruct))foreach(var item in dialogQueue)if(action.DelayedDestruct.Contains(item.ID))item.Destruct();if(Lists.IsEmpty(dialogQueue)||index<0||index>=dialogQueue.Count){GameLogger.Log("DIALOG","Next: queue exhausted, ending");End();}index++;}/// <summary> Terminates the dialog queue. Destructs all remaining constructed actions, hides the dialog box, processes the end-dialog cleanup queue (if any), and raises the dialog-end event via <see cref="SceneEventController"/>. Guards against end-queue actions starting a new dialog mid-cleanup by checking if the queue changed. </summary>
public void End(){GameLogger.Log("DIALOG",$"End: {dialogQueue?.Count ?? 0} actions in queue, {(endDialogQueue?.Count ?? 0)} end actions");if(Lists.IsNotEmpty(dialogQueue)){foreach(var action in dialogQueue)if(action!=null&&action.IsConstructed)action.Destruct();dialogQueue.Clear();}ExecutableEvent locker=null;if(DialogBox!=null){locker=DialogBox.Locker;TrackOneRunCompletion(locker);DialogBox.Locker=null;DialogBox.Hide();}if(Lists.IsNotEmpty(endDialogQueue)){GameLogger.Log("DIALOG","End: processing end actions...");var oldQueue=dialogQueue;foreach(var action in endDialogQueue){if(action==null||action.IsDestructed)continue;if(!action.IsConstructed)action.Construct();action.Run(this);action.Destruct();}endDialogQueue.Clear();if(dialogQueue!=oldQueue){GameLogger.Log("DIALOG","End: new dialog started during end actions, skipping cleanup",LogLevel.Warning);return;}}dialogQueue=null;endDialogQueue=null;index=0;if(DialogBox!=null){ModEventBus.Publish(new DialogEndedEvent{LockerId=locker?.ID});}}/// <summary> Drops the running queue without finishing it: destructs constructed actions and forgets the queue, but — unlike <see cref="End"/> — runs no end actions, does not mark a one-run scenario completed and publishes no <see cref="DialogEndedEvent"/>. Used to restart a scenario that is being edited; the caller hides the box (<see cref="DialogBox.Abort"/>). </summary>
public void Abort(){if(Lists.IsNotEmpty(dialogQueue)){foreach(var action in dialogQueue)if(action!=null&&action.IsConstructed)action.Destruct();}dialogQueue=null;endDialogQueue=null;index=0;isLastGoTo=false;IsHeld=false;GameLogger.Log("DIALOG","Abort: queue dropped without ending");}/// <summary> Records a finished one-run scenario in the per-game completion history (<see cref="RuntimeData.CompletedScenarioIds"/>) so the scenario runner never re-triggers it on later visits to the scene or after save/load. Only <see cref="ScenarioEvent"/> with an explicit ScenarioId is tracked: its ID matches the scenario definition name used by the runner's filters, whereas generic lockers (e.g. the ones flagged OneRun by ActionSwitchScene) carry type-name IDs that would poison the history. </summary>
private static void TrackOneRunCompletion(ExecutableEvent locker){if(!(locker is ScenarioEvent scenario)|| !scenario.IsOneRun||string.IsNullOrEmpty(scenario.ScenarioId))return;var runtime=GameData.Instance?.Runtime;if(runtime!=null&&runtime.CompletedScenarioIds.Add(scenario.ID))GameLogger.Log("DIALOG",$"One-run scenario '{scenario.ID}' marked as completed");}}}

===== FILE mods/dialog_base/src/UI/Dialog/IDialogDebugger.cs =====
using Cthangover.Mods.Dialog.Action;namespace Cthangover.Mods.Dialog{/// <summary> Hook a debugging tool installs in <see cref="DialogRuntime.Debugger"/> to stop a dialog before an action runs (pause, step, breakpoints). The runtime asks before every action of every dialog — including standalone runtimes without a <see cref="DialogBox"/> (hover commands) — so the implementation must answer quickly and hold only dialogs it can resume (<see cref="DialogBox.Resume"/>). </summary>
public interface IDialogDebugger{/// <summary> Returns true to hold <paramref name="runtime"/> before <paramref name="action"/>: the runtime marks itself <see cref="DialogRuntime.IsHeld"/> and returns without running it; clicks are ignored until <see cref="DialogBox.Resume"/>. </summary>
bool ShouldHold(DialogRuntime runtime,IActionCommand action);}}

===== FILE mods/dialog_base/src/UI/Executable/ExecutableEvent.cs =====
using Cthangover.Core.Scenes;using Cthangover.Mods.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.Utils;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Executable{/// <summary> Base for auto-triggering dialog events. A standalone event (not inside an IExecutableEventChain) only re-fires when the condition state changes (bumped <see cref="ConditionStateMonitor.Version"/>), after its own running dialog ends, or on the first run — with a 0.5s debounce. Checks that the DialogBox is free (IsRunning == false) before building and executing a dialog queue. IsOneRun prevents re-triggering after first execution; CanRepeat allows the event to fire again on subsequent scenes/visits. CheckConditions is virtual — ScenarioEvent overrides it to evaluate script conditions. </summary>
public partial class ExecutableEvent:Node,IOnUpdateEvent,IExecutableEvent{protected DialogBox dialogBox;private SceneEventController eventController;private float timestamp;// Version at which the event last ran (MinValue forces the first run).
private long _lastRunVersion=long.MinValue;// "Waiting for own dialog to end" flag, set after starting a run.
private bool _awaitingOwnEnd;/// <summary> <c>true</c> until <see cref="RunDialog"/> is called for the first time. Used by chains to determine whether a one-shot event has already fired. </summary>
public bool IsFirstRun{get;private set;}=true;/// <summary> If <c>true</c>, the event fires exactly once and never again, even across scene revisits. After <see cref="RunDialog"/> completes, <see cref="IsFirstRun"/> becomes <c>false</c>. </summary>
[Export]public bool IsOneRun{get;set;}=false;/// <summary> If <c>true</c>, the event can re-trigger on subsequent scene visits. When <c>false</c>, the event only fires on the first visit to the scene. </summary>
[Export]public bool CanRepeat{get;set;}=true;/// <summary> Virtual guard evaluated before triggering. Defaults to <c>true</c>. Override to implement conditional logic (e.g. <see cref="ScenarioEvent"/> checks script conditions via <see cref="ScenarioCondition.Evaluate"/>). </summary>
public virtual bool CheckConditions=>true;/// <summary> Unique identifier for this event instance. Defaults to the full type name. Override to provide a stable, save-friendly ID. </summary>
public virtual string ID=>GetType().FullName;/// <summary> Priority within the event chain — lower values execute first. Default is <c>1</c>, placing this after high-priority handlers. </summary>
public int Priority=>1;/// <summary>Finds the shared <see cref="DialogBox"/> and registers with the event controller for update polling unless a parent chain already drives this event.</summary>
public override void _Ready(){if(dialogBox==null){var root=GetTree()?.Root;if(root!=null){dialogBox=SceneContextNode.FindNode<DialogBox>("DialogBox");}}var isInChain=GetParent()is IExecutableEventChain;if(!isInChain){eventController=SceneContextNode.FindNode<SceneEventController>("EventController");eventController?.AddUpdateEventListener(this);}GameLogger.Log("EVENT",$"{GetType().Name}._Ready: dialogBox={(dialogBox != null ? "found" : "null")}, chain={isInChain}, registered={!isInChain}",LogLevel.Debug);}/// <summary>Detaches from the event controller's update polling when the event leaves the tree.</summary>
public override void _ExitTree(){if(eventController!=null)eventController.RemoveUpdateEventListener(this);}/// <summary> Debounced poll (twice per second) that re-checks the repeat guards and the <c>ConditionStateMonitor</c> version, then triggers the dialog when the scene state changed since the last run. </summary>
public void OnUpdate(){if(!IsFirstRun&& !CanRepeat)return;if(dialogBox==null)return;if(_awaitingOwnEnd){if(dialogBox.IsRunning)return;_awaitingOwnEnd=false;_lastRunVersion=ConditionStateMonitor.Version;}if(ConditionStateMonitor.Version==_lastRunVersion)return;var time=Godot.Time.GetTicksUsec()/1_000_000.0;if(time-timestamp<0.5f)return;timestamp=(float)time;if(dialogBox.IsRunning)return;if(RunDialog())_awaitingOwnEnd=true;}/// <summary> Builds a <see cref="DialogQueue"/> via <see cref="CreateDialog"/> and queues it on the <see cref="DialogBox"/>, passing <c>this</c> as the locker to block other dialog attempts. Returns <c>false</c> if the one-shot already ran, the dialog box is unavailable, or another locker is active. </summary>
public bool RunDialog(){if(!IsFirstRun&&IsOneRun)return false;if(dialogBox==null)return false;if(dialogBox.Locker!=null)return false;GameLogger.Log("EVENT",$"{GetType().Name}.RunDialog: starting dialog...");var dlg=new DialogQueue();CreateDialog(dlg);dialogBox.SetDialogQueueAndRun(dlg,null,StartIndex(dlg),this);IsFirstRun=false;return true;}protected virtual void CreateDialog(DialogQueue dlg){}/// <summary>Queue index the dialog starts at; 0 unless a subclass starts mid-script.</summary>
protected virtual int StartIndex(DialogQueue dlg)=>0;}}

===== FILE mods/dialog_base/src/UI/Executable/ExecutableEventChainBase.cs =====
using Cthangover.Mods.Dialog;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Executable{/// <summary> Abstract chain base: manages a list of ExecutableEvents and exposes Play()/Stop()/IsActive for external control. GetNext() is abstract so subclasses define their own selection strategy (sequential, conditional, random). Play() calls RunDialog() on the next event, which internally calls SetDialogQueueAndRun with `this` as the locker — the chain is blocked until the dialog finishes. </summary>
public abstract partial class ExecutableEventChainBase:Godot.Node,IExecutableEventChain{protected bool isActive=true;protected System.Collections.Generic.List<ExecutableEvent>chain;protected DialogBox dialogBox;/// <summary> Controls whether the chain is actively polling. Setting to <c>false</c> pauses event dispatch; setting to <c>true</c> resumes. </summary>
public bool IsActive{get=>isActive;set{isActive=value;}}/// <summary> Pauses the chain. No further events will be dispatched until <see cref="Play"/> is called. </summary>
public void Stop(){IsActive=false;}/// <summary> Activates the chain and immediately attempts to run the next event returned by <see cref="GetNext"/>. If no event is available, the chain deactivates itself. </summary>
public void Play(){IsActive=true;var next=GetNext();if(next==null){IsActive=false;return;}next.RunDialog();}protected abstract ExecutableEvent GetNext();/// <summary>Interface implementation: casts to <see cref="ExecutableEvent"/> and registers it with the chain node.</summary>
public virtual void AddEvent(IExecutableEvent evt){var typed=(ExecutableEvent)evt;AddChild(typed);chain??=new System.Collections.Generic.List<ExecutableEvent>();chain.Add(typed);}/// <summary>Interface implementation: frees every registered event and empties the chain list.</summary>
public virtual void ClearEvents(){if(chain==null)return;foreach(var evt in chain)evt.QueueFree();chain.Clear();}}}

===== FILE mods/dialog_base/src/UI/Executable/ExecutableEventListChain.cs =====
using Cthangover.Core.Scenes;using Cthangover.Mods.Dialog;using Cthangover.Core.Utils;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Executable{/// <summary> Sequential list-based chain: rescans GetNext() only when the condition state changes (bumped <see cref="ConditionStateMonitor.Version"/>), on the end of its own running dialog, or on the first scan — with a debounce. When an event runs, it's removed from the list first, so it's consumed and the chain shrinks until empty (no self-loop is possible, so no skip-guard is needed). Respects IsOneRun (skips already-run events) and CheckConditions (skips events whose conditions fail). Use for ordered event sequences where each event should play once in order. </summary>
public partial class ExecutableEventListChain:ExecutableEventChainBase{private float checkDelay=1f;private float timestamp;private long _lastScanVersion=long.MinValue;private bool _pendingDialog;private bool _wakeup;/// <summary>Debounced chain driver: waits out the running dialog, rescans when the condition state changed and starts the next unconsumed event.</summary>
public override void _Process(double delta){if(!isActive||chain.Count==0){IsActive=false;return;}if(_pendingDialog){if(dialogBox==null||dialogBox.Locker!=null)return;_pendingDialog=false;_lastScanVersion=ConditionStateMonitor.Version;_wakeup=true;return;}double time=Godot.Time.GetTicksUsec()/1_000_000.0;if(time-timestamp<checkDelay)return;timestamp=(float)time;var version=ConditionStateMonitor.Version;if(version==_lastScanVersion&& !_wakeup)return;_wakeup=false;_lastScanVersion=version;var next=GetNext();if(next==null)return;chain.Remove(next);next.RunDialog();_pendingDialog=true;}protected override ExecutableEvent GetNext(){if(Lists.IsEmpty(chain))return null;foreach(var additional in chain){if(additional.IsOneRun&& !additional.IsFirstRun)continue;if(additional.CheckConditions)return additional;}return null;}}}

===== FILE mods/dialog_base/src/UI/Executable/ExecutableMainEventChain.cs =====
using System.Collections.Generic;using Cthangover.Core.Events;using Cthangover.Mods.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.Scenes;using Cthangover.Core.Utils;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Executable{/// <summary> The primary event chain for a scene. Registers as IOnUpdateEvent and rescans registered ExecutableEvents only when the condition state changes (bumped <see cref="ConditionStateMonitor.Version"/>), on dialog end, or on the first scan — with a 0.5s debounce. On _Ready, auto-collects child ExecutableEvent nodes and handles deferred scene switches (pending scene name from SceneManager). Exposes LastBackgroundID so action commands can remember which background was active for scene restore on revisit. AddEvent/RemoveEvent/ClearEvents allow dynamic event management via code. Events are checked in insertion order; the first satisfying both IsOneRun and CheckConditions gets executed. A skip-guard prevents re-running the same event at the same state version, breaking self-trigger loops while preserving A→B chaining. </summary>
public partial class ExecutableMainEventChain:Control,IExecutableEventChain,IOnUpdateEvent{private bool isActive=true;private List<ExecutableEvent>allEvents=new();private DialogBox dialogBox;private SceneEventController eventController;private float timestamp;// Version of the last event scan (MinValue forces the first scan).
private long _lastScanVersion=long.MinValue;// Version at the moment the chain's own last-run event finished its dialog.
private long _lastRunEndVersion=long.MinValue;// The last event the chain actually started.
private ExecutableEvent _lastRunEvent;// "Wake up and rescan" flag, set on dialog end or new event added.
private bool _wakeup;/// <summary> Stores the last background ID set by action commands (e.g. "bg_home"). Used on scene revisit to restore the previously active background. </summary>
public string LastBackgroundID{get;set;}/// <summary> Event priority for <see cref="SceneEventController"/> sorting. Value <c>1</c> places this chain after high-priority handlers but before general subscribers. </summary>
public int Priority=>1;/// <summary> Enables or disables event polling. When <c>false</c>, <see cref="OnUpdate"/> returns immediately without dispatching events. </summary>
public bool IsActive{get=>isActive;set{isActive=value;}}/// <summary> Removes and frees all currently registered events from the chain via <c>QueueFree</c> and empties the internal event list. </summary>
public void ClearEvents(){GameLogger.Log("CHAIN",$"ClearEvents: removing {allEvents.Count} event(s)");foreach(var evt in allEvents){var id=evt is ScenarioEvent se?se.Condition??"no_condition":evt.GetType().Name;GameLogger.Log("CHAIN",$"  QueueFree: {evt.GetType().Name} [condition={id}]");evt.QueueFree();}allEvents.Clear();}/// <summary> Adds an <see cref="ExecutableEvent"/> to the chain and reparents it via <c>AddChild</c>. Events are dispatched in insertion order. </summary> <param name="evt">The event to register and parent to this chain.</param>
public void AddEvent(IExecutableEvent evt){var typed=(ExecutableEvent)evt;var id=typed is ScenarioEvent se?se.Condition??"no_condition":typed.GetType().Name;GameLogger.Log("CHAIN",$"AddEvent: {typed.GetType().Name} [condition={id}] — calling AddChild");AddChild(typed);allEvents.Add(typed);_wakeup=true;GameLogger.Log("CHAIN",$"  AddEvent: done, allEvents.Count={allEvents.Count}");}/// <summary> Removes an event from the chain and queues it for deletion. No-op if the event is not currently registered. </summary> <param name="evt">The event to unregister and free.</param>
public void RemoveEvent(ExecutableEvent evt){if(allEvents.Remove(evt)){evt.QueueFree();GameLogger.Log("CHAIN",$"removed event '{evt.GetType().Name}'");}}/// <summary>Finds the shared <see cref="DialogBox"/> and event controller, collects child events, publishes the shell-ready event and replays any pending scene switch.</summary>
public override void _Ready(){GameLogger.Log("CHAIN",$"_Ready: starting, tree scene='{GetTree()?.CurrentScene?.Name}'");dialogBox=SceneContextNode.FindNode<DialogBox>("DialogBox");eventController=SceneContextNode.FindNode<SceneEventController>("EventController");eventController?.AddUpdateEventListener(this);ModEventBus.Subscribe<DialogEndedEvent>(OnDialogEnded);foreach(var child in GetChildren()){if(child is ExecutableEvent evt){allEvents.Add(evt);GameLogger.Log("CHAIN",$"collected event '{evt.GetType().Name}'");}}ModEventBus.Publish(new ShellReadyEvent{Root=this});var sceneManager=GetNodeOrNull<SceneManager>("/root/SceneManager");if(sceneManager!=null&& !string.IsNullOrEmpty(sceneManager.PendingSceneName)){var pending=sceneManager.PendingSceneName;GameLogger.Log("CHAIN",$"_Ready: deferring SwitchScene('{pending}')");sceneManager.PendingSceneName=null;Callable.From(()=>sceneManager.SwitchScene(pending)).CallDeferred();}GameLogger.Log("CHAIN",$"_Ready: done, allEvents={allEvents.Count}");}/// <summary>Detaches the update and dialog-end listeners from the event controller when the chain leaves the tree.</summary>
public override void _ExitTree(){eventController?.RemoveUpdateEventListener(this);ModEventBus.Unsubscribe<DialogEndedEvent>(OnDialogEnded);}/// <summary> Called when any dialog queue finishes. Wakes the chain so the next event can be picked up without waiting for a state change, and — for the chain's own last-run event — records the version at its dialog end so that the state bumps it produced are considered accounted for (breaks self-loops). </summary>
private void OnDialogEnded(DialogEndedEvent evt){_wakeup=true;if(_lastRunEvent!=null&&evt.LockerId==_lastRunEvent.ID)_lastRunEndVersion=ConditionStateMonitor.Version;}/// <summary>Debounced scan that starts the next runnable event when the condition state changed since the last completed run.</summary>
public void OnUpdate(){if(!isActive)return;var time=Godot.Time.GetTicksUsec()/1_000_000.0;if(time-timestamp<0.5f)return;timestamp=(float)time;if(dialogBox==null||dialogBox.IsRunning)return;var version=ConditionStateMonitor.Version;if(version==_lastScanVersion&& !_wakeup)return;_wakeup=false;_lastScanVersion=version;var dialog=FindEvent(allEvents,_lastRunEvent,_lastRunEndVersion);if(dialog==null)return;if(dialog.RunDialog())_lastRunEvent=dialog;}/// <summary> <c>true</c> if the chain has any registered events remaining. Used to check whether a scene still has pending dialog content. </summary>
public bool HasEvents=>allEvents.Count>0;/// <summary> Deactivates the chain, preventing any further event dispatch until reactivation. </summary>
public void Stop(){isActive=false;}private ExecutableEvent FindEvent(List<ExecutableEvent>list,ExecutableEvent skipEvent,long skipEndVersion){if(Lists.IsEmpty(list))return null;foreach(var nextEvent in list){if(nextEvent==skipEvent&&ConditionStateMonitor.Version<=skipEndVersion)continue;if(nextEvent.IsOneRun&& !nextEvent.IsFirstRun)continue;if(nextEvent.CheckConditions)return nextEvent;}return null;}}}

===== FILE mods/dialog_base/src/UI/Executable/IdentifiableEventObjectBehaviour.cs =====
namespace Cthangover.Mods.Dialog.Executable{using Cthangover.Core.UI.Executable;/// <summary> Simplest IEventObject implementation: carries an ID and self-destructs via QueueFree when Destruct() is called. Used as a base for scene objects that must be tracked by the event system for cleanup. </summary>
public partial class IdentifiableEventObjectBehaviour:Godot.Node,IEventObject{/// <summary> Identifier for this event object. Set externally to allow the event tracking system to locate and manage this instance. </summary>
public string ID{get;private set;}/// <summary> Signals cleanup by calling <c>QueueFree</c> to remove this node from the scene tree and release resources. </summary>
public void Destruct(){QueueFree();}}}

===== FILE mods/dialog_base/src/UI/Executable/RunEventBehaviour.cs =====
namespace Cthangover.Mods.Dialog.Executable{/// <summary> Placeholder/stub node. Likely represents a future or deprecated behaviour component for manually triggering ExecutableEvents. </summary>
public partial class RunEventBehaviour:Godot.Node{}}

===== FILE mods/dialog_base/src/UI/Executable/ScenarioEvent.cs =====
using System.Collections.Generic;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Mods.Dialog.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Mods.Dialog;using Cthangover.Core.Utils;using Godot;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Dialog;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Executable;using Cthangover.Core.Scenarios;using Cthangover.Core.Interactive;namespace Cthangover.Mods.Dialog.Executable{/// <summary> ExecutableEvent backed by a scenario file (.scenario). Converts scenario text using ScenarioConverter and appends the resulting actions to the dialog queue. Resolves the scenario path via convention: if ScenarioPath is set, uses that; otherwise searches all mods for a matching file name derived from the class name (stripping "Event" suffix), with a fallback to res://mods/cthangover_game/scenarios/. CustomMappings allows code to register arbitrary class-to-path bindings for mod-provided scenarios. Supports inline ScenarioText for embedded scenarios that don't need a separate file. Checks conditions through ScenarioCondition.Evaluate before triggering. </summary>
public partial class ScenarioEvent:ExecutableEvent,IConditionedEvent{private static readonly Dictionary<string,string>CustomMappings=new();/// <summary> Registers a custom mapping from a class name to a scenario file path. Use from mod initialization code for mod-provided scenario paths that deviate from the default naming convention. </summary> <param name="className">The C# class name (without namespace).</param> <param name="scenarioPath">The resource path to the .scenario file.</param>
public static void RegisterScenario(string className,string scenarioPath){CustomMappings[className]=scenarioPath;}/// <summary> Explicit resource path to a scenario file. When set, overrides the default path resolution in <see cref="ResolveScenarioPath"/>. </summary>
[Export]public string ScenarioPath{get;set;}/// <summary> Inline scenario text. If non-empty, parsed by <see cref="ScenarioConverter"/> instead of loading from a file. Enables embedding small scenarios directly in code or scene data. </summary>
public string ScenarioText{get;set;}/// <summary> A condition expression evaluated by <see cref="ScenarioCondition.Evaluate"/>. If empty, the event always passes condition checks. </summary>
public string Condition{get;set;}/// <summary> Stable identifier for save/load tracking. If set, becomes the value of <see cref="ExecutableEvent.ID"/> instead of the type name. </summary>
public string ScenarioId{get;set;}/// <summary> Overrides the time-of-day lighting flag for this event's dialog. <c>true</c> enables time blending, <c>false</c> disables it, <c>null</c> leaves the current setting unchanged. </summary>
public bool?LightUseTime{get;set;}/// <summary> Returns <see cref="ScenarioId"/> if set; otherwise falls back to the full type name from <see cref="ExecutableEvent.ID"/>. </summary>
public override string ID=>ScenarioId??base.ID;/// <summary> Mod (or content overlay) the scenario text was read from; null for generated text (test play, inline scenarios). With <see cref="SourcePath"/> it lets debugging tools open and edit the file behind a running event. </summary>
public string SourceModId{get;set;}/// <summary>Mod-relative path of the scenario file; see <see cref="SourceModId"/>.</summary>
public string SourcePath{get;set;}/// <summary> Label (<c>:name</c> in the script, without the colon) to start at instead of the first line. Used to restart a scenario from where an edited part begins; an unknown label falls back to the start. </summary>
public string StartLabel{get;set;}protected override void CreateDialog(DialogQueue dlg){if(LightUseTime.HasValue)dlg.LightUseTime(LightUseTime.Value);var converter=new ScenarioConverter();DialogQueue loaded;if(!string.IsNullOrEmpty(ScenarioText)){loaded=converter.Convert(ScenarioText);}else{loaded=converter.Convert(ScenarioDefs.Resolve(ScenarioId));}if(loaded==null){GameLogger.Log("SCENE",$"Failed to load scenario for {GetType().Name}",LogLevel.Error);return;}foreach(var action in loaded.Queue)dlg.Queue.Add(action);foreach(var obj in loaded.RuntimeObjectList)dlg.RuntimeObjectList.Add(obj);}/// <summary>Index of the <see cref="StartLabel"/> point in the built queue, or 0.</summary>
protected override int StartIndex(DialogQueue dlg){if(string.IsNullOrEmpty(StartLabel))return 0;for(var i=0;i<dlg.Queue.Count;i++){if(dlg.Queue[i]?.ID==StartLabel)return i;}GameLogger.Log("SCENE",$"ScenarioEvent '{ID}': start label '{StartLabel}' not found, starting from the top",LogLevel.Warning);return 0;}private long _cachedVersion=long.MinValue;private bool _cachedResult;/// <summary> Evaluates <see cref="Condition"/> through <see cref="ScenarioCondition.Evaluate"/>. Empty conditions always pass. The result is cached and reused until <see cref="ConditionStateMonitor.Version"/> changes, so unchanged world state does not trigger repeated parsing/evaluation of the same condition. </summary>
public override bool CheckConditions{get{if(string.IsNullOrEmpty(Condition))return true;var version=ConditionStateMonitor.Version;if(_cachedVersion==version)return _cachedResult;_cachedResult=ScenarioCondition.Evaluate(Condition);_cachedVersion=version;return _cachedResult;}}}}

===== FILE mods/dialog_base/test_suites/autoplay/src/DialogAutoDriverNode.cs =====
using System.Collections.Generic;using Cthangover.Core.UI.Dialog;using Cthangover.Core.Utils;using Godot;namespace Mods.TestRunner{/// <summary> Мод-порт интеграционного <c>DialogAutoDriver</c>: автопроигрывает активный диалог, кликая <c>IDialogBox.NextAction()</c> каждые 0.1с и отвечая на выборы по индексам из <c>--choices=</c> (по порядку, после исчерпания — первый вариант). Тик реализован дочерним Godot <see cref="Timer"/>, а не override <c>_Process</c>: сборка мода компилируется Roslyn вне source-генератора Godot, поэтому виртуальные методы Node не диспетчеризуются, тогда как сигнал <c>Timer.Timeout</c> — это Godot-объект и работает из мод-сборок. DialogBox ищется лениво через корень дерева по имени «DialogBox» (контракт ядра <see cref="IDialogBox"/>, типы dialog_base не нужны). Завершение диалога и общий таймаут печатают строку <c>[TEST_RESULT] name=test_runner passed=N failed=M</c> — формат парсит tests/Cthangover.TestRunner (ResultParser), после чего процесс завершается с соответствующим кодом выхода. Как и в оригинале, <c>IsDialogEnded</c> истинно до загрузки первой очереди, поэтому end-обработка ждёт, пока диалог реально наблюдался запущенным, плюс стартовая пауза 0.5с на построение сцены. </summary>
public partial class DialogAutoDriverNode:Node{/// <summary>Имя, под которым раннер атрибутирует результат прогона.</summary>
private const string ResultName="test_runner";/// <summary>Пауза после старта, чтобы сцена успела построиться (как в SceneSwitcher).</summary>
private const double StartDelaySeconds=0.5;/// <summary>Сколько бокс может быть найден, но не запущен, прежде чем считаем сцену без диалога.</summary>
private const double FirstDialogGraceSeconds=3.0;/// <summary>Интервал автопрокликивания, сек (как в оригинальном драйвере).</summary>
private const double AdvanceIntervalSeconds=0.1;private readonly List<int>choices;private readonly float?timeoutSeconds;private IDialogBox dialogBox;private int choiceIndex;private double elapsedSeconds;private double dialogFoundAtSeconds;private bool hasSeenRunningDialog;private bool finished;/// <summary> Создаёт драйвер; <paramref name="choices"/> — индексы вариантов по порядку появления, <paramref name="timeoutSeconds"/> — общий лимит прогона (null — без таймаута). Тикающий таймер-ребёнок ставится прямо в конструкторе (без override _EnterTree/_Ready — мод-сборка вне source-генератора Godot): WaitTime 0.1с с автостартом воспроизводит ритм оригинального драйвера, а сигнал <c>Timer.Timeout</c> — Godot-сигнал и работает из мод-сборок. </summary>
public DialogAutoDriverNode(List<int>choices,float?timeoutSeconds){this.choices=choices??new List<int>();this.timeoutSeconds=timeoutSeconds;var timer=new Timer{Name="TickTimer",WaitTime=AdvanceIntervalSeconds,Autostart=true,OneShot=false};AddChild(timer);timer.Timeout+=Tick;}/// <summary>Один тик автопроигрывания: ленивый поиск бокса, прокликивание, выборы, end/timeout-обработка.</summary>
private void Tick(){if(finished)return;elapsedSeconds+=AdvanceIntervalSeconds;if(timeoutSeconds.HasValue&&elapsedSeconds>=timeoutSeconds.Value){Finish(0,1,$"timeout ({timeoutSeconds.Value}s) reached");return;}if(elapsedSeconds<StartDelaySeconds)return;if(dialogBox is not GodotObject liveBox|| !GodotObject.IsInstanceValid(liveBox)||liveBox is not Node liveNode|| !liveNode.IsInsideTree()){dialogBox=FindDialogBox();if(dialogBox==null)return;dialogFoundAtSeconds=elapsedSeconds;hasSeenRunningDialog=false;var choicesInfo=choices.Count>0?"choices=["+string.Join(",",choices)+"]":"no choices, will pick first variant each time";GameLogger.Log("TEST",$"DialogAutoDriverNode started, interval={AdvanceIntervalSeconds}, {choicesInfo}");}if(dialogBox.IsRunning)hasSeenRunningDialog=true;// IsDialogEnded истинно и до первой очереди — ждём наблюдения запущенного диалога плюс льготный период на пустых сценах.
var waitingForFirstDialog= !hasSeenRunningDialog&&elapsedSeconds-dialogFoundAtSeconds<FirstDialogGraceSeconds;if(dialogBox.IsDialogEnded&& !waitingForFirstDialog){GameLogger.Log("TEST","DialogAutoDriverNode: dialog ended, quitting");Finish(1,0,null);return;}if(dialogBox.IsWaitingAnswer){HandleSelection();return;}dialogBox.NextAction();}/// <summary>Выбирает показанный вариант по индексу из --choices= (вне диапазона — первый).</summary>
private void HandleSelection(){var count=dialogBox.AnswerVariantCount;if(count<=0)return;var pick=0;if(choiceIndex<choices.Count){pick=choices[choiceIndex];if(pick>=count)pick=0;choiceIndex++;}GameLogger.Log("TEST",$"DialogAutoDriverNode: choice #{choiceIndex} picking index={pick} out of {count}");dialogBox.TrySelectVariant(pick);}/// <summary> Ищет DialogBox через корень дерева: драйвер часто добавляется до появления диалоговой сцены, а каст к контракту ядра избавляет мод от ссылки на конкретную реализацию. </summary>
private IDialogBox FindDialogBox(){var tree=GetTree();if(tree?.Root==null)return null;return tree.Root.FindChild("DialogBox",true,false)as IDialogBox;}/// <summary>Печатает итоговую строку для раннера и завершает процесс (0 — успех, 1 — провал).</summary>
private void Finish(int passed,int failed,string reason){if(finished)return;finished=true;if(reason!=null)GameLogger.Log("TEST",$"DialogAutoDriverNode: {reason}");GameLogger.Log("TEST",$"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");GetTree().Quit(failed==0?0:1);}}}

===== FILE mods/dialog_base/test_suites/autoplay/src/TestRunnerInitializer.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Mods.TestRunner{/// <summary> Точка входа тестового мода: переносит интеграционную обвязку диалогов (tests DialogAutoDriver + SceneSwitcher) в engine-мод. Активируется только при наличии пользовательских флагов <c>--choices=</c> или <c>--timeout=</c> (как <c>OS.GetCmdlineUserArgs()</c>; флаг <c>--scene=</c> мод не читает — навигацией по сценам управляет ядро). При активации создаёт <see cref="DialogAutoDriverNode"/> и добавляет его в корень дерева через deferred-вызов: <c>OnModResourcesReady</c> срабатывает до окончания построения сцены, прямой AddChild в этот момент отклоняется движком, а драйверу всё равно нужно дождаться диалога (та же задержка 0.5с, что у SceneSwitcher.ScheduleDriverAttach). Повторный <c>ModManager.Reload()</c> не должен плодить драйверы — защищено статическим флагом. </summary>
public class TestRunnerInitializer:IModInitializer{private static bool driverQueued;/// <summary>Инициализация per-mod не требуется — драйвер ставится на resources-ready.</summary>
public void OnModLoaded(string modId){}/// <summary> Разбирает пользовательские аргументы командной строки и при активационном флаге ставит драйвер автопроигрывания в корень дерева. </summary>
public void OnModResourcesReady(){if(driverQueued)return;var args=OS.GetCmdlineUserArgs();var hasChoices=args.Any(a=>a.StartsWith("--choices=",System.StringComparison.Ordinal));var hasTimeout=args.Any(a=>a.StartsWith("--timeout=",System.StringComparison.Ordinal));if(!hasChoices&& !hasTimeout)return;var choices=ParseChoices(args);var timeout=ParseTimeout(args);// Godot. квалифицирован: в dev-компиляции модов (ToolsModCompileTests) в глобальном пространстве имён существует namespace Engine из тестовой сборки, и неквалифицированное имя резолвится в него вместо Godot.Engine.
var root=(Godot.Engine.GetMainLoop()as SceneTree)?.Root;if(root==null){GameLogger.Log("TEST","TestRunnerInitializer: tree root not available, driver not attached",LogLevel.Error);return;}driverQueued=true;GameLogger.Log("TEST",$"TestRunnerInitializer: activating dialog auto-driver, choices=[{string.Join(",", choices)}], timeout={timeout?.ToString() ?? "none"}s");// Deferred: сцена ещё строится; драйвер внутри сам выдерживает стартовую паузу и лениво ищет DialogBox, поэтому порядок с загрузкой сцены не важен.
root.CallDeferred("add_child",new DialogAutoDriverNode(choices,timeout));}private static List<int>ParseChoices(string[]args){var raw=args.Where(a=>a.StartsWith("--choices=",System.StringComparison.Ordinal)).Select(a=>a.Substring("--choices=".Length)).FirstOrDefault();if(string.IsNullOrEmpty(raw))return new List<int>();return raw.Split(',').Select(s=>int.TryParse(s.Trim(),out var n)?n:-1).Where(n=>n>=0).ToList();}private static float?ParseTimeout(string[]args){var raw=args.Where(a=>a.StartsWith("--timeout=",System.StringComparison.Ordinal)).Select(a=>a.Substring("--timeout=".Length)).FirstOrDefault();return float.TryParse(raw,System.Globalization.CultureInfo.InvariantCulture,out var t)&&t>0?t:null;}}}

===== FILE mods/dialog_base/test_suites/gdscript/src/GdscriptTest.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Text.RegularExpressions;using Cthangover.Core.Bootstrap;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;using Cthangover.Core.Utils;using Godot;namespace Mods.DialogBase.Tests{/// <summary>Entry point of the GDScript itest; the suite is mounted only with <c>--itest=gdscript</c>.</summary>
public class GdscriptTestInitializer:IModInitializer{private static bool queued;public void OnModLoaded(string modId){}public void OnModResourcesReady(){if(queued|| !OS.GetCmdlineUserArgs().Contains("--itest=gdscript"))return;queued=true;GameLogger.Log("TEST","GdscriptTest: activating");(Godot.Engine.GetMainLoop()as SceneTree)?.Root?.CallDeferred("add_child",new GdscriptTestDriverNode());}}/// <summary> Checks the GDScript mod pipeline end to end on the suite's own <c>scripts/*.gd</c>: every script with <c>get_name()</c> + <c>run()</c> is a registered scenario action, and every <c>get_condition_name()</c> script is a working condition — <c>gds.is_rain</c> and <c>gds.flag_equals("victory")</c> are evaluated through the same <see cref="ConditionParser"/> scenarios use, against flags that must make them true and false. Prints <c>[TEST_RESULT] name=gdscript</c>. </summary>
public partial class GdscriptTestDriverNode:Node{private const string ResultName="gdscript";private const string SuiteModId="dialog_base__gdscript";private const double TimeoutSeconds=60;private static readonly Regex ReturnedName=new(@"func\s+(get_name|get_condition_name)\s*\(\s*\)\s*:\s*\n\s*return\s+""([^""]+)""",RegexOptions.Compiled);private int passed;private int failed;private double elapsed;private bool done;public override void _Process(double delta){if(done)return;elapsed+=delta;if(elapsed>TimeoutSeconds){Check("boot finished in time",false);Finish();return;}if(!Boot.IsReady)return;try{Run();}catch(Exception ex){Check($"no exception ({ex.GetType().Name}: {ex.Message})",false);}Finish();}private void Run(){var actions=new List<string>();var conditions=new List<string>();foreach(var file in ModManager.Instance.ListFiles(SuiteModId,"scripts").Where(f=>f.EndsWith(".gd",StringComparison.Ordinal))){var text=(ModManager.Instance.ReadFileText(SuiteModId,file)??string.Empty).Replace("\r\n","\n");foreach(Match match in ReturnedName.Matches(text)){if(match.Groups[1].Value=="get_condition_name")conditions.Add(match.Groups[2].Value);else if(text.Contains("func run("))actions.Add(match.Groups[2].Value);}}Check($"suite scripts found ({actions.Count} action(s), {conditions.Count} condition(s))",actions.Count>0&&conditions.Count>0);foreach(var name in actions)Check($"action '{name}' registered",ScenarioActionFactory.Instance.Get(name)!=null);Expect("gds.is_rain",new(){["weather"]="rain"},true);Expect("gds.is_rain",new(){["weather"]="sun"},false);Expect("gds.flag_equals(\"victory\")",new(){["custom_flag"]="victory"},true);Expect("gds.flag_equals(\"victory\")",new(){["custom_flag"]="defeat"},false);Expect("gds.is_rain && gds.flag_equals(\"victory\")",new(){["weather"]="rain",["custom_flag"]="victory"},true);}private void Expect(string condition,Dictionary<string,string>flags,bool expected){var node=ConditionParser.Parse(condition);var actual=node!=null&&node.Evaluate(new EvaluationContext{Flags=flags});Check($"'{condition}' with {string.Join(",", flags.Select(f => $"{f.Key}={f.Value}"))} → {expected}"+(actual==expected?string.Empty:$" (got {actual})"),actual==expected);}private void Check(string what,bool ok){if(ok)passed++;else failed++;GameLogger.Log("TEST",$"  [{(ok ? "PASS" : "FAIL")}] {what}");}private void Finish(){done=true;GameLogger.Log("TEST",$"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");GetTree().Quit(failed==0?0:1);}}}

===== FILE mods/editor_tools/src/CatalogExport/CatalogExportToolProvider.cs =====
using Cthangover.Core.UI.Tool;using Godot;using Cthangover.Mods.Interface.Tool;namespace Cthangover.Core.UI.Tool.CatalogExport{/// <summary> Registers the catalog export window as a developer tool. Moved here from the old mods menu when mod management UI was delegated to the <c>mod_manager</c> mod — exporting a catalog entry is an authoring task, not part of day-to-day mod management. </summary>
public class CatalogExportToolProvider:IToolProvider{/// <summary>Unique tool identifier.</summary>
public string Id=>"catalog_export";/// <summary>Translation key for the tool's display name.</summary>
public string LocaleKey=>"mods/catalog_title";/// <summary>Creates the export window instance.</summary>
public Window CreateWindow()=>new CatalogExportWindow();/// <summary>Adds the catalog export launcher button to the main menu tool block.</summary>
public void AddMenuButton(Container container)=>ToolMenuButton.Attach(container,this);}}

===== FILE mods/editor_tools/src/CatalogExport/CatalogExportWindow.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Text.Json;using Cthangover.Core.Mods;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Cthangover.Mods.Interface.Theming;using Cthangover.Mods.Interface.Tool;using Cthangover.Tools.Common;using Godot;namespace Cthangover.Core.UI.Tool.CatalogExport{/// <summary> Developer tool that builds a catalog entry (JSON for the online mod catalog) from a locally installed mod's manifest. Ported out of the old mods menu so that mod-management UI can live in the <c>mod_manager</c> mod while this authoring utility stays a tool. <para/> Left: the form — read-only manifest data, catalog-only metadata (download URL, categories, tags, changelog, status, minimum game version, icon URL) and localized names. Right: the live JSON preview. Actions: copy the JSON or export it next to the mod's zip via <see cref="ModCatalogExporter"/> (archive and hashes are computed there). </summary>
public partial class CatalogExportWindow:ToolWindow{private static readonly string[]Statuses={"stable","beta","deprecated","archived"};private static readonly CategoryType[]Categories=Enum.GetValues<CategoryType>();private OptionButton _modSelect;private VBoxContainer _formHost;private CodeEdit _previewEdit;private ModInfo _modInfo;private ModManifest _manifest;private LineEdit _urlEdit;private readonly List<CheckBox>_categoryChecks=new();private LineEdit _tagsEdit;private TextEdit _changelogEdit;private OptionButton _statusOpt;private LineEdit _minGameVerEdit;private LineEdit _iconUrlEdit;private VBoxContainer _localeContainer;public CatalogExportWindow():base("mods/catalog_title"){Size=new Vector2I(1200,820);CthangoverTheme.Apply(this);var outer=CreateFillContainer();AddChild(outer);var toolbar=CreateToolbar();outer.AddChild(toolbar);_modSelect=new OptionButton{CustomMinimumSize=new Vector2(320,0),TooltipText=ToolUi.Tr("mods/catalog_select_mod")};_modSelect.ItemSelected+=OnModSelected;toolbar.AddChild(_modSelect);toolbar.AddChild(UiKit.Spacer());toolbar.AddChild(ToolUi.Button("mods/catalog_copy",OnCopyPressed));toolbar.AddChild(ToolUi.Button("mods/catalog_export_file",OnExportPressed,ButtonKind.Primary,"tools/catalog_export/export_tip"));var split=new HSplitContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};outer.AddChild(split);_formHost=new VBoxContainer();var form=CreateSidebar(_formHost,520);form.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;split.AddChild(form);var previewColumn=new VBoxContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};previewColumn.AddChild(ToolUi.Section("tools/catalog_export/preview"));_previewEdit=ToolUi.Code(editable:false);previewColumn.AddChild(_previewEdit);split.AddChild(previewColumn);SetHint(ToolUi.Tr("tools/catalog_export/hint"));PopulateModList();}private void PopulateModList(){var ordered=ModRegistry.Instance.GetOrderedModIds(filterDisabled:false);foreach(var id in ordered){var info=ModRegistry.Instance.GetMod(id);if(info==null)continue;_modSelect.AddItem($"{info.DisplayTitle}  ({id})");_modSelect.SetItemMetadata(_modSelect.ItemCount-1,id);}if(_modSelect.ItemCount>0){_modSelect.Selected=0;BuildForm(_modSelect.GetItemMetadata(0).AsString());}}private void OnModSelected(long index){BuildForm(_modSelect.GetItemMetadata((int)index).AsString());}private void BuildForm(string modId){foreach(var child in _formHost.GetChildren())child.QueueFree();_categoryChecks.Clear();_modInfo=ModRegistry.Instance.GetMod(modId)as ModInfo;_manifest=_modInfo?.Manifest;if(_modInfo==null||_manifest==null)return;_formHost.AddChild(ToolUi.Section("tools/catalog_export/mod_info"));AddReadOnly("tools/catalog_export/id",_modInfo.Id);AddReadOnly("tools/catalog_export/name",_modInfo.DisplayTitle);AddReadOnly("mods/version",_modInfo.Version??"—");AddReadOnly("mods/author",_modInfo.Author??"—");_formHost.AddChild(ToolUi.Section("tools/catalog_export/metadata"));_urlEdit=ToolUi.Field(_formHost,"mods/catalog_download_url",ToolUi.Text("https://…/"+modId+".zip",_=>UpdatePreview()));_statusOpt=ToolUi.Field(_formHost,"mods/catalog_status",new OptionButton());foreach(var status in Statuses)_statusOpt.AddItem(status);_statusOpt.ItemSelected+=_=>UpdatePreview();_minGameVerEdit=ToolUi.Field(_formHost,"mods/catalog_min_game_version",ToolUi.Text("1.0.0",_=>UpdatePreview()));_iconUrlEdit=ToolUi.Field(_formHost,"mods/catalog_icon_url",ToolUi.Text("https://…/icon.png",_=>UpdatePreview()));_tagsEdit=ToolUi.Field(_formHost,"mods/catalog_tags",ToolUi.Text("story, ru",_=>UpdatePreview()));_formHost.AddChild(ToolUi.Section("mods/catalog_category"));var grid=new GridContainer{Columns=3};grid.AddThemeConstantOverride("h_separation",UiTokens.Space5);var preselected=_manifest.Category??CategoryType.Other;foreach(var category in Categories){var check=new CheckBox{Text=GetCategoryDisplayName(category),ButtonPressed=(preselected&category)!=0};check.Toggled+=_=>UpdatePreview();_categoryChecks.Add(check);grid.AddChild(check);}_formHost.AddChild(grid);_formHost.AddChild(ToolUi.Section("mods/catalog_changelog"));_changelogEdit=new TextEdit{CustomMinimumSize=new Vector2(0,80),WrapMode=TextEdit.LineWrappingMode.Boundary,};_changelogEdit.TextChanged+=UpdatePreview;_formHost.AddChild(_changelogEdit);_formHost.AddChild(ToolUi.Section("tools/catalog_export/locales"));_formHost.AddChild(ToolUi.Hint(ToolUi.Tr("tools/catalog_export/locales_hint")));_localeContainer=new VBoxContainer();_localeContainer.AddThemeConstantOverride("separation",UiTokens.Space1);_formHost.AddChild(_localeContainer);_formHost.AddChild(ToolUi.Button("mods/catalog_add_locale",()=>AddLocaleRow(string.Empty,string.Empty,string.Empty)));UpdatePreview();SetStatus(string.Empty);}private void AddReadOnly(string labelKey,string value){var label=UiKit.Label(value,UiTokens.FontBody,UiTokens.Body);label.AddThemeFontOverride("font",ToolUi.Mono());ToolUi.Field(_formHost,labelKey,label);}private static string GetCategoryDisplayName(CategoryType cat)=>cat switch{CategoryType.Story=>ToolUi.Tr("mods/category_story"),CategoryType.Characters=>ToolUi.Tr("mods/category_characters"),CategoryType.Gameplay=>ToolUi.Tr("mods/category_gameplay"),CategoryType.Visual=>ToolUi.Tr("mods/category_visual"),CategoryType.Audio=>ToolUi.Tr("mods/category_audio"),CategoryType.UI=>ToolUi.Tr("mods/category_ui"),CategoryType.Locale=>ToolUi.Tr("mods/category_locale"),CategoryType.Library=>ToolUi.Tr("mods/category_library"),CategoryType.Engine=>ToolUi.Tr("mods/category_engine"),CategoryType.Overhaul=>ToolUi.Tr("mods/category_overhaul"),CategoryType.Tools=>ToolUi.Tr("mods/category_tools"),CategoryType.Editor=>ToolUi.Tr("mods/category_editor"),CategoryType.Debug=>ToolUi.Tr("mods/category_debug"),CategoryType.Other=>ToolUi.Tr("mods/category_other"),_=>cat.ToString(),};private void AddLocaleRow(string lang,string name,string description){var row=new HBoxContainer();row.AddThemeConstantOverride("separation",UiTokens.Space1);var langEdit=new LineEdit{CustomMinimumSize=new Vector2(52,0),PlaceholderText="en",MaxLength=5,Text=lang};row.AddChild(langEdit);var nameEdit=new LineEdit{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,PlaceholderText=ToolUi.Tr("mods/catalog_locale_name"),Text=name};row.AddChild(nameEdit);var descEdit=new LineEdit{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,PlaceholderText=ToolUi.Tr("mods/catalog_locale_desc"),Text=description};row.AddChild(descEdit);var removeBtn=UiKit.IconButton("✕",ToolUi.Tr("tools/catalog_export/remove_locale"));removeBtn.Pressed+=()=>RemoveLocaleRow(row);row.AddChild(removeBtn);langEdit.TextChanged+=_=>UpdatePreview();nameEdit.TextChanged+=_=>UpdatePreview();descEdit.TextChanged+=_=>UpdatePreview();_localeContainer.AddChild(row);UpdatePreview();}private void RemoveLocaleRow(HBoxContainer row){_localeContainer.RemoveChild(row);row.QueueFree();UpdatePreview();}private void UpdatePreview(){if(_previewEdit==null||_modInfo==null||_manifest==null||_changelogEdit==null)return;var entry=BuildExportEntry();// Hashes are computed by the exporter from the real archive.
entry.Downloads[0].ArchiveSha256="computed-on-export";entry.Downloads[0].InstallSha256="computed-on-export";_previewEdit.Text=JsonSerializer.Serialize(entry,JsonOptionsProvider.WriteIndentedIgnoreNullOptions);}private List<CatalogLocaleEntry>CollectLocales(){var list=new List<CatalogLocaleEntry>();foreach(var child in _localeContainer.GetChildren()){if(child is not HBoxContainer row||row.IsQueuedForDeletion()||row.GetChildCount()<3)continue;var lang=((LineEdit)row.GetChild(0)).Text.Trim();if(lang.Length==0)continue;list.Add(new CatalogLocaleEntry{Lang=lang,Name=NullIfEmpty(((LineEdit)row.GetChild(1)).Text),Description=NullIfEmpty(((LineEdit)row.GetChild(2)).Text),});}if(list.Count==0)list.Add(new CatalogLocaleEntry{Lang="en",Name=_manifest.Name,Description=_manifest.Description});return list;}private CatalogExportEntry BuildExportEntry(){var entry=BuildEntryFromManifest(_manifest);entry.Downloads=new List<CatalogEntryDownload>{new(){Version=entry.Version,Urls=new List<string>{_urlEdit.Text.Trim()},SizeBytes=0,Changelog=NullIfEmpty(_changelogEdit.Text),},};entry.Category=CollectSelectedCategories();entry.Tags=ParseTags(_tagsEdit.Text);entry.Status=Statuses[Math.Max(0,_statusOpt.Selected)];entry.MinGameVersion=NullIfEmpty(_minGameVerEdit.Text);entry.IconUrl=NullIfEmpty(_iconUrlEdit.Text);entry.Locale=CollectLocales();return entry;}private void OnCopyPressed(){DisplayServer.ClipboardSet(_previewEdit.Text);SetStatus(ToolUi.Tr("tools/catalog_export/copied"),ToolStatusKind.Ok);}private void OnExportPressed(){if(_modInfo==null)return;if(string.IsNullOrWhiteSpace(_urlEdit.Text)){SetStatus(ToolUi.Tr("tools/catalog_export/url_required"),ToolStatusKind.Error);return;}try{var entry=BuildExportEntry();var modsRoot=ModRegistry.ModsPath;var isZip=_modInfo.FileProvider.RootPath.EndsWith(".zip",StringComparison.OrdinalIgnoreCase);var path=isZip?ModCatalogExporter.ExportFromZip(_modInfo,entry,modsRoot):ModCatalogExporter.ExportFromFolder(_modInfo,entry,modsRoot);SetStatus(string.Format(ToolUi.Tr("tools/catalog_export/exported"),path),ToolStatusKind.Ok);}catch(Exception ex){GameLogger.Log("CATALOG_EXPORT",$"export of '{_modInfo.Id}' failed: {ex.Message}",LogLevel.Error);SetStatus($"{ToolUi.Tr("tools/catalog_export/export_error")}: {ex.Message}",ToolStatusKind.Error);}}private static List<string>ParseTags(string text){if(string.IsNullOrWhiteSpace(text))return null;return text.Split(',').Select(t=>t.Trim()).Where(t=>t.Length>0).ToList();}private static string NullIfEmpty(string text){var trimmed=text?.Trim();return string.IsNullOrEmpty(trimmed)?null:trimmed;}private static CatalogExportEntry BuildEntryFromManifest(ModManifest manifest){var depends=manifest.Depends?.Select(d=>d.ToString()).Where(s=>s.Length>0).ToList();return new CatalogExportEntry{Id=manifest.Id,Name=manifest.Name,Author=manifest.Author,Version=manifest.Version,Description=manifest.Description,Category=manifest.Category??CategoryType.Other,Depends=depends,ConflictsWith=manifest.ConflictsWith,};}private CategoryType CollectSelectedCategories(){var selected=(CategoryType)0;for(var i=0;i<Categories.Length&&i<_categoryChecks.Count;i++){if(_categoryChecks[i].ButtonPressed)selected|=Categories[i];}return selected!=0?selected:CategoryType.Other;}}}

===== FILE mods/editor_tools/src/Common/BackgroundCanvas.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Tools.Common{/// <summary> Base of the editors that place things over a scene background (map zones, interactive colliders, lights). Draws the background with the same aspect-preserving "fit" layout the game uses and converts between canvas pixels and the normalised [0..1] coordinates stored in the data. Getting this rectangle wrong is what made saved positions drift: the data means "fraction of the background", not "fraction of the panel". <para/> Subclasses paint their overlay in <see cref="DrawContent"/> and rebuild their <see cref="DragHandle"/>s in <see cref="RebuildHandles"/>, which is also called whenever the canvas is resized or the background changes. </summary>
public abstract partial class BackgroundCanvas:Control{private static readonly Vector2 FallbackSize=new(1920,1024);/// <summary>Live handles of the current selection.</summary>
protected readonly List<DragHandle>_handles=new();/// <summary>Reference background; <c>null</c> draws a flat placeholder.</summary>
public Texture2D Background{get;private set;}protected BackgroundCanvas(){MouseFilter=MouseFilterEnum.Stop;ClipContents=true;Resized+=OnResized;Connect("draw",Callable.From((Action)DrawAll));}/// <summary>Assigns the background texture and relayouts handles.</summary>
public void SetBackground(Texture2D texture){Background=texture;RebuildHandles();QueueRedraw();}/// <summary>Recreates the handles from the current selection.</summary>
public abstract void RebuildHandles();/// <summary>Paints the overlay above the background inside <paramref name="rect"/>.</summary>
protected abstract void DrawContent(Rect2 rect);/// <summary> Aspect-preserving rectangle the background occupies inside the canvas — identical to the runtime "fit" layout. </summary>
public Rect2 GetBackgroundRect(){var controlSize=Size;if(controlSize.X<=0f||controlSize.Y<=0f)return new Rect2();var textureSize=Background?.GetSize()??FallbackSize;if(textureSize.X<=0f||textureSize.Y<=0f)textureSize=FallbackSize;var scale=Mathf.Min(controlSize.X/textureSize.X,controlSize.Y/textureSize.Y);var size=textureSize*scale;return new Rect2((controlSize-size)/2f,size);}/// <summary>Canvas-local pixel of a normalised point.</summary>
public Vector2 NormToLocal(Vector2 normalized){var rect=GetBackgroundRect();return rect.Position+normalized*rect.Size;}/// <summary>Normalised point of a canvas-local pixel (not clamped).</summary>
public Vector2 LocalToNorm(Vector2 local){var rect=GetBackgroundRect();if(rect.Size.X<=0f||rect.Size.Y<=0f)return Vector2.Zero;return(local-rect.Position)/rect.Size;}/// <summary>Clamps both axes into [0..1].</summary>
public static Vector2 Clamp01(Vector2 value){return new Vector2(Mathf.Clamp(value.X,0f,1f),Mathf.Clamp(value.Y,0f,1f));}/// <summary>Frees every handle.</summary>
protected void ClearHandles(){foreach(var handle in _handles)handle.QueueFree();_handles.Clear();}/// <summary> Adds a handle centred on a normalised point. The handle enters the tree before it is positioned (setting a global position on a detached Control lands it in the wrong place). <paramref name="drag"/> receives the clamped normalised point while dragging. </summary>
protected DragHandle AddHandle(Vector2 normalized,Color color,int index,Action<Vector2>drag,float diameter=14f){var handle=new DragHandle(color,index,diameter);handle.DragUpdate=globalCenter=>drag(Clamp01(LocalToNorm(globalCenter-GlobalPosition)));AddChild(handle);handle.CenterAt(GlobalPosition+NormToLocal(Clamp01(normalized)));_handles.Add(handle);return handle;}private void OnResized(){RebuildHandles();QueueRedraw();}private void DrawAll(){var rect=GetBackgroundRect();if(rect.Size.X<=0f||rect.Size.Y<=0f)return;if(Background!=null)DrawTextureRect(Background,rect,false);else DrawRect(rect,UiTokens.Surface2);DrawContent(rect);}}}

===== FILE mods/editor_tools/src/Common/CollapsibleSection.cs =====
using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Tools.Common{/// <summary> Sidebar section with a clickable uppercase caption (style guide <c>.ct-section-title</c> plus a ▸/▾ marker) that shows or hides its <see cref="Body"/>. Editors expand the sections relevant to the selected object and collapse the rest, so a long property panel stays readable without losing access to rarely used fields. </summary>
public partial class CollapsibleSection:VBoxContainer{private readonly Button _header;private readonly string _title;/// <summary>Container for the section's controls.</summary>
public VBoxContainer Body{get;}/// <summary>Whether the body is shown.</summary>
public bool Expanded{get=>Body.Visible;set{Body.Visible=value;_header.Text=(value?"▾  ":"▸  ")+_title;}}/// <summary>Creates a section titled by <paramref name="titleKey"/>.</summary>
public CollapsibleSection(string titleKey,bool expanded=true){_title=ToolUi.Tr(titleKey).ToUpperInvariant();AddThemeConstantOverride("separation",UiTokens.Space2);_header=new Button{Flat=true,Alignment=HorizontalAlignment.Left,FocusMode=FocusModeEnum.None,MouseDefaultCursorShape=CursorShape.PointingHand};_header.AddThemeFontSizeOverride("font_size",UiTokens.FontSmall);_header.AddThemeColorOverride("font_color",UiTokens.Muted);_header.AddThemeColorOverride("font_hover_color",UiTokens.AccentBright);_header.AddThemeColorOverride("font_pressed_color",UiTokens.Muted);_header.AddThemeStyleboxOverride("normal",new StyleBoxEmpty());_header.AddThemeStyleboxOverride("hover",new StyleBoxEmpty());_header.AddThemeStyleboxOverride("pressed",new StyleBoxEmpty());_header.Pressed+=()=>Expanded= !Expanded;AddChild(_header);Body=new VBoxContainer();Body.AddThemeConstantOverride("separation",UiTokens.Space2);AddChild(Body);Expanded=expanded;}}}

===== FILE mods/editor_tools/src/Common/DragHandle.cs =====
using System;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Tools.Common{/// <summary> Round draggable point used by every canvas editor (polygon vertices, rect corners, markers, lights). It is a small Control placed over its canvas: the left button drags it and reports the new centre in global coordinates, the right button reports a delete request. Input arrives through the <c>GuiInput</c> event and drawing through the <c>draw</c> signal — the forms that dispatch reliably for mod assemblies. <para/> <see cref="Index"/> is free for the owner: a vertex index or a negative role constant (marker, label, rect corner...). </summary>
public partial class DragHandle:Control{/// <summary>Owner-defined identity of the handle.</summary>
public int Index;/// <summary>Called while dragging with the handle centre in global coordinates.</summary>
public Action<Vector2>DragUpdate;/// <summary>Called on left press.</summary>
public Action Clicked;/// <summary>Called on left release after a press.</summary>
public Action DragEnd;/// <summary>Called on right press.</summary>
public Action RightClicked;private readonly Color _fill;private bool _dragging;private bool _hovered;private Vector2 _dragOffset;/// <summary>Creates a handle of the given fill colour, role index and diameter.</summary>
public DragHandle(Color fill,int index,float diameter=14f){_fill=fill;Index=index;Size=new Vector2(diameter,diameter);MouseFilter=MouseFilterEnum.Stop;MouseDefaultCursorShape=CursorShape.Drag;GuiInput+=OnGuiInput;MouseEntered+=()=>{_hovered=true;QueueRedraw();};MouseExited+=()=>{_hovered=false;QueueRedraw();};Connect("draw",Callable.From((Action)Paint));}/// <summary>Places the handle so that its centre sits at <paramref name="globalCenter"/>; call after it is in the tree.</summary>
public void CenterAt(Vector2 globalCenter){GlobalPosition=globalCenter-Size/2f;}private void Paint(){var center=Size/2f;var radius=Size.X/2f-1f;DrawCircle(center,radius,UiTokens.Surface0);DrawCircle(center,radius-2f,_fill);DrawArc(center,radius,0f,Mathf.Tau,24,_hovered||_dragging?UiTokens.AccentHover:UiTokens.Ink,1.5f,true);}private void OnGuiInput(InputEvent@event){if(@event is InputEventMouseButton mb){if(mb.ButtonIndex==MouseButton.Left){if(mb.Pressed){_dragging=true;_dragOffset=GetGlobalMousePosition()-GlobalPosition;Clicked?.Invoke();}else if(_dragging){_dragging=false;DragEnd?.Invoke();}AcceptEvent();}else if(mb.ButtonIndex==MouseButton.Right&&mb.Pressed){RightClicked?.Invoke();AcceptEvent();}}else if(@event is InputEventMouseMotion&&_dragging){GlobalPosition=GetGlobalMousePosition()-_dragOffset;DragUpdate?.Invoke(GlobalPosition+Size/2f);AcceptEvent();}}}}

===== FILE mods/editor_tools/src/Common/FilteredList.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Tools.Common{/// <summary> Search field + list used wherever an editor picks from a long catalogue (backgrounds, icons, files, definitions). Items are identified by a stable id, not by row index: filtering reorders and hides rows, so the list keeps the id↔row mapping itself and raises <see cref="Chosen"/> / <see cref="Activated"/> with the id. <see cref="Select"/> is silent so windows can mirror their model into the list without feedback loops. </summary>
public partial class FilteredList:VBoxContainer{private readonly LineEdit _search;private readonly ItemList _list;private readonly List<(string Id,string Text,Texture2D Icon)>_items=new();private readonly List<string>_visibleIds=new();private string _selectedId;private bool _silent;/// <summary>Raised when the user selects a row (click or keyboard).</summary>
public event Action<string>Chosen;/// <summary>Raised on double-click / Enter.</summary>
public event Action<string>Activated;/// <summary>Currently selected id, or <c>null</c>.</summary>
public string SelectedId=>_selectedId;/// <summary>Creates the list; <paramref name="minHeight"/> is the list part's minimum height.</summary>
public FilteredList(float minHeight=140f,string placeholderKey="tools/common/search"){AddThemeConstantOverride("separation",UiTokens.Space1);_search=new LineEdit{PlaceholderText=TranslationServer.Translate(placeholderKey),ClearButtonEnabled=true};_search.TextChanged+=_=>Rebuild();AddChild(_search);_list=new ItemList{CustomMinimumSize=new Vector2(0,minHeight),SizeFlagsVertical=SizeFlags.ExpandFill,AllowReselect=true};_list.ItemSelected+=OnItemSelected;_list.ItemActivated+=index=>Activated?.Invoke(IdAt((int)index));AddChild(_list);}/// <summary>Turns rows into an icon grid of <paramref name="iconSize"/> px thumbnails.</summary>
public void UseIconGrid(int iconSize){_list.FixedIconSize=new Vector2I(iconSize,iconSize);_list.IconMode=ItemList.IconModeEnum.Top;_list.MaxColumns=0;_list.SameColumnWidth=true;_list.FixedColumnWidth=iconSize+24;}/// <summary>Replaces all items; keeps the current selection when its id is still present.</summary>
public void SetItems(IEnumerable<(string Id,string Text,Texture2D Icon)>items){_items.Clear();_items.AddRange(items);Rebuild();}/// <summary>Replaces all items with text-only rows where text equals id.</summary>
public void SetItems(IEnumerable<string>ids){_items.Clear();foreach(var id in ids)_items.Add((id,id,null));Rebuild();}/// <summary>Selects the row with <paramref name="id"/> (null clears) without raising events.</summary>
public void Select(string id){_selectedId=id;_silent=true;_list.DeselectAll();var index=_visibleIds.IndexOf(id);if(index>=0){_list.Select(index);_list.EnsureCurrentIsVisible();}_silent=false;}/// <summary>Number of items before filtering.</summary>
public int Count=>_items.Count;private void Rebuild(){var filter=_search.Text.Trim();_silent=true;_list.Clear();_visibleIds.Clear();foreach(var item in _items){if(filter.Length>0&&item.Text.IndexOf(filter,StringComparison.OrdinalIgnoreCase)<0&&item.Id.IndexOf(filter,StringComparison.OrdinalIgnoreCase)<0)continue;var index=item.Icon!=null?_list.AddItem(item.Text,item.Icon):_list.AddItem(item.Text);_list.SetItemTooltip(index,item.Id);_visibleIds.Add(item.Id);}_silent=false;Select(_selectedId);}private void OnItemSelected(long index){if(_silent)return;_selectedId=IdAt((int)index);Chosen?.Invoke(_selectedId);}private string IdAt(int index)=>index>=0&&index<_visibleIds.Count?_visibleIds[index]:null;}}

===== FILE mods/editor_tools/src/Common/ModFileSelector.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.UI.Theming;using Cthangover.Mods.Interface.Theming;using Cthangover.Tools.Services;using Godot;namespace Cthangover.Tools.Common{/// <summary> Toolbar group "mod ▾ file ▾ +" for editors that own one kind of file inside a mod folder (<c>map/*.json</c>, <c>interactives/*.json</c>). Only writable (folder-backed) mods are offered, and mods that already contain such files come first, so the author lands on real data instead of an arbitrary engine mod with nothing to edit. <para/> The selector never loads anything itself: it raises <see cref="FileRequested"/> and the window decides (usually after an "unsaved changes" confirmation). When the window declines, it calls <see cref="Show"/> with the file still open to roll the dropdowns back. A <c>null</c> path means "this mod has no such file yet". </summary>
public partial class ModFileSelector:HBoxContainer{private readonly string _directory;private readonly string _extension;private readonly OptionButton _modSelect;private readonly OptionButton _fileSelect;private readonly List<string>_mods=new();private readonly List<string>_files=new();private bool _silent;/// <summary>Raised with (modId, mod-relative path or null) when the author picks or creates a file.</summary>
public event Action<string,string>FileRequested;/// <summary>Mod shown in the selector.</summary>
public string CurrentMod=>_modSelect.Selected>=0&&_modSelect.Selected<_mods.Count?_mods[_modSelect.Selected]:null;/// <summary>File shown in the selector, or <c>null</c>.</summary>
public string CurrentPath=>_fileSelect.Selected>=0&&_fileSelect.Selected<_files.Count?_files[_fileSelect.Selected]:null;/// <summary>Creates the selector for files in <paramref name="directory"/> with <paramref name="extension"/>.</summary>
public ModFileSelector(string directory,string extension){_directory=directory;_extension=extension;AddThemeConstantOverride("separation",UiTokens.Space2);_modSelect=new OptionButton{CustomMinimumSize=new Vector2(170,0),TooltipText=ToolUi.Tr("tools/common/mod_tip")};_modSelect.ItemSelected+=_=>OnModPicked();AddChild(_modSelect);_fileSelect=new OptionButton{CustomMinimumSize=new Vector2(220,0),TooltipText=ToolUi.Tr("tools/common/file_tip")};_fileSelect.ItemSelected+=_=>OnFilePicked();AddChild(_fileSelect);var newBtn=UiKit.IconButton("+",ToolUi.Tr("tools/common/new_file"));newBtn.Pressed+=OnNewPressed;AddChild(newBtn);}/// <summary>Re-reads mods and files and requests the first file of the first mod that has any.</summary>
public void Refresh(){var writable=ModFileService.ListWritableMods();_mods.Clear();_mods.AddRange(writable.Where(HasFiles));_mods.AddRange(writable.Where(m=> !HasFiles(m)));_silent=true;_modSelect.Clear();foreach(var mod in _mods)_modSelect.AddItem(HasFiles(mod)?mod:mod+"  ·");_silent=false;if(_mods.Count==0){FileRequested?.Invoke(null,null);return;}_modSelect.Select(0);FillFiles(_mods[0]);FileRequested?.Invoke(_mods[0],CurrentPath);}/// <summary>Silently shows <paramref name="modId"/>/<paramref name="path"/> (a not-yet-saved path is added).</summary>
public void Show(string modId,string path){_silent=true;var modIndex=_mods.IndexOf(modId);if(modIndex>=0){_modSelect.Select(modIndex);FillFiles(modId);if(path!=null&& !_files.Contains(path)){_files.Add(path);_fileSelect.AddItem(ShortName(path));}_fileSelect.Select(path!=null?_files.IndexOf(path):-1);}_silent=false;}private bool HasFiles(string modId)=>ModFileService.ListFiles(modId,_directory,_extension).Count>0;private void FillFiles(string modId){_files.Clear();_files.AddRange(ModFileService.ListFiles(modId,_directory,_extension));var silent=_silent;_silent=true;_fileSelect.Clear();foreach(var file in _files)_fileSelect.AddItem(ShortName(file));_fileSelect.Disabled=_files.Count==0;if(_files.Count>0)_fileSelect.Select(0);_silent=silent;}private string ShortName(string path){var prefix=_directory+"/";return path.StartsWith(prefix,StringComparison.OrdinalIgnoreCase)?path.Substring(prefix.Length):path;}private void OnModPicked(){if(_silent)return;var mod=CurrentMod;FillFiles(mod);FileRequested?.Invoke(mod,CurrentPath);}private void OnFilePicked(){if(!_silent)FileRequested?.Invoke(CurrentMod,CurrentPath);}private void OnNewPressed(){if(CurrentMod==null)return;var dialog=new ConfirmationDialog{Title=ToolUi.Tr("tools/common/new_file")};var column=new VBoxContainer();column.AddChild(UiKit.Hint(string.Format(ToolUi.Tr("tools/common/new_file_hint"),CurrentMod,_directory,_extension)));var nameEdit=new LineEdit{PlaceholderText="new_file",CustomMinimumSize=new Vector2(320,0)};column.AddChild(nameEdit);dialog.AddChild(column);dialog.RegisterTextEnter(nameEdit);dialog.Confirmed+=()=>{var name=SanitizeName(nameEdit.Text);dialog.QueueFree();if(name.Length==0)return;FileRequested?.Invoke(CurrentMod,_directory+"/"+name+_extension);};dialog.Canceled+=()=>dialog.QueueFree();AddChild(dialog);dialog.PopupCentered();nameEdit.GrabFocus();}private string SanitizeName(string raw){var name=(raw??string.Empty).Trim().Replace('\\','/').Trim('/');if(name.EndsWith(_extension,StringComparison.OrdinalIgnoreCase))name=name.Substring(0,name.Length-_extension.Length);var chars=name.Where(c=>char.IsLetterOrDigit(c)||c=='_'||c=='-'||c=='/').ToArray();return new string(chars);}}}

===== FILE mods/editor_tools/src/Common/ToolUi.cs =====
using System;using Cthangover.Core.UI.Theming;using Cthangover.Mods.Interface.Theming;using Cthangover.Mods.Interface.Tool;using Godot;namespace Cthangover.Tools.Common{/// <summary> Building blocks shared by every editor window of the tools mod, so all of them read the same way: uppercase muted section captions, "label + control" rows with a fixed label column, numeric spin boxes and token buttons from <see cref="UiKit"/>. Editors are built in code (the skill-code exception for utility tools), so this is the place where their look is kept consistent with the style guide instead of each window inventing its own spacing and label styles. </summary>
public static class ToolUi{/// <summary>Width of the label column in <see cref="Field"/> rows.</summary>
public const int LabelWidth=110;/// <summary>Translates a locale key.</summary>
public static string Tr(string key)=>TranslationServer.Translate(key);/// <summary>Uppercase muted section caption (style guide <c>.ct-section-title</c>) with a little air above.</summary>
public static Control Section(string titleKey){var box=new MarginContainer{MouseFilter=Control.MouseFilterEnum.Ignore};box.AddThemeConstantOverride("margin_top",UiTokens.Space2);box.AddChild(UiKit.SectionTitle(Tr(titleKey),UiTokens.FontSmall));return box;}/// <summary> Adds a "label | control" row to <paramref name="parent"/>: the label is muted, fixed width and carries the optional tooltip; the control fills the rest. Returns the control for chaining. </summary>
public static T Field<T>(Container parent,string labelKey,T control,string tooltipKey=null)where T:Control{var row=new HBoxContainer();row.AddThemeConstantOverride("separation",UiTokens.Space3);var label=new Label{Text=Tr(labelKey),CustomMinimumSize=new Vector2(LabelWidth,0),VerticalAlignment=VerticalAlignment.Center,ClipText=true,MouseFilter=Control.MouseFilterEnum.Pass,TooltipText=tooltipKey!=null?Tr(tooltipKey):string.Empty};label.AddThemeColorOverride("font_color",UiTokens.Muted);label.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);row.AddChild(label);control.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;row.AddChild(control);parent.AddChild(row);return control;}/// <summary> Numeric field. Values are typed and shown culture-independently by <see cref="SpinBox"/>, which also clamps and steps them, so the editors no longer parse free text ("0,5" vs "0.5"). </summary>
public static SpinBox Number(double min,double max,double step,Action<double>changed){var spin=new SpinBox{MinValue=min,MaxValue=max,Step=step,SelectAllOnFocus=true,SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,CustomArrowStep=step};spin.GetLineEdit().AddThemeFontOverride("font",Mono());if(changed!=null)spin.ValueChanged+=value=>changed(value);return spin;}/// <summary>Single-line text field with a placeholder and an optional change handler.</summary>
public static LineEdit Text(string placeholder="",Action<string>changed=null){var edit=new LineEdit{PlaceholderText=placeholder??string.Empty};if(changed!=null)edit.TextChanged+=text=>changed(text);return edit;}/// <summary>Token button with a localized caption, optional tooltip and kind.</summary>
public static Button Button(string textKey,Action pressed,ButtonKind kind=ButtonKind.Secondary,string tooltipKey=null){var button=UiKit.Button(Tr(textKey),kind);if(tooltipKey!=null)button.TooltipText=Tr(tooltipKey);if(pressed!=null)button.Pressed+=pressed;return button;}/// <summary>Thin vertical divider between toolbar groups.</summary>
public static VSeparator Divider()=>new(){CustomMinimumSize=new Vector2(UiTokens.Space3,0)};/// <summary>Faint helper text that wraps inside narrow sidebars.</summary>
public static Label Hint(string text){var label=UiKit.Hint(text);label.AutowrapMode=TextServer.AutowrapMode.WordSmart;label.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;label.CustomMinimumSize=new Vector2(80,0);return label;}/// <summary>Monospace font for IDs, numbers and code (style guide <c>--ct-mono</c>).</summary>
public static Font Mono()=>ToolWindow.GetMonospaceFont();/// <summary>Code editor preset: monospace, line numbers, current-line highlight, no wrap.</summary>
public static CodeEdit Code(bool editable=true){var edit=new CodeEdit{Editable=editable,GuttersDrawLineNumbers=true,HighlightCurrentLine=true,WrapMode=TextEdit.LineWrappingMode.None,SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill,IndentUseSpaces=true,IndentSize=2,AutoBraceCompletionEnabled=false};edit.AddThemeFontOverride("font",Mono());edit.AddThemeFontSizeOverride("font_size",UiTokens.FontBody);edit.AddThemeColorOverride("current_line_color",UiTokens.WhiteTrace);edit.AddThemeColorOverride("line_number_color",UiTokens.Faint);edit.AddThemeColorOverride("font_readonly_color",UiTokens.Body);return edit;}}}

===== FILE mods/editor_tools/src/Hub/NovelEditorHub.cs =====
namespace Cthangover.Tools.Hub{using Cthangover.Core.Scenes;using Cthangover.Core.UI.Theming;using Cthangover.Core.UI.Tool;using Cthangover.Core.Utils;using Cthangover.Mods.Interface.Theming;using Godot;/// <summary> Launcher screen of the <c>novel_editor</c> collection, entered as its logical entry scene. Lists every registered tool the same way the main menu does — each <see cref="IToolProvider"/> appends its own button via <see cref="IToolProvider.AddMenuButton"/> — so a new editor shows up here without touching this screen. Tool windows open on the tree root and survive scene switches (playing a scenario, returning to the menu). </summary>
public partial class NovelEditorHub:Control{/// <summary>Logical scene name; the collection's <c>entry.scene</c>.</summary>
public const string SceneName="novel_editor";private const int PanelWidth=420;public override void _Ready(){CthangoverTheme.Apply(this);AddChild(new ColorRect{Color=UiTokens.Page,MouseFilter=MouseFilterEnum.Ignore,AnchorRight=1f,AnchorBottom=1f,});var center=new CenterContainer{AnchorRight=1f,AnchorBottom=1f};AddChild(center);var panel=new PanelContainer{CustomMinimumSize=new Vector2(PanelWidth,0)};var frame=UiKit.Frame(UiTokens.RadiusLg,UiTokens.Surface1,UiTokens.Line);frame.SetContentMarginAll(UiTokens.Space8);panel.AddThemeStyleboxOverride("panel",frame);center.AddChild(panel);var column=UiKit.Column(UiTokens.Space5);panel.AddChild(column);column.AddChild(UiKit.Label(Tr("tools/hub/title").ToUpperInvariant(),UiTokens.FontTitle,UiTokens.InkBright));var hint=UiKit.Hint(Tr("tools/hub/hint"));hint.AutowrapMode=TextServer.AutowrapMode.WordSmart;column.AddChild(hint);column.AddChild(new HSeparator());column.AddChild(UiKit.SectionTitle(Tr("tools/hub/editors"),UiTokens.FontSmall));var tools=UiKit.Column(UiTokens.Space2);tools.MouseFilter=MouseFilterEnum.Pass;column.AddChild(tools);foreach(var tool in ToolFactory.Instance.GetAll())tool.AddMenuButton(tools);if(tools.GetChildCount()==0)column.AddChild(UiKit.EmptyState(Tr("tools/hub/empty")));column.AddChild(new HSeparator());var menu=UiKit.Button(Tr("tools/hub/main_menu"));menu.Pressed+=OnMainMenuPressed;column.AddChild(menu);GameLogger.Log("TOOLS",$"Novel editor hub: {tools.GetChildCount()} tool button(s)");}private void OnMainMenuPressed(){GetNodeOrNull<GodotSceneService>("/root/GodotSceneService")?.SwitchToMenu();}}}

===== FILE mods/editor_tools/src/Hub/ToolsInitializer.cs =====
namespace Cthangover.Tools.Hub{using Cthangover.Core.Mods;using Cthangover.Core.Scenes;/// <summary> Lifecycle entry point of the tools mod. Publishes the logical scene <see cref="NovelEditorHub.SceneName"/>, the entry scene of the <c>novel_editor</c> collection: launching that collection from the game picker goes through the regular new-game flow and lands on the editor launcher instead of a story scene. </summary>
public class ToolsInitializer:IModInitializer{private const string HubScenePath="scenes/novel_editor_hub.tscn";public void OnModLoaded(string modId){}public void OnModResourcesReady(){SceneRouteRegistry.Register(NovelEditorHub.SceneName,HubScenePath,fade:false);}}}

===== FILE mods/editor_tools/src/InteractiveEditor/InteractiveEditorCanvas.cs =====
using System;using System.Linq;using Cthangover.Core.Interactive;using Cthangover.Core.UI.Theming;using Cthangover.Tools.Common;using Godot;namespace Cthangover.Tools.InteractiveEditor{/// <summary> Preview of the interactives of one file over a scene background. Every collider is drawn (others faint, the selected one filled), so the author sees how objects of the same scene sit next to each other; clicking a collider selects it. The selected collider gets handles: <list type="bullet"> <item>rect — top-left and bottom-right corners plus a centre handle that moves the whole rect;</item> <item>circle — centre and a radius handle;</item> <item>polygon — one handle per vertex (right-click deletes it); a click on empty background appends a vertex.</item> </list> Geometry is in the same normalised background space as <c>InteractiveObject</c>: x/y/width/height are fractions of the background size, a circle radius is a fraction of its shorter side. </summary>
public partial class InteractiveEditorCanvas:BackgroundCanvas{private const int TopLeft=0;private const int BottomRight=1;private const int Move=2;private const int RadiusHandle=1;private const float MinExtent=0.005f;/// <summary>Data being edited; set once by the window.</summary>
public InteractiveEditorController Controller;/// <summary>Raised whenever geometry of the selected item changes.</summary>
public event Action Changed;/// <summary>Raised when a click on the canvas selects another item (-1: none).</summary>
public event Action<int>ItemPicked;public InteractiveEditorCanvas(){GuiInput+=OnGuiInput;}/// <summary>Recreates the handles of the selected item's collider.</summary>
public override void RebuildHandles(){ClearHandles();var hit=Controller?.Selected?.HitArea;if(hit==null||GetBackgroundRect().Size.X<=0f)return;switch(hit.Type){case"circle":AddHandle(new Vector2(hit.X,hit.Y),UiTokens.Info,Move,p=>{hit.X=p.X;hit.Y=p.Y;Moved();});AddHandle(RadiusPoint(hit),UiTokens.Info,RadiusHandle,p=>{var shortSide=ShortSide();var centre=NormToLocal(new Vector2(hit.X,hit.Y));hit.Radius=Mathf.Max(MinExtent,(NormToLocal(p)-centre).Length()/shortSide);Moved();});break;case"polygon":var vertices=hit.Vertices??Array.Empty<Vector2>();for(var i=0;i<vertices.Length;i++){var index=i;var handle=AddHandle(vertices[i],UiTokens.Accent,i,p=>{hit.Vertices[index]=p;Moved();});handle.RightClicked=()=>RemoveVertex(index);}break;default:AddHandle(new Vector2(hit.X,hit.Y),UiTokens.Success,TopLeft,p=>{var right=hit.X+hit.Width;var bottom=hit.Y+hit.Height;hit.X=Mathf.Min(p.X,right-MinExtent);hit.Y=Mathf.Min(p.Y,bottom-MinExtent);hit.Width=right-hit.X;hit.Height=bottom-hit.Y;Moved();});AddHandle(new Vector2(hit.X+hit.Width,hit.Y+hit.Height),UiTokens.Success,BottomRight,p=>{hit.Width=Mathf.Max(MinExtent,p.X-hit.X);hit.Height=Mathf.Max(MinExtent,p.Y-hit.Y);Moved();});AddHandle(new Vector2(hit.X+hit.Width/2f,hit.Y+hit.Height/2f),UiTokens.AccentBright,Move,p=>{hit.X=Mathf.Clamp(p.X-hit.Width/2f,0f,1f-hit.Width);hit.Y=Mathf.Clamp(p.Y-hit.Height/2f,0f,1f-hit.Height);Moved();},12f);break;}foreach(var handle in _handles)handle.DragEnd=()=>{RebuildHandles();Changed?.Invoke();};}/// <summary>Keeps the other handles glued to the shape while one is dragged.</summary>
private void Moved(){var hit=Controller.Selected.HitArea;foreach(var handle in _handles){Vector2?target=hit.Type switch{"circle"=>handle.Index==Move?new Vector2(hit.X,hit.Y):RadiusPoint(hit),"polygon"=>null,_=>handle.Index switch{TopLeft=>new Vector2(hit.X,hit.Y),BottomRight=>new Vector2(hit.X+hit.Width,hit.Y+hit.Height),_=>new Vector2(hit.X+hit.Width/2f,hit.Y+hit.Height/2f)}};if(target.HasValue)handle.CenterAt(GlobalPosition+NormToLocal(target.Value));}QueueRedraw();Changed?.Invoke();}private Vector2 RadiusPoint(HitAreaDefinition hit){var rect=GetBackgroundRect();var offset=rect.Size.X>0f?hit.Radius*ShortSide()/rect.Size.X:0f;return new Vector2(hit.X+offset,hit.Y);}private float ShortSide(){var rect=GetBackgroundRect();return Mathf.Max(1f,Mathf.Min(rect.Size.X,rect.Size.Y));}private void RemoveVertex(int index){var hit=Controller.Selected?.HitArea;if(hit?.Vertices==null||index>=hit.Vertices.Length)return;hit.Vertices=hit.Vertices.Where((_,i)=>i!=index).ToArray();RebuildHandles();QueueRedraw();Changed?.Invoke();}private void OnGuiInput(InputEvent@event){if(Controller==null||@event is not InputEventMouseButton mb|| !mb.Pressed||mb.ButtonIndex!=MouseButton.Left)return;var norm=LocalToNorm(mb.Position);var hitIndex=HitTest(norm);var selected=Controller.Selected;if(hitIndex>=0&&hitIndex!=Controller.SelectedIndex){Controller.Select(hitIndex);RebuildHandles();QueueRedraw();ItemPicked?.Invoke(hitIndex);}else if(hitIndex<0&&selected?.HitArea.Type=="polygon"){var vertices=selected.HitArea.Vertices??Array.Empty<Vector2>();selected.HitArea.Vertices=vertices.Append(Clamp01(norm)).ToArray();RebuildHandles();QueueRedraw();Changed?.Invoke();}AcceptEvent();}/// <summary>Topmost item whose collider contains the normalised point, or -1.</summary>
private int HitTest(Vector2 norm){var rect=GetBackgroundRect();var local=NormToLocal(norm);for(var i=Controller.Items.Count-1;i>=0;i--){var hit=Controller.Items[i].HitArea;if(hit==null)continue;var inside=hit.Type switch{"circle"=>(local-NormToLocal(new Vector2(hit.X,hit.Y))).Length()<=hit.Radius*Mathf.Min(rect.Size.X,rect.Size.Y),"polygon"=>hit.Vertices is{Length:>=3}&&Geometry2D.IsPointInPolygon(norm,hit.Vertices),_=>new Rect2(hit.X,hit.Y,hit.Width,hit.Height).HasPoint(norm)};if(inside)return i;}return-1;}protected override void DrawContent(Rect2 rect){if(Controller==null)return;for(var i=0;i<Controller.Items.Count;i++)DrawCollider(Controller.Items[i],rect,i==Controller.SelectedIndex);}private void DrawCollider(InteractiveDefinition def,Rect2 rect,bool selected){var hit=def.HitArea;if(hit==null)return;var line=selected?UiTokens.AccentBright:UiTokens.Alpha(UiTokens.Ink,0.55f);var fill=selected?UiTokens.Alpha(UiTokens.Accent,0.22f):UiTokens.Alpha(UiTokens.Ink,0.06f);Vector2 labelAt;switch(hit.Type){case"circle":var centre=NormToLocal(new Vector2(hit.X,hit.Y));var radius=hit.Radius*Mathf.Min(rect.Size.X,rect.Size.Y);DrawCircle(centre,radius,fill);DrawArc(centre,radius,0f,Mathf.Tau,48,line,selected?2f:1f,true);labelAt=centre+new Vector2(-radius,-radius);break;case"polygon":var vertices=hit.Vertices??Array.Empty<Vector2>();var points=vertices.Select(NormToLocal).ToArray();if(points.Length>=3&&Geometry2D.TriangulatePolygon(points).Length>0)DrawColoredPolygon(points,fill);if(points.Length>=2)DrawPolyline(points.Append(points[0]).ToArray(),line,selected?2f:1f,true);labelAt=points.Length>0?points[0]:rect.Position;break;default:var box=new Rect2(NormToLocal(new Vector2(hit.X,hit.Y)),new Vector2(hit.Width,hit.Height)*rect.Size);DrawRect(box,fill);DrawRect(box,line,false,selected?2f:1f);labelAt=box.Position;break;}var font=GetThemeDefaultFont();var size=UiTokens.FontSmall;DrawString(font,labelAt+new Vector2(4f,-4f),def.ID??"?",HorizontalAlignment.Left,-1,size,selected?UiTokens.AccentBright:UiTokens.Body);}}}

===== FILE mods/editor_tools/src/InteractiveEditor/InteractiveEditorController.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Text.Json;using Cthangover.Core.Interactive;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Tools.InteractiveEditor{/// <summary> Data model of the interactive editor: every definition of one <c>interactives/*.json</c> file plus the selection. Definitions are edited in place (the same objects the game deserialises), so the file round-trips without a parallel field-by-field copy that could forget a property. <see cref="Ensure"/> fills the optional sub-objects the editor needs (hit area, highlight, actions) before a definition is shown. </summary>
public class InteractiveEditorController{/// <summary>Collider kinds understood by <c>InteractiveObject</c>.</summary>
public static readonly string[]HitTypes={"rect","circle","polygon"};/// <summary>Render layers understood by the interactive layer host.</summary>
public static readonly string[]Layers={"foreground","background","ui"};/// <summary>Definitions of the open file, in file order.</summary>
public List<InteractiveDefinition>Items{get;private set;}=new();/// <summary>Index of the selected definition, or -1.</summary>
public int SelectedIndex{get;private set;}= -1;/// <summary>The selected definition, or <c>null</c>.</summary>
public InteractiveDefinition Selected=>SelectedIndex>=0&&SelectedIndex<Items.Count?Items[SelectedIndex]:null;/// <summary>Replaces the items with the contents of a <c>{"Items": [...]}</c> file. Throws on malformed JSON.</summary>
public void FromJson(string json){var data=string.IsNullOrWhiteSpace(json)?null:JsonSerializer.Deserialize<FileData<InteractiveDefinition>>(json,JsonOptionsProvider.ReadOptions);Items=data?.Items??new List<InteractiveDefinition>();foreach(var item in Items)Ensure(item);SelectedIndex=Items.Count>0?0:-1;}/// <summary>Serialises all items into the standard envelope (indented, nulls omitted).</summary>
public string ToJson(){var data=new FileData<InteractiveDefinition>{Items=Items};return JsonSerializer.Serialize(data,JsonOptionsProvider.WriteIndentedIgnoreNullOptions);}/// <summary>Drops all items.</summary>
public void Clear(){Items=new List<InteractiveDefinition>();SelectedIndex= -1;}/// <summary>Selects by index; out of range clears the selection.</summary>
public void Select(int index){SelectedIndex=index>=0&&index<Items.Count?index:-1;}/// <summary>Appends a new clickable rect in the middle of the background and selects it.</summary>
public InteractiveDefinition Add(){var def=new InteractiveDefinition{ID=MakeUniqueId("interactive"),Cursor="PointingHand",HitArea=new HitAreaDefinition{Type="rect",X=0.45f,Y=0.45f,Width=0.1f,Height=0.1f}};Ensure(def);Items.Add(def);SelectedIndex=Items.Count-1;return def;}/// <summary>Appends a deep copy of the selected definition with a fresh ID, slightly offset.</summary>
public InteractiveDefinition DuplicateSelected(){var source=Selected;if(source==null)return null;var json=JsonSerializer.Serialize(source,JsonOptionsProvider.WriteCompactOptions);var copy=JsonSerializer.Deserialize<InteractiveDefinition>(json,JsonOptionsProvider.ReadOptions);copy.ID=MakeUniqueId(source.ID);Ensure(copy);Offset(copy.HitArea,new Vector2(0.02f,0.02f));Items.Add(copy);SelectedIndex=Items.Count-1;return copy;}/// <summary>Removes the selected definition and keeps a sensible selection.</summary>
public void RemoveSelected(){if(Selected==null)return;Items.RemoveAt(SelectedIndex);SelectedIndex=Items.Count==0? -1:Math.Min(SelectedIndex,Items.Count-1);}/// <summary> Changes the collider kind of a definition, converting the current shape so it stays roughly where it was (rect ↔ circle by bounds, polygon from the bounds' corners). </summary>
public static void SetHitType(InteractiveDefinition def,string type){var hit=def.HitArea;if(hit.Type==type)return;var bounds=Bounds(hit);hit.Type=type;switch(type){case"circle":hit.X=bounds.GetCenter().X;hit.Y=bounds.GetCenter().Y;hit.Radius=Mathf.Max(0.01f,Mathf.Min(bounds.Size.X,bounds.Size.Y)/2f);break;case"polygon":hit.Vertices=new[]{bounds.Position,new Vector2(bounds.End.X,bounds.Position.Y),bounds.End,new Vector2(bounds.Position.X,bounds.End.Y)};break;default:hit.X=bounds.Position.X;hit.Y=bounds.Position.Y;hit.Width=bounds.Size.X;hit.Height=bounds.Size.Y;break;}}/// <summary> Normalised bounding box of a hit area (circle radius taken as a fraction of both axes — an approximation good enough for conversions and duplication offsets). </summary>
public static Rect2 Bounds(HitAreaDefinition hit){switch(hit?.Type){case"circle":return new Rect2(hit.X-hit.Radius,hit.Y-hit.Radius,hit.Radius*2f,hit.Radius*2f);case"polygon"when hit.Vertices is{Length:>0}:var min=hit.Vertices[0];var max=hit.Vertices[0];foreach(var v in hit.Vertices){min=new Vector2(Mathf.Min(min.X,v.X),Mathf.Min(min.Y,v.Y));max=new Vector2(Mathf.Max(max.X,v.X),Mathf.Max(max.Y,v.Y));}return new Rect2(min,max-min);case null:return new Rect2(0.45f,0.45f,0.1f,0.1f);default:return new Rect2(hit.X,hit.Y,hit.Width,hit.Height);}}/// <summary>Makes sure the optional parts the editor binds to exist.</summary>
public static void Ensure(InteractiveDefinition def){def.HitArea??=new HitAreaDefinition();def.HitArea.Type=HitTypes.Contains(def.HitArea.Type)?def.HitArea.Type:"rect";def.Highlight??=new HighlightDefinition();def.Actions??=new InteractiveActionDefinition();}/// <summary>ID not used by another item: <c>base</c>, <c>base_2</c>, <c>base_3</c>…</summary>
public string MakeUniqueId(string baseId){var used=new HashSet<string>(Items.Select(i=>i.ID??string.Empty),StringComparer.OrdinalIgnoreCase);if(!used.Contains(baseId))return baseId;var suffix=2;while(used.Contains($"{baseId}_{suffix}"))suffix++;return$"{baseId}_{suffix}";}private static void Offset(HitAreaDefinition hit,Vector2 delta){hit.X+=delta.X;hit.Y+=delta.Y;if(hit.Vertices!=null)hit.Vertices=hit.Vertices.Select(v=>v+delta).ToArray();}}}

===== FILE mods/editor_tools/src/InteractiveEditor/InteractiveEditorToolProvider.cs =====
using Cthangover.Core.UI.Tool;using Godot;using Cthangover.Mods.Interface.Tool;namespace Cthangover.Tools.InteractiveEditor{/// <summary> Registers the interactive editor as a tool window in the developer toolbox. Implements <see cref="IToolProvider"/> so the tool system can instantiate <see cref="InteractiveEditorWindow"/> on demand. </summary>
public class InteractiveEditorToolProvider:IToolProvider{/// <summary>Unique tool identifier referenced by the toolbox UI.</summary>
public string Id=>"interactive_editor";/// <summary>Translation key for the tool's display name.</summary>
public string LocaleKey=>"tools/interactive_editor/title";/// <summary>Creates the editor window instance.</summary>
public Window CreateWindow()=>new InteractiveEditorWindow();/// <summary>Adds the interactive editor's launcher button to the main menu tool block.</summary>
public void AddMenuButton(Container container)=>ToolMenuButton.Attach(container,this);}/// <summary> Provides a button in the developer toolbox that opens the interactive editor. Controls visibility via the <see cref="IsVisible"/> policy. </summary>
public class InteractiveEditorToolBoxButton:IToolBoxButton{/// <summary>Matches <see cref="InteractiveEditorToolProvider.Id"/>.</summary>
public string ToolId=>"interactive_editor";/// <summary>No custom icon — uses the tool title text.</summary>
public string IconPath=>"";/// <summary>Translation key for the button label.</summary>
public string LocaleKey=>"tools/interactive_editor/title";/// <summary>Always visible in the toolbox.</summary>
public bool IsVisible()=>true;}}

===== FILE mods/editor_tools/src/InteractiveEditor/InteractiveEditorWindow.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Interactive;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Cthangover.Mods.Interface.Theming;using Cthangover.Mods.Interface.Tool;using Cthangover.Tools.Common;using Cthangover.Tools.Services;using Godot;namespace Cthangover.Tools.InteractiveEditor{/// <summary> Editor of interactive object definitions (<c>interactives/*.json</c>). The author opens a file of a writable mod, sees every object of the file over its scene background, picks one in the list or on the canvas and edits its collider by dragging handles or typing numbers, plus its look (texture, layer, highlight) and actions (scenario, commands, hover commands). Saving writes the whole file back, other objects included. <para/> The background is chosen automatically from the scene whose scenario adds the selected object (<c>interactive_add</c>), when that is known. </summary>
public partial class InteractiveEditorWindow:ToolWindow{private const string InteractivesDir="interactives";private readonly InteractiveEditorController _controller=new();private readonly List<string>_backgroundIds=new();private Dictionary<string,string>_interactiveScenes;private string _modId;private string _path;private bool _suppress;private ModFileSelector _fileSelector;private FilteredList _itemList;private FilteredList _bgList;private InteractiveEditorCanvas _canvas;private CodeEdit _jsonEdit;private Label _propsEmpty;private VBoxContainer _propsBody;private LineEdit _idEdit;private LineEdit _textureEdit;private OptionButton _layerSelect;private SpinBox _zIndex;private CheckBox _enabledCheck;private CheckBox _visibleCheck;private OptionButton _cursorSelect;private OptionButton _hitTypeSelect;private VBoxContainer _rectParams;private VBoxContainer _circleParams;private VBoxContainer _polyParams;private SpinBox _rectX,_rectY,_rectW,_rectH;private SpinBox _circleX,_circleY,_circleR;private VBoxContainer _vertexList;private ColorPickerButton _highlightColor;private SpinBox _highlightScale;private LineEdit _clickScenario;private CodeEdit _clickCommands;private LineEdit _hoverEnter;private LineEdit _hoverLeave;public InteractiveEditorWindow():base("tools/interactive_editor/title"){CthangoverTheme.Apply(this);BuildUI();LoadBackgrounds();SetHint(ToolUi.Tr("tools/interactive_editor/hint"));_fileSelector.Refresh();}// ── UI construction ────────────────────────────────────────
private void BuildUI(){var outer=CreateFillContainer();AddChild(outer);var toolbar=CreateToolbar();outer.AddChild(toolbar);_fileSelector=new ModFileSelector(InteractivesDir,".json");_fileSelector.FileRequested+=OnFileRequested;toolbar.AddChild(_fileSelector);toolbar.AddChild(ToolUi.Button("tools/common/save",Save,ButtonKind.Primary,"tools/common/save_tip"));toolbar.AddChild(UiKit.Spacer());var jsonToggle=ToolUi.Button("tools/common/json",null,ButtonKind.Tab,"tools/common/json_tip");jsonToggle.ToggleMode=true;jsonToggle.Toggled+=on=>_jsonEdit.Visible=on;toolbar.AddChild(jsonToggle);var main=new HSplitContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};outer.AddChild(main);var left=new VBoxContainer();main.AddChild(CreateSidebar(left,260));BuildLeft(left);var centerSplit=new HSplitContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill};main.AddChild(centerSplit);var canvasColumn=new VSplitContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};centerSplit.AddChild(canvasColumn);var canvasPanel=new PanelContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};canvasPanel.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusMd,UiTokens.Well,UiTokens.Line));canvasColumn.AddChild(canvasPanel);_canvas=new InteractiveEditorCanvas{Controller=_controller};_canvas.Changed+=OnCanvasChanged;_canvas.ItemPicked+=_=>RefreshAll();canvasPanel.AddChild(_canvas);_jsonEdit=ToolUi.Code(editable:false);_jsonEdit.CustomMinimumSize=new Vector2(0,160);_jsonEdit.Visible=false;canvasColumn.AddChild(_jsonEdit);var right=new VBoxContainer();centerSplit.AddChild(CreateSidebar(right,330));BuildProperties(right);}private void BuildLeft(VBoxContainer column){column.AddChild(ToolUi.Section("tools/interactive_editor/items"));_itemList=new FilteredList(180);_itemList.Chosen+=OnItemChosen;column.AddChild(_itemList);var row=new HBoxContainer();row.AddThemeConstantOverride("separation",UiTokens.Space1);column.AddChild(row);AddExpanding(row,ToolUi.Button("tools/interactive_editor/add",OnAdd));AddExpanding(row,ToolUi.Button("tools/interactive_editor/duplicate",OnDuplicate));column.AddChild(ToolUi.Button("tools/interactive_editor/delete",OnDelete,ButtonKind.Danger));column.AddChild(ToolUi.Section("tools/interactive_editor/background"));_bgList=new FilteredList(160);_bgList.SizeFlagsVertical=Control.SizeFlags.ExpandFill;_bgList.Chosen+=ShowBackground;column.AddChild(_bgList);}private static void AddExpanding(Container parent,Control control){control.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;parent.AddChild(control);}private void BuildProperties(VBoxContainer column){column.AddChild(ToolUi.Section("tools/interactive_editor/props"));_propsEmpty=ToolUi.Hint(ToolUi.Tr("tools/interactive_editor/none_selected"));column.AddChild(_propsEmpty);var body=new VBoxContainer();body.AddThemeConstantOverride("separation",UiTokens.Space2);column.AddChild(body);_propsBody=body;_idEdit=ToolUi.Field(body,"tools/interactive_editor/label_id",ToolUi.Text("crypt_spot",_=>OnIdChanged()));_idEdit.AddThemeFontOverride("font",ToolUi.Mono());_textureEdit=ToolUi.Field(body,"tools/interactive_editor/label_texture_key",ToolUi.Text(ToolUi.Tr("tools/interactive_editor/texture_placeholder"),_=>OnFieldsChanged()),"tools/interactive_editor/texture_tip");_layerSelect=ToolUi.Field(body,"tools/interactive_editor/label_layer",new OptionButton());foreach(var layer in InteractiveEditorController.Layers)_layerSelect.AddItem(layer);_layerSelect.ItemSelected+=_=>OnFieldsChanged();_zIndex=ToolUi.Field(body,"tools/interactive_editor/label_z",ToolUi.Number(-100,100,1,_=>OnFieldsChanged()));_cursorSelect=ToolUi.Field(body,"tools/interactive_editor/label_cursor",new OptionButton());_cursorSelect.AddItem(ToolUi.Tr("tools/interactive_editor/cursor_none"));_cursorSelect.AddItem("PointingHand");_cursorSelect.ItemSelected+=_=>OnFieldsChanged();_enabledCheck=new CheckBox{Text=ToolUi.Tr("tools/interactive_editor/enabled")};_enabledCheck.Toggled+=_=>OnFieldsChanged();body.AddChild(_enabledCheck);_visibleCheck=new CheckBox{Text=ToolUi.Tr("tools/interactive_editor/visible")};_visibleCheck.Toggled+=_=>OnFieldsChanged();body.AddChild(_visibleCheck);body.AddChild(ToolUi.Section("tools/interactive_editor/collider"));_hitTypeSelect=ToolUi.Field(body,"tools/interactive_editor/label_collider_type",new OptionButton());foreach(var type in InteractiveEditorController.HitTypes)_hitTypeSelect.AddItem(HitTypeLabel(type));_hitTypeSelect.ItemSelected+=OnHitTypeChanged;_rectParams=new VBoxContainer();body.AddChild(_rectParams);_rectX=ToolUi.Field(_rectParams,"tools/interactive_editor/label_x",ToolUi.Number(0,1,0.001,_=>OnGeometryTyped()));_rectY=ToolUi.Field(_rectParams,"tools/interactive_editor/label_y",ToolUi.Number(0,1,0.001,_=>OnGeometryTyped()));_rectW=ToolUi.Field(_rectParams,"tools/interactive_editor/label_w",ToolUi.Number(0.001,1,0.001,_=>OnGeometryTyped()));_rectH=ToolUi.Field(_rectParams,"tools/interactive_editor/label_h",ToolUi.Number(0.001,1,0.001,_=>OnGeometryTyped()));_circleParams=new VBoxContainer();body.AddChild(_circleParams);_circleX=ToolUi.Field(_circleParams,"tools/interactive_editor/label_cx",ToolUi.Number(0,1,0.001,_=>OnGeometryTyped()));_circleY=ToolUi.Field(_circleParams,"tools/interactive_editor/label_cy",ToolUi.Number(0,1,0.001,_=>OnGeometryTyped()));_circleR=ToolUi.Field(_circleParams,"tools/interactive_editor/label_r",ToolUi.Number(0.001,1,0.001,_=>OnGeometryTyped()));_polyParams=new VBoxContainer();body.AddChild(_polyParams);_polyParams.AddChild(ToolUi.Hint(ToolUi.Tr("tools/interactive_editor/polygon_hint")));_vertexList=new VBoxContainer();_vertexList.AddThemeConstantOverride("separation",UiTokens.Space1);_polyParams.AddChild(_vertexList);body.AddChild(ToolUi.Section("tools/interactive_editor/highlight"));_highlightColor=ToolUi.Field(body,"tools/interactive_editor/label_highlight_color",new ColorPickerButton{CustomMinimumSize=new Vector2(0,28)});_highlightColor.ColorChanged+=_=>OnFieldsChanged();_highlightScale=ToolUi.Field(body,"tools/interactive_editor/label_highlight_scale",ToolUi.Number(0.5,2,0.01,_=>OnFieldsChanged()));body.AddChild(ToolUi.Section("tools/interactive_editor/actions"));_clickScenario=ToolUi.Field(body,"tools/interactive_editor/label_onclick_scenario",ToolUi.Text("scenarios/…/file.scenario",_=>OnFieldsChanged()));body.AddChild(UiKit.Label(ToolUi.Tr("tools/interactive_editor/label_onclick_commands"),UiTokens.FontBodySmall,UiTokens.Muted));_clickCommands=ToolUi.Code();_clickCommands.GuttersDrawLineNumbers=false;_clickCommands.CustomMinimumSize=new Vector2(0,70);_clickCommands.SyntaxHighlighter=ScenarioSyntaxService.CreateHighlighter();_clickCommands.TextChanged+=OnFieldsChanged;body.AddChild(_clickCommands);_hoverEnter=ToolUi.Field(body,"tools/interactive_editor/label_hover_enter",ToolUi.Text("",_=>OnFieldsChanged()));_hoverLeave=ToolUi.Field(body,"tools/interactive_editor/label_hover_leave",ToolUi.Text("",_=>OnFieldsChanged()));}// ── loading & saving ───────────────────────────────────────
private void LoadBackgrounds(){_backgroundIds.AddRange(ModResourceService.GetBackgroundIds());_bgList.SetItems(_backgroundIds);}private void ShowBackground(string id){if(string.IsNullOrEmpty(id))return;_bgList.Select(id);_canvas.SetBackground(ModResourceService.LoadBackgroundTexture(id));}/// <summary>Shows the background of the scene that adds the selected object, when known.</summary>
private void ShowSceneBackground(){var id=_controller.Selected?.ID;if(id==null)return;_interactiveScenes??=SceneBackgroundService.MapInteractivesToScenes();if(!_interactiveScenes.TryGetValue(id,out var scene))return;var background=SceneBackgroundService.ForScene(scene);if(background==null||background==_bgList.SelectedId)return;if(ModResourceService.LoadBackgroundTexture(background)==null)SetStatus(string.Format(ToolUi.Tr("tools/common/background_missing"),background,scene),ToolStatusKind.Error);else ShowBackground(background);}private void OnFileRequested(string modId,string path){RunDiscardingChanges(()=>Load(modId,path),()=>_fileSelector.Show(_modId,_path));}private void Load(string modId,string path){_modId=modId;_path=path;_controller.Clear();MarkClean();if(modId==null)SetStatus(ToolUi.Tr("tools/common/no_writable_mods"),ToolStatusKind.Error);else if(path==null)SetStatus(string.Format(ToolUi.Tr("tools/common/no_files"),modId));else{try{var text=ModFileService.ReadText(modId,path);_controller.FromJson(text);SetStatus(string.Format(ToolUi.Tr(string.IsNullOrEmpty(text)?"tools/common/new_file_status":"tools/common/loaded"),modId+"/"+path));}catch(Exception ex){SetStatus($"{ToolUi.Tr("tools/common/load_error")}: {ex.Message}",ToolStatusKind.Error);GameLogger.Log("INTERACTIVE_EDITOR",$"load failed for '{modId}/{path}': {ex.Message}",LogLevel.Error);}}_fileSelector.Show(_modId,_path);RefreshAll();ShowSceneBackground();}/// <summary>Ctrl+S.</summary>
protected override void OnSaveShortcut()=>Save();private void Save(){if(_path==null){SetStatus(ToolUi.Tr("tools/common/no_file_to_save"),ToolStatusKind.Error);return;}var problem=Validate();if(problem!=null){SetStatus(problem,ToolStatusKind.Error);return;}if(!ModFileService.WriteText(_modId,_path,_controller.ToJson())){SetStatus(ToolUi.Tr("tools/common/readonly"),ToolStatusKind.Error);return;}MarkClean();_fileSelector.Show(_modId,_path);SetStatus(string.Format(ToolUi.Tr("tools/common/saved"),_modId+"/"+_path),ToolStatusKind.Ok);}/// <summary>First blocking problem of the file (empty/duplicate ID, polygon with &lt; 3 vertices), or null.</summary>
private string Validate(){foreach(var item in _controller.Items){if(string.IsNullOrWhiteSpace(item.ID))return ToolUi.Tr("tools/interactive_editor/id_empty");if(item.HitArea.Type=="polygon"&&(item.HitArea.Vertices?.Length??0)<3)return string.Format(ToolUi.Tr("tools/interactive_editor/polygon_too_small"),item.ID);}var duplicate=_controller.Items.GroupBy(i=>i.ID,StringComparer.OrdinalIgnoreCase).FirstOrDefault(g=>g.Count()>1);return duplicate!=null?$"{ToolUi.Tr("tools/interactive_editor/duplicate_id")} {duplicate.Key}":null;}// ── list actions ───────────────────────────────────────────
private void OnItemChosen(string indexText){if(_suppress|| !int.TryParse(indexText,out var index))return;_controller.Select(index);RefreshAll();ShowSceneBackground();}private void OnAdd(){if(_path==null){SetStatus(ToolUi.Tr("tools/common/no_file_to_save"),ToolStatusKind.Error);return;}_controller.Add();Changed();RefreshAll();}private void OnDuplicate(){if(_controller.DuplicateSelected()==null)return;Changed();RefreshAll();}private void OnDelete(){var item=_controller.Selected;if(item==null)return;ShowConfirm("tools/interactive_editor/delete","tools/interactive_editor/delete_confirm",()=>{_controller.RemoveSelected();Changed();RefreshAll();SetStatus(string.Format(ToolUi.Tr("tools/interactive_editor/deleted"),item.ID));});}/// <summary>Delete removes the selected object (with confirmation).</summary>
protected override bool HandleKey(InputEventKey key){if(key.Keycode!=Key.Delete||_controller.Selected==null)return false;OnDelete();return true;}// ── property editing ───────────────────────────────────────
private void OnIdChanged(){var item=_controller.Selected;if(_suppress||item==null)return;item.ID=_idEdit.Text.Trim();Changed();RefreshItemList();}private void OnFieldsChanged(){var item=_controller.Selected;if(_suppress||item==null)return;item.Texture=NullIfEmpty(_textureEdit.Text);item.Layer=InteractiveEditorController.Layers[Math.Max(0,_layerSelect.Selected)];item.ZIndex=(int)_zIndex.Value;item.Enabled=_enabledCheck.ButtonPressed;item.Visible=_visibleCheck.ButtonPressed;item.Cursor=_cursorSelect.Selected==1?"PointingHand":null;item.Highlight.ColorHex="#"+_highlightColor.Color.ToHtml(true);item.Highlight.Scale=(float)_highlightScale.Value;var commands=_clickCommands.Text.Split('\n').Select(l=>l.Trim()).Where(l=>l.Length>0).ToArray();var scenario=NullIfEmpty(_clickScenario.Text);item.Actions.OnClick=scenario!=null||commands.Length>0?new ClickAction{Scenario=scenario,Commands=commands.Length>0?commands:null}:null;item.Actions.OnHoverEnter=NullIfEmpty(_hoverEnter.Text);item.Actions.OnHoverLeave=NullIfEmpty(_hoverLeave.Text);Changed();}private void OnHitTypeChanged(long index){var item=_controller.Selected;if(_suppress||item==null)return;InteractiveEditorController.SetHitType(item,InteractiveEditorController.HitTypes[index]);Changed();RefreshAll();}private void OnGeometryTyped(){var hit=_controller.Selected?.HitArea;if(_suppress||hit==null)return;if(hit.Type=="circle"){hit.X=(float)_circleX.Value;hit.Y=(float)_circleY.Value;hit.Radius=(float)_circleR.Value;}else if(hit.Type=="rect"){hit.X=(float)_rectX.Value;hit.Y=(float)_rectY.Value;hit.Width=(float)_rectW.Value;hit.Height=(float)_rectH.Value;}Changed();_canvas.RebuildHandles();_canvas.QueueRedraw();}private void OnCanvasChanged(){Changed();_suppress=true;WriteGeometry(_controller.Selected?.HitArea);_suppress=false;}private void Changed(){SetDirty();if(_jsonEdit.Visible)_jsonEdit.Text=_controller.ToJson();}// ── refresh ────────────────────────────────────────────────
private void RefreshAll(){RefreshItemList();_suppress=true;WriteSidebar();_suppress=false;_jsonEdit.Text=_controller.ToJson();_canvas.RebuildHandles();_canvas.QueueRedraw();}private void RefreshItemList(){var items=_controller.Items.Select((item,i)=>(i.ToString(),(string.IsNullOrEmpty(item.ID)?"?":item.ID)+"  ·  "+HitTypeLabel(item.HitArea.Type),(Texture2D)null));_itemList.SetItems(items);_itemList.Select(_controller.SelectedIndex>=0?_controller.SelectedIndex.ToString():null);}private void WriteSidebar(){var item=_controller.Selected;_propsEmpty.Visible=item==null;_propsBody.Visible=item!=null;if(item==null)return;_idEdit.Text=item.ID??string.Empty;_textureEdit.Text=item.Texture??string.Empty;_layerSelect.Select(Math.Max(0,Array.IndexOf(InteractiveEditorController.Layers,item.Layer)));_zIndex.Value=item.ZIndex;_enabledCheck.ButtonPressed=item.Enabled;_visibleCheck.ButtonPressed=item.Visible;_cursorSelect.Select(string.IsNullOrEmpty(item.Cursor)?0:1);_hitTypeSelect.Select(Math.Max(0,Array.IndexOf(InteractiveEditorController.HitTypes,item.HitArea.Type)));_highlightColor.Color=item.Highlight.Color;_highlightScale.Value=item.Highlight.Scale;_clickScenario.Text=item.Actions.OnClick?.Scenario??string.Empty;_clickCommands.Text=item.Actions.OnClick?.Commands!=null?string.Join("\n",item.Actions.OnClick.Commands):string.Empty;_hoverEnter.Text=item.Actions.OnHoverEnter??string.Empty;_hoverLeave.Text=item.Actions.OnHoverLeave??string.Empty;WriteGeometry(item.HitArea);}private void WriteGeometry(HitAreaDefinition hit){if(hit==null)return;_rectParams.Visible=hit.Type=="rect";_circleParams.Visible=hit.Type=="circle";_polyParams.Visible=hit.Type=="polygon";_rectX.Value=hit.X;_rectY.Value=hit.Y;_rectW.Value=hit.Width;_rectH.Value=hit.Height;_circleX.Value=hit.X;_circleY.Value=hit.Y;_circleR.Value=hit.Radius;RefreshVertexList(hit);}/// <summary>X/Y spin rows per polygon vertex; handlers read the vertex at change time.</summary>
private void RefreshVertexList(HitAreaDefinition hit){var vertices=hit.Type=="polygon"?hit.Vertices??Array.Empty<Vector2>():Array.Empty<Vector2>();var rows=_vertexList.GetChildren();// Reuse rows while dragging so focus and scroll are not lost on every motion.
if(rows.Count==vertices.Length){for(var i=0;i<vertices.Length;i++){var row=rows[i];((SpinBox)row.GetChild(1)).SetValueNoSignal(vertices[i].X);((SpinBox)row.GetChild(2)).SetValueNoSignal(vertices[i].Y);}return;}foreach(var child in rows)child.QueueFree();for(var i=0;i<vertices.Length;i++){var index=i;var row=new HBoxContainer();row.AddThemeConstantOverride("separation",UiTokens.Space1);var number=UiKit.Label((i+1).ToString(),UiTokens.FontSmall,UiTokens.Faint);number.CustomMinimumSize=new Vector2(20,0);row.AddChild(number);var x=ToolUi.Number(0,1,0.001,null);x.Value=vertices[i].X;x.ValueChanged+=v=>OnVertexTyped(index,(float)v,null);row.AddChild(x);var y=ToolUi.Number(0,1,0.001,null);y.Value=vertices[i].Y;y.ValueChanged+=v=>OnVertexTyped(index,null,(float)v);row.AddChild(y);_vertexList.AddChild(row);}}private void OnVertexTyped(int index,float?x,float?y){var hit=_controller.Selected?.HitArea;if(_suppress||hit?.Vertices==null||index>=hit.Vertices.Length)return;var v=hit.Vertices[index];hit.Vertices[index]=new Vector2(x??v.X,y??v.Y);Changed();_canvas.RebuildHandles();_canvas.QueueRedraw();}private static string HitTypeLabel(string type)=>ToolUi.Tr(type switch{"circle"=>"tools/interactive_editor/hit_circle","polygon"=>"tools/interactive_editor/hit_polygon",_=>"tools/interactive_editor/hit_rect"});private static string NullIfEmpty(string text){var trimmed=text?.Trim();return string.IsNullOrEmpty(trimmed)?null:trimmed;}}}

===== FILE mods/editor_tools/src/LightEditor/LightEditorCanvas.cs =====
using System;using Cthangover.Core.UI.Theming;using Cthangover.Tools.Common;using Godot;namespace Cthangover.Core.UI.Tool.LightEditor{/// <summary> Night-time preview of a scene with its static lights. The background is drawn through the game's <c>timed_sprite</c> shader (set as this control's material), so the preview lights the picture exactly like the game; the lights' positions are pushed to the shader in this canvas' coordinates and their radii scaled from the 1920-px game width to the drawn background width. Radius rings and handles live in a separate child overlay — anything drawn by this control itself would be shaded. <para/> A click on a handle selects that light and drags it; a double-click on empty background adds a light there. </summary>
public partial class LightEditorCanvas:BackgroundCanvas{private readonly Control _overlay;/// <summary>Data being edited; set once by the window.</summary>
public LightEditorController Controller;/// <summary>Preview material (may be null when the shader is missing).</summary>
public ShaderMaterial PreviewMaterial{get=>Material as ShaderMaterial;set=>Material=value;}/// <summary>Raised when a light moved or was added on the canvas.</summary>
public event Action Changed;/// <summary>Raised when the selection changed on the canvas.</summary>
public event Action SelectionChanged;public LightEditorCanvas(){_overlay=new Control{MouseFilter=MouseFilterEnum.Ignore};AddChild(_overlay);_overlay.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);_overlay.Connect("draw",Callable.From((Action)DrawRings));GuiInput+=OnGuiInput;}/// <summary>One handle per light, coloured like the light.</summary>
public override void RebuildHandles(){ClearHandles();if(Controller==null||GetBackgroundRect().Size.X<=0f)return;for(var i=0;i<Controller.Lights.Count;i++){var index=i;var light=Controller.Lights[i];var handle=AddHandle(new Vector2(light.X,light.Y),light.ToColor(),i,p=>{light.X=p.X;light.Y=p.Y;Refresh();Changed?.Invoke();},index==Controller.SelectedIndex?20f:16f);handle.Clicked=()=>SelectLight(index);}Refresh();}/// <summary>Redraws the preview and re-uploads the shader uniforms.</summary>
public void Refresh(){QueueRedraw();_overlay.QueueRedraw();}private void SelectLight(int index){if(index==Controller.SelectedIndex)return;Controller.Select(index);SelectionChanged?.Invoke();CallDeferred(nameof(RebuildHandles));}private void OnGuiInput(InputEvent@event){if(Controller==null||@event is not InputEventMouseButton{Pressed:true,ButtonIndex:MouseButton.Left}mb)return;if(mb.DoubleClick&&Controller.Add(Clamp01(LocalToNorm(mb.Position)))!=null){RebuildHandles();Changed?.Invoke();SelectionChanged?.Invoke();}AcceptEvent();}protected override void DrawContent(Rect2 rect){// The shader compares against world_pos (MODEL_MATRIX * VERTEX), i.e. canvas coordinates of the window, not this control's local ones.
Controller?.UpdatePreview(PreviewMaterial,n=>GlobalPosition+NormToLocal(n),rect.Size.X/LightEditorController.GameWidth);}private void DrawRings(){var rect=GetBackgroundRect();if(Controller==null||rect.Size.X<=0f)return;var scale=rect.Size.X/LightEditorController.GameWidth;for(var i=0;i<Controller.Lights.Count;i++){var light=Controller.Lights[i];var selected=i==Controller.SelectedIndex;var centre=NormToLocal(new Vector2(light.X,light.Y));var color=selected?UiTokens.AccentBright:UiTokens.Alpha(light.ToColor(),0.45f);_overlay.DrawArc(centre,light.Radius*scale,0f,Mathf.Tau,64,color,selected?1.5f:1f,true);}}}}

===== FILE mods/editor_tools/src/LightEditor/LightEditorController.cs =====
using System;using System.Collections.Generic;using System.Text.Json;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Cthangover.Mods.Time;using Godot;namespace Cthangover.Core.UI.Tool.LightEditor{/// <summary> Data of the light editor: the static lights of one scenario and their place in the scenario text. Static lights live in a <c>light_set</c> line (<c>light_set [{"x":…,"y":…,"radius":…,"influence":…,"color":…}]</c>) that <c>UiLightController.SetStaticLights</c> reads at runtime: x/y are fractions of the viewport, radius is in viewport pixels at the game resolution. The controller parses that line, edits the list and writes the line back (or inserts one right after the scenario header), and feeds the <c>timed_sprite</c> shader of the preview the same way the game does, so the preview shows the real night lighting. </summary>
public class LightEditorController{/// <summary>Static light slots the shader has (slot 0 is the dynamic light).</summary>
public const int MaxLights=10;/// <summary>Viewport width the stored radii refer to.</summary>
public const float GameWidth=1920f;private const string Command="light_set";private const int ShaderSlots=11;private static readonly Vector2 Offscreen=new(-1000f,-1000f);/// <summary>The edited lights, in file order.</summary>
public List<LightDef>Lights{get;private set;}=new();/// <summary>Index of the selected light, or -1.</summary>
public int SelectedIndex{get;private set;}= -1;/// <summary>The selected light, or <c>null</c>.</summary>
public LightDef Selected=>SelectedIndex>=0&&SelectedIndex<Lights.Count?Lights[SelectedIndex]:null;/// <summary>Selects by index; out of range clears the selection.</summary>
public void Select(int index)=>SelectedIndex=index>=0&&index<Lights.Count?index:-1;/// <summary>Adds a warm light at the given normalised point and selects it; null when all slots are used.</summary>
public LightDef Add(Vector2 position){if(Lights.Count>=MaxLights)return null;var light=new LightDef{X=position.X,Y=position.Y,Radius=400f,Influence=1f,ColorHex="#ffcc66"};Lights.Add(light);SelectedIndex=Lights.Count-1;return light;}/// <summary>Removes the selected light.</summary>
public void RemoveSelected(){if(Selected==null)return;Lights.RemoveAt(SelectedIndex);SelectedIndex=Math.Min(SelectedIndex,Lights.Count-1);}/// <summary>Compact JSON array exactly as written into the <c>light_set</c> line.</summary>
public string ToJson()=>JsonSerializer.Serialize(Lights,JsonOptionsProvider.WriteCompactOptions);/// <summary>The scenario line for the current lights.</summary>
public string ToScenarioLine()=>Command+" "+ToJson();/// <summary> Loads the lights of the first <c>light_set</c> line of a scenario. Returns <c>false</c> when the scenario has none (the list is then empty and saving inserts a new line). Throws on malformed JSON. </summary>
public bool LoadFromScenario(string text){Lights=new List<LightDef>();SelectedIndex= -1;var index=FindLine(text,out var lines);if(index<0)return false;var json=ArgumentOf(lines[index]);Lights=JsonSerializer.Deserialize<List<LightDef>>(json,JsonOptionsProvider.ReadOptions)??new List<LightDef>();SelectedIndex=Lights.Count>0?0:-1;return true;}/// <summary> Returns the scenario text with its <c>light_set</c> line replaced by the current lights, or — when it has none — with a new line inserted right after the <c>---</c> header separator (at the top otherwise). </summary>
public string WriteToScenario(string text){var index=FindLine(text,out var lines);var newline=text.Contains("\r\n")?"\r\n":"\n";if(index>=0){var indent=lines[index].Substring(0,lines[index].Length-lines[index].TrimStart().Length);lines[index]=indent+ToScenarioLine();}else{var separator=lines.FindIndex(l=>l.Trim()=="---");lines.Insert(separator+1,ToScenarioLine());}return string.Join(newline,lines);}/// <summary>True when the scenario text contains a <c>light_set</c> line.</summary>
public static bool HasLights(string text)=>FindLine(text,out _)>=0;private static int FindLine(string text,out List<string>lines){lines=new List<string>((text??string.Empty).Replace("\r\n","\n").Split('\n'));return lines.FindIndex(l=>l.TrimStart().StartsWith(Command+" ",StringComparison.Ordinal));}/// <summary>Argument of a <c>light_set</c> line; the old quoted form (<c>"[{\"x\":…}]"</c>) is unescaped.</summary>
private static string ArgumentOf(string line){var arg=line.Trim().Substring(Command.Length).Trim();if(arg.Length>=2&&arg[0]=='"'&&arg[arg.Length-1]=='"')arg=arg.Substring(1,arg.Length-2).Replace("\\\"","\"");return arg;}// ── shader preview ───────────────────────────────────────── <summary> Material of the preview: the game's <c>timed_sprite</c> shader at night weights. <c>null</c> when the shader is not installed. </summary>
public static ShaderMaterial CreatePreviewMaterial(){var shader=Shaders.Resolve("timed_sprite");if(shader==null)return null;var material=new ShaderMaterial{Shader=shader};material.SetShaderParameter("time_weights",new Vector4(0,0,0,1));material.SetShaderParameter("use_time",true);return material;}/// <summary>Binds the background and its optional depth/albedo masks to the preview material.</summary>
public static void SetPreviewTextures(ShaderMaterial material,Texture2D depth,Texture2D albedo){if(material==null)return;material.SetShaderParameter("depth_mask",depth);material.SetShaderParameter("albedo_map",albedo);}/// <summary> Uploads the lights into the shader uniforms. <paramref name="toCanvas"/> maps a normalised position into the preview's canvas coordinates (the space of the shader's <c>world_pos</c>); <paramref name="radiusScale"/> shrinks game-resolution radii to the preview size. </summary>
public void UpdatePreview(ShaderMaterial material,Func<Vector2,Vector2>toCanvas,float radiusScale){if(material==null)return;var positions=new Godot.Collections.Array<Vector2>();var colors=new Godot.Collections.Array<Color>();var radii=new Godot.Collections.Array<float>();var influence=new Godot.Collections.Array<float>();// Slot 0 is the dynamic (cursor) light at runtime; keep it off.
positions.Add(Offscreen);colors.Add(UiTokens.White);radii.Add(0f);influence.Add(0f);var count=Math.Min(Lights.Count,MaxLights);for(var i=0;i<ShaderSlots-1;i++){var on=i<count;positions.Add(on?toCanvas(new Vector2(Lights[i].X,Lights[i].Y)):Offscreen);colors.Add(on?Lights[i].ToColor():UiTokens.White);radii.Add(on?Lights[i].Radius:0f);influence.Add(on?Lights[i].Influence:0f);}material.SetShaderParameter("light_count",count+1);material.SetShaderParameter("light_positions",positions);material.SetShaderParameter("light_colors",colors);material.SetShaderParameter("light_radii",radii);material.SetShaderParameter("light_influence",influence);material.SetShaderParameter("light_radius_scale",radiusScale);}}}

===== FILE mods/editor_tools/src/LightEditor/LightEditorToolProvider.cs =====
using Cthangover.Core.UI.Tool;using Godot;using Cthangover.Mods.Interface.Tool;namespace Cthangover.Core.UI.Tool.LightEditor{/// <summary>Registers the light editor as a tool window. Creates <see cref="LightEditorWindow"/> instances.</summary>
public class LightEditorToolProvider:IToolProvider{/// <summary>Unique tool identifier.</summary>
public string Id=>"light_editor";/// <summary>Translation key for the tool's display name.</summary>
public string LocaleKey=>"tools/light_editor/title";/// <summary>Creates the editor window instance.</summary>
public Window CreateWindow()=>new LightEditorWindow();/// <summary>Adds the light editor's launcher button to the main menu tool block.</summary>
public void AddMenuButton(Container container)=>ToolMenuButton.Attach(container,this);}/// <summary>Toolbox button that opens the light editor. Always visible.</summary>
public class LightEditorToolBoxButton:IToolBoxButton{/// <summary>Matches <see cref="LightEditorToolProvider.Id"/>.</summary>
public string ToolId=>"light_editor";/// <summary>No custom icon — uses text label.</summary>
public string IconPath=>"";/// <summary>Translation key for the button label.</summary>
public string LocaleKey=>"tools/light_editor/title";/// <summary>Always visible in the toolbox.</summary>
public bool IsVisible()=>true;}}

===== FILE mods/editor_tools/src/LightEditor/LightEditorWindow.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Factories.Impls;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Cthangover.Mods.Interface.Theming;using Cthangover.Mods.Interface.Tool;using Cthangover.Tools.Common;using Cthangover.Tools.Services;using Godot;namespace Cthangover.Core.UI.Tool.LightEditor{/// <summary> Editor of the static lights of a scene. The author picks a scenario — the ones that already set lights come first — and edits its <c>light_set</c> line visually: drag lights on a night-time preview of the scene background, double-click to add one, tune radius, influence and colour. Saving rewrites that line in the scenario (or inserts it after the header). The background follows the scenario's scene. </summary>
public partial class LightEditorWindow:ToolWindow{private readonly LightEditorController _controller=new();private readonly List<ScenarioFileInfo>_scenarios=new();private readonly List<string>_backgroundIds=new();private ScenarioFileInfo _scenario;private string _scenarioText;private bool _suppress;private OptionButton _scenarioSelect;private LightEditorCanvas _canvas;private ItemList _lightList;private Button _addBtn;private Button _deleteBtn;private VBoxContainer _propsBody;private Label _propsEmpty;private SpinBox _x;private SpinBox _y;private SpinBox _radius;private SpinBox _influence;private ColorPickerButton _color;private FilteredList _bgList;private CodeEdit _lineEdit;public LightEditorWindow():base("tools/light_editor/title"){CthangoverTheme.Apply(this);BuildUI();LoadBackgrounds();LoadScenarioList();SetHint(ToolUi.Tr("tools/light_editor/hint"));}// ── UI construction ────────────────────────────────────────
private void BuildUI(){var outer=CreateFillContainer();AddChild(outer);var toolbar=CreateToolbar();outer.AddChild(toolbar);_scenarioSelect=new OptionButton{CustomMinimumSize=new Vector2(420,0),TooltipText=ToolUi.Tr("tools/light_editor/scenario_tip")};_scenarioSelect.ItemSelected+=OnScenarioPicked;toolbar.AddChild(_scenarioSelect);toolbar.AddChild(ToolUi.Button("tools/common/save",Save,ButtonKind.Primary,"tools/light_editor/save_tip"));toolbar.AddChild(ToolUi.Button("tools/light_editor/copy_line",CopyLine,ButtonKind.Secondary,"tools/light_editor/copy_line_tip"));toolbar.AddChild(UiKit.Spacer());var lineToggle=ToolUi.Button("tools/light_editor/show_line",null,ButtonKind.Tab);lineToggle.ToggleMode=true;lineToggle.Toggled+=on=>_lineEdit.Visible=on;toolbar.AddChild(lineToggle);var main=new HSplitContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};outer.AddChild(main);var side=new VBoxContainer();main.AddChild(CreateSidebar(side,300));BuildSidebar(side);var canvasColumn=new VSplitContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};main.AddChild(canvasColumn);var canvasPanel=new PanelContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};canvasPanel.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusMd,UiTokens.Well,UiTokens.Line));canvasColumn.AddChild(canvasPanel);_canvas=new LightEditorCanvas{Controller=_controller,PreviewMaterial=LightEditorController.CreatePreviewMaterial()};_canvas.Changed+=OnCanvasChanged;_canvas.SelectionChanged+=RefreshAll;canvasPanel.AddChild(_canvas);_lineEdit=ToolUi.Code(editable:false);_lineEdit.CustomMinimumSize=new Vector2(0,70);_lineEdit.WrapMode=TextEdit.LineWrappingMode.Boundary;_lineEdit.Visible=false;canvasColumn.AddChild(_lineEdit);}private void BuildSidebar(VBoxContainer column){column.AddChild(ToolUi.Section("tools/light_editor/static_lights"));_lightList=new ItemList{CustomMinimumSize=new Vector2(0,150)};_lightList.ItemSelected+=index=>{_controller.Select((int)index);RefreshAll();};column.AddChild(_lightList);var row=new HBoxContainer();row.AddThemeConstantOverride("separation",UiTokens.Space1);column.AddChild(row);_addBtn=ToolUi.Button("tools/light_editor/add_light",OnAdd);_addBtn.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;row.AddChild(_addBtn);_deleteBtn=ToolUi.Button("tools/light_editor/delete",OnDelete,ButtonKind.Danger);_deleteBtn.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;row.AddChild(_deleteBtn);column.AddChild(ToolUi.Section("tools/light_editor/selected_props"));_propsEmpty=ToolUi.Hint(ToolUi.Tr("tools/light_editor/none_selected"));column.AddChild(_propsEmpty);_propsBody=new VBoxContainer();_propsBody.AddThemeConstantOverride("separation",UiTokens.Space2);column.AddChild(_propsBody);_x=ToolUi.Field(_propsBody,"tools/light_editor/x",ToolUi.Number(0,1,0.001,_=>OnFieldsChanged()));_y=ToolUi.Field(_propsBody,"tools/light_editor/y",ToolUi.Number(0,1,0.001,_=>OnFieldsChanged()));_radius=ToolUi.Field(_propsBody,"tools/light_editor/radius",ToolUi.Number(10,3000,10,_=>OnFieldsChanged()),"tools/light_editor/radius_tip");_influence=ToolUi.Field(_propsBody,"tools/light_editor/influence",ToolUi.Number(0,1,0.05,_=>OnFieldsChanged()),"tools/light_editor/influence_tip");_color=ToolUi.Field(_propsBody,"tools/light_editor/color",new ColorPickerButton{CustomMinimumSize=new Vector2(0,28),EditAlpha=false});_color.ColorChanged+=_=>OnFieldsChanged();column.AddChild(ToolUi.Section("tools/light_editor/background"));_bgList=new FilteredList(180);_bgList.SizeFlagsVertical=Control.SizeFlags.ExpandFill;_bgList.Chosen+=ShowBackground;column.AddChild(_bgList);}// ── loading ────────────────────────────────────────────────
private void LoadBackgrounds(){_backgroundIds.AddRange(ModResourceService.GetBackgroundIds());_bgList.SetItems(_backgroundIds);}/// <summary>Scenarios with a <c>light_set</c> line first (marked ●), then all others.</summary>
private void LoadScenarioList(){var withLights=new List<ScenarioFileInfo>();var others=new List<ScenarioFileInfo>();foreach(var file in ScenarioFileService.ScanScenarioFiles().SelectMany(m=>m.Groups).SelectMany(g=>g.Files)){var text=ScenarioFileService.ReadAllText(file.ModId,file.RelativePath);(text!=null&&LightEditorController.HasLights(text)?withLights:others).Add(file);}_scenarios.Clear();_scenarios.AddRange(withLights);_scenarios.AddRange(others);_scenarioSelect.Clear();_scenarioSelect.AddItem(ToolUi.Tr("tools/light_editor/pick_scenario"));foreach(var file in _scenarios)_scenarioSelect.AddItem((withLights.Contains(file)?"●  ":"    ")+file.ModId+": "+file.RelativePath);_scenarioSelect.Select(0);if(withLights.Count>0){_scenarioSelect.Select(1);OpenScenario(withLights[0]);}else{RefreshAll();}}private void OnScenarioPicked(long index){var file=index>0&&index-1<_scenarios.Count?_scenarios[(int)index-1]:null;RunDiscardingChanges(()=>OpenScenario(file),()=>_scenarioSelect.Select(_scenario==null?0:_scenarios.IndexOf(_scenario)+1));}private void OpenScenario(ScenarioFileInfo file){_scenario=file;_scenarioText=null;MarkClean();if(file!=null){_scenarioText=ScenarioFileService.ReadAllText(file.ModId,file.RelativePath)??string.Empty;try{var found=_controller.LoadFromScenario(_scenarioText);SetStatus(string.Format(ToolUi.Tr(found?"tools/light_editor/loaded":"tools/light_editor/no_lights_yet"),file.RelativePath));}catch(Exception ex){SetStatus($"{ToolUi.Tr("tools/common/load_error")}: {ex.Message}",ToolStatusKind.Error);GameLogger.Log("LIGHT_EDITOR",$"light_set parse failed in '{file.Key}': {ex.Message}",LogLevel.Error);}var scene=ScenarioFileService.ParseMetadata(_scenarioText).Scene;var background=SceneBackgroundService.ForScene(scene);if(background!=null&&ModResourceService.LoadBackgroundTexture(background)==null)SetStatus(string.Format(ToolUi.Tr("tools/common/background_missing"),background,scene),ToolStatusKind.Error);else if(background!=null)ShowBackground(background);}else{_controller.LoadFromScenario(string.Empty);}RefreshAll();}/// <summary>Shows a background with its optional <c>_depth</c>/<c>_albedo</c> masks in the shader.</summary>
private void ShowBackground(string id){if(string.IsNullOrEmpty(id))return;_bgList.Select(id);_canvas.SetBackground(ModResourceService.LoadBackgroundTexture(id));LightEditorController.SetPreviewTextures(_canvas.PreviewMaterial,ModResourceService.LoadBackgroundTexture(id+"_depth"),ModResourceService.LoadBackgroundTexture(id+"_albedo"));_canvas.Refresh();}// ── saving ───────────────────────────────────────────────── <summary>Ctrl+S.</summary>
protected override void OnSaveShortcut()=>Save();private void Save(){if(_scenario==null){SetStatus(ToolUi.Tr("tools/light_editor/pick_scenario"),ToolStatusKind.Error);return;}var updated=_controller.WriteToScenario(_scenarioText);if(!ScenarioFileService.WriteAllText(_scenario.ModId,_scenario.RelativePath,updated)){SetStatus(ToolUi.Tr("tools/common/readonly"),ToolStatusKind.Error);return;}_scenarioText=updated;MarkClean();SetStatus(string.Format(ToolUi.Tr("tools/common/saved"),_scenario.ModId+"/"+_scenario.RelativePath),ToolStatusKind.Ok);}private void CopyLine(){DisplayServer.ClipboardSet(_controller.ToScenarioLine());SetStatus(ToolUi.Tr("tools/light_editor/copied"),ToolStatusKind.Ok);}// ── editing ────────────────────────────────────────────────
private void OnAdd(){if(_controller.Add(new Vector2(0.5f,0.5f))==null){SetStatus(string.Format(ToolUi.Tr("tools/light_editor/limit"),LightEditorController.MaxLights),ToolStatusKind.Error);return;}SetDirty();RefreshAll();}private void OnDelete(){if(_controller.Selected==null)return;_controller.RemoveSelected();SetDirty();RefreshAll();}/// <summary>Delete removes the selected light.</summary>
protected override bool HandleKey(InputEventKey key){if(key.Keycode!=Key.Delete||_controller.Selected==null)return false;OnDelete();return true;}private void OnFieldsChanged(){var light=_controller.Selected;if(_suppress||light==null)return;light.X=(float)_x.Value;light.Y=(float)_y.Value;light.Radius=(float)_radius.Value;light.Influence=(float)_influence.Value;light.ColorHex="#"+_color.Color.ToHtml(false);SetDirty();RefreshList();_lineEdit.Text=_controller.ToScenarioLine();_canvas.RebuildHandles();}private void OnCanvasChanged(){SetDirty();_suppress=true;WriteProperties();_suppress=false;RefreshList();_lineEdit.Text=_controller.ToScenarioLine();}// ── refresh ────────────────────────────────────────────────
private void RefreshAll(){RefreshList();_suppress=true;WriteProperties();_suppress=false;_lineEdit.Text=_controller.ToScenarioLine();_addBtn.Disabled=_controller.Lights.Count>=LightEditorController.MaxLights;_deleteBtn.Disabled=_controller.Selected==null;_canvas.RebuildHandles();}private void RefreshList(){_lightList.Clear();for(var i=0;i<_controller.Lights.Count;i++){var light=_controller.Lights[i];var index=_lightList.AddItem($"●  {i + 1}.  {light.X:0.000}, {light.Y:0.000}   r {light.Radius:0}");_lightList.SetItemCustomFgColor(index,light.ToColor());}if(_controller.SelectedIndex>=0)_lightList.Select(_controller.SelectedIndex);}private void WriteProperties(){var light=_controller.Selected;_propsEmpty.Visible=light==null;_propsBody.Visible=light!=null;if(light==null)return;_x.Value=light.X;_y.Value=light.Y;_radius.Value=light.Radius;_influence.Value=light.Influence;_color.Color=light.ToColor();}}}

===== FILE mods/editor_tools/src/MapEditor/MapEditorCanvas.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.UI.Theming;using Cthangover.Mods.Map;using Cthangover.Tools.Common;using Godot;namespace Cthangover.Tools.MapEditor{/// <summary> Interactive preview of the edited map: the background (laid out by <see cref="BackgroundCanvas"/> exactly like <c>MapWidget</c>), every zone polygon, marker and text label, and the draggable handles of the selected zone. Two modes: <list type="bullet"> <item><b>select</b> — a click picks the topmost zone (polygon first, then marker/label dots); handles move vertices and positions;</item> <item><b>draw</b> — clicks append polygon vertices to the selected region while a dashed rubber band shows the next edge; a double-click (or Enter/Esc via the window) finishes the polygon.</item> </list> Right-click deletes the polygon vertex nearest to the pointer; while drawing, a click that misses every vertex removes the last one ("undo point"), in select mode only a targeted vertex is deleted. <para/> The canvas owns no data: it reads and mutates <see cref="MapEditorController"/> and raises <see cref="Changed"/> so the window can refresh the sidebar and JSON. Zone fills and outlines are painted through <see cref="MapZoneStyle"/> — the definition the game's <c>MapZonesLayer</c> uses — so the editor and the live map look the same. </summary>
public partial class MapEditorCanvas:BackgroundCanvas{/// <summary>Mode constant: clicking selects zones and drags handles.</summary>
public const string ModeSelect="select";/// <summary>Mode constant: clicking appends polygon vertices.</summary>
public const string ModeDraw="draw";/// <summary>Handle index of the marker position.</summary>
public const int MarkerHandle= -2;/// <summary>Handle index of the label position.</summary>
public const int LabelHandle= -3;private const float PickRadius=24f;private static readonly Color VertexColor=MapZoneStyle.VertexHandle;private static readonly Color MarkerColor=MapZoneStyle.MarkerHandle;private static readonly Color LabelColor=MapZoneStyle.LabelHandle;/// <summary>Data being edited; set once by the window.</summary>
public MapEditorController Controller;/// <summary>Current interaction mode, one of <see cref="ModeSelect"/> / <see cref="ModeDraw"/>.</summary>
public string Mode{get;private set;}=ModeSelect;/// <summary>Raised whenever the canvas mutates controller data (drag, draw, delete).</summary>
public event Action Changed;/// <summary>Raised with the newly picked zone index (-1 for empty space).</summary>
public event Action<int>ZonePicked;/// <summary>Raised when the canvas leaves draw mode on its own (double-click finishing a polygon).</summary>
public event Action ModeChanged;private Vector2?_pointer;public MapEditorCanvas(){MapZoneStyle.Prepare(this);GuiInput+=OnGuiInput;MouseExited+=OnMouseExited;}/// <summary>Switches the interaction mode and refreshes the handles.</summary>
public void SetMode(string mode){Mode=mode==ModeDraw?ModeDraw:ModeSelect;MouseDefaultCursorShape=Mode==ModeDraw?CursorShape.Cross:CursorShape.Arrow;RebuildHandles();QueueRedraw();}/// <summary>Leaves draw mode (double-click, Enter, Esc) and tells the window.</summary>
public void FinishDrawing(){if(Mode!=ModeDraw)return;SetMode(ModeSelect);_pointer=null;ModeChanged?.Invoke();}/// <summary>Recreates the handles from the current selection. Call after structural changes.</summary>
public override void RebuildHandles(){ClearHandles();var zone=Controller?.Selected;if(zone==null||GetBackgroundRect().Size.X<=0f)return;if(zone.Points!=null){for(var i=0;i<zone.Points.Count;i++){var index=i;var handle=AddHandle(zone.Points[i],VertexColor,i,p=>OnHandleDrag(index,p));handle.RightClicked=()=>RemoveVertexAt(index);handle.DragEnd=()=>Changed?.Invoke();}}if(HasMarker(zone))AddHandle(zone.ImagePos,MarkerColor,MarkerHandle,p=>OnHandleDrag(MarkerHandle,p),18f).DragEnd=()=>Changed?.Invoke();if(zone.LabelPos.HasValue)AddHandle(zone.LabelPos.Value,LabelColor,LabelHandle,p=>OnHandleDrag(LabelHandle,p),18f).DragEnd=()=>Changed?.Invoke();}private void OnHandleDrag(int index,Vector2 normalized){if(Controller==null)return;if(index==MarkerHandle)Controller.SetImagePos(normalized);else if(index==LabelHandle)Controller.SetLabelPos(normalized);else Controller.SetVertex(index,normalized);QueueRedraw();Changed?.Invoke();}private void OnGuiInput(InputEvent@event){if(Controller==null)return;if(@event is InputEventMouseMotion motion){if(Mode==ModeDraw){_pointer=motion.Position;QueueRedraw();}return;}if(@event is not InputEventMouseButton mb|| !mb.Pressed)return;if(mb.ButtonIndex==MouseButton.Left){if(Mode==ModeDraw){// The first click of a double-click already placed the vertex; the double-click itself only finishes the polygon.
if(mb.DoubleClick){FinishDrawing();}else{Controller.AddVertex(Clamp01(LocalToNorm(mb.Position)));RebuildHandles();Changed?.Invoke();QueueRedraw();}}else{var hit=HitTest(mb.Position);if(hit!=Controller.SelectedIndex){Controller.Select(hit);RebuildHandles();ZonePicked?.Invoke(hit);QueueRedraw();}}AcceptEvent();}else if(mb.ButtonIndex==MouseButton.Right){RemoveVertexAtPointer(mb.Position,Mode==ModeDraw);AcceptEvent();}}private void OnMouseExited(){_pointer=null;QueueRedraw();}/// <summary>Topmost zone under a canvas-local point: polygons win over marker/label dots.</summary>
private int HitTest(Vector2 local){var zones=Controller.Zones;var rect=GetBackgroundRect();for(var i=zones.Count-1;i>=0;i--){var points=zones[i].Points;if(points!=null&&points.Count>=3&&Geometry2D.IsPointInPolygon(local,ToPixels(points,rect)))return i;}for(var i=zones.Count-1;i>=0;i--){var zone=zones[i];if(HasMarker(zone)&&(local-ToPixel(zone.ImagePos,rect)).Length()<=PickRadius)return i;if(zone.LabelPos.HasValue&&(local-ToPixel(zone.LabelPos.Value,rect)).Length()<=PickRadius)return i;}return-1;}protected override void DrawContent(Rect2 rect){var zones=Controller?.Zones;if(zones==null)return;for(var i=0;i<zones.Count;i++)DrawZone(zones[i],rect,i==Controller.SelectedIndex);DrawRubberBand(rect);}/// <summary> In draw mode shows where the next vertex goes: a dashed segment from the last vertex to the pointer and back to the first one, so the author sees the polygon closing before clicking. </summary>
private void DrawRubberBand(Rect2 rect){var points=Controller.Selected?.Points;if(Mode!=ModeDraw|| !_pointer.HasValue||points==null||points.Count==0)return;var pointer=_pointer.Value;DrawDashedLine(ToPixel(points[points.Count-1],rect),pointer,UiTokens.AccentBright,1.5f,6f);if(points.Count>=2)DrawDashedLine(pointer,ToPixel(points[0],rect),UiTokens.AccentLine,1f,6f);}private void DrawZone(MapZone zone,Rect2 rect,bool selected){if(zone.Points!=null&&zone.Points.Count>=2){var locked=MapZoneRules.IsLocked(zone,!zone.Locked);MapZoneStyle.Draw(this,ToPixels(zone.Points,rect),selected,false,locked,zone.Hidden);}if(HasMarker(zone))DrawMarker(zone,ToPixel(zone.ImagePos,rect),selected);if(zone.LabelPos.HasValue)DrawLabelPill(zone,ToPixel(zone.LabelPos.Value,rect),selected);}/// <summary> Draws the marker emblem. A marker without an icon is rendered as a placeholder crosshair instead of disappearing, so a freshly created marker stays visible, selectable and draggable while the author is still choosing an icon. </summary>
private void DrawMarker(MapZone zone,Vector2 center,bool selected){var outline=selected?UiTokens.Accent:UiTokens.AccentLine;var accent=selected?UiTokens.Accent:MarkerColor;var icon=string.IsNullOrEmpty(zone.Image)?null:Textures.Resolve(zone.Image);if(icon==null){DrawCircle(center,18f,UiTokens.Alpha(UiTokens.Surface0,0.55f));DrawArc(center,18f,0f,Mathf.Tau,32,accent,2f,true);DrawLine(center-new Vector2(7f,0f),center+new Vector2(7f,0f),accent,2f);DrawLine(center-new Vector2(0f,7f),center+new Vector2(0f,7f),accent,2f);return;}DrawCircle(center,18f,UiTokens.Alpha(UiTokens.White,0.85f));DrawArc(center,18f,0f,Mathf.Tau,32,outline,1.5f,true);DrawTextureRect(icon,new Rect2(center-new Vector2(14f,14f),new Vector2(28f,28f)),false);}private void DrawLabelPill(MapZone zone,Vector2 center,bool selected){var text=string.IsNullOrEmpty(zone.Title)?zone.ID:TranslationServer.Translate(zone.Title).ToString();if(string.IsNullOrEmpty(text))text=zone.ID;var font=GetThemeDefaultFont();var fontSize=GetThemeDefaultFontSize();var textSize=font.GetStringSize(text,HorizontalAlignment.Left,-1,fontSize);var pill=new Rect2(center-new Vector2(textSize.X/2f+8f,textSize.Y/2f+3f),textSize+new Vector2(16f,6f));DrawRect(pill,UiTokens.Alpha(UiTokens.Surface0,0.85f));DrawRect(pill,selected?UiTokens.Accent:UiTokens.Line,false,1f);DrawString(font,new Vector2(pill.Position.X+8f,pill.Position.Y+pill.Size.Y/2f+fontSize/3f),text,HorizontalAlignment.Left,-1,fontSize,UiTokens.Ink);}private static Vector2[]ToPixels(IReadOnlyList<Vector2>normalized,Rect2 rect){var result=new Vector2[normalized.Count];for(var i=0;i<result.Length;i++)result[i]=rect.Position+normalized[i]*rect.Size;return result;}private static Vector2 ToPixel(Vector2 normalized,Rect2 rect){return rect.Position+normalized*rect.Size;}/// <summary> A zone is marker-capable when it has an icon, or when it is still a bare marker placeholder — no polygon and no text label. The placeholder keeps a freshly created marker visible and draggable before an icon is picked; once the zone grows a region or becomes a pure inscription, the marker is only shown when an icon exists. The region currently being drawn is excluded while it has no vertices. </summary>
private bool HasMarker(MapZone zone){if(!string.IsNullOrEmpty(zone.Image))return true;if(Mode==ModeDraw&&zone==Controller?.Selected)return false;return(zone.Points==null||zone.Points.Count==0)&& !zone.LabelPos.HasValue;}/// <summary> Right-click on empty canvas: removes the polygon vertex nearest to the pointer. While drawing, a click that misses every vertex removes the last one instead, which makes right-click a natural "undo point"; in select mode only a targeted vertex is deleted, so a stray click cannot destroy geometry. </summary>
private void RemoveVertexAtPointer(Vector2 local,bool fallbackToLast){var zone=Controller?.Selected;if(zone?.Points==null||zone.Points.Count==0)return;var rect=GetBackgroundRect();var index=FindNearestVertex(zone,local,rect);if(index<0&&fallbackToLast)index=zone.Points.Count-1;if(index>=0)RemoveVertexAt(index);}private void RemoveVertexAt(int index){if(Controller==null)return;Controller.RemoveVertex(index);RebuildHandles();Changed?.Invoke();QueueRedraw();}private static int FindNearestVertex(MapZone zone,Vector2 local,Rect2 rect){var best= -1;var bestDistance=PickRadius;for(var i=0;i<zone.Points.Count;i++){var distance=(local-ToPixel(zone.Points[i],rect)).Length();if(distance<=bestDistance){bestDistance=distance;best=i;}}return best;}}}

===== FILE mods/editor_tools/src/MapEditor/MapEditorController.cs =====
using System;using System.Collections.Generic;using Cthangover.Mods.Map;using Cthangover.Tools.Services;using Godot;namespace Cthangover.Tools.MapEditor{/// <summary> Data model for the map editor. Holds the zone list being edited, the current selection and all structural operations (add/remove zone, add/remove/move vertex, move marker and label, toggle label mode). Contains no UI and no runtime state: it edits raw authoring data that <see cref="MapFileService"/> serialises back into <c>mods/&lt;mod&gt;/map/*.json</c>, while the live game state belongs to <c>MapZoneService</c>. <para/> New zones get unique IDs automatically and a sensible default shape (a small square for regions, centre position for markers and labels), so an author can place an element first and refine it afterwards. All coordinates are normalised [0..1] and clamped by the canvas, not here. </summary>
public class MapEditorController{private const float DefaultRegionHalfSize=0.06f;private static readonly Vector2 DefaultCenter=new(0.5f,0.5f);/// <summary>Zones currently being edited, in map file order.</summary>
public List<MapZone>Zones{get;private set;}=new();/// <summary>Index of the selected zone, or -1 when nothing is selected.</summary>
public int SelectedIndex{get;private set;}= -1;/// <summary>The selected zone, or <c>null</c> when the selection is empty.</summary>
public MapZone Selected=>SelectedIndex>=0&&SelectedIndex<Zones.Count?Zones[SelectedIndex]:null;/// <summary>Replaces all zones with the contents of a map file JSON envelope.</summary>
public void FromJson(string json){Zones=MapFileService.ParseZones(json);SelectedIndex=Zones.Count>0?0:-1;}/// <summary>Serialises the current zone list into the map file JSON envelope.</summary>
public string ToJson()=>MapFileService.BuildJson(Zones);/// <summary>Drops all zones and the selection.</summary>
public void Clear(){Zones.Clear();SelectedIndex= -1;}/// <summary>Creates a rectangular region around the map centre and selects it.</summary>
public MapZone AddRegion(){var zone=new MapZone{ID=MakeUniqueId("region"),Title=string.Empty,Points=new List<Vector2>{DefaultCenter+new Vector2(-DefaultRegionHalfSize,-DefaultRegionHalfSize),DefaultCenter+new Vector2(DefaultRegionHalfSize,-DefaultRegionHalfSize),DefaultCenter+new Vector2(DefaultRegionHalfSize,DefaultRegionHalfSize),DefaultCenter+new Vector2(-DefaultRegionHalfSize,DefaultRegionHalfSize),},};return Append(zone);}/// <summary> Creates a region without vertices and selects it — the start of drawing a polygon point by point on the canvas. </summary>
public MapZone StartRegion(){var zone=new MapZone{ID=MakeUniqueId("region"),Title=string.Empty,Points=new List<Vector2>(),};return Append(zone);}/// <summary> Creates an icon marker at the map centre with no icon chosen yet and selects it. The author assigns <c>image</c> and <c>scene</c> in the sidebar. </summary>
public MapZone AddMarker(){var zone=new MapZone{ID=MakeUniqueId("marker"),Title=string.Empty,ImagePos=DefaultCenter,};return Append(zone);}/// <summary> Creates a free-standing text label at the map centre and selects it. The label exists as soon as <c>label_pos</c> is set; <c>MapWidget</c> draws zones without an icon as inscriptions. </summary>
public MapZone AddLabel(){var zone=new MapZone{ID=MakeUniqueId("label"),Title=string.Empty,LabelPos=DefaultCenter,};return Append(zone);}/// <summary>Removes the zone at the given index and adjusts the selection.</summary>
public void RemoveAt(int index){if(index<0||index>=Zones.Count)return;Zones.RemoveAt(index);if(Zones.Count==0)SelectedIndex= -1;else if(SelectedIndex>index)SelectedIndex--;else if(SelectedIndex==index)SelectedIndex=Math.Min(index,Zones.Count-1);}/// <summary>Selects the zone at the given index; out-of-range clears the selection.</summary>
public void Select(int index){SelectedIndex=index>=0&&index<Zones.Count?index:-1;}/// <summary>Appends a vertex to the selected region's polygon.</summary>
public void AddVertex(Vector2 normalized){var zone=Selected;if(zone==null)return;zone.Points??=new List<Vector2>();zone.Points.Add(normalized);}/// <summary>Moves one vertex of the selected region.</summary>
public void SetVertex(int index,Vector2 normalized){var zone=Selected;if(zone?.Points==null||index<0||index>=zone.Points.Count)return;zone.Points[index]=normalized;}/// <summary>Removes one vertex of the selected region.</summary>
public void RemoveVertex(int index){var zone=Selected;if(zone?.Points==null||index<0||index>=zone.Points.Count)return;zone.Points.RemoveAt(index);}/// <summary>Moves the icon marker of the selected zone.</summary>
public void SetImagePos(Vector2 normalized){var zone=Selected;if(zone!=null)zone.ImagePos=normalized;}/// <summary>Moves the text label of the selected zone.</summary>
public void SetLabelPos(Vector2 normalized){var zone=Selected;if(zone!=null)zone.LabelPos=normalized;}/// <summary> Enables or disables the free-standing label of the selected zone. Enabling without a known position places it at the map centre. </summary>
public void SetLabelEnabled(bool enabled){var zone=Selected;if(zone==null)return;zone.LabelPos=enabled?zone.LabelPos??DefaultCenter:null;}/// <summary>ID set of every zone in the list, for duplicate validation.</summary>
public HashSet<string>UsedIds(){var ids=new HashSet<string>(StringComparer.OrdinalIgnoreCase);foreach(var zone in Zones){if(!string.IsNullOrEmpty(zone.ID))ids.Add(zone.ID);}return ids;}/// <summary> Builds an ID that is not used yet by appending a numeric suffix (<c>region</c>, <c>region_2</c>, <c>region_3</c>, ...). </summary>
public string MakeUniqueId(string baseId){var used=UsedIds();if(!used.Contains(baseId))return baseId;var suffix=2;while(used.Contains($"{baseId}_{suffix}"))suffix++;return$"{baseId}_{suffix}";}private MapZone Append(MapZone zone){Zones.Add(zone);SelectedIndex=Zones.Count-1;return zone;}}}

===== FILE mods/editor_tools/src/MapEditor/MapEditorToolBoxButton.cs =====
using Cthangover.Core.UI.Tool;namespace Cthangover.Tools.MapEditor{/// <summary> Toolbox button that opens the map editor from the in-game toolbar. Matches the tool id registered by <see cref="MapEditorToolProvider"/>; always visible because map authoring is part of the normal dev workflow. </summary>
public class MapEditorToolBoxButton:IToolBoxButton{/// <summary>Matches <see cref="MapEditorToolProvider.Id"/>.</summary>
public string ToolId=>"map_editor";/// <summary>No dedicated icon — the button uses its text label.</summary>
public string IconPath=>"";/// <summary>Translation key for the button label.</summary>
public string LocaleKey=>"tools/map_editor/title";/// <summary>Always visible in the toolbox.</summary>
public bool IsVisible()=>true;}}

===== FILE mods/editor_tools/src/MapEditor/MapEditorToolProvider.cs =====
using Cthangover.Core.UI.Tool;using Godot;using Cthangover.Mods.Interface.Tool;namespace Cthangover.Tools.MapEditor{/// <summary> Registers the map editor as a discoverable tool window. Discovery is automatic: <c>ToolFactory</c> scans mod assemblies for <see cref="IToolProvider"/> implementations, so no manifest entry is needed. The provider only knows how to build the window; all editing logic lives in <see cref="MapEditorWindow"/>. </summary>
public class MapEditorToolProvider:IToolProvider{/// <summary>Unique tool identifier used by the toolbox button.</summary>
public string Id=>"map_editor";/// <summary>Translation key for the tool's display name.</summary>
public string LocaleKey=>"tools/map_editor/title";/// <summary>Creates the editor window instance.</summary>
public Window CreateWindow()=>new MapEditorWindow();/// <summary>Adds the map editor's launcher button to the main menu tool block.</summary>
public void AddMenuButton(Container container)=>ToolMenuButton.Attach(container,this);}}

===== FILE mods/editor_tools/src/MapEditor/MapEditorWindow.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Localization;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Cthangover.Mods.Interface.Theming;using Cthangover.Mods.Interface.Tool;using Cthangover.Mods.Map;using Cthangover.Tools.Common;using Cthangover.Tools.Services;using Godot;namespace Cthangover.Tools.MapEditor{/// <summary> Editor window for the world map. The author picks a writable mod and a file under its <c>map/</c> folder (or creates one), then draws region polygons, places icon markers and free-standing text labels over a background preview. <para/> Layout: toolbar (file, save, mode, background, preview), zone list on the left, canvas in the middle, properties of the selected zone on the right, and an optional read-only JSON view below the canvas. Switching file or mod with unsaved edits asks first. <para/> The editor edits raw authoring data only: the produced JSON matches <c>MapZone</c>. Runtime state — what scenarios open or reveal — is not touched; after a successful save the running map widget is asked to reload so the edit is visible immediately. </summary>
public partial class MapEditorWindow:ToolWindow{private readonly MapEditorController _controller=new();private readonly List<string>_backgroundIds=new();private readonly List<string>_sceneIds=new();private string _modId;private string _path;private ModFileSelector _fileSelector;private Button _selectModeBtn;private Button _drawModeBtn;private OptionButton _backgroundSelect;private FilteredList _zoneList;private Button _deleteBtn;private VBoxContainer _props;private Label _propsEmpty;private VBoxContainer _propsBody;private LineEdit _idEdit;private LineEdit _titleEdit;private Label _titlePreview;private OptionButton _sceneSelect;private LineEdit _descEdit;private LineEdit _imageEdit;private TextureRect _imagePreview;private FilteredList _iconList;private SpinBox _posX;private SpinBox _posY;private CheckBox _labelCheck;private SpinBox _labelX;private SpinBox _labelY;private CheckBox _lockedCheck;private CheckBox _hiddenCheck;private Label _availabilityLabel;private VBoxContainer _vertexList;private CollapsibleSection _markerSection;private CollapsibleSection _labelSection;private CollapsibleSection _vertexSection;private MapZone _sectionsShownFor;private MapEditorCanvas _canvas;private TextEdit _jsonEdit;private Button _jsonToggle;private MapWidget _preview;private bool _suppress;public MapEditorWindow():base("tools/map_editor/title"){CthangoverTheme.Apply(this);ReloadLocalization();BuildUI();LoadSceneList();LoadTextureList();LoadBackgroundList();SetHint(ToolUi.Tr("tools/map_editor/hint"));_fileSelector.Refresh();}// ── UI construction ────────────────────────────────────────
private void BuildUI(){var outer=CreateFillContainer();AddChild(outer);outer.AddChild(BuildToolbar());var main=new HSplitContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};outer.AddChild(main);var zonesColumn=new VBoxContainer();main.AddChild(CreateSidebar(zonesColumn,240));BuildZoneList(zonesColumn);var centerSplit=new HSplitContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill};main.AddChild(centerSplit);var canvasColumn=new VSplitContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};centerSplit.AddChild(canvasColumn);var canvasPanel=new PanelContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};canvasPanel.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusMd,UiTokens.Well,UiTokens.Line));canvasColumn.AddChild(canvasPanel);_canvas=new MapEditorCanvas{Controller=_controller};_canvas.Changed+=OnCanvasChanged;_canvas.ZonePicked+=OnCanvasZonePicked;_canvas.ModeChanged+=SyncModeButtons;canvasPanel.AddChild(_canvas);_jsonEdit=ToolUi.Code(editable:false);_jsonEdit.CustomMinimumSize=new Vector2(0,160);_jsonEdit.Visible=false;canvasColumn.AddChild(_jsonEdit);_props=new VBoxContainer();centerSplit.AddChild(CreateSidebar(_props,320));BuildProperties(_props);}private HBoxContainer BuildToolbar(){var toolbar=CreateToolbar();_fileSelector=new ModFileSelector(MapFileService.MapDir,".json");_fileSelector.FileRequested+=OnFileRequested;toolbar.AddChild(_fileSelector);toolbar.AddChild(ToolUi.Button("tools/common/save",Save,ButtonKind.Primary,"tools/common/save_tip"));toolbar.AddChild(ToolUi.Button("tools/map_editor/reload_map",OnReloadMap,ButtonKind.Secondary,"tools/map_editor/reload_map_tip"));toolbar.AddChild(ToolUi.Divider());var modeGroup=new ButtonGroup();_selectModeBtn=ModeButton("tools/map_editor/select_mode",modeGroup,MapEditorCanvas.ModeSelect);_selectModeBtn.ButtonPressed=true;toolbar.AddChild(_selectModeBtn);_drawModeBtn=ModeButton("tools/map_editor/draw_region",modeGroup,MapEditorCanvas.ModeDraw);toolbar.AddChild(_drawModeBtn);toolbar.AddChild(ToolUi.Divider());_backgroundSelect=new OptionButton{TooltipText=ToolUi.Tr("tools/map_editor/background"),CustomMinimumSize=new Vector2(160,0)};_backgroundSelect.ItemSelected+=OnBackgroundSelected;toolbar.AddChild(_backgroundSelect);toolbar.AddChild(UiKit.Spacer());_jsonToggle=ToolUi.Button("tools/common/json",null,ButtonKind.Tab,"tools/common/json_tip");_jsonToggle.ToggleMode=true;_jsonToggle.Toggled+=on=>_jsonEdit.Visible=on;toolbar.AddChild(_jsonToggle);toolbar.AddChild(ToolUi.Button("tools/map_editor/preview",OnPreviewPressed,ButtonKind.Secondary,"tools/map_editor/preview_tip"));return toolbar;}private Button ModeButton(string textKey,ButtonGroup group,string mode){var button=ToolUi.Button(textKey,null,ButtonKind.Tab);button.ToggleMode=true;button.ButtonGroup=group;button.Toggled+=on=>{if(on&& !_suppress&&_canvas!=null)_canvas.SetMode(mode);};return button;}private void BuildZoneList(VBoxContainer column){column.AddChild(ToolUi.Section("tools/map_editor/zones"));_zoneList=new FilteredList(240);_zoneList.SizeFlagsVertical=Control.SizeFlags.ExpandFill;_zoneList.Chosen+=OnZoneChosen;column.AddChild(_zoneList);var addRow=new HBoxContainer();addRow.AddThemeConstantOverride("separation",UiTokens.Space1);column.AddChild(addRow);AddExpanding(addRow,ToolUi.Button("tools/map_editor/add_region",OnAddRegion,ButtonKind.Secondary,"tools/map_editor/add_region_tip"));AddExpanding(addRow,ToolUi.Button("tools/map_editor/add_marker",OnAddMarker));AddExpanding(addRow,ToolUi.Button("tools/map_editor/add_label",OnAddLabel));_deleteBtn=ToolUi.Button("tools/map_editor/delete",OnDeleteZone,ButtonKind.Danger,"tools/map_editor/delete_tip");column.AddChild(_deleteBtn);}private static void AddExpanding(Container parent,Control control){control.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;parent.AddChild(control);}private void BuildProperties(VBoxContainer column){column.AddChild(ToolUi.Section("tools/map_editor/props"));_propsEmpty=ToolUi.Hint(ToolUi.Tr("tools/map_editor/zone_missing"));column.AddChild(_propsEmpty);var body=new VBoxContainer();body.AddThemeConstantOverride("separation",UiTokens.Space2);column.AddChild(body);_propsBody=body;_idEdit=ToolUi.Field(body,"tools/map_editor/id",ToolUi.Text("region",_=>OnIdChanged()));_idEdit.AddThemeFontOverride("font",ToolUi.Mono());_titleEdit=ToolUi.Field(body,"tools/map_editor/title_key",ToolUi.Text("ui/map/…",_=>OnTitleChanged()));_titlePreview=ToolUi.Hint(string.Empty);body.AddChild(_titlePreview);_descEdit=ToolUi.Field(body,"tools/map_editor/description",ToolUi.Text("ui/map/…",_=>OnFieldsChanged()));_sceneSelect=ToolUi.Field(body,"tools/map_editor/scene",new OptionButton());_sceneSelect.ItemSelected+=_=>OnFieldsChanged();body.AddChild(ToolUi.Section("tools/map_editor/availability"));_lockedCheck=new CheckBox{Text=ToolUi.Tr("tools/map_editor/locked")};_lockedCheck.Toggled+=_=>OnFieldsChanged();body.AddChild(_lockedCheck);_hiddenCheck=new CheckBox{Text=ToolUi.Tr("tools/map_editor/hidden")};_hiddenCheck.Toggled+=_=>OnFieldsChanged();body.AddChild(_hiddenCheck);_availabilityLabel=ToolUi.Hint(string.Empty);body.AddChild(_availabilityLabel);_markerSection=new CollapsibleSection("tools/map_editor/marker");body.AddChild(_markerSection);var marker=_markerSection.Body;var imageRow=new HBoxContainer();_imagePreview=new TextureRect{CustomMinimumSize=new Vector2(32,32),ExpandMode=TextureRect.ExpandModeEnum.IgnoreSize,StretchMode=TextureRect.StretchModeEnum.KeepAspectCentered};_imageEdit=ToolUi.Text(ToolUi.Tr("tools/map_editor/image_placeholder"),_=>OnImageChanged());_imageEdit.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;imageRow.AddChild(_imageEdit);imageRow.AddChild(_imagePreview);ToolUi.Field(marker,"tools/map_editor/image",imageRow);_iconList=new FilteredList(110,"tools/map_editor/icon_search");_iconList.Chosen+=OnIconChosen;marker.AddChild(_iconList);_posX=ToolUi.Field(marker,"tools/map_editor/x",ToolUi.Number(0,1,0.001,_=>OnPositionChanged()));_posY=ToolUi.Field(marker,"tools/map_editor/y",ToolUi.Number(0,1,0.001,_=>OnPositionChanged()));_labelSection=new CollapsibleSection("tools/map_editor/label_section");body.AddChild(_labelSection);_labelCheck=new CheckBox{Text=ToolUi.Tr("tools/map_editor/label")};_labelCheck.Toggled+=OnLabelToggled;_labelSection.Body.AddChild(_labelCheck);_labelX=ToolUi.Field(_labelSection.Body,"tools/map_editor/x",ToolUi.Number(0,1,0.001,_=>OnPositionChanged()));_labelY=ToolUi.Field(_labelSection.Body,"tools/map_editor/y",ToolUi.Number(0,1,0.001,_=>OnPositionChanged()));_vertexSection=new CollapsibleSection("tools/map_editor/vertices");body.AddChild(_vertexSection);_vertexSection.Body.AddChild(ToolUi.Hint(ToolUi.Tr("tools/map_editor/vertices_hint")));_vertexList=new VBoxContainer();_vertexList.AddThemeConstantOverride("separation",UiTokens.Space1);_vertexSection.Body.AddChild(_vertexList);_vertexSection.Body.AddChild(ToolUi.Button("tools/map_editor/add_vertex",OnAddVertex));}// ── data loading ───────────────────────────────────────────
private void LoadSceneList(){_sceneIds.Clear();_sceneSelect.Clear();_sceneSelect.AddItem(ToolUi.Tr("tools/map_editor/scene_none"));var defs=SceneDefinitions.Collect();if(defs!=null)_sceneIds.AddRange(defs.Keys.OrderBy(key=>key,StringComparer.OrdinalIgnoreCase));foreach(var scene in _sceneIds)_sceneSelect.AddItem(scene);}private void LoadTextureList(){_iconList.SetItems(ModResourceService.GetIconIds());}private void LoadBackgroundList(){_backgroundIds.Clear();_backgroundSelect.Clear();_backgroundIds.AddRange(ModResourceService.GetBackgroundIds());foreach(var id in _backgroundIds)_backgroundSelect.AddItem(id);var index=_backgroundIds.FindIndex(id=>string.Equals(id,"map",StringComparison.OrdinalIgnoreCase));if(index<0&&_backgroundIds.Count>0)index=0;if(index>=0){_backgroundSelect.Select(index);_canvas.SetBackground(ModResourceService.LoadBackgroundTexture(_backgroundIds[index]));}}// ── file actions ───────────────────────────────────────────
private void OnFileRequested(string modId,string path){RunDiscardingChanges(()=>Load(modId,path),()=>_fileSelector.Show(_modId,_path));}private void Load(string modId,string path){_modId=modId;_path=path;_controller.Clear();MarkClean();if(modId==null){SetStatus(ToolUi.Tr("tools/common/no_writable_mods"),ToolStatusKind.Error);}else if(path==null){SetStatus(string.Format(ToolUi.Tr("tools/common/no_files"),modId),ToolStatusKind.Info);}else{try{var text=ModFileService.ReadText(modId,path);if(!string.IsNullOrEmpty(text))_controller.FromJson(text);SetStatus(string.Format(ToolUi.Tr(string.IsNullOrEmpty(text)?"tools/common/new_file_status":"tools/common/loaded"),modId+"/"+path));}catch(Exception ex){SetStatus($"{ToolUi.Tr("tools/common/load_error")}: {ex.Message}",ToolStatusKind.Error);GameLogger.Log("MAP_EDITOR",$"load failed for '{modId}/{path}': {ex.Message}",LogLevel.Error);}}_fileSelector.Show(_modId,_path);_canvas.FinishDrawing();RefreshAll();}/// <summary>Ctrl+S.</summary>
protected override void OnSaveShortcut()=>Save();private void Save(){if(_path==null){SetStatus(ToolUi.Tr("tools/common/no_file_to_save"),ToolStatusKind.Error);return;}var invalid=_controller.Zones.FirstOrDefault(z=>string.IsNullOrWhiteSpace(z.ID));if(invalid!=null){SetStatus(ToolUi.Tr("tools/map_editor/id_empty"),ToolStatusKind.Error);return;}var duplicate=_controller.Zones.GroupBy(z=>z.ID,StringComparer.OrdinalIgnoreCase).FirstOrDefault(g=>g.Count()>1);if(duplicate!=null){SetStatus($"{ToolUi.Tr("tools/map_editor/duplicate_id")} {duplicate.Key}",ToolStatusKind.Error);return;}if(!ModFileService.WriteText(_modId,_path,_controller.ToJson())){SetStatus(ToolUi.Tr("tools/common/readonly"),ToolStatusKind.Error);return;}MarkClean();_fileSelector.Show(_modId,_path);SetStatus(string.Format(ToolUi.Tr("tools/common/saved"),_modId+"/"+_path),ToolStatusKind.Ok);ReloadRunningMap();}/// <summary> Pushes the saved data into the running game. When the map panel was already opened once, its widget re-reads through the service and rebuilds; otherwise only the service is reloaded and the widget picks the data up on its first open. </summary>
private static void ReloadRunningMap(){if(UiHost.Instance!=null&&UiHost.Instance.TryGetInstance("map",out var control)&&control is MapWidget mapWidget){mapWidget.Reload();return;}MapZoneService.Instance.Reload();}private void OnReloadMap(){ReloadRunningMap();SetStatus(ToolUi.Tr("tools/map_editor/reloaded"),ToolStatusKind.Ok);}// ── shortcuts ────────────────────────────────────────────── <summary>Esc/Enter finish drawing, Delete removes the selected zone (with confirmation).</summary>
protected override bool HandleKey(InputEventKey key){if(_preview!=null)return false;switch(key.Keycode){case Key.Escape:case Key.Enter:case Key.KpEnter:if(_canvas.Mode!=MapEditorCanvas.ModeDraw)return false;_canvas.FinishDrawing();return true;case Key.Delete:OnDeleteZone();return true;default:return false;}}// ── game-like preview ────────────────────────────────────── <summary>True while the game-like preview covers the editor.</summary>
public bool PreviewActive=>_preview!=null;/// <summary> Opens the game-like preview on top of the editor: the real <c>map_widget.tscn</c> fed with the current — possibly unsaved — zone list, so hover highlights, marker pills, the location card and the transition button behave exactly as in the running map. The preview ends through the widget itself: its close button or Escape hides it, and the editor resumes. Returns <c>false</c> when the map widget scene cannot be resolved. </summary>
public bool StartPreview(){if(_preview!=null)return true;ReloadLocalization();var widget=TscnScenes.LoadAndBuild("scenes/ui/map_widget.tscn")as MapWidget;if(widget==null){SetStatus(ToolUi.Tr("tools/map_editor/preview_missing"),ToolStatusKind.Error);return false;}widget.SetPreviewZones(_controller.Zones);widget.VisibilityChanged+=OnPreviewVisibilityChanged;AddChild(widget);widget.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);_preview=widget;widget.EnsureConstructed();widget.Show();SetStatus(ToolUi.Tr("tools/map_editor/preview_on"));return true;}/// <summary>Closes the preview and returns to editing.</summary>
public void StopPreview(){if(_preview==null)return;var widget=_preview;_preview=null;if(GodotObject.IsInstanceValid(widget)){widget.VisibilityChanged-=OnPreviewVisibilityChanged;widget.QueueFree();}UpdateTitlePreview();_canvas.QueueRedraw();SetStatus(ToolUi.Tr("tools/map_editor/preview_off"));}private void OnPreviewPressed(){StartPreview();}private void OnPreviewVisibilityChanged(){if(_preview!=null&&GodotObject.IsInstanceValid(_preview)&& !_preview.Visible)StopPreview();}/// <summary> Re-reads every mod's locale files so keys edited after the game booted — the common case while authoring titles and descriptions — resolve to their new values in the editor and in the preview. </summary>
private static void ReloadLocalization(){if(GameData.Instance==null)return;try{LocaleLoader.LoadCurrentLanguage();}catch(Exception ex){GameLogger.Log("MAP_EDITOR",$"localisation reload failed: {ex.Message}",LogLevel.Warning);}}private void OnBackgroundSelected(long index){if(index<0||index>=_backgroundIds.Count)return;_canvas.SetBackground(ModResourceService.LoadBackgroundTexture(_backgroundIds[(int)index]));}// ── zone structure actions ─────────────────────────────────
private void OnAddRegion(){_controller.StartRegion();_canvas.SetMode(MapEditorCanvas.ModeDraw);SyncModeButtons();SetDirty();RefreshAll();SetStatus(ToolUi.Tr("tools/map_editor/draw_started"));}private void OnAddMarker(){_controller.AddMarker();_canvas.FinishDrawing();SetDirty();RefreshAll();}private void OnAddLabel(){_controller.AddLabel();_canvas.FinishDrawing();SetDirty();RefreshAll();}private void OnDeleteZone(){var zone=_controller.Selected;if(zone==null){SetStatus(ToolUi.Tr("tools/map_editor/zone_missing"),ToolStatusKind.Error);return;}var index=_controller.SelectedIndex;ShowConfirm("tools/map_editor/delete","tools/map_editor/delete_confirm",()=>{_controller.RemoveAt(index);_canvas.FinishDrawing();SetDirty();RefreshAll();SetStatus(string.Format(ToolUi.Tr("tools/map_editor/deleted"),zone.ID));});}private void OnZoneChosen(string indexText){if(_suppress|| !int.TryParse(indexText,out var index))return;_canvas.FinishDrawing();_controller.Select(index);RefreshAll();}private void OnCanvasZonePicked(int index){RefreshSelectionOnly();}private void OnCanvasChanged(){SetDirty();RefreshValuesOnly();}private void SyncModeButtons(){_suppress=true;if(_canvas.Mode==MapEditorCanvas.ModeDraw)_drawModeBtn.ButtonPressed=true;else _selectModeBtn.ButtonPressed=true;_suppress=false;}private void OnAddVertex(){var zone=_controller.Selected;if(zone==null){SetStatus(ToolUi.Tr("tools/map_editor/zone_missing"),ToolStatusKind.Error);return;}_controller.AddVertex(zone.Points!=null&&zone.Points.Count>0?zone.Points[zone.Points.Count-1]+new Vector2(0.02f,0.02f):new Vector2(0.5f,0.5f));SetDirty();RefreshAll();}private void RemoveVertex(int index){_controller.RemoveVertex(index);SetDirty();RefreshAll();}// ── property editors ───────────────────────────────────────
private void OnIdChanged(){if(_suppress)return;var zone=_controller.Selected;if(zone==null)return;var id=_idEdit.Text.Trim();if(ValidateId(id,out var error)){zone.ID=id;SetDirty();SetStatus(string.Empty);_idEdit.RemoveThemeColorOverride("font_color");}else{SetStatus(error,ToolStatusKind.Error);_idEdit.AddThemeColorOverride("font_color",UiTokens.DangerText);}RefreshJson();RefreshZoneList();}private bool ValidateId(string id,out string error){error=null;if(string.IsNullOrWhiteSpace(id)){error=ToolUi.Tr("tools/map_editor/id_empty");return false;}foreach(var zone in _controller.Zones){if(!ReferenceEquals(zone,_controller.Selected)&&string.Equals(zone.ID,id,StringComparison.OrdinalIgnoreCase)){error=ToolUi.Tr("tools/map_editor/duplicate_id");return false;}}return true;}private void OnTitleChanged(){if(_suppress||_controller.Selected==null)return;_controller.Selected.Title=_titleEdit.Text.Trim();UpdateTitlePreview();SetDirty();RefreshJson();_canvas.QueueRedraw();}private void OnFieldsChanged(){if(_suppress)return;var zone=_controller.Selected;if(zone==null)return;zone.Scene=_sceneSelect.Selected<=0?string.Empty:_sceneIds[_sceneSelect.Selected-1];zone.Description=_descEdit.Text.Trim();zone.Locked=_lockedCheck.ButtonPressed;zone.Hidden=_hiddenCheck.ButtonPressed;SetDirty();RefreshJson();UpdateAvailability(zone);_canvas.QueueRedraw();}private void OnImageChanged(){if(_suppress||_controller.Selected==null)return;_controller.Selected.Image=_imageEdit.Text.Trim();_iconList.Select(_controller.Selected.Image);AfterImageChanged();}private void OnIconChosen(string id){if(_suppress||_controller.Selected==null||id==null)return;_controller.Selected.Image=id;_suppress=true;_imageEdit.Text=id;_suppress=false;AfterImageChanged();}private void AfterImageChanged(){UpdateImagePreview(_controller.Selected);SetDirty();_canvas.RebuildHandles();_canvas.QueueRedraw();RefreshJson();}private void OnPositionChanged(){if(_suppress)return;var zone=_controller.Selected;if(zone==null)return;zone.ImagePos=new Vector2((float)_posX.Value,(float)_posY.Value);if(zone.LabelPos.HasValue)zone.LabelPos=new Vector2((float)_labelX.Value,(float)_labelY.Value);SetDirty();_canvas.RebuildHandles();_canvas.QueueRedraw();RefreshJson();}private void OnLabelToggled(bool enabled){if(_suppress)return;_controller.SetLabelEnabled(enabled);SetDirty();RefreshValuesOnly();_canvas.RebuildHandles();_canvas.QueueRedraw();}// ── refresh ────────────────────────────────────────────────
private void RefreshAll(){RefreshZoneList();RefreshValuesOnly();_canvas.RebuildHandles();_canvas.QueueRedraw();}private void RefreshSelectionOnly(){RefreshZoneList();RefreshValuesOnly();}private void RefreshValuesOnly(){_suppress=true;try{WriteSidebarFromController();}finally{_suppress=false;}RefreshVertexList();RefreshJson();}/// <summary>Zone rows are "kind glyph + id"; the row id is the zone index.</summary>
private void RefreshZoneList(){var items=new List<(string,string,Texture2D)>();for(var i=0;i<_controller.Zones.Count;i++){var zone=_controller.Zones[i];var id=string.IsNullOrEmpty(zone.ID)?"?":zone.ID;items.Add((i.ToString(),KindGlyph(zone)+"  "+id,null));}_zoneList.SetItems(items);_zoneList.Select(_controller.SelectedIndex>=0?_controller.SelectedIndex.ToString():null);_deleteBtn.Disabled=_controller.Selected==null;}private static string KindGlyph(MapZone zone){if(zone.Points!=null&&zone.Points.Count>0)return"▱";if(!string.IsNullOrEmpty(zone.Image)|| !zone.LabelPos.HasValue)return"◆";return"T";}private void WriteSidebarFromController(){var zone=_controller.Selected;var hasZone=zone!=null;_propsEmpty.Visible= !hasZone;_propsBody.Visible=hasZone;if(!hasZone)return;_idEdit.Text=zone.ID??string.Empty;_idEdit.RemoveThemeColorOverride("font_color");_titleEdit.Text=zone.Title??string.Empty;_descEdit.Text=zone.Description??string.Empty;_imageEdit.Text=zone.Image??string.Empty;_lockedCheck.ButtonPressed=zone.Locked;_hiddenCheck.ButtonPressed=zone.Hidden;_labelCheck.ButtonPressed=zone.LabelPos.HasValue;_labelX.Editable=zone.LabelPos.HasValue;_labelY.Editable=zone.LabelPos.HasValue;var sceneIndex=_sceneIds.FindIndex(id=>string.Equals(id,zone.Scene,StringComparison.OrdinalIgnoreCase));_sceneSelect.Select(sceneIndex>=0?sceneIndex+1:0);_iconList.Select(zone.Image);UpdateImagePreview(zone);_posX.Value=zone.ImagePos.X;_posY.Value=zone.ImagePos.Y;var labelPos=zone.LabelPos??Vector2.Zero;_labelX.Value=labelPos.X;_labelY.Value=labelPos.Y;UpdateTitlePreview();UpdateAvailability(zone);ExpandRelevantSections(zone);}/// <summary> On a new selection expands only the sections that matter for the zone's kind (polygon → vertices, marker → icon, label → label); the author may open the others by hand, which sticks until the next pick. </summary>
private void ExpandRelevantSections(MapZone zone){if(ReferenceEquals(zone,_sectionsShownFor))return;_sectionsShownFor=zone;var hasPolygon=zone.Points is{Count:>0}||_canvas.Mode==MapEditorCanvas.ModeDraw;_vertexSection.Expanded=hasPolygon;_labelSection.Expanded=zone.LabelPos.HasValue;_markerSection.Expanded= !string.IsNullOrEmpty(zone.Image)||(!hasPolygon&& !zone.LabelPos.HasValue);}private void UpdateImagePreview(MapZone zone){_imagePreview.Texture=zone==null||string.IsNullOrEmpty(zone.Image)?null:Textures.Resolve(zone.Image);}/// <summary> Shows the availability the current authoring data produces in the game. The sidebar flags alone are not the whole truth: the player also refuses to travel to a zone with an empty or unregistered scene, so the hint goes through the shared <see cref="MapZoneRules"/> and the editor cannot disagree with the map the player sees. </summary>
private void UpdateAvailability(MapZone zone){if(zone==null){_availabilityLabel.Text=string.Empty;return;}string state;var color=UiTokens.DangerText;if(zone.Hidden)state=ToolUi.Tr("tools/map_editor/effective_hidden");else if(zone.Locked)state=ToolUi.Tr("tools/map_editor/effective_locked");else if(string.IsNullOrEmpty(zone.Scene))state=ToolUi.Tr("tools/map_editor/effective_no_scene");else if(MapZoneRules.IsLocked(zone,true))state=ToolUi.Tr("tools/map_editor/effective_unknown_scene");else{state=ToolUi.Tr("tools/map_editor/effective_open");color=UiTokens.Ok;}_availabilityLabel.Text=$"{ToolUi.Tr("tools/map_editor/effective")} {state}";_availabilityLabel.AddThemeColorOverride("font_color",color);}private void UpdateTitlePreview(){var key=_titleEdit.Text.Trim();if(string.IsNullOrEmpty(key)){_titlePreview.Text=string.Empty;return;}var translated=TranslationServer.Translate(key).ToString();_titlePreview.Text=string.Equals(translated,key,StringComparison.Ordinal)?ToolUi.Tr("tools/map_editor/no_translation"):"« "+translated+" »";}/// <summary> One row per vertex with X/Y spin boxes. Handlers read the vertex at change time — capturing the original point made editing Y revert X. </summary>
private void RefreshVertexList(){foreach(var child in _vertexList.GetChildren())child.QueueFree();var zone=_controller.Selected;if(zone?.Points==null)return;for(var i=0;i<zone.Points.Count;i++){var index=i;var row=new HBoxContainer();row.AddThemeConstantOverride("separation",UiTokens.Space1);var number=UiKit.Label((i+1).ToString(),UiTokens.FontSmall,UiTokens.Faint);number.CustomMinimumSize=new Vector2(20,0);row.AddChild(number);var xSpin=ToolUi.Number(0,1,0.001,null);xSpin.Value=zone.Points[i].X;xSpin.ValueChanged+=value=>OnVertexEdited(index,(float)value,null);row.AddChild(xSpin);var ySpin=ToolUi.Number(0,1,0.001,null);ySpin.Value=zone.Points[i].Y;ySpin.ValueChanged+=value=>OnVertexEdited(index,null,(float)value);row.AddChild(ySpin);var deleteBtn=UiKit.IconButton("✕",ToolUi.Tr("tools/map_editor/delete_vertex"));deleteBtn.Pressed+=()=>RemoveVertex(index);row.AddChild(deleteBtn);_vertexList.AddChild(row);}}private void OnVertexEdited(int index,float?x,float?y){var zone=_controller.Selected;if(zone?.Points==null||index>=zone.Points.Count)return;var current=zone.Points[index];_controller.SetVertex(index,new Vector2(x??current.X,y??current.Y));SetDirty();_canvas.RebuildHandles();_canvas.QueueRedraw();RefreshJson();}private void RefreshJson(){_jsonEdit.Text=_controller.ToJson();}}}

===== FILE mods/editor_tools/src/ScenarioEditor/ScenarioEditTabs.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using Cthangover.Tools.Common;using Cthangover.Tools.Services;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.UI.Tool.ScenarioEditor{/// <summary> Tabbed editor area of the scenario editor: a permanent graph tab plus one text tab per opened .scenario file, with dirty tracking and a monospace syntax-highlighted code editor with line numbers. Only one <see cref="CodeEdit"/> exists; switching tabs swaps its text with the tab's <see cref="TabData.Buffer"/>, which therefore holds unsaved edits of background tabs. </summary>
public partial class ScenarioEditTabs:VBoxContainer{private const int GraphTabIndex=0;private TabBar _tabBar;private CodeEdit _textEdit;private ScenarioGraphView _graphView;private readonly List<TabData>_tabs=new();private int _activeTabIndex= -1;private bool _suppressTextChanged;public CodeEdit CodeEditor=>_textEdit;public string CurrentText=>_textEdit.Text;public string CurrentKey=>_activeTabIndex>=0&&_activeTabIndex<_tabs.Count?_tabs[_activeTabIndex].Key:null;public string CurrentModId=>_activeTabIndex>=0&&_activeTabIndex<_tabs.Count?_tabs[_activeTabIndex].ModId:null;public string CurrentRelativePath=>_activeTabIndex>=0&&_activeTabIndex<_tabs.Count?_tabs[_activeTabIndex].RelativePath:null;public bool HasActiveFile=>_activeTabIndex>=0&&_activeTabIndex<_tabs.Count;public bool IsCurrentDirty=>_activeTabIndex>=0&&_activeTabIndex<_tabs.Count&&_tabs[_activeTabIndex].IsDirty;public event Action<string>TextTabSelected;public event Action GraphTabSelected;public event Action<bool>DirtyChanged;public event Action<string>GraphFileRequested;/// <summary>Raised on every user edit of the active text tab.</summary>
public event Action BufferChanged;public ScenarioEditTabs(ScenarioGraphView graphView){_graphView=graphView;SizeFlagsHorizontal=SizeFlags.ExpandFill;SizeFlagsVertical=SizeFlags.ExpandFill;_tabBar=new TabBar();_tabBar.TabCloseDisplayPolicy=TabBar.CloseButtonDisplayPolicy.ShowActiveOnly;_tabBar.TabSelected+=OnTabSelected;_tabBar.TabClosePressed+=OnTabClosePressed;_tabBar.AddTab(TranslationServer.Translate("tools/scenario_editor/tab_graph"));AddChild(_tabBar);_textEdit=ToolUi.Code();_textEdit.SyntaxHighlighter=ScenarioSyntaxService.CreateHighlighter();_textEdit.TextChanged+=OnTextChanged;AddChild(_textEdit);_graphView.ScenarioFileRequested+=OnGraphScenarioFileRequested;AddChild(_graphView);}/// <summary>Opens the scenario in a new tab, or just activates its existing tab.</summary>
public void OpenFile(string key){for(int i=0;i<_tabs.Count;i++){if(_tabs[i].Key==key){SwitchToTextTab(i);return;}}if(!ScenarioFileService.TryParseKey(key,out var modId,out var relativePath))return;try{var text=ScenarioFileService.ReadAllText(modId,relativePath);if(text==null){GameLogger.Log("SCENARIO_EDITOR",$"Failed to read scenario '{key}'",LogLevel.Error);return;}var tab=new TabData{Key=key,ModId=modId,RelativePath=relativePath,FileName=Path.GetFileNameWithoutExtension(relativePath),Buffer=text,IsDirty=false};_tabs.Add(tab);_tabBar.AddTab(tab.FileName);_tabBar.SetTabTooltip(_tabBar.TabCount-1,ScenarioFileService.GetDisplayPath(modId,relativePath));SwitchToTextTab(_tabs.Count-1);}catch(Exception ex){GameLogger.Log("SCENARIO_EDITOR",$"Failed to load file: {ex.Message}",LogLevel.Error);}}/// <summary>Marks the active tab clean, remembering the editor text as the saved baseline.</summary>
public void SaveCurrentFile(){if(_activeTabIndex<0||_activeTabIndex>=_tabs.Count)return;var tab=_tabs[_activeTabIndex];tab.Buffer=_textEdit.Text;if(tab.IsDirty){tab.IsDirty=false;UpdateTabTitle(_activeTabIndex);UpdateGlobalDirty();}}/// <summary>Replaces the editor text without firing the text-changed handler.</summary>
public void SetTextSilent(string text){_suppressTextChanged=true;_textEdit.Text=text;_suppressTextChanged=false;}/// <summary>Switches to the graph tab when no file tabs are open.</summary>
public void SetViewToGraph(){if(_tabs.Count>0)return;_tabBar.CurrentTab=GraphTabIndex;_textEdit.Visible=false;_graphView.Visible=true;GraphTabSelected?.Invoke();}/// <summary>Rebuilds the graph from the current scenario files, if the graph tab is visible.</summary>
public void RefreshGraph(){if(_graphView.Visible){var data=ScenarioFileService.BuildSceneGraph();_graphView.Populate(data);}}/// <summary>Returns false when any tab still has unsaved changes.</summary>
public bool ConfirmCloseAll(){for(int i=_tabs.Count-1;i>=0;i--){if(_tabs[i].IsDirty)return false;}return true;}private void OnTabSelected(long index){if(index==GraphTabIndex){SaveActiveTextEditContent();_activeTabIndex= -1;_textEdit.Visible=false;_graphView.Visible=true;GraphTabSelected?.Invoke();}else{_graphView.Visible=false;_textEdit.Visible=true;SwitchToTextTab((int)index-1);}}private void OnTabClosePressed(long index){if(index==GraphTabIndex)return;CloseTextTab((int)index-1);}private void SwitchToTextTab(int index){if(index<0||index>=_tabs.Count||index==_activeTabIndex)return;if(_activeTabIndex>=0&&_activeTabIndex<_tabs.Count)_tabs[_activeTabIndex].Buffer=_textEdit.Text;_activeTabIndex=index;_tabBar.CurrentTab=index+1;var tab=_tabs[index];SetTextSilent(tab.Buffer);TextTabSelected?.Invoke(tab.Buffer);}private void CloseTextTab(int index){if(index<0||index>=_tabs.Count)return;if(_tabs[index].IsDirty){var dialog=new ConfirmationDialog();dialog.Title=TranslationServer.Translate("tools/scenario_editor/unsaved_title");dialog.DialogText=TranslationServer.Translate("tools/scenario_editor/unsaved_switch_text");int capturedIndex=index;dialog.Confirmed+=()=>{dialog.QueueFree();DoCloseTextTab(capturedIndex);};dialog.Canceled+=()=>dialog.QueueFree();AddChild(dialog);dialog.PopupCentered();return;}DoCloseTextTab(index);}private void DoCloseTextTab(int index){_tabs.RemoveAt(index);_tabBar.RemoveTab(index+1);if(_tabs.Count==0){_activeTabIndex= -1;SetTextSilent("");TextTabSelected?.Invoke("");UpdateGlobalDirty();return;}if(index==_activeTabIndex){var newIndex=Math.Min(index,_tabs.Count-1);_activeTabIndex= -1;SwitchToTextTab(newIndex);}else if(index<_activeTabIndex){_activeTabIndex--;_tabBar.CurrentTab=_activeTabIndex+1;}UpdateGlobalDirty();}private void OnTextChanged(){if(_suppressTextChanged||_activeTabIndex<0||_activeTabIndex>=_tabs.Count)return;var tab=_tabs[_activeTabIndex];if(!tab.IsDirty){tab.IsDirty=true;UpdateTabTitle(_activeTabIndex);UpdateGlobalDirty();}BufferChanged?.Invoke();}private void UpdateTabTitle(int index){if(index<0||index>=_tabs.Count)return;var tab=_tabs[index];_tabBar.SetTabTitle(index+1,tab.IsDirty?tab.FileName+" *":tab.FileName);}private void UpdateGlobalDirty(){bool anyDirty=_tabs.Any(t=>t.IsDirty);DirtyChanged?.Invoke(anyDirty);}private void SaveActiveTextEditContent(){if(_activeTabIndex>=0&&_activeTabIndex<_tabs.Count)_tabs[_activeTabIndex].Buffer=_textEdit.Text;}private void OnGraphScenarioFileRequested(string filePath){GraphFileRequested?.Invoke(filePath);}}}

===== FILE mods/editor_tools/src/ScenarioEditor/ScenarioEditorToolProvider.cs =====
using Cthangover.Core.UI.Tool;using Godot;using Cthangover.Mods.Interface.Tool;namespace Cthangover.Core.UI.Tool.ScenarioEditor{/// <summary>Registers the scenario editor as a tool window. Creates <see cref="ScenarioEditorWindow"/> instances.</summary>
public class ScenarioEditorToolProvider:IToolProvider{/// <summary>Unique tool identifier.</summary>
public string Id=>"scenario_editor";/// <summary>Translation key for the tool's display name.</summary>
public string LocaleKey=>"tools/scenario_editor/title";/// <summary>Creates the editor window instance.</summary>
public Window CreateWindow()=>new ScenarioEditorWindow();/// <summary>Adds the scenario editor's launcher button to the main menu tool block.</summary>
public void AddMenuButton(Container container)=>ToolMenuButton.Attach(container,this);}}

===== FILE mods/editor_tools/src/ScenarioEditor/ScenarioEditorWindow.cs =====
using System;using Cthangover.Core.Scenes;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Cthangover.Mods.Interface.Theming;using Cthangover.Mods.Interface.Tool;using Cthangover.Tools.Common;using Cthangover.Tools.Services;using Godot;namespace Cthangover.Core.UI.Tool.ScenarioEditor{/// <summary> Tool window hosting the scenario editor: a searchable tree of every mod's .scenario files, tabbed code/graph editors, a metadata panel that follows the text as it is typed, background thumbnails, plus a play-test mode that runs the current scenario in-game. <para/> Shortcuts: Ctrl+S saves the active file, F5 play-tests it. </summary>
public partial class ScenarioEditorWindow:ToolWindow{private const double InfoRefreshDelay=0.4;private Control _editLayout;private ScenarioFileTreePanel _fileTreePanel;private ScenarioEditTabs _editTabs;private ScenarioInfoPanel _infoPanel;private ScenarioThumbnailPanel _thumbPanel;private Timer _infoTimer;private Button _saveBtn;private Button _openFileBtn;private Button _playBtn;private Label _filePathLabel;private bool _playActive;private string _returnRoute;private CanvasLayer _stopOverlay;public ScenarioEditorWindow():base("tools/scenario_editor/title"){CthangoverTheme.Apply(this);BuildUI();SetHint(ToolUi.Tr("tools/scenario_editor/hint"));}/// <summary>Opens with the graph view so the editor starts on the cross-scene map.</summary>
public override void _Ready(){_editTabs.SetViewToGraph();}/// <summary>Returns from play-test mode as soon as the in-game test play ends.</summary>
public override void _Process(double delta){if(_playActive&& !SceneManager.IsTestPlayActive)ReturnFromPlay();}private void BuildUI(){_editLayout=new Control{AnchorRight=1f,AnchorBottom=1f};AddChild(_editLayout);var outerVBox=CreateFillContainer();_editLayout.AddChild(outerVBox);var toolbar=CreateToolbar();outerVBox.AddChild(toolbar);_saveBtn=ToolUi.Button("tools/common/save",OnSavePressed,ButtonKind.Primary,"tools/common/save_tip");toolbar.AddChild(_saveBtn);_playBtn=ToolUi.Button("tools/scenario_editor/play",OnPlayPressed,ButtonKind.Secondary,"tools/scenario_editor/play_tooltip");toolbar.AddChild(_playBtn);_openFileBtn=ToolUi.Button("tools/scenario_editor/open_file",OnOpenFilePressed,ButtonKind.Secondary,"tools/scenario_editor/open_file_tip");toolbar.AddChild(_openFileBtn);toolbar.AddChild(ToolUi.Divider());toolbar.AddChild(ToolUi.Button("tools/scenario_editor/rescan",OnRescanPressed,ButtonKind.Secondary,"tools/scenario_editor/rescan_tip"));_filePathLabel=new Label{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,HorizontalAlignment=HorizontalAlignment.Right,VerticalAlignment=VerticalAlignment.Center,TextOverrunBehavior=TextServer.OverrunBehavior.TrimEllipsis};_filePathLabel.AddThemeFontOverride("font",ToolUi.Mono());_filePathLabel.AddThemeColorOverride("font_color",UiTokens.Muted);_filePathLabel.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);toolbar.AddChild(_filePathLabel);var mainSplit=new HSplitContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};outerVBox.AddChild(mainSplit);var leftVBox=new VBoxContainer{SizeFlagsVertical=Control.SizeFlags.ExpandFill};var leftPanel=CreateSidebar(leftVBox,320);mainSplit.AddChild(leftPanel);var tabsContainer=new TabContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill,CustomMinimumSize=new Vector2(0,420)};leftVBox.AddChild(tabsContainer);_fileTreePanel=(ScenarioFileTreePanel)TscnScenes.LoadAndBuild("scenes/scenario_file_tree_panel.tscn");_fileTreePanel.FileSelected+=OnFileSelected;tabsContainer.AddChild(_fileTreePanel);tabsContainer.SetTabTitle(0,ToolUi.Tr("tools/scenario_editor/tab_files"));_infoPanel=(ScenarioInfoPanel)TscnScenes.LoadAndBuild("scenes/scenario_info_panel.tscn");tabsContainer.AddChild(_infoPanel);tabsContainer.SetTabTitle(1,ToolUi.Tr("tools/scenario_editor/tab_info"));leftVBox.AddChild(ToolUi.Section("tools/scenario_editor/bg_previews"));_thumbPanel=(ScenarioThumbnailPanel)TscnScenes.LoadAndBuild("scenes/scenario_thumbnail_panel.tscn");leftVBox.AddChild(_thumbPanel);_editTabs=new ScenarioEditTabs(new ScenarioGraphView());_editTabs.TextTabSelected+=OnTextTabSelected;_editTabs.GraphTabSelected+=OnGraphTabSelected;_editTabs.DirtyChanged+=OnDirtyChanged;_editTabs.GraphFileRequested+=OnGraphFileRequested;_editTabs.BufferChanged+=()=>_infoTimer.Start();mainSplit.AddChild(_editTabs);_infoTimer=new Timer{OneShot=true,WaitTime=InfoRefreshDelay};_infoTimer.Timeout+=()=>_infoPanel.Update(_editTabs.CurrentText);AddChild(_infoTimer);}private void OnFileSelected(string filePath){_editTabs.OpenFile(filePath);}private void OnGraphFileRequested(string filePath){_fileTreePanel.SelectFile(filePath);}private void OnRescanPressed(){_fileTreePanel.Reload();_editTabs.RefreshGraph();SetStatus(ToolUi.Tr("tools/scenario_editor/rescanned"));}private void OnTextTabSelected(string text){var hasFile=_editTabs.CurrentKey!=null;_filePathLabel.Text=hasFile?ScenarioFileService.GetDisplayPath(_editTabs.CurrentModId,_editTabs.CurrentRelativePath):"";SetFileButtonsEnabled(hasFile);_infoPanel.Update(text);_thumbPanel.Load(text);}private void OnGraphTabSelected(){_filePathLabel.Text="";SetFileButtonsEnabled(false);_infoPanel.Clear();_thumbPanel.Clear();_editTabs.RefreshGraph();}private void SetFileButtonsEnabled(bool enabled){_saveBtn.Disabled= !enabled;_openFileBtn.Disabled= !enabled;_playBtn.Disabled= !enabled;}private void OnDirtyChanged(bool anyDirty){if(anyDirty)SetDirty();else MarkClean();}/// <summary>Ctrl+S.</summary>
protected override void OnSaveShortcut()=>OnSavePressed();/// <summary>F5 play-tests the active scenario.</summary>
protected override bool HandleKey(InputEventKey key){if(key.Keycode!=Key.F5|| !_editTabs.HasActiveFile)return false;OnPlayPressed();return true;}private void OnSavePressed(){if(!_editTabs.HasActiveFile)return;var modId=_editTabs.CurrentModId;var relativePath=_editTabs.CurrentRelativePath;var text=_editTabs.CurrentText;try{if(!ScenarioFileService.WriteAllText(modId,relativePath,text)){SetStatus(ToolUi.Tr("tools/scenario_editor/readonly_zip"),ToolStatusKind.Error);return;}_editTabs.SaveCurrentFile();SetStatus(string.Format(ToolUi.Tr("tools/common/saved"),ScenarioFileService.GetDisplayPath(modId,relativePath)),ToolStatusKind.Ok);_infoPanel.Update(text);_thumbPanel.Load(text);GameLogger.Log("SCENARIO_EDITOR",$"Saved: {modId}/{relativePath}");}catch(Exception ex){GameLogger.Log("SCENARIO_EDITOR",$"Save failed: {ex.Message}",LogLevel.Error);SetStatus(string.Format(ToolUi.Tr("tools/scenario_editor/save_error"),ex.Message),ToolStatusKind.Error);}}private void OnOpenFilePressed(){if(_editTabs.CurrentKey==null)return;var path=ScenarioFileService.GetAbsolutePath(_editTabs.CurrentModId,_editTabs.CurrentRelativePath);if(string.IsNullOrEmpty(path)){SetStatus(ToolUi.Tr("tools/scenario_editor/readonly_zip"),ToolStatusKind.Error);return;}var err=OS.ShellOpen(path);if(err!=Error.Ok){GameLogger.Log("SCENARIO_EDITOR",$"ShellOpen '{path}' failed: {err}",LogLevel.Error);SetStatus($"{ToolUi.Tr("tools/scenario_editor/open_file_error")}: {err}",ToolStatusKind.Error);}}private void OnPlayPressed(){if(!_editTabs.HasActiveFile)return;var text=_editTabs.CurrentText;var metadata=ScenarioFileService.ParseMetadata(text);if(metadata.Scene=="-"){SetStatus(ToolUi.Tr("tools/scenario_editor/no_scene_error"),ToolStatusKind.Error);return;}var modId=_editTabs.CurrentModId;var relativePath=_editTabs.CurrentRelativePath;try{if(ScenarioFileService.WriteAllText(modId,relativePath,text)){_editTabs.SaveCurrentFile();}else{GameLogger.Log("SCENARIO_EDITOR",$"Test play: '{modId}/{relativePath}' is read-only, playing from the editor buffer",LogLevel.Warning);}}catch(Exception ex){SetStatus(string.Format(ToolUi.Tr("tools/scenario_editor/save_error"),ex.Message),ToolStatusKind.Error);return;}SceneManager.IsTestPlayActive=true;SceneManager.TestScenarioText=text;GameLogger.Log("SCENARIO_EDITOR",$"Test play START: scene='{metadata.Scene}', queue={text.Split('\n').Length} lines");Visible=false;// The screen the editor was opened from (the novel_editor hub, a mod menu) is freed by the shell load below; remember its route so the play-test returns there instead of to the engine's main menu.
var sceneService=GetNodeOrNull<GodotSceneService>("/root/GodotSceneService");var current=sceneService?.CurrentGodotSceneName;_returnRoute= !string.IsNullOrEmpty(current)&&SceneRouteRegistry.TryGet(current,out _)?current:null;_stopOverlay=CreateStopOverlay();var tree=Godot.Engine.GetMainLoop()as SceneTree;tree?.Root.AddChild(_stopOverlay);var sceneManager=GetNodeOrNull<SceneManager>("/root/SceneManager");if(sceneManager!=null){sceneManager.Initialize();sceneManager.PendingSceneName=metadata.Scene;}sceneService?.LoadShell();_playActive=true;}private void OnStopPressed(){SceneManager.IsTestPlayActive=false;ReturnFromPlay();}private void ReturnFromPlay(){_playActive=false;Visible=true;if(_stopOverlay!=null){_stopOverlay.QueueFree();_stopOverlay=null;}var sceneService=GetNodeOrNull<GodotSceneService>("/root/GodotSceneService");if(_returnRoute!=null&&SceneRouteRegistry.TryGet(_returnRoute,out var route))sceneService?.SwitchRoute(route);else sceneService?.SwitchToMenu();_returnRoute=null;SetStatus(ToolUi.Tr("tools/scenario_editor/play_stopped"));}/// <summary>Stop button shown over the running game while the scenario is play-tested.</summary>
private CanvasLayer CreateStopOverlay(){var layer=new CanvasLayer{Layer=128};var btn=ToolUi.Button("tools/scenario_editor/stop",OnStopPressed,ButtonKind.Danger);btn.Theme=ToolTheme.Get();btn.CustomMinimumSize=new Vector2(120,36);var margin=new MarginContainer();margin.AddThemeConstantOverride("margin_top",UiTokens.Space3);margin.AddThemeConstantOverride("margin_left",UiTokens.Space3);margin.AddChild(btn);layer.AddChild(margin);return layer;}}}

===== FILE mods/editor_tools/src/ScenarioEditor/ScenarioFileTreePanel.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.UI.Theming;using Cthangover.Tools.Services;using Godot;namespace Cthangover.Core.UI.Tool.ScenarioEditor{/// <summary> Left-hand tree of every mod's scenario files, grouped by mod and subdirectory, with a search field that keeps only matching files (and expands their groups). Raises <see cref="FileSelected"/> with the scenario key on picks. <para/> Built from <c>scenario_file_tree_panel.tscn</c>: the tscn builder creates the script node through its constructor before the child nodes exist, so children are looked up lazily (<see cref="Bind"/>) — not in the constructor, which is what used to crash the whole editor on open. </summary>
public partial class ScenarioFileTreePanel:VBoxContainer{private Tree _fileTree;private LineEdit _search;private List<ModScenarioTree>_mods=new();private readonly Dictionary<TreeItem,string>_itemToKey=new();private readonly Dictionary<string,TreeItem>_keyToItem=new();private bool _isProgrammaticSelection;private string _selectedKey;public event Action<string>FileSelected;/// <summary>Binds child nodes and fills the tree once the panel enters the tree.</summary>
public override void _Ready(){Reload();}/// <summary>Re-scans the mods' scenario files and rebuilds the tree.</summary>
public void Reload(){Bind();_mods=ScenarioFileService.ScanScenarioFiles();Populate();}private void Bind(){if(_fileTree!=null)return;_fileTree=GetNode<Tree>("FileTree");_fileTree.CellSelected+=OnCellSelected;_search=GetNode<LineEdit>("Search");_search.PlaceholderText=TranslationServer.Translate("tools/scenario_editor/search_files");_search.TextChanged+=_=>Populate();}private void Populate(){_fileTree.Clear();_itemToKey.Clear();_keyToItem.Clear();var filter=_search.Text.Trim();var root=_fileTree.CreateItem();foreach(var mod in _mods){TreeItem modItem=null;foreach(var group in mod.Groups){TreeItem groupItem=null;foreach(var file in group.Files){if(filter.Length>0&&file.RelativePath.IndexOf(filter,StringComparison.OrdinalIgnoreCase)<0)continue;if(modItem==null){modItem=_fileTree.CreateItem(root);modItem.SetText(0,mod.ModId);modItem.SetSelectable(0,false);modItem.SetCustomColor(0,UiTokens.AccentBright);}if(groupItem==null){if(string.IsNullOrEmpty(group.DirectoryPath)){groupItem=modItem;}else{groupItem=_fileTree.CreateItem(modItem);groupItem.SetText(0,group.DirectoryPath+"/");groupItem.SetSelectable(0,false);groupItem.SetCustomColor(0,UiTokens.Muted);groupItem.Collapsed=filter.Length==0;}}var fileItem=_fileTree.CreateItem(groupItem);fileItem.SetText(0,file.Name);fileItem.SetTooltipText(0,ScenarioFileService.GetDisplayPath(file.ModId,file.RelativePath));_itemToKey[fileItem]=file.Key;_keyToItem[file.Key]=fileItem;}}}if(_selectedKey!=null&&_keyToItem.TryGetValue(_selectedKey,out var selected))SelectSilently(selected);}/// <summary>Selects the tree row of a scenario key and raises <see cref="FileSelected"/>.</summary>
public void SelectFile(string key){Bind();_selectedKey=key;if(!_keyToItem.TryGetValue(key,out var item)){_search.Text=string.Empty;Populate();if(!_keyToItem.TryGetValue(key,out item))return;}SelectSilently(item);FileSelected?.Invoke(key);}private void SelectSilently(TreeItem item){for(var parent=item.GetParent();parent!=null;parent=parent.GetParent())parent.Collapsed=false;_isProgrammaticSelection=true;item.Select(0);_fileTree.ScrollToItem(item);_isProgrammaticSelection=false;}private void OnCellSelected(){if(_isProgrammaticSelection)return;var item=_fileTree.GetSelected();if(item==null|| !_itemToKey.TryGetValue(item,out var key))return;_selectedKey=key;FileSelected?.Invoke(key);}}}

===== FILE mods/editor_tools/src/ScenarioEditor/ScenarioGraphLayout.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Tools.Services;using Godot;namespace Cthangover.Core.UI.Tool.ScenarioEditor{/// <summary> Layered left-to-right layout of the scene graph: a scene's column is its distance (in scene switches) from the start scenes — the <c>start_scene</c> node, or every scene nothing switches to when there is none — so the story reads from left to right. Scenes no start reaches are stacked in one extra column on the right. Inside a column scenes are sorted by name, which keeps the picture stable between rebuilds. </summary>
public static class ScenarioGraphLayout{private const float ColumnWidth=300f;private const float RowHeight=130f;private const float Margin=40f;/// <summary>Canvas position per lower-cased scene name.</summary>
public static Dictionary<string,Vector2>Compute(SceneGraphData data){var names=data.Nodes.Select(n=>n.SceneName.ToLowerInvariant()).Distinct().ToList();var outgoing=names.ToDictionary(n=>n,_=>new List<string>());var incoming=new HashSet<string>();foreach(var edge in data.Edges){var from=edge.FromScene.ToLowerInvariant();var to=edge.ToScene.ToLowerInvariant();if(!outgoing.ContainsKey(from)|| !outgoing.ContainsKey(to)||from==to)continue;outgoing[from].Add(to);incoming.Add(to);}var starts=data.Nodes.Where(n=>n.IsStartScene).Select(n=>n.SceneName.ToLowerInvariant()).ToList();if(starts.Count==0)starts=names.Where(n=> !incoming.Contains(n)).ToList();var depth=new Dictionary<string,int>();var queue=new Queue<string>();foreach(var start in starts){depth[start]=0;queue.Enqueue(start);}while(queue.Count>0){var current=queue.Dequeue();foreach(var next in outgoing[current]){if(depth.ContainsKey(next))continue;depth[next]=depth[current]+1;queue.Enqueue(next);}}var unreachableColumn=depth.Count>0?depth.Values.Max()+1:0;var result=new Dictionary<string,Vector2>();foreach(var column in names.GroupBy(n=>depth.TryGetValue(n,out var d)?d:unreachableColumn).OrderBy(g=>g.Key)){var row=0;foreach(var name in column.OrderBy(n=>n,StringComparer.Ordinal))result[name]=new Vector2(Margin+column.Key*ColumnWidth,Margin+row++ *RowHeight);}return result;}}}

===== FILE mods/editor_tools/src/ScenarioEditor/ScenarioGraphView.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.UI.Theming;using Cthangover.Tools.Services;using Godot;using Cthangover.Mods.Interface.Theming;namespace Cthangover.Core.UI.Tool.ScenarioEditor{/// <summary>Cross-scene graph of all scenario files: a GraphEdit of scene nodes linked by switch targets, with a detail panel for the selected scenario.</summary>
public partial class ScenarioGraphView:HSplitContainer{private GraphEdit _graph;private RichTextLabel _infoLabel;private Dictionary<string,GraphNode>_nodes;private Dictionary<string,SceneGraphNode>_lookup;public event Action<string>ScenarioFileRequested;public ScenarioGraphView(){SizeFlagsHorizontal=SizeFlags.ExpandFill;SizeFlagsVertical=SizeFlags.ExpandFill;_graph=new GraphEdit{SizeFlagsHorizontal=SizeFlags.ExpandFill,SizeFlagsVertical=SizeFlags.ExpandFill,RightDisconnects=false,ConnectionLinesThickness=2f,ConnectionLinesAntialiased=true,MinimapEnabled=true,ShowGrid=true,SnappingEnabled=true,SnappingDistance=20};_graph.ConnectionRequest+=OnConnectionRequest;_graph.DisconnectionRequest+=OnDisconnectionRequest;_graph.NodeSelected+=OnNodeSelected;_graph.NodeDeselected+=OnNodeDeselected;AddChild(_graph);var panel=new VBoxContainer{CustomMinimumSize=new Vector2(260,0)};panel.SizeFlagsVertical=SizeFlags.ExpandFill;AddChild(panel);var hint=UiKit.Hint(TranslationServer.Translate("tools/scenario_editor/graph_select_node"));hint.AutowrapMode=TextServer.AutowrapMode.WordSmart;panel.AddChild(hint);_infoLabel=new RichTextLabel{BbcodeEnabled=true,SizeFlagsVertical=SizeFlags.ExpandFill};_infoLabel.AddThemeFontSizeOverride("normal_font_size",UiTokens.FontBody);_infoLabel.AddThemeFontSizeOverride("bold_font_size",UiTokens.FontBodyLarge);_infoLabel.AddThemeColorOverride("default_color",UiTokens.Body);_infoLabel.MetaClicked+=OnLinkClicked;panel.AddChild(_infoLabel);}/// <summary>Rebuilds the graph nodes and edges, preserving the nodes' saved canvas positions.</summary>
public void Populate(SceneGraphData data){var savedPositions=SaveNodePositions();ClearGraph();_nodes=new Dictionary<string,GraphNode>();_lookup=new Dictionary<string,SceneGraphNode>();var layout=ScenarioGraphLayout.Compute(data);foreach(var nodeData in data.Nodes){var key=nodeData.SceneName.ToLowerInvariant();if(_nodes.ContainsKey(key))continue;var bgCount=nodeData.Backgrounds?.Count??0;var firstBg=bgCount>0?nodeData.Backgrounds[0]:"";var position=savedPositions.TryGetValue(key,out var saved)?saved:layout[key];var gn=new GraphNode{Title=nodeData.SceneName,Size=new Vector2(240,90),PositionOffset=position};var nodeLabel=new Label{Text=string.Format(Tr("tools/scenario_editor/graph_node"),nodeData.ModId,nodeData.ScenarioCount)+(bgCount>0?"\n"+string.Format(Tr("tools/scenario_editor/graph_node_bg"),firstBg)+(bgCount>1?$" +{bgCount - 1}":""):"")};nodeLabel.AddThemeColorOverride("font_color",UiTokens.Body);nodeLabel.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);gn.AddChild(nodeLabel);var color=UiTokens.White;if(nodeData.IsStartScene){color=UiTokens.Success;gn.AddThemeStyleboxOverride("titlebar",CreateTitlebarStyle(UiTokens.Alpha(UiTokens.Success,0.3f)));}else if(nodeData.HasErrors){color=UiTokens.DangerText;gn.AddThemeStyleboxOverride("titlebar",CreateTitlebarStyle(UiTokens.Alpha(UiTokens.Danger,0.5f)));}gn.SetSlot(0,true,0,UiTokens.White,true,0,color);_graph.AddChild(gn);_nodes[key]=gn;_lookup[key]=nodeData;}foreach(var edge in data.Edges){var srcK=edge.FromScene.ToLowerInvariant();var dstK=edge.ToScene.ToLowerInvariant();if(!_nodes.ContainsKey(srcK)|| !_nodes.ContainsKey(dstK))continue;_graph.ConnectNode(_nodes[srcK].Name,0,_nodes[dstK].Name,0);}}private Dictionary<string,Vector2>SaveNodePositions(){var positions=new Dictionary<string,Vector2>();if(_nodes==null)return positions;foreach(var kv in _nodes)positions[kv.Key]=kv.Value.PositionOffset;return positions;}private static StyleBoxFlat CreateTitlebarStyle(Color bgColor){var style=UiKit.Frame(UiTokens.RadiusMd,bgColor);style.CornerRadiusBottomLeft=0;style.CornerRadiusBottomRight=0;return style;}private void ClearGraph(){foreach(var c in _graph.GetChildren())if(c is GraphNode gn)gn.QueueFree();_infoLabel.Text="";_nodes?.Clear();_lookup?.Clear();}private void OnConnectionRequest(StringName fromNode,long fromPort,StringName toNode,long toPort){}private void OnDisconnectionRequest(StringName fromNode,long fromPort,StringName toNode,long toPort){}private void OnNodeSelected(Node n){if(n is not GraphNode gn)return;var key=gn.Title.ToString().ToLowerInvariant();if(!_lookup.TryGetValue(key,out var nodeData))return;var sb=new System.Text.StringBuilder();sb.AppendLine($"[b]{nodeData.SceneName}[/b]");sb.AppendLine(string.Format(Tr("tools/scenario_editor/graph_detail_mod"),nodeData.ModId));if(nodeData.Backgrounds.Count>0)sb.AppendLine(string.Format(Tr("tools/scenario_editor/graph_node_bg"),string.Join(", ",nodeData.Backgrounds)));sb.AppendLine();sb.AppendLine(string.Format(Tr("tools/scenario_editor/graph_detail_scenarios"),nodeData.Scenarios.Count));foreach(var sc in nodeData.Scenarios.OrderBy(s=>s.Priority)){var url=Uri.EscapeDataString(sc.Key);sb.AppendLine($"  [{sc.Priority}] [url={url}]{sc.Name}[/url]");}_infoLabel.Text=sb.ToString();}private void OnNodeDeselected(Node n){_infoLabel.Text="";}private static string Tr(string key)=>TranslationServer.Translate(key);private void OnLinkClicked(Variant meta){var path=Uri.UnescapeDataString(meta.AsString());ScenarioFileRequested?.Invoke(path);}}}

===== FILE mods/editor_tools/src/ScenarioEditor/ScenarioInfoPanel.cs =====
using System.Linq;using Cthangover.Tools.Services;using Cthangover.Core.UI.Theming;using Cthangover.Mods.Interface.Tool;using Godot;namespace Cthangover.Core.UI.Tool.ScenarioEditor{/// <summary>Read-only metadata panel for the scenario in the active text tab: scene, priority, condition, switch targets, locale keys and quest references.</summary>
public partial class ScenarioInfoPanel:VBoxContainer{private Label _infoScene;private Label _infoPriority;private Label _infoCondition;private Label _infoSwitchTargets;private Label _infoLocaleKeys;private Label _infoQuestRefs;/// <summary>Binds the rows once the panel enters the tree (see <see cref="Bind"/>).</summary>
public override void _Ready(){Bind();}/// <summary> Looks the rows up lazily: the tscn builder runs the constructor before the child nodes exist, so lookups there would fail. </summary>
private void Bind(){if(_infoScene!=null)return;SetHint("HintScene","tools/scenario_editor/info_scene");SetHint("HintPriority","tools/scenario_editor/info_priority");SetHint("HintCondition","tools/scenario_editor/info_condition");SetHint("HintSwitchTargets","tools/scenario_editor/info_switch_targets");SetHint("HintLocaleKeys","tools/scenario_editor/info_locale_keys");SetHint("HintQuestRefs","tools/scenario_editor/info_quest_refs");_infoScene=Value("InfoScene");_infoPriority=Value("InfoPriority");_infoCondition=Value("InfoCondition");_infoSwitchTargets=Value("InfoSwitchTargets");_infoLocaleKeys=Value("InfoLocaleKeys");_infoQuestRefs=Value("InfoQuestRefs");}private void SetHint(string node,string key){var label=GetNode<Label>("Scroll/VBox/"+node);label.Text=TranslationServer.Translate(key).ToString().ToUpperInvariant();label.AddThemeColorOverride("font_color",UiTokens.Muted);label.AddThemeFontSizeOverride("font_size",UiTokens.FontSmall);}private Label Value(string node){var label=GetNode<Label>("Scroll/VBox/"+node);label.AutowrapMode=TextServer.AutowrapMode.WordSmart;label.SizeFlagsHorizontal=SizeFlags.ExpandFill;label.CustomMinimumSize=new Vector2(120,0);label.AddThemeFontOverride("font",ToolWindow.GetMonospaceFont());label.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);return label;}/// <summary>Parses the given scenario text and refreshes every metadata row.</summary>
public void Update(string text){Bind();if(string.IsNullOrEmpty(text)){Clear();return;}var metadata=ScenarioFileService.ParseMetadata(text);var refs=ScenarioFileService.ExtractReferences(text);_infoScene.Text=metadata.Scene;_infoPriority.Text=metadata.Priority;_infoCondition.Text=metadata.Condition;_infoSwitchTargets.Text=refs.SwitchTargets.Count>0?string.Join(", ",refs.SwitchTargets.Distinct()):"-";_infoLocaleKeys.Text=refs.LocaleKeys.Count>0?string.Join(", ",refs.LocaleKeys.Distinct()):"-";_infoQuestRefs.Text=refs.QuestRefs.Count>0?string.Join(", ",refs.QuestRefs.Distinct()):"-";}/// <summary>Resets every metadata row to the "-" placeholder.</summary>
public void Clear(){Bind();_infoScene.Text="-";_infoPriority.Text="-";_infoCondition.Text="-";_infoSwitchTargets.Text="-";_infoLocaleKeys.Text="-";_infoQuestRefs.Text="-";}}}

===== FILE mods/editor_tools/src/ScenarioEditor/ScenarioThumbnailPanel.cs =====
using System.Linq;using Cthangover.Tools.Services;using Godot;namespace Cthangover.Core.UI.Tool.ScenarioEditor{/// <summary>Row of background thumbnails referenced by the scenario in the active text tab.</summary>
public partial class ScenarioThumbnailPanel:ScrollContainer{private HBoxContainer _thumbContainer;/// <summary> Container lookup is lazy: the tscn builder runs the constructor before the child nodes exist. </summary>
private HBoxContainer Thumbs=>_thumbContainer??=GetNode<HBoxContainer>("ThumbContainer");/// <summary>Loads the background thumbnails referenced by the given scenario text (missing ones render as placeholders).</summary>
public void Load(string text){Clear();if(string.IsNullOrEmpty(text))return;var refs=ScenarioFileService.ExtractReferences(text);if(refs.BackgroundRefs.Count==0){Thumbs.AddChild(new Label{Text=TranslationServer.Translate("tools/scenario_editor/no_backgrounds")});return;}foreach(var id in refs.BackgroundRefs.Distinct()){var tex=ModResourceService.LoadBackgroundTexture(id);if(tex!=null){Thumbs.AddChild(new TextureRect{Texture=tex,ExpandMode=TextureRect.ExpandModeEnum.IgnoreSize,StretchMode=TextureRect.StretchModeEnum.KeepAspectCentered,CustomMinimumSize=new Vector2(120,90),TooltipText=id});}else{Thumbs.AddChild(new Label{Text=$"[? {id}]",TooltipText=string.Format(TranslationServer.Translate("tools/scenario_editor/bg_missing"),id)});}}}/// <summary>Removes every thumbnail from the row.</summary>
public new void Clear(){foreach(var c in Thumbs.GetChildren())c.QueueFree();}}}

===== FILE mods/editor_tools/src/ScenarioEditor/TabData.cs =====
namespace Cthangover.Core.UI.Tool.ScenarioEditor{/// <summary> State of one open scenario file tab: identity, the current text buffer (including unsaved edits while the tab is in the background) and the dirty flag. </summary>
public sealed class TabData{public string Key;public string ModId;public string RelativePath;public string FileName;public string Buffer;public bool IsDirty;}}

===== FILE mods/editor_tools/src/SceneBuilder/InspectableProperty.cs =====
namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary> Declarative description of a single editable node property in the scene builder inspector. Instances come from <see cref="InspectorPropertyCatalog"/> and are consumed by <see cref="InspectorPanel"/> to build row editors. <see cref="Key"/> doubles as the .tscn property name written back by <see cref="TscnTextSurgery"/>, so only keys Godot's <c>Set()</c> understands may appear here — the runtime builder applies unknown keys the same way, which keeps live preview and saved file consistent. </summary>
public class InspectableProperty{public string Key{get;set;}public InspectorPropertyKind Kind{get;set;}/// <summary>Whole-number mode: committed values are rounded and serialized as .tscn ints.</summary>
public bool IsInteger{get;set;}public double Min{get;set;}= -100000d;public double Max{get;set;}=100000d;public double Step{get;set;}=1d;public long[]EnumValues{get;set;}public string[]EnumLabels{get;set;}}}

===== FILE mods/editor_tools/src/SceneBuilder/InspectorBoolField.cs =====
using System;using Godot;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary>CheckBox editor for bool properties; commits the pressed state under the bound .tscn key.</summary>
public partial class InspectorBoolField:CheckBox{public event Action<string,bool>Committed;private string _key;/// <summary>Binds the checkbox to the property key and seeds it with the current value.</summary>
public void Setup(InspectableProperty property,bool value){_key=property.Key;ButtonPressed=value;Toggled+=OnToggled;}private void OnToggled(bool pressed){Committed?.Invoke(_key,pressed);}}}

===== FILE mods/editor_tools/src/SceneBuilder/InspectorColorField.cs =====
using System;using Godot;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary>ColorPickerButton editor for color properties (modulate, self_modulate); commits RGBA including alpha edits.</summary>
public partial class InspectorColorField:ColorPickerButton{public event Action<string,Color>Committed;private string _key;/// <summary>Binds the picker to the property key and seeds it with the current color.</summary>
public void Setup(InspectableProperty property,Color value){_key=property.Key;Color=value;SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;CustomMinimumSize=new Vector2(60f,24f);ColorChanged+=OnColorChanged;}private void OnColorChanged(Color color){Committed?.Invoke(_key,color);}}}

===== FILE mods/editor_tools/src/SceneBuilder/InspectorEnumField.cs =====
using System;using Godot;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary> OptionButton-based editor for enum-valued properties (grow direction, mouse filter, ...). Maps the selected list index back to the stored numeric value from <see cref="InspectableProperty.EnumValues"/>; unknown current values leave the dropdown unselected instead of lying about state. </summary>
public partial class InspectorEnumField:OptionButton{public event Action<string,long>Committed;private InspectableProperty _property;/// <summary>Populates the dropdown from the property's enum labels and selects the current value, if known.</summary>
public void Setup(InspectableProperty property,long currentValue){_property=property;SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;for(var i=0;i<property.EnumLabels.Length;i++)AddItem(property.EnumLabels[i]);Selected=IndexOfValue(currentValue);ItemSelected+=OnItemSelected;}private int IndexOfValue(long value){if(_property.EnumValues==null)return-1;for(var i=0;i<_property.EnumValues.Length;i++){if(_property.EnumValues[i]==value)return i;}return-1;}private void OnItemSelected(long index){if(_property.EnumValues!=null&&index>=0&&index<_property.EnumValues.Length)Committed?.Invoke(_property.Key,_property.EnumValues[index]);}}}

===== FILE mods/editor_tools/src/SceneBuilder/InspectorNumericField.cs =====
using System;using Godot;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary> SpinBox-based inspector editor for scalar properties. Carries the .tscn property key and re-emits committed values through <see cref="Committed"/>, so the panel needs no per-widget closures (mod assemblies cannot rely on Godot's source-generated signal wiring). Integer mode rounds away the fractional part so enum-ish ints survive serialization. </summary>
public partial class InspectorNumericField:SpinBox{public event Action<string,double>Committed;private string _key;private bool _integer;/// <summary>Applies the property's min/max/step and integer mode, then seeds the initial (clamped) value.</summary>
public void Setup(InspectableProperty property,double initialValue){_key=property.Key;_integer=property.IsInteger;MinValue=property.Min;MaxValue=property.Max;Step=property.Step;Value=System.Math.Clamp(initialValue,property.Min,property.Max);SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;ValueChanged+=OnValueChanged;}private void OnValueChanged(double value){Committed?.Invoke(_key,_integer?System.Math.Round(value):value);}}}

===== FILE mods/editor_tools/src/SceneBuilder/InspectorPanel.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary> Godot-editor-style property inspector for the scene builder tool. Shows curated rows from <see cref="InspectorPropertyCatalog"/> for the selected mod-scene node, applies every edit to the live node via <c>Set()</c> (so the preview reacts immediately) and reports it through <see cref="PropertyEdited"/> so the host window can patch the .tscn text. Initial widget values come from <paramref name="fileValues"/> — the raw literals stored in the file — because post-layout live values would show computed sizes instead of authored anchors; keys present in the file are marked with "*". Controls managed by a Container get a warning banner and container-relevant rows only (anchors/offsets would be recomputed by the parent every frame anyway). For Control nodes an anchors-preset dropdown applies Godot's preset math, then rebuilds all rows so widgets reflect the resulting anchor/offset values; the rebuild is deferred because it frees widgets mid-signal. Host must call <see cref="ModWidget.EnsureConstructed"/> after adding the panel to the tree (ModWidget contract). </summary>
public partial class InspectorPanel:ModWidget{/// <summary>Raised after a live edit: (node, tscn property key, new value).</summary>
public event Action<Node,string,Variant>PropertyEdited;private static readonly string[]PresetNames={"Top Left","Top Right","Bottom Left","Bottom Right","Center Left","Center Top","Center Right","Center Bottom","Center","Left Wide","Top Wide","Right Wide","Bottom Wide","VCenter Wide","HCenter Wide","Full Rect"};private Node _node;private bool _suppress;private readonly Dictionary<string,InspectableProperty>_propertiesByKey=new();private Label _header;private Label _hint;private Label _banner;private HBoxContainer _presetRow;private OptionButton _presetDropdown;private VBoxContainer _rows;protected override void Construct(){// ModWidget is a plain Control: it neither sizes its child nor reports the child's height. Anchor the column to full width and mirror its height into the minimum size so the hosting ScrollContainer can scroll (and wrapped hints get real width).
var root=new VBoxContainer();root.AddThemeConstantOverride("separation",UiTokens.Space2);AddChild(root);root.SetAnchorsAndOffsetsPreset(LayoutPreset.TopWide);root.MinimumSizeChanged+=()=>CustomMinimumSize=new Vector2(0,root.GetCombinedMinimumSize().Y);_hint=new Label();_hint.AutowrapMode=TextServer.AutowrapMode.WordSmart;_hint.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;_hint.AddThemeColorOverride("font_color",UiTokens.Faint);_hint.Visible=false;root.AddChild(_hint);_header=new Label();_header.AddThemeColorOverride("font_color",UiTokens.InkBright);_header.AddThemeFontSizeOverride("font_size",UiTokens.FontBodyLarge);_header.Visible=false;root.AddChild(_header);_banner=new Label();_banner.AutowrapMode=TextServer.AutowrapMode.WordSmart;_banner.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;_banner.AddThemeColorOverride("font_color",UiTokens.Warning);_banner.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);_banner.Visible=false;root.AddChild(_banner);_presetRow=new HBoxContainer();_presetRow.Visible=false;root.AddChild(_presetRow);var presetLabel=new Label{Text=TranslationServer.Translate("tools/scene_builder/inspector_preset"),TooltipText="anchors_preset"};presetLabel.CustomMinimumSize=new Vector2(120f,0f);presetLabel.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);presetLabel.AddThemeColorOverride("font_color",UiTokens.Muted);_presetRow.AddChild(presetLabel);_presetDropdown=new OptionButton();_presetDropdown.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;for(var i=0;i<PresetNames.Length;i++)_presetDropdown.AddItem(PresetNames[i]);_presetDropdown.ItemSelected+=OnPresetSelected;_presetRow.AddChild(_presetDropdown);_rows=new VBoxContainer();_rows.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;_rows.AddThemeConstantOverride("separation",2);root.AddChild(_rows);}/// <summary> Shows editable rows for a mod-scene node. <paramref name="fileValues"/> carries the raw tscn literals of this node's section (null when the section is not in the file yet); they seed widget values and mark file-stored keys. </summary>
public void ShowNode(Node node,IReadOnlyDictionary<string,string>fileValues){EnsureConstructed();_suppress=true;_node=node;ClearBody();_propertiesByKey.Clear();_hint.Visible=false;_header.Visible=true;_header.Text=node.Name+"  ·  "+node.GetClass();var insideContainer=node is Control&&node.GetParent()is Container;_banner.Visible=insideContainer;if(insideContainer)_banner.Text=TranslationServer.Translate("tools/scene_builder/inspector_container_banner");var isControl=node is Control;_presetRow.Visible=isControl&& !insideContainer;_presetDropdown.Selected= -1;foreach(var entry in InspectorPropertyCatalog.Build(node,insideContainer)){var categoryLabel=UiKit.SectionTitle(TranslationServer.Translate(entry.CategoryKey),UiTokens.FontSmall);_rows.AddChild(categoryLabel);foreach(var property in entry.Properties){_propertiesByKey[property.Key]=property;AddPropertyRow(property,fileValues);}}_suppress=false;}/// <summary>Placeholder view (hint/read-only notice) when no editable node is selected.</summary>
public void ShowMessage(string message){EnsureConstructed();_suppress=true;_node=null;_propertiesByKey.Clear();ClearBody();_header.Visible=false;_presetRow.Visible=false;_banner.Visible=false;_hint.Visible=true;_hint.Text=message;_suppress=false;}private void AddPropertyRow(InspectableProperty property,IReadOnlyDictionary<string,string>fileValues){var storedInFile=fileValues!=null&&fileValues.ContainsKey(property.Key);string rawValue=null;if(storedInFile)fileValues.TryGetValue(property.Key,out rawValue);var row=new HBoxContainer();var labelText=Humanize(property.Key);if(storedInFile)labelText+=" *";var label=new Label{Text=labelText,TooltipText=BuildTooltip(property.Key,storedInFile),MouseFilter=MouseFilterEnum.Pass,ClipText=true};label.CustomMinimumSize=new Vector2(120f,0f);label.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);label.AddThemeColorOverride("font_color",storedInFile?UiTokens.Ink:UiTokens.Muted);label.SizeFlagsVertical=Control.SizeFlags.ShrinkCenter;row.AddChild(label);switch(property.Kind){case InspectorPropertyKind.Numeric:{var initial=rawValue!=null&&TscnLiteralReader.TryReadFloat(rawValue,out var parsed)?parsed:ReadDouble(property.Key);var field=new InspectorNumericField();field.Setup(property,initial);field.Committed+=OnNumericCommitted;row.AddChild(field);break;}case InspectorPropertyKind.Vector2:{var initial=rawValue!=null&&TscnLiteralReader.TryReadVector2(rawValue,out var parsed)?parsed:ReadVector2(property.Key);var field=new InspectorVector2Field();field.Setup(property,initial);field.Committed+=OnVectorCommitted;row.AddChild(field);break;}case InspectorPropertyKind.Bool:{var initial=rawValue!=null&&TscnLiteralReader.TryReadBool(rawValue,out var parsed)?parsed:ReadBool(property.Key);var field=new InspectorBoolField();field.Setup(property,initial);field.Committed+=OnBoolCommitted;row.AddChild(field);break;}case InspectorPropertyKind.String:{var initial=rawValue!=null?TscnLiteralReader.ReadString(rawValue):null;if(initial==null)initial=ReadString(property.Key);var field=new InspectorTextField();field.Setup(property,initial);field.Committed+=OnTextCommitted;row.AddChild(field);break;}case InspectorPropertyKind.Color:{var initial=rawValue!=null&&TscnLiteralReader.TryReadColor(rawValue,out var parsed)?parsed:ReadColor(property.Key);var field=new InspectorColorField();field.Setup(property,initial);field.Committed+=OnColorCommitted;row.AddChild(field);break;}case InspectorPropertyKind.Enum:{var initial=rawValue!=null&&TscnLiteralReader.TryReadLong(rawValue,out var parsed)?parsed:ReadLong(property.Key);var field=new InspectorEnumField();field.Setup(property,initial);field.Committed+=OnEnumCommitted;row.AddChild(field);break;}}_rows.AddChild(row);}private static string BuildTooltip(string key,bool storedInFile){return storedInFile?key+"\n"+TranslationServer.Translate("tools/scene_builder/inspector_in_file_tooltip"):key;}private void OnPresetSelected(long index){if(_suppress|| !(_node is Control control))return;control.SetAnchorsAndOffsetsPreset((Control.LayoutPreset)index,Control.LayoutPresetMode.KeepSize);if(ReadLong("layout_mode")!=1)EmitChange("layout_mode",Variant.From(1L));EmitChange("anchor_left",Variant.From(control.AnchorLeft));EmitChange("anchor_top",Variant.From(control.AnchorTop));EmitChange("anchor_right",Variant.From(control.AnchorRight));EmitChange("anchor_bottom",Variant.From(control.AnchorBottom));EmitChange("offset_left",Variant.From(control.OffsetLeft));EmitChange("offset_top",Variant.From(control.OffsetTop));EmitChange("offset_right",Variant.From(control.OffsetRight));EmitChange("offset_bottom",Variant.From(control.OffsetBottom));EmitChange("grow_horizontal",Variant.From((long)(int)control.GrowHorizontal));EmitChange("grow_vertical",Variant.From((long)(int)control.GrowVertical));// Deferred: this handler runs inside the dropdown's own signal and the resync frees the widget tree being iterated.
CallDeferred(nameof(ResyncRows));}private void ResyncRows(){if(_node==null|| !GodotObject.IsInstanceValid(_node))return;// File values may be stale here; the host refreshes rows through ShowNode after committing text, so a plain rebuild suffices.
ShowNode(_node,null);}private void EmitChange(string key,Variant value){if(_suppress||_node==null)return;_node.Set(key,value);PropertyEdited?.Invoke(_node,key,value);}private void OnNumericCommitted(string key,double value){var isInteger=_propertiesByKey.TryGetValue(key,out var prop)&&prop.IsInteger;EmitChange(key,isInteger?Variant.From((long)System.Math.Round(value)):Variant.From(value));}private void OnVectorCommitted(string key,Vector2 value){EmitChange(key,Variant.From(value));}private void OnBoolCommitted(string key,bool pressed){EmitChange(key,Variant.From(pressed));}private void OnTextCommitted(string key,string text){EmitChange(key,Variant.From(text));}private void OnColorCommitted(string key,Color color){EmitChange(key,Variant.From(color));}private void OnEnumCommitted(string key,long value){EmitChange(key,Variant.From(value));}private void ClearBody(){foreach(var child in _rows.GetChildren()){_rows.RemoveChild(child);child.Free();}}private double ReadDouble(string key){var v=_node.Get(key);return v.VariantType==Variant.Type.Nil?0d:v.AsDouble();}private long ReadLong(string key){var v=_node.Get(key);if(v.VariantType==Variant.Type.Int)return v.AsInt64();if(v.VariantType==Variant.Type.Float)return(long)v.AsDouble();return 0;}private bool ReadBool(string key){var v=_node.Get(key);return v.VariantType!=Variant.Type.Nil&&v.AsBool();}private string ReadString(string key){var v=_node.Get(key);return v.VariantType==Variant.Type.Nil?"":v.AsString();}private Color ReadColor(string key){var v=_node.Get(key);return v.VariantType==Variant.Type.Nil?UiTokens.White:v.AsColor();}private Vector2 ReadVector2(string key){var v=_node.Get(key);return v.VariantType==Variant.Type.Nil?Vector2.Zero:v.AsVector2();}private static string Humanize(string key){var parts=key.Split('_');for(var i=0;i<parts.Length;i++){if(parts[i].Length==0)continue;parts[i]=char.ToUpperInvariant(parts[i][0])+parts[i].Substring(1);}return string.Join(" ",parts);}}}

===== FILE mods/editor_tools/src/SceneBuilder/InspectorPropertyCatalog.cs =====
using System.Collections.Generic;using Godot;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary> Curated property lists shown by the scene builder inspector, mirroring the Godot editor's most-used entries. Deliberately hardcoded instead of derived from <c>ClassDB</c>: the runtime tscn builder ignores theme overrides and metadata, so a fixed allow-list guarantees every visible property is both live-applied via <c>Set()</c> and faithfully written back to the file. Two shapes: a free Control gets the layout/rect/visibility/misc set; a Control managed by a Container ignores anchors/offsets entirely (the parent recomputes them every frame), so it gets container-relevant fields instead — otherwise the inspector would show editable-looking values that do nothing, which is exactly how anchored scenes looked "wrong" before file-truth init. </summary>
public static class InspectorPropertyCatalog{/// <summary>Builds the ordered (categoryKey, properties) groups for the given node.</summary>
public static List<(string CategoryKey,List<InspectableProperty>Properties)>Build(Node node,bool insideContainer){var result=new List<(string,List<InspectableProperty>)>();if(node is Control){if(insideContainer){result.Add(("tools/scene_builder/inspector_cat_container",BuildContainer()));}else{result.Add(("tools/scene_builder/inspector_cat_layout",BuildLayout()));result.Add(("tools/scene_builder/inspector_cat_rect",BuildRect()));}result.Add(("tools/scene_builder/inspector_cat_visibility",BuildControlVisibility()));result.Add(("tools/scene_builder/inspector_cat_misc",BuildMisc()));}else if(node is CanvasItem){result.Add(("tools/scene_builder/inspector_cat_visibility",BuildCanvasItemVisibility()));}return result;}private static List<InspectableProperty>BuildContainer(){return new List<InspectableProperty>{Int("size_flags_horizontal",0,15),Int("size_flags_vertical",0,15),Vector2("custom_minimum_size",0d,16384d,1d)};}private static List<InspectableProperty>BuildLayout(){return new List<InspectableProperty>{Enum("layout_mode",new long[]{0,1,2,3},new[]{"Position","Anchors","Container","Uncontrolled"}),Float("anchor_left",-8d,8d,0.01d),Float("anchor_top",-8d,8d,0.01d),Float("anchor_right",-8d,8d,0.01d),Float("anchor_bottom",-8d,8d,0.01d),Enum("grow_horizontal",new long[]{0,1,2},new[]{"Begin","End","Both"}),Enum("grow_vertical",new long[]{0,1,2},new[]{"Begin","End","Both"}),Float("offset_left",-100000d,100000d,1d),Float("offset_top",-100000d,100000d,1d),Float("offset_right",-100000d,100000d,1d),Float("offset_bottom",-100000d,100000d,1d)};}private static List<InspectableProperty>BuildRect(){return new List<InspectableProperty>{Vector2("custom_minimum_size",0d,16384d,1d),Vector2("position",-100000d,100000d,1d),Vector2("size",0d,100000d,1d),Float("rotation",-36000d,36000d,1d),Vector2("scale",-64d,64d,0.05d),Vector2("pivot_offset",-100000d,100000d,1d)};}private static List<InspectableProperty>BuildControlVisibility(){return new List<InspectableProperty>{Bool("visible"),Color("modulate"),Color("self_modulate"),Bool("clip_contents")};}private static List<InspectableProperty>BuildCanvasItemVisibility(){return new List<InspectableProperty>{Bool("visible"),Color("modulate"),Color("self_modulate")};}private static List<InspectableProperty>BuildMisc(){return new List<InspectableProperty>{Enum("mouse_filter",new long[]{0,1,2},new[]{"Stop","Pass","Ignore"}),Enum("focus_mode",new long[]{0,1,2},new[]{"None","Click","All"}),Text("tooltip_text")};}private static InspectableProperty Float(string key,double min,double max,double step){return new InspectableProperty{Key=key,Kind=InspectorPropertyKind.Numeric,Min=min,Max=max,Step=step};}private static InspectableProperty Int(string key,int min,int max){return new InspectableProperty{Key=key,Kind=InspectorPropertyKind.Numeric,IsInteger=true,Min=min,Max=max,Step=1d};}private static InspectableProperty Vector2(string key,double min,double max,double step){return new InspectableProperty{Key=key,Kind=InspectorPropertyKind.Vector2,Min=min,Max=max,Step=step};}private static InspectableProperty Bool(string key){return new InspectableProperty{Key=key,Kind=InspectorPropertyKind.Bool};}private static InspectableProperty Text(string key){return new InspectableProperty{Key=key,Kind=InspectorPropertyKind.String};}private static InspectableProperty Color(string key){return new InspectableProperty{Key=key,Kind=InspectorPropertyKind.Color};}private static InspectableProperty Enum(string key,long[]values,string[]labels){return new InspectableProperty{Key=key,Kind=InspectorPropertyKind.Enum,IsInteger=true,EnumValues=values,EnumLabels=labels};}}}

===== FILE mods/editor_tools/src/SceneBuilder/InspectorPropertyKind.cs =====
namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary> Editing widget category for one inspector property. Drives which concrete field control <see cref="InspectorPanel"/> instantiates and which Variant shape is emitted back through <see cref="InspectorPanel.PropertyEdited"/>. </summary>
public enum InspectorPropertyKind{Numeric,Vector2,Bool,String,Color,Enum}}

===== FILE mods/editor_tools/src/SceneBuilder/InspectorTextField.cs =====
using System;using Godot;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary>LineEdit editor for string properties (tooltip_text); every keystroke commits, matching the live-preview editing model.</summary>
public partial class InspectorTextField:LineEdit{public event Action<string,string>Committed;private string _key;/// <summary>Binds the line edit to the property key and seeds it with the current text.</summary>
public void Setup(InspectableProperty property,string value){_key=property.Key;Text=value??"";SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;TextChanged+=OnTextChanged;}private void OnTextChanged(string text){Committed?.Invoke(_key,text);}}}

===== FILE mods/editor_tools/src/SceneBuilder/InspectorVector2Field.cs =====
using System;using Godot;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary> Paired x/y SpinBox row for Vector2 properties. Both axes share one commit channel: any axis change emits the full current vector, which keeps the live node, the preview and the .tscn text in sync on every keystroke. </summary>
public partial class InspectorVector2Field:HBoxContainer{public event Action<string,Vector2>Committed;private string _key;private SpinBox _x;private SpinBox _y;/// <summary>Builds the x/y spin pair bound to the property key and seeds both axes.</summary>
public void Setup(InspectableProperty property,Vector2 initialValue){_key=property.Key;SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;AddChild(new Label{Text="x",CustomMinimumSize=new Vector2(14f,0f)});_x=CreateAxis(property,initialValue.X);AddChild(new Label{Text="y",CustomMinimumSize=new Vector2(14f,0f)});_y=CreateAxis(property,initialValue.Y);}private SpinBox CreateAxis(InspectableProperty property,double initial){var box=new InspectorNumericField();box.Setup(property,initial);box.CustomMinimumSize=new Vector2(60f,0f);box.Committed+=OnAxisChanged;AddChild(box);return box;}private void OnAxisChanged(string key,double value){Committed?.Invoke(_key,new Vector2((float)_x.Value,(float)_y.Value));}}}

===== FILE mods/editor_tools/src/SceneBuilder/SceneBuilderController.cs =====
using System;using System.Collections.Generic;using System.IO;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary> Controller for the scene builder tool. Composes a preview from a base scene (a native Godot scene from <c>res://scenes</c>, or an empty root) and a mod-provided <c>.tscn</c> scene layered on top of it via the runtime tscn pipeline (<see cref="TscnSceneResolver"/>). Builds an interactive node hierarchy tree, tracks selection for the highlight overlay, and reads/writes the raw tscn source so the tool can edit and save mod scenes in place. Mod scene listing is sourced from <see cref="TscnSceneResolver.Collect"/> (deduplicated by mod load order), while base scenes come from <see cref="ModScenes.CollectTscnFiles"/>. </summary>
public class SceneBuilderController{private const string ResScenesPrefix="res://scenes/";private Node _loadedRoot;private Node _modRoot;private ColorRect _highlight;private readonly Dictionary<Node,TreeItem>_nodeToItem=new();private readonly Dictionary<TreeItem,Node>_itemToNode=new();/// <summary> Returns the native base scenes available under <c>res://scenes</c> as (Display, Path) tuples. Display is the path relative to <c>res://scenes/</c> so nested scenes remain distinguishable. </summary>
public List<(string Display,string Path)>GetBaseSceneList(){var result=new List<(string,string)>();foreach(var(_,path)in ModScenes.CollectTscnFiles()){var display=path;if(display.StartsWith(ResScenesPrefix,StringComparison.Ordinal))display=display.Substring(ResScenesPrefix.Length);result.Add((display,path));}return result;}/// <summary> Returns every mod-provided tscn scene as (ModId, Name, RelativePath) tuples, deduplicated by mod load order. RelativePath is the key used by <see cref="TscnSceneResolver.LoadAndBuild"/> (e.g. "scenes/foo.tscn"). </summary>
public List<(string ModId,string Name,string RelativePath)>GetModSceneIndex(){var result=new List<(string,string,string)>();var resolver=ModManager.Instance.GetResolver<TscnSceneResolver>();var index=resolver?.Collect();if(index==null)return result;foreach(var kvp in index){var relativePath=kvp.Key;var modId=kvp.Value.ModId;result.Add((modId,Path.GetFileName(relativePath),relativePath));}result.Sort((a,b)=>{var modCompare=string.Compare(a.Item1,b.Item1,StringComparison.OrdinalIgnoreCase);return modCompare!=0?modCompare:string.Compare(a.Item3,b.Item3,StringComparison.OrdinalIgnoreCase);});return result;}/// <summary> Loads a native base scene into <paramref name="viewport"/> as the root. Replaces any previously loaded scene and attaches the selection highlight overlay. </summary>
public Node LoadBaseScene(string path,SubViewport viewport){UnloadScene();var packed=GD.Load<PackedScene>(path);if(packed==null)return null;var instance=packed.Instantiate();viewport.AddChild(instance);AttachHighlight(instance);_loadedRoot=instance;return instance;}/// <summary> Loads an empty full-rect <see cref="Control"/> as the preview root, used when no base scene is selected so mod scenes still have a parent. </summary>
public Node LoadEmptyScene(SubViewport viewport){UnloadScene();var root=new Control{Name="EmptyScene"};root.AnchorRight=1f;root.AnchorBottom=1f;viewport.AddChild(root);AttachHighlight(root);_loadedRoot=root;return root;}/// <summary> Builds a mod scene via the runtime tscn pipeline and adds it as a child of the current base root. Returns the built node, or null if there is no base root or the scene could not be resolved. </summary>
public Node LoadModScene(string relativePath){UnloadModScene();if(_loadedRoot==null)return null;var node=TscnScenes.LoadAndBuild(relativePath);if(node==null)return null;_loadedRoot.AddChild(node);_modRoot=node;return node;}/// <summary>Reads the raw tscn source of a mod scene through its file provider.</summary>
public string ReadSceneText(string modId,string relativePath){return ModManager.Instance.ReadFileText(modId,relativePath);}/// <summary> Writes the edited tscn source back to the mod on disk. Returns false when the mod has no filesystem backing (e.g. zip mods). </summary>
public bool SaveSceneText(string modId,string relativePath,string text){var absolutePath=ModManager.Instance.GetFileSystemPath(modId,relativePath);if(string.IsNullOrEmpty(absolutePath))return false;File.WriteAllText(absolutePath,text);return true;}/// <summary> Invalidates the resolver cache for one scene so a subsequent reload re-parses the edited file instead of returning the cached build. </summary>
public void InvalidateScene(string relativePath){ModManager.Instance.GetResolver<TscnSceneResolver>()?.Invalidate(relativePath);}/// <summary>Root node of the loaded mod scene; null when no mod scene is loaded.</summary>
public Node GetModRoot(){return _modRoot;}/// <summary> Creates a live instance of the given Godot class under the parent with a sibling-unique name and returns it, or null when ClassDB cannot instantiate the type. The caller is responsible for the matching tscn write-back (and for rolling the live node back if that fails). </summary>
public Node CreateUniqueChild(Node parentNode,string typeName){if(parentNode==null||string.IsNullOrEmpty(typeName))return null;var instance=ClassDB.Instantiate(typeName).As<Node>();if(instance==null)return null;var name=typeName;var suffix=2;while(parentNode.HasNode(name))name=typeName+suffix++;instance.Name=name;parentNode.AddChild(instance);return instance;}/// <summary> Detaches the node from the preview tree immediately (so hierarchy rebuilds right away don't show a ghost) and queues it for deletion. Returns false for nodes outside the mod subtree or the mod root itself. </summary>
public bool DeleteModNode(Node node){if(!IsEditableModNode(node)||node==_modRoot)return false;var parent=node.GetParent();parent?.RemoveChild(node);node.QueueFree();return true;}/// <summary>Frees the loaded scene root (and its mod scene child) and clears mappings.</summary>
public void UnloadScene(){_nodeToItem.Clear();_itemToNode.Clear();_highlight=null;_modRoot=null;if(_loadedRoot!=null&&GodotObject.IsInstanceValid(_loadedRoot))_loadedRoot.QueueFree();_loadedRoot=null;}private void UnloadModScene(){if(_modRoot==null)return;if(_loadedRoot!=null&&GodotObject.IsInstanceValid(_loadedRoot)&&_modRoot.GetParent()==_loadedRoot)_loadedRoot.RemoveChild(_modRoot);if(GodotObject.IsInstanceValid(_modRoot))_modRoot.QueueFree();_modRoot=null;}private void AttachHighlight(Node root){_highlight=new ColorRect();_highlight.Color=UiTokens.Alpha(UiTokens.Accent,0.35f);_highlight.Visible=false;_highlight.MouseFilter=Control.MouseFilterEnum.Ignore;_highlight.ZIndex=4096;root.AddChild(_highlight);_highlight.Owner=root;}/// <summary> Recursively walks the loaded scene tree and populates the given <see cref="Tree"/> control with nodes labelled <c>Name · Class</c>: the mod scene root in gold, editable mod nodes in ink, read-only base-scene nodes faint. Skips the internal highlight rect. </summary>
public void BuildHierarchy(Tree tree){tree.Clear();_nodeToItem.Clear();_itemToNode.Clear();if(_loadedRoot==null)return;var treeRoot=tree.CreateItem();tree.HideRoot=true;AddNodeToTree(tree,treeRoot,_loadedRoot);}private void AddNodeToTree(Tree tree,TreeItem parent,Node node){var item=tree.CreateItem(parent);item.SetText(0,$"{node.Name}  ·  {node.GetClass()}");item.SetSelectable(0,true);// Mod-scene nodes are the editable ones; base-scene nodes stay faint.
item.SetCustomColor(0,IsEditableModNode(node)?UiTokens.Ink:UiTokens.Faint);if(node==_modRoot)item.SetCustomColor(0,UiTokens.AccentBright);_nodeToItem[node]=item;_itemToNode[item]=node;for(int i=0;i<node.GetChildCount();i++){var child=node.GetChild(i);if(child!=_highlight)AddNodeToTree(tree,item,child);}}/// <summary> Highlights the selected node by positioning the highlight rect over it. For <see cref="Control"/> nodes, uses <c>GetGlobalRect()</c>; for others, approximates the position via parent chain traversal. Hides the highlight if the root node itself is selected. </summary>
public void SelectNode(TreeItem item){if(_highlight==null||item==null){HideHighlight();return;}if(!_itemToNode.TryGetValue(item,out var node)){HideHighlight();return;}if(node==_loadedRoot){HideHighlight();return;}var control=node as Control;if(control!=null){var rect=control.GetGlobalRect();_highlight.Position=rect.Position;_highlight.Size=rect.Size;_highlight.Visible=true;}else{var pos=new Vector2(0,0);var current=node;while(current!=_loadedRoot&&current!=null){if(current is Control c){pos+=c.GetGlobalRect().Position;break;}current=current.GetParent();}_highlight.Position=pos;_highlight.Size=new Vector2(20,20);_highlight.Color=UiTokens.Alpha(UiTokens.Danger,0.5f);_highlight.Visible=true;}}/// <summary>Hierarchy tree item for a live node; null when unmapped.</summary>
public TreeItem GetItemForNode(Node node){_nodeToItem.TryGetValue(node,out var item);return item;}/// <summary>Reverse lookup of the hierarchy item for a live node; null when unmapped.</summary>
public Node GetNodeForItem(TreeItem item){return _itemToNode.TryGetValue(item,out var node)?node:null;}/// <summary> True when the node belongs to the loaded mod scene subtree (including the mod root itself) and therefore may be edited by the inspector. Base-scene nodes and tool internals are excluded so edits never leak into files this tool cannot save. </summary>
public bool IsEditableModNode(Node node){if(node==null||_modRoot==null||node==_highlight)return false;for(Node current=node;current!=null;current=current.GetParent()){if(current==_modRoot)return true;}return false;}/// <summary> Address path of a mod node in .tscn terms, relative to the mod scene root ("", "Panel", "Panel/Btn") — exactly what TscnTextSurgery needs to locate the matching <c>[node]</c> section. Returns "" for the root node itself (its section has no parent attribute) and null for non-editable nodes. </summary>
public string GetModNodePath(Node node){if(!IsEditableModNode(node))return null;if(node==_modRoot)return"";var segments=new List<string>();for(Node current=node;current!=null&&current!=_modRoot;current=current.GetParent())segments.Add(current.Name);segments.Reverse();return string.Join("/",segments);}/// <summary>Topmost loaded-scene node whose visual rect contains the viewport position; null when nothing matches.</summary>
public Node FindNodeAtPosition(Vector2 position){if(_loadedRoot==null)return null;return FindNodeAtPositionRecursive(_loadedRoot,position);}private Node FindNodeAtPositionRecursive(Node node,Vector2 position){for(int i=node.GetChildCount()-1;i>=0;i--){var child=node.GetChild(i);if(child==_highlight)continue;var hit=FindNodeAtPositionRecursive(child,position);if(hit!=null)return hit;}if(node==_loadedRoot)return null;if(node is Control control&&control.GetGlobalRect().HasPoint(position))return node;return null;}/// <summary>Hides the node highlight overlay and resets its colour.</summary>
public void HideHighlight(){if(_highlight==null)return;_highlight.Visible=false;_highlight.Color=UiTokens.Alpha(UiTokens.Accent,0.35f);}}}

===== FILE mods/editor_tools/src/SceneBuilder/SceneBuilderToolProvider.cs =====
using Cthangover.Core.UI.Tool;using Godot;using Cthangover.Mods.Interface.Tool;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary>Registers the scene builder as a tool window. Creates <see cref="SceneBuilderWindow"/> instances.</summary>
public class SceneBuilderToolProvider:IToolProvider{/// <summary>Unique tool identifier.</summary>
public string Id=>"scene_builder";/// <summary>Translation key for the tool's display name.</summary>
public string LocaleKey=>"tools/scene_builder/title";/// <summary>Creates the editor window instance.</summary>
public Window CreateWindow()=>new SceneBuilderWindow();/// <summary>Adds the scene builder's launcher button to the main menu tool block.</summary>
public void AddMenuButton(Container container)=>ToolMenuButton.Attach(container,this);}}

===== FILE mods/editor_tools/src/SceneBuilder/SceneBuilderWindow.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Theming;using Cthangover.Mods.Interface.Tool;using Cthangover.Tools.Common;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary> Developer tool window for interactively previewing a mod <c>.tscn</c> scene layered on top of a base game scene. Features three chained selectors (base scene → mod → scene-in-mod), a live viewport with a selectable node hierarchy tree, draggable resize/anchor handles over the preview, and one compact tabbed sidebar holding the property inspector and the tscn source editor (edits applied live, debounced auto-save), plus an always-visible output strip for status and errors. All logic lives in <see cref="SceneBuilderController"/>. </summary>
public partial class SceneBuilderWindow:ToolWindow{private SceneBuilderController _controller;private OptionButton _baseDropdown;private OptionButton _modDropdown;private OptionButton _sceneDropdown;private Tree _hierarchyTree;private SubViewport _viewport;private TabContainer _sideTabs;private InspectorPanel _inspector;private SelectionOverlay _selectionOverlay;private Timer _autosaveTimer;private Node _selectedNode;private TscnFileProps _fileProps=TscnFileProps.Build(null);private CodeEdit _tscnEditor;private OptionButton _newNodeTypeDropdown;private int _shownBase;private int _shownMod;private int _shownScene;private bool _loadingText;/// <summary>Logical game resolution (project.godot) the preview canvas is laid out at.</summary>
private static readonly Vector2I GameResolution=new(1920,1024);/// <summary>Curated palette for node creation — common Control classes the runtime builder instantiates via ClassDB.</summary>
private static readonly string[]CreatableTypes={"Control","Label","Button","Panel","ColorRect","TextureRect","RichTextLabel","LineEdit","VBoxContainer","HBoxContainer","GridContainer","MarginContainer","CenterContainer","ScrollContainer"};private List<(string Display,string Path)>_baseScenes;private List<(string ModId,string Name,string RelativePath)>_modScenes;private List<string>_mods;private List<(string ModId,string Name,string RelativePath)>_currentModScenes=new();/// <summary>Constructs the window, creates the controller, builds UI, and populates selectors.</summary>
public SceneBuilderWindow():base("tools/scene_builder/title"){CthangoverTheme.Apply(this);_controller=new SceneBuilderController();BuildUI();PopulateBaseList();PopulateModList();RebuildPreview();SetHint(ToolUi.Tr("tools/scene_builder/hint"));}private void BuildUI(){var outerVBox=CreateFillContainer();AddChild(outerVBox);var toolbar=CreateToolbar();outerVBox.AddChild(toolbar);_baseDropdown=Dropdown(toolbar,"tools/scene_builder/base_scene",OnBaseSelected);_modDropdown=Dropdown(toolbar,"tools/scene_builder/mod",OnModSelected);_sceneDropdown=Dropdown(toolbar,"tools/scene_builder/scene",OnSceneSelected);toolbar.AddChild(ToolUi.Divider());toolbar.AddChild(ToolUi.Button("tools/common/save",OnSavePressed,ButtonKind.Primary,"tools/scene_builder/save_tip"));toolbar.AddChild(ToolUi.Button("tools/scene_builder/reload",OnReloadPressed,ButtonKind.Secondary,"tools/scene_builder/reload_tip"));var mainSplit=new HSplitContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};outerVBox.AddChild(mainSplit);mainSplit.AddChild(BuildHierarchyPanel());var centerSplit=new HSplitContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill};mainSplit.AddChild(centerSplit);centerSplit.AddChild(BuildPreview());centerSplit.AddChild(BuildSidePanel());_autosaveTimer=new Timer{OneShot=true,WaitTime=0.4};_autosaveTimer.Timeout+=OnAutosaveTimeout;AddChild(_autosaveTimer);}private static OptionButton Dropdown(HBoxContainer toolbar,string tooltipKey,Action<long>selected){var dropdown=new OptionButton{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,TooltipText=ToolUi.Tr(tooltipKey),FitToLongestItem=false};dropdown.ItemSelected+=index=>selected(index);toolbar.AddChild(dropdown);return dropdown;}private Control BuildHierarchyPanel(){var column=new VBoxContainer();column.AddThemeConstantOverride("separation",UiTokens.Space2);var panel=new PanelContainer{CustomMinimumSize=new Vector2(280,0)};panel.AddThemeStyleboxOverride("panel",SidebarBox());panel.AddChild(column);column.AddChild(ToolUi.Section("tools/scene_builder/hierarchy"));var actions=new HBoxContainer();actions.AddThemeConstantOverride("separation",UiTokens.Space1);column.AddChild(actions);_newNodeTypeDropdown=new OptionButton{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,TooltipText=ToolUi.Tr("tools/scene_builder/node_type_tip")};foreach(var t in CreatableTypes)_newNodeTypeDropdown.AddItem(t);_newNodeTypeDropdown.Selected=0;actions.AddChild(_newNodeTypeDropdown);var addBtn=UiKit.IconButton("+",ToolUi.Tr("tools/scene_builder/add_node_tip"));addBtn.Pressed+=OnAddNodePressed;actions.AddChild(addBtn);var deleteBtn=UiKit.IconButton("\u2212",ToolUi.Tr("tools/scene_builder/delete_node_tip"));deleteBtn.Pressed+=OnDeleteNodePressed;actions.AddChild(deleteBtn);var refreshBtn=UiKit.IconButton("\u27F3",ToolUi.Tr("tools/scene_builder/refresh_tip"));refreshBtn.Pressed+=OnRefreshPressed;actions.AddChild(refreshBtn);_hierarchyTree=new Tree{SizeFlagsVertical=Control.SizeFlags.ExpandFill,HideRoot=true};_hierarchyTree.CellSelected+=OnTreeCellSelected;column.AddChild(_hierarchyTree);column.AddChild(ToolUi.Hint(ToolUi.Tr("tools/scene_builder/hierarchy_hint")));return panel;}/// <summary> Preview canvas. The sub-viewport lays the scene out at the game resolution (2D size override) and is scaled to fit inside an aspect-locked box, so anchors and sizes look exactly as in the game whatever the window size. The selection overlay shares the box, so its coordinate mapping is a plain scale. </summary>
private Control BuildPreview(){var previewContainer=new PanelContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};previewContainer.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusMd,UiTokens.Well,UiTokens.Line));var aspect=new AspectRatioContainer{Ratio=(float)GameResolution.X/GameResolution.Y,StretchMode=AspectRatioContainer.StretchModeEnum.Fit};previewContainer.AddChild(aspect);_viewport=new SubViewport{Size=GameResolution,Size2DOverride=GameResolution,Size2DOverrideStretch=true};var clickCatcher=new Control();clickCatcher.MouseFilter=Control.MouseFilterEnum.Stop;clickCatcher.ZIndex=4095;clickCatcher.AnchorRight=1f;clickCatcher.AnchorBottom=1f;clickCatcher.GuiInput+=OnViewportClick;_viewport.AddChild(clickCatcher);var viewportContainer=new SubViewportContainer{Stretch=true};viewportContainer.AddChild(_viewport);aspect.AddChild(viewportContainer);_selectionOverlay=new SelectionOverlay();aspect.AddChild(_selectionOverlay);_selectionOverlay.Setup(_viewport,viewportContainer,GameResolution);_selectionOverlay.PickRequested+=OnOverlayPick;_selectionOverlay.BatchCommitted+=OnOverlayBatchCommitted;_selectionOverlay.EnsureConstructed();return previewContainer;}/// <summary>Inspector and tscn source share one tabbed column.</summary>
private Control BuildSidePanel(){var panel=new PanelContainer{CustomMinimumSize=new Vector2(380,0)};panel.AddThemeStyleboxOverride("panel",SidebarBox());_sideTabs=new TabContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};panel.AddChild(_sideTabs);var inspectorScroll=new ScrollContainer{HorizontalScrollMode=ScrollContainer.ScrollMode.Disabled};_sideTabs.AddChild(inspectorScroll);_sideTabs.SetTabTitle(0,ToolUi.Tr("tools/scene_builder/tab_inspector"));_inspector=new InspectorPanel{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill};inspectorScroll.AddChild(_inspector);_inspector.PropertyEdited+=OnInspectorPropertyEdited;_inspector.EnsureConstructed();_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_select"));// Titles are set explicitly: a node name cannot hold "." (".tscn").
var tscnTab=new VBoxContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};_sideTabs.AddChild(tscnTab);_sideTabs.SetTabTitle(1,ToolUi.Tr("tools/scene_builder/tab_tscn"));_tscnEditor=ToolUi.Code();_tscnEditor.TextChanged+=OnTscnTextChanged;tscnTab.AddChild(_tscnEditor);tscnTab.AddChild(ToolUi.Hint(ToolUi.Tr("tools/scene_builder/tscn_hint")));return panel;}private void PopulateBaseList(){_baseDropdown.Clear();_baseDropdown.AddItem(ToolUi.Tr("tools/scene_builder/empty_scene"));_baseScenes=_controller.GetBaseSceneList();foreach(var s in _baseScenes)_baseDropdown.AddItem(s.Display);_baseDropdown.Selected=0;}private void PopulateModList(){_modDropdown.Clear();_modDropdown.AddItem(ToolUi.Tr("tools/scene_builder/select_mod"));_modScenes=_controller.GetModSceneIndex();_mods=_modScenes.Select(s=>s.ModId).Distinct().ToList();foreach(var modId in _mods)_modDropdown.AddItem(modId);_modDropdown.Selected=0;_sceneDropdown.Clear();_sceneDropdown.AddItem(ToolUi.Tr("tools/scene_builder/select_scene"));_sceneDropdown.Selected=0;}private void PopulateSceneListForMod(string modId){_currentModScenes=new List<(string,string,string)>();if(!string.IsNullOrEmpty(modId)){foreach(var s in _modScenes)if(s.ModId==modId)_currentModScenes.Add(s);}_sceneDropdown.Clear();_sceneDropdown.AddItem(ToolUi.Tr("tools/scene_builder/select_scene"));foreach(var s in _currentModScenes)_sceneDropdown.AddItem(s.Name);_sceneDropdown.Selected=0;}private bool TryGetSelectedModScene(out(string ModId,string Name,string RelativePath)scene){scene=default;var idx=(int)_sceneDropdown.Selected-1;if(idx<0||idx>=_currentModScenes.Count)return false;scene=_currentModScenes[idx];return true;}private void RebuildPreview(){_shownBase=_baseDropdown.Selected;_shownMod=_modDropdown.Selected;_shownScene=_sceneDropdown.Selected;var baseIdx=(int)_baseDropdown.Selected;if(baseIdx<=0||baseIdx-1>=_baseScenes.Count)_controller.LoadEmptyScene(_viewport);else _controller.LoadBaseScene(_baseScenes[baseIdx-1].Path,_viewport);if(TryGetSelectedModScene(out var scene))_controller.LoadModScene(scene.RelativePath);_controller.BuildHierarchy(_hierarchyTree);ShowTscnText();RefreshFileSnapshot();_selectedNode=null;_selectionOverlay.Track(null);_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_select"));}/// <summary>Re-parses the current editor text into the file-truth property snapshot used by the inspector.</summary>
private void RefreshFileSnapshot(){_fileProps=TscnFileProps.Build(_tscnEditor.Text);}/// <summary>Restarts the debounced auto-save; called after every inspector/overlay write-back.</summary>
private void KickAutosave(){_autosaveTimer.Stop();_autosaveTimer.Start();}private void OnAutosaveTimeout(){SaveCurrentText(true);}private void ShowTscnText(){_loadingText=true;_tscnEditor.Text="";if(TryGetSelectedModScene(out var scene)){var text=_controller.ReadSceneText(scene.ModId,scene.RelativePath);_tscnEditor.Text=text??"";}_tscnEditor.ClearUndoHistory();_loadingText=false;MarkClean();}/// <summary>Hand edits of the source mark the window dirty; they reach the preview on save.</summary>
private void OnTscnTextChanged(){if(!_loadingText)SetDirty();}private void OnBaseSelected(long index){RunDiscardingChanges(RebuildPreview,RestoreDropdowns);}private void OnModSelected(long index){RunDiscardingChanges(()=>{var modId=index==0?null:_mods[(int)index-1];PopulateSceneListForMod(modId);RebuildPreview();},RestoreDropdowns);}private void OnSceneSelected(long index){RunDiscardingChanges(RebuildPreview,RestoreDropdowns);}/// <summary>Rolls the selectors back to the scene still shown after a refused switch.</summary>
private void RestoreDropdowns(){_baseDropdown.Select(_shownBase);_modDropdown.Select(_shownMod);PopulateSceneListForMod(_shownMod<=0?null:_mods[_shownMod-1]);_sceneDropdown.Select(_shownScene);}private void OnReloadPressed(){RunDiscardingChanges(()=>{if(TryGetSelectedModScene(out var scene))_controller.InvalidateScene(scene.RelativePath);RebuildPreview();ShowOutput(ToolUi.Tr("tools/scene_builder/reloaded"),false);});}/// <summary>Ctrl+S: save the source and rebuild the preview from it.</summary>
protected override void OnSaveShortcut()=>OnSavePressed();/// <summary>Delete removes the selected mod node after a confirmation.</summary>
protected override bool HandleKey(InputEventKey key){if(key.Keycode!=Key.Delete||_selectedNode==null|| !_controller.IsEditableModNode(_selectedNode))return false;ShowConfirm("tools/scene_builder/delete_node_title","tools/scene_builder/delete_node_confirm",OnDeleteNodePressed);return true;}/// <summary> Saves the source and, when it was edited by hand, rebuilds the preview from the saved file — hand edits do not reach the live preview otherwise (inspector and overlay edits are applied live already). </summary>
private void OnSavePressed(){var handEdited=_dirty;if(!SaveCurrentText(false)|| !handEdited|| !TryGetSelectedModScene(out var scene))return;_controller.InvalidateScene(scene.RelativePath);RebuildPreview();ShowOutput(ToolUi.Tr("tools/scene_builder/saved_rebuilt"),false);}/// <summary> Writes the editor text to disk. auto=true marks the debounced inspector-driven save: failures are logged and reported, but the wording differs from the explicit Save button feedback. </summary>
private bool SaveCurrentText(bool auto){if(!TryGetSelectedModScene(out var scene)){if(!auto)ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"),true);return false;}if(_controller.SaveSceneText(scene.ModId,scene.RelativePath,_tscnEditor.Text)){MarkClean();ShowOutput(ToolUi.Tr(auto?"tools/scene_builder/autosaved":"tools/scene_builder/saved_ok"),false);return true;}ShowOutput(ToolUi.Tr("tools/scene_builder/error_save"),true);GameLogger.Log("SCENE_BUILDER",$"save failed: mod='{scene.ModId}' path='{scene.RelativePath}' (zip mods are read-only)",LogLevel.Error);return false;}private void OnRefreshPressed(){_controller.BuildHierarchy(_hierarchyTree);}private void OnTreeCellSelected(){var item=_hierarchyTree.GetSelected();if(item==null)return;_controller.SelectNode(item);SyncInspector(item);}private void OnViewportClick(InputEvent evt){if(evt is InputEventMouseButton mb&&mb.ButtonIndex==MouseButton.Left&&mb.Pressed){var node=_controller.FindNodeAtPosition(mb.Position);if(node==null)return;var item=_controller.GetItemForNode(node);if(item==null)return;var current=_hierarchyTree.GetSelected();if(current!=null)current.Deselect(0);item.Select(0);_hierarchyTree.ScrollToItem(item);_controller.SelectNode(item);SyncInspector(item);}}/// <summary> Mirrors the hierarchy selection into the inspector and the overlay: mod-scene nodes get editable rows and manipulation handles, everything else gets a read-only notice. </summary>
private void SyncInspector(TreeItem item){var node=_controller.GetNodeForItem(item);_selectedNode=node;if(node!=null&&_controller.IsEditableModNode(node)){var nodePath=_controller.GetModNodePath(node);_inspector.ShowNode(node,_fileProps.GetNodeProperties(nodePath));_selectionOverlay.Track(node as Control);_sideTabs.CurrentTab=0;}else{_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_readonly"));_selectionOverlay.Track(null);}}private void OnOverlayPick(Vector2 canvasPosition){var node=_controller.FindNodeAtPosition(canvasPosition);if(node==null)return;var item=_controller.GetItemForNode(node);if(item==null)return;SelectTreeItem(item);}/// <summary>Moves tree selection to the item and mirrors it into highlight/inspector/overlay.</summary>
private void SelectTreeItem(TreeItem item){var current=_hierarchyTree.GetSelected();if(current!=null)current.Deselect(0);item.Select(0);_hierarchyTree.ScrollToItem(item);_controller.SelectNode(item);SyncInspector(item);}private void OnAddNodePressed(){if(!TryGetSelectedModScene(out var scene)){ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"),true);return;}var typeName=CreatableTypes[System.Math.Max(_newNodeTypeDropdown.Selected,0)];var parent=ResolveAddParent();if(parent==null){ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"),true);return;}var created=_controller.CreateUniqueChild(parent,typeName);if(created==null){ShowOutput(ToolUi.Tr("tools/scene_builder/err_add_failed"),true);GameLogger.Log("SCENE_BUILDER",$"ClassDB.Instantiate failed for '{typeName}'",LogLevel.Error);return;}var props=BuildInitialNodeProps(created,parent);var parentPath=_controller.GetModNodePath(parent);if(!TscnTextSurgery.TryInsertNodeSection(_tscnEditor.Text,parentPath,created.Name,typeName,props,out var updated)){created.GetParent().RemoveChild(created);created.QueueFree();ShowOutput(ToolUi.Tr("tools/scene_builder/err_add_parent_missing"),true);GameLogger.Log("SCENE_BUILDER",$"node insert rejected: parent section '{parentPath}' absent from file",LogLevel.Warning);return;}_tscnEditor.Text=updated;RefreshFileSnapshot();KickAutosave();_controller.BuildHierarchy(_hierarchyTree);var item=_controller.GetItemForNode(created);if(item!=null)SelectTreeItem(item);}/// <summary>Selected editable mod node, falling back to the mod scene root.</summary>
private Node ResolveAddParent(){if(_selectedNode!=null&&_controller.IsEditableModNode(_selectedNode))return _selectedNode;return _controller.GetModRoot();}/// <summary> Minimal viable layout written with a fresh node: container children get container mode; free Controls get an anchors-mode box of visible size so a new node is immediately grabbable by the overlay handles. </summary>
private static List<KeyValuePair<string,string>>BuildInitialNodeProps(Node created,Node parent){var props=new List<KeyValuePair<string,string>>();if(created is Control){if(parent is Container)props.Add(new KeyValuePair<string,string>("layout_mode","2"));else{props.Add(new KeyValuePair<string,string>("layout_mode","1"));props.Add(new KeyValuePair<string,string>("offset_right","120.0"));props.Add(new KeyValuePair<string,string>("offset_bottom","40.0"));}}return props;}private void OnDeleteNodePressed(){var node=_selectedNode;if(node==null|| !_controller.IsEditableModNode(node)){ShowOutput(ToolUi.Tr("tools/scene_builder/err_del_none"),true);return;}if(node==_controller.GetModRoot()){ShowOutput(ToolUi.Tr("tools/scene_builder/err_del_root"),true);return;}var path=_controller.GetModNodePath(node);if(!_controller.DeleteModNode(node)){ShowOutput(ToolUi.Tr("tools/scene_builder/err_del_none"),true);return;}if(TscnTextSurgery.TryRemoveNodeSubtree(_tscnEditor.Text,path,out var updated)){_tscnEditor.Text=updated;RefreshFileSnapshot();KickAutosave();}else{ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_missing_section"),true);GameLogger.Log("SCENE_BUILDER",$"subtree remove failed: section '{path}' absent from file",LogLevel.Warning);}_selectedNode=null;_selectionOverlay.Track(null);_inspector.ShowMessage(ToolUi.Tr("tools/scene_builder/inspector_select"));_controller.BuildHierarchy(_hierarchyTree);}/// <summary> Applies one overlay drag result: patches every key of the batch into the tscn text, refreshes the file snapshot, inspector rows and handles, then arms the auto-save. </summary>
private void OnOverlayBatchCommitted(Node node,List<KeyValuePair<string,Variant>>batch){if(!TryGetSelectedModScene(out var scene)){ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"),true);return;}var nodePath=_controller.GetModNodePath(node);if(nodePath==null){ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_readonly"),true);GameLogger.Log("SCENE_BUILDER",$"batch rejected: '{node.Name}' is not an editable mod node",LogLevel.Warning);return;}var text=_tscnEditor.Text;foreach(var pair in batch){var serialized=TscnValueFormatter.Format(pair.Value);if(serialized==null){ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_bad_value"),true);GameLogger.Log("SCENE_BUILDER",$"unserializable value for '{pair.Key}'",LogLevel.Warning);continue;}if(!TscnTextSurgery.TryUpsertProperty(text,nodePath,pair.Key,serialized,out text)){ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_missing_section"),true);GameLogger.Log("SCENE_BUILDER",$"section not found: path='{nodePath}' key='{pair.Key}'",LogLevel.Warning);}}_tscnEditor.Text=text;RefreshFileSnapshot();RebuildInspectorFor(node);_selectionOverlay.Refresh();KickAutosave();}/// <summary> Write-back pipeline for one inspector edit: serialize the Variant into tscn syntax and patch the corresponding property line of the node's section in the source editor. Every failure is reported loudly — silent drops previously made edits look like they "did nothing". </summary>
private void OnInspectorPropertyEdited(Node node,string key,Variant value){if(!TryGetSelectedModScene(out var scene)){ShowOutput(ToolUi.Tr("tools/scene_builder/error_no_scene"),true);return;}var serialized=TscnValueFormatter.Format(value);if(serialized==null){ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_bad_value"),true);GameLogger.Log("SCENE_BUILDER",$"unserializable value for '{key}'",LogLevel.Warning);return;}var nodePath=_controller.GetModNodePath(node);if(nodePath==null){ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_readonly"),true);GameLogger.Log("SCENE_BUILDER",$"edit rejected: '{node.Name}' is not an editable mod node",LogLevel.Warning);return;}if(!TscnTextSurgery.TryUpsertProperty(_tscnEditor.Text,nodePath,key,serialized,out var updated)){ShowOutput(ToolUi.Tr("tools/scene_builder/inspector_missing_section"),true);GameLogger.Log("SCENE_BUILDER",$"section not found: path='{nodePath}' key='{key}'",LogLevel.Warning);return;}_tscnEditor.Text=updated;RefreshFileSnapshot();_selectionOverlay.Refresh();KickAutosave();}/// <summary>Full inspector resync after batch writes (drag finished, focus loss is fine).</summary>
private void RebuildInspectorFor(Node node){if(_selectedNode!=node|| !_controller.IsEditableModNode(node))return;var nodePath=_controller.GetModNodePath(node);_inspector.ShowNode(node,_fileProps.GetNodeProperties(nodePath));}private void ShowOutput(string text,bool isError){SetStatus(text,isError?ToolStatusKind.Error:ToolStatusKind.Ok);}}}

===== FILE mods/editor_tools/src/SceneBuilder/SelectionOverlay.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary> Godot-editor-style manipulation overlay drawn over the scene builder preview: 8 resize markers (corners + edges, editing offset_* respecting anchors) and 4 draggable anchor points with crosshair lines (editing anchor_* while preserving the visual rectangle). One input surface owns the whole interaction — markers are purely visual (input-ignore) and the overlay itself hit-tests them in its <c>GuiInput</c> handler — because per-marker Controls cannot capture motion outside their small rects and virtual overrides (_Process/_GuiInput) do not dispatch in mod assemblies. Pressing inside the selected rectangle and dragging moves the node (all four offsets shift together); a press that does not move is a pick. Live edits go straight onto the node during the drag; the accumulated key/value batch is emitted once on mouse release via <see cref="BatchCommitted"/> so text write-back happens in one pass. Coordinates map between the game-resolution canvas of the SubViewport and this overlay (which shares the viewport container's rectangle) by a plain scale. </summary>
public partial class SelectionOverlay:ModWidget{/// <summary>Left press that did not grab any marker: (position in viewport canvas coordinates).</summary>
public event Action<Vector2>PickRequested;/// <summary>Raised once per finished drag: (node, ordered key/value batch to persist).</summary>
public event Action<Node,List<KeyValuePair<string,Variant>>>BatchCommitted;private const float HandleHalf=5f;private const float HandleInset=8f;private const float AnchorDotHalf=3.5f;private const float AnchorLineLength=44f;private const float AnchorHitRadius=10f;private const float HandleHitPadding=5f;private const float BorderThickness=1.5f;// Resize marker indices: 0=TL corner, 1=top edge, 2=TR, 3=left, 4=right, 5=BL, 6=bottom, 7=BR. Anchor dot indices: 0=TL, 1=TR, 2=BL, 3=BR of the anchor quad.
private const int ResizeMarkerCount=8;private const int AnchorDotCount=4;private const float MoveThreshold=4f;private enum DragKind{None,Resize,Anchor,PendingMove,Move}private struct Marker{public Rect2 Rect;public bool IsAnchor;public int Index;}private SubViewport _viewport;private Control _containerArea;private Vector2 _canvasSize;private Vector2 _pressPoint;private readonly float[]_moveStartOffsets=new float[4];private Control _selected;private readonly List<Marker>_markers=new();private readonly List<ColorRect>_borderRects=new();private readonly List<ColorRect>_handleRects=new();private readonly List<ColorRect>_anchorLines=new();private readonly List<ColorRect>_anchorDots=new();private DragKind _drag=DragKind.None;private int _dragIndex;private readonly Dictionary<string,double>_pendingValues=new();/// <summary>Binds the overlay to the preview pipeline; call once before use.</summary>
public void Setup(SubViewport viewport,Control containerArea,Vector2I canvasSize){_viewport=viewport;_containerArea=containerArea;_canvasSize=canvasSize;_containerArea.Resized+=Refresh;Refresh();}/// <summary>Selects the node to manipulate; hides markers for null/non-manipulable nodes.</summary>
public void Track(Control selected){_selected=selected;_drag=DragKind.None;_pendingValues.Clear();Refresh();}protected override void Construct(){MouseFilter=Control.MouseFilterEnum.Stop;Resized+=Refresh;for(var i=0;i<4;i++)_borderRects.Add(CreateMarker(UiTokens.Alpha(UiTokens.AccentBright,0.95f)));for(var i=0;i<ResizeMarkerCount;i++)_handleRects.Add(CreateMarker(UiTokens.Accent));for(var i=0;i<AnchorDotCount*2;i++)_anchorLines.Add(CreateMarker(UiTokens.Alpha(UiTokens.Info,0.55f)));for(var i=0;i<AnchorDotCount;i++)_anchorDots.Add(CreateMarker(UiTokens.Info));GuiInput+=OnOverlayGui;}private ColorRect CreateMarker(Color color){var rect=new ColorRect{Color=color,MouseFilter=Control.MouseFilterEnum.Ignore};AddChild(rect);return rect;}/// <summary>Recomputes all marker geometry from the tracked node's current state.</summary>
public void Refresh(){var control=_selected as Control;var parent=control?.GetParent()as Control;var manipulable=control!=null&&GodotObject.IsInstanceValid(control)&&parent!=null&& !(parent is Container);if(!manipulable||_viewport==null||_containerArea==null){SetVisibleAll(false);_markers.Clear();return;}UpdateMarkers(control);UpdateMarkerControls();}private void UpdateMarkers(Control control){_markers.Clear();var rect=CanvasRectToOverlay(control.GetGlobalRect());for(var i=0;i<ResizeMarkerCount;i++){var center=GetHandleCenter(rect,i);_markers.Add(new Marker{Rect=new Rect2(center-new Vector2(HandleHalf+HandleHitPadding,HandleHalf+HandleHitPadding),new Vector2(HandleHalf+HandleHitPadding,HandleHalf+HandleHitPadding)*2f),IsAnchor=false,Index=i});}var parent=(Control)control.GetParent();var parentOrigin=parent.GetGlobalRect().Position;for(var i=0;i<AnchorDotCount;i++){var anchorPoint=GetAnchorCorner(parentOrigin,parent.Size,control.AnchorLeft,control.AnchorTop,control.AnchorRight,control.AnchorBottom,i);var center=CanvasToOverlay(anchorPoint);_markers.Add(new Marker{Rect=new Rect2(center-new Vector2(AnchorHitRadius,AnchorHitRadius),new Vector2(AnchorHitRadius,AnchorHitRadius)*2f),IsAnchor=true,Index=i});}}private void UpdateMarkerControls(){var anyVisible=_markers.Count>0;var rect=anyVisible?CanvasRectToOverlay(((Control)_selected).GetGlobalRect()):new Rect2();for(var i=0;i<_borderRects.Count;i++){var bar=_borderRects[i];bar.Visible=anyVisible;switch(i){case 0:bar.Position=rect.Position-new Vector2(BorderThickness,BorderThickness);bar.Size=new Vector2(rect.Size.X+BorderThickness*2f,BorderThickness);break;case 1:bar.Position=new Vector2(rect.Position.X-BorderThickness,rect.End.Y);bar.Size=new Vector2(rect.Size.X+BorderThickness*2f,BorderThickness);break;case 2:bar.Position=new Vector2(rect.Position.X-BorderThickness,rect.Position.Y);bar.Size=new Vector2(BorderThickness,rect.Size.Y);break;case 3:bar.Position=new Vector2(rect.End.X,rect.Position.Y);bar.Size=new Vector2(BorderThickness,rect.Size.Y);break;}}for(var i=0;i<_handleRects.Count;i++){var handle=_handleRects[i];handle.Visible=anyVisible;if(anyVisible)handle.Position=GetHandleCenter(rect,i)-new Vector2(HandleHalf,HandleHalf);handle.Size=new Vector2(HandleHalf*2f,HandleHalf*2f);}for(var i=0;i<AnchorDotCount;i++){var markerIdx=ResizeMarkerCount+i;var has=anyVisible&&markerIdx<_markers.Count;var dot=_anchorDots[i];var hLine=_anchorLines[i*2];var vLine=_anchorLines[i*2+1];dot.Visible=has;hLine.Visible=has;vLine.Visible=has;if(!has)continue;var center=_markers[markerIdx].Rect.GetCenter();dot.Position=center-new Vector2(AnchorDotHalf,AnchorDotHalf);dot.Size=new Vector2(AnchorDotHalf*2f,AnchorDotHalf*2f);hLine.Position=new Vector2(center.X-AnchorLineLength/2f,center.Y-0.5f);hLine.Size=new Vector2(AnchorLineLength,1f);vLine.Position=new Vector2(center.X-0.5f,center.Y-AnchorLineLength/2f);vLine.Size=new Vector2(1f,AnchorLineLength);}}private void SetVisibleAll(bool visible){foreach(var rect in _borderRects)rect.Visible=visible;foreach(var rect in _handleRects)rect.Visible=visible;foreach(var rect in _anchorLines)rect.Visible=visible;foreach(var rect in _anchorDots)rect.Visible=visible;}private static Vector2 GetHandleCenter(Rect2 rect,int index){var inset=HandleHalf+3f;switch(index){case 0:return rect.Position+new Vector2(inset,inset);case 1:return new Vector2(rect.GetCenter().X,rect.Position.Y);case 2:return new Vector2(rect.End.X-inset,rect.Position.Y+inset);case 3:return new Vector2(rect.Position.X,rect.GetCenter().Y);case 4:return new Vector2(rect.End.X,rect.GetCenter().Y);case 5:return new Vector2(rect.Position.X+inset,rect.End.Y-inset);case 6:return new Vector2(rect.GetCenter().X,rect.End.Y);default:return rect.End-new Vector2(inset,inset);}}private static Vector2 GetAnchorCorner(Vector2 parentOrigin,Vector2 parentSize,float aLeft,float aTop,float aRight,float aBottom,int index){var x=index%2==0?aLeft:aRight;var y=index<2?aTop:aBottom;return parentOrigin+new Vector2(x*parentSize.X,y*parentSize.Y);}private void OnOverlayGui(InputEvent evt){if(evt is InputEventMouseButton mb&&mb.ButtonIndex==MouseButton.Left){if(mb.Pressed)OnPress(mb.Position);else if(_drag==DragKind.PendingMove){_drag=DragKind.None;PickRequested?.Invoke(LocalToCanvas(mb.Position));}else if(_drag!=DragKind.None)FinishDrag();return;}if(evt is InputEventMouseMotion motion){if(_drag==DragKind.PendingMove&&(motion.Position-_pressPoint).Length()>=MoveThreshold)_drag=DragKind.Move;if(_drag is DragKind.Resize or DragKind.Anchor or DragKind.Move){ApplyDrag(motion.Position);Refresh();}else{UpdateHoverCursor(motion.Position);}}}private void OnPress(Vector2 localPoint){// Anchor dots win over coinciding resize markers (full-rect nodes keep their corners resizable because resize centers sit inset).
for(var i=0;i<_markers.Count;i++){if(!_markers[i].IsAnchor|| !_markers[i].Rect.HasPoint(localPoint))continue;BeginDrag(DragKind.Anchor,_markers[i].Index);AcceptEvent();return;}for(var i=0;i<_markers.Count;i++){if(_markers[i].IsAnchor|| !_markers[i].Rect.HasPoint(localPoint))continue;BeginDrag(DragKind.Resize,_markers[i].Index);AcceptEvent();return;}if(_markers.Count>0&&_selected is Control control&&CanvasRectToOverlay(control.GetGlobalRect()).HasPoint(localPoint)){BeginDrag(DragKind.PendingMove,0);_pressPoint=localPoint;_moveStartOffsets[0]=control.OffsetLeft;_moveStartOffsets[1]=control.OffsetTop;_moveStartOffsets[2]=control.OffsetRight;_moveStartOffsets[3]=control.OffsetBottom;AcceptEvent();return;}PickRequested?.Invoke(LocalToCanvas(localPoint));}private void BeginDrag(DragKind kind,int index){_drag=kind;_dragIndex=index;_pendingValues.Clear();}private void ApplyDrag(Vector2 localPoint){if(!(_selected is Control control)|| !(control.GetParent()is Control parent))return;var mouseInParent=LocalToCanvas(localPoint)-parent.GetGlobalRect().Position;var parentSize=parent.Size;if(_drag==DragKind.Resize){ApplyResizeEdge(control,parentSize,mouseInParent,_dragIndex);return;}if(_drag==DragKind.Move){var delta=LocalToCanvas(localPoint)-LocalToCanvas(_pressPoint);var dx=Mathf.Round(delta.X);var dy=Mathf.Round(delta.Y);StorePair(control,"offset_left",_moveStartOffsets[0]+dx);StorePair(control,"offset_top",_moveStartOffsets[1]+dy);StorePair(control,"offset_right",_moveStartOffsets[2]+dx);StorePair(control,"offset_bottom",_moveStartOffsets[3]+dy);return;}ApplyAnchorDrag(control,parentSize,mouseInParent,_dragIndex);}private void ApplyResizeEdge(Control control,Vector2 parentSize,Vector2 mouseInParent,int index){// Bit layout of the index: 0=TL,1=T,2=TR,3=L,4=R,5=BL,6=B,7=BR.
var topSide=index==0||index==1||index==2;var bottomSide=index==5||index==6||index==7;var leftSide=index==0||index==3||index==5;var rightSide=index==2||index==4||index==7;if(topSide)StorePair(control,"offset_top",mouseInParent.Y-control.AnchorTop*parentSize.Y);if(bottomSide)StorePair(control,"offset_bottom",mouseInParent.Y-control.AnchorBottom*parentSize.Y);if(leftSide)StorePair(control,"offset_left",mouseInParent.X-control.AnchorLeft*parentSize.X);if(rightSide)StorePair(control,"offset_right",mouseInParent.X-control.AnchorRight*parentSize.X);}private void ApplyAnchorDrag(Control control,Vector2 parentSize,Vector2 mouseInParent,int index){var moveRight=index%2==1;var moveBottom=index>=2;var newAnchorX=Mathf.Clamp(mouseInParent.X/Mathf.Max(parentSize.X,1f),0f,1f);var newAnchorY=Mathf.Clamp(mouseInParent.Y/Mathf.Max(parentSize.Y,1f),0f,1f);// Preserve the visual rectangle: shift the matching offset by the anchor delta so only the anchor moves, exactly like the Godot editor.
var oldAnchorX=moveRight?control.AnchorRight:control.AnchorLeft;var oldOffsetX=moveRight?control.OffsetRight:control.OffsetLeft;var newOffsetX=oldOffsetX+(oldAnchorX-newAnchorX)*parentSize.X;var oldAnchorY=moveBottom?control.AnchorBottom:control.AnchorTop;var oldOffsetY=moveBottom?control.OffsetBottom:control.OffsetTop;var newOffsetY=oldOffsetY+(oldAnchorY-newAnchorY)*parentSize.Y;if(moveRight){StorePair(control,"anchor_right",newAnchorX);StorePair(control,"offset_right",newOffsetX);}else{StorePair(control,"anchor_left",newAnchorX);StorePair(control,"offset_left",newOffsetX);}if(moveBottom){StorePair(control,"anchor_bottom",newAnchorY);StorePair(control,"offset_bottom",newOffsetY);}else{StorePair(control,"anchor_top",newAnchorY);StorePair(control,"offset_top",newOffsetY);}}private void StorePair(Control control,string key,double value){_pendingValues[key]=value;control.Set(key,value);}private void FinishDrag(){var node=_selected;_drag=DragKind.None;if(node==null||_pendingValues.Count==0){_pendingValues.Clear();return;}// Canonical tscn ordering: layout gate, anchors, offsets.
var batch=new List<KeyValuePair<string,Variant>>();if(_pendingValues.ContainsKey("anchor_left")&&ReadLayoutMode(node)!=1)batch.Add(new KeyValuePair<string,Variant>("layout_mode",Variant.From(1L)));foreach(var key in new[]{"anchor_left","anchor_top","anchor_right","anchor_bottom"})if(_pendingValues.TryGetValue(key,out var v))batch.Add(new KeyValuePair<string,Variant>(key,Variant.From(v)));foreach(var key in new[]{"offset_left","offset_top","offset_right","offset_bottom"})if(_pendingValues.TryGetValue(key,out var v))batch.Add(new KeyValuePair<string,Variant>(key,Variant.From(v)));_pendingValues.Clear();BatchCommitted?.Invoke(node,batch);}private static long ReadLayoutMode(Node node){var v=node.Get("layout_mode");if(v.VariantType==Variant.Type.Int)return v.AsInt64();if(v.VariantType==Variant.Type.Float)return(long)v.AsDouble();return 0;}private void UpdateHoverCursor(Vector2 localPoint){for(var i=0;i<_markers.Count;i++){if(!_markers[i].Rect.HasPoint(localPoint))continue;if(_markers[i].IsAnchor){MouseDefaultCursorShape=Control.CursorShape.Move;return;}switch(_markers[i].Index){case 0:case 7:MouseDefaultCursorShape=Control.CursorShape.Fdiagsize;return;case 2:case 5:MouseDefaultCursorShape=Control.CursorShape.Bdiagsize;return;case 1:case 6:MouseDefaultCursorShape=Control.CursorShape.Vsize;return;default:MouseDefaultCursorShape=Control.CursorShape.Hsize;return;}}MouseDefaultCursorShape=_markers.Count>0&&_selected is Control control&&CanvasRectToOverlay(control.GetGlobalRect()).HasPoint(localPoint)?Control.CursorShape.Move:Control.CursorShape.Arrow;}// ── coordinate mapping ──────────────────────────────────────────
private Vector2 ComputeStretchScale(){return new Vector2(_canvasSize.X>0?_containerArea.Size.X/_canvasSize.X:1f,_canvasSize.Y>0?_containerArea.Size.Y/_canvasSize.Y:1f);}private Vector2 CanvasToOverlay(Vector2 canvasPoint){return canvasPoint*ComputeStretchScale();}private Rect2 CanvasRectToOverlay(Rect2 canvasRect){var scale=ComputeStretchScale();return new Rect2(canvasRect.Position*scale,canvasRect.Size*scale);}private Vector2 LocalToCanvas(Vector2 localPoint){var scale=ComputeStretchScale();return new Vector2(scale.X>0.0001f?localPoint.X/scale.X:localPoint.X,scale.Y>0.0001f?localPoint.Y/scale.Y:localPoint.Y);}}}

===== FILE mods/editor_tools/src/SceneBuilder/TscnFileProps.cs =====
using System.Collections.Generic;using Cthangover.Core.Scenes.TscnParsing;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary> Snapshot of the raw node properties currently stored in a .tscn source text: node address → property key → unparsed literal. The inspector uses it to show what the FILE contains rather than post-layout live values — after Godot lays out a control its position/size reflect computed layout, which previously made anchored nodes look like they had none. Rebuild via <see cref="Build"/> whenever the source text changes. Section addressing matches <see cref="TscnTextSurgery"/> through <see cref="TscnNodeAddressing"/>. </summary>
public class TscnFileProps{private readonly Dictionary<string,Dictionary<string,string>>_nodes=new();/// <summary>Parses tscn source into the snapshot; empty snapshot for null/empty text.</summary>
public static TscnFileProps Build(string text){var result=new TscnFileProps();if(string.IsNullOrEmpty(text))return result;Dictionary<string,string>current=null;foreach(var token in new TscnLexer().Tokenize(text)){if(token.Type==TscnTokenType.SectionHeader){current=null;if(token.SectionName=="node"){var path=TscnNodeAddressing.ComputePath(token.SectionAttrs);if(path!=null&& !result._nodes.ContainsKey(path)){current=new Dictionary<string,string>();result._nodes[path]=current;}}}else if(token.Type==TscnTokenType.Property&&current!=null){if(!current.ContainsKey(token.PropertyKey))current[token.PropertyKey]=token.PropertyValue;}}return result;}/// <summary>Raw property literals of one node; null when the section is absent from the file.</summary>
public IReadOnlyDictionary<string,string>GetNodeProperties(string nodePath){return _nodes.TryGetValue(nodePath??"",out var props)?props:null;}}}

===== FILE mods/editor_tools/src/SceneBuilder/TscnLiteralReader.cs =====
using System.Globalization;using Godot;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary> Inverse of <see cref="TscnValueFormatter"/>: parses raw .tscn property literals (as stored in the file, e.g. "0.5", "Vector2(160, 50)", "\"Go\"") into display values for inspector widgets. Returns false for anything it cannot represent — object references like ExtResource("..."), sub-resources — so callers fall back to live-node values instead of showing garbage. Pure string math, safe outside the engine runtime. </summary>
public static class TscnLiteralReader{/// <summary>Parses a bare .tscn float literal; constructors and references return false.</summary>
public static bool TryReadFloat(string raw,out double value){value=0d;if(raw==null||raw.Contains('('))return false;return double.TryParse(raw.Trim(),NumberStyles.Float,CultureInfo.InvariantCulture,out value);}/// <summary>Parses a bare .tscn integer literal; floats and constructors return false.</summary>
public static bool TryReadLong(string raw,out long value){value=0;if(raw==null||raw.Contains('.')||raw.Contains('('))return false;return long.TryParse(raw.Trim(),NumberStyles.Integer,CultureInfo.InvariantCulture,out value);}/// <summary>Parses a .tscn boolean literal (true/false).</summary>
public static bool TryReadBool(string raw,out bool value){var t=raw?.Trim();if(t=="true"){value=true;return true;}if(t=="false"){value=false;return true;}value=false;return false;}/// <summary>Reads a quoted .tscn string literal; null when the raw text is not a quoted string.</summary>
public static string ReadString(string raw){if(raw==null)return null;var t=raw.Trim();if(t.Length<2||t[0]!='"'||t[t.Length-1]!='"')return null;var body=t.Substring(1,t.Length-2);return body.Replace("\\n","\n").Replace("\\r","\r").Replace("\\t","\t").Replace("\\\"","\"").Replace("\\\\","\\");}/// <summary>Parses a <c>Vector2(x, y)</c> constructor literal.</summary>
public static bool TryReadVector2(string raw,out Vector2 value){value=Vector2.Zero;if(!TryReadConstructor(raw,"Vector2",out var parts)||parts.Length!=2)return false;if(!double.TryParse(parts[0],NumberStyles.Float,CultureInfo.InvariantCulture,out var x)|| !double.TryParse(parts[1],NumberStyles.Float,CultureInfo.InvariantCulture,out var y))return false;value=new Vector2((float)x,(float)y);return true;}/// <summary>Parses a <c>Color(r, g, b[, a])</c> constructor literal.</summary>
public static bool TryReadColor(string raw,out Color value){value=Colors.White;if(!TryReadConstructor(raw,"Color",out var parts)||parts.Length is not(3 or 4))return false;if(!double.TryParse(parts[0],NumberStyles.Float,CultureInfo.InvariantCulture,out var r)|| !double.TryParse(parts[1],NumberStyles.Float,CultureInfo.InvariantCulture,out var g)|| !double.TryParse(parts[2],NumberStyles.Float,CultureInfo.InvariantCulture,out var b))return false;var a=1d;if(parts.Length==4&& !double.TryParse(parts[3],NumberStyles.Float,CultureInfo.InvariantCulture,out a))return false;value=new Color((float)r,(float)g,(float)b,(float)a);return true;}private static bool TryReadConstructor(string raw,string typeName,out string[]args){args=null;if(raw==null)return false;var t=raw.Trim();var prefix=typeName+"(";if(!t.StartsWith(prefix,System.StringComparison.Ordinal)|| !t.EndsWith(")",System.StringComparison.Ordinal))return false;var body=t.Substring(prefix.Length,t.Length-prefix.Length-1);args=body.Split(',');for(var i=0;i<args.Length;i++)args[i]=args[i].Trim();return true;}}}

===== FILE mods/editor_tools/src/SceneBuilder/TscnNodeAddressing.cs =====
using System.Collections.Generic;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary> Single source of truth for mapping a <c>[node]</c> section's attributes to the address path used across the scene builder tool (inspector selection, tscn text surgery, file property snapshots). Both <see cref="TscnTextSurgery"/> and <see cref="TscnFileProps"/> must resolve sections identically, so the rule lives here and nowhere else: no parent attribute = scene root (""), parent="." = direct child of root, otherwise parent path + "/" + name. </summary>
internal static class TscnNodeAddressing{/// <summary>Computes the node address from section attributes; null when attributes lack a usable name.</summary>
public static string ComputePath(Dictionary<string,string>attrs){if(attrs==null|| !attrs.TryGetValue("name",out var name)||string.IsNullOrEmpty(name))return null;if(!attrs.TryGetValue("parent",out var parent))return"";return parent=="."?name:parent+"/"+name;}}}

===== FILE mods/editor_tools/src/SceneBuilder/TscnTextSurgery.cs =====
using System.Collections.Generic;using Cthangover.Core.Scenes.TscnParsing;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary> Targeted line edits of raw .tscn source without a full re-serialization: the file is tokenized with <see cref="TscnLexer"/> (which reports 1-based line numbers), the affected <c>[node]</c> sections are located by their Godot address path (via <see cref="TscnNodeAddressing"/>, shared with <see cref="TscnFileProps"/>), and only the involved lines are touched. Everything outside them — comments, blank lines, formatting — survives. Beyond property upsert/removal it can insert a whole new node section right after its parent's block (parent-before-child order is what the runtime builder relies on) and delete a node subtree together with any dangling [connection] entries. Newly inserted properties follow Godot's layout convention (<c>layout_mode</c> → anchors → grow → offsets → rest) so the builder applies them in a valid sequence. </summary>
public static class TscnTextSurgery{/// <summary> Inserts or replaces one property line inside the given node's section. nodePath uses .tscn addressing relative to the scene root ("", "Panel", "Panel/Btn"); "" addresses the root node section itself. Returns false when the section does not exist in this file (e.g. override sections for instanced sub-scenes). </summary>
public static bool TryUpsertProperty(string text,string nodePath,string key,string tscnValue,out string newText){newText=text;if(text==null||string.IsNullOrEmpty(key))return false;var lines=SplitLines(text,out var crlf);var tokens=new TscnLexer().Tokenize(text);var blocks=CollectBlocks(tokens);if(!TryFindBlock(blocks,nodePath,out var block)|| !block.IsNode)return false;var replaceLine= -1;var insertAfterLine=block.Start;var group=GroupOrder(key);foreach(var token in tokens){if(token.Type!=TscnTokenType.Property||token.LineNumber<=block.Start||token.LineNumber>block.End)continue;if(token.PropertyKey==key){replaceLine=token.LineNumber;break;}if(GroupOrder(token.PropertyKey)<=group)insertAfterLine=token.LineNumber;}var newLine=key+" = "+tscnValue;if(replaceLine>=0)lines[replaceLine-1]=newLine;else lines.Insert(insertAfterLine,newLine);newText=JoinLines(lines,crlf);return true;}/// <summary> Removes one property line from the given node's section. Returns false when the section or the property line is not present. </summary>
public static bool TryRemoveProperty(string text,string nodePath,string key,out string newText){newText=text;if(text==null||string.IsNullOrEmpty(key))return false;var lines=SplitLines(text,out var crlf);var tokens=new TscnLexer().Tokenize(text);var blocks=CollectBlocks(tokens);if(!TryFindBlock(blocks,nodePath,out var block)|| !block.IsNode)return false;foreach(var token in tokens){if(token.Type!=TscnTokenType.Property||token.LineNumber<=block.Start||token.LineNumber>block.End)continue;if(token.PropertyKey!=key)continue;lines.RemoveAt(token.LineNumber-1);newText=JoinLines(lines,crlf);return true;}return false;}/// <summary> Inserts a complete <c>[node]</c> section directly after its parent's block so the runtime builder meets the parent first. parentPath "" adds a child of the scene root (parent="."). Returns false when the parent section is absent from this file or name/type contain illegal characters. </summary>
public static bool TryInsertNodeSection(string text,string parentPath,string name,string type,List<KeyValuePair<string,string>>properties,out string newText){newText=text;if(text==null||string.IsNullOrEmpty(name)||string.IsNullOrEmpty(type))return false;if(name.Contains('"')||name.Contains('[')||type.Contains('"')||type.Contains('['))return false;var lines=SplitLines(text,out var crlf);var blocks=CollectBlocks(new TscnLexer().Tokenize(text));if(!TryFindBlock(blocks,parentPath??"",out var parentBlock)|| !parentBlock.IsNode)return false;var parentAttr=string.IsNullOrEmpty(parentPath)?".":parentPath;var newBlock=new List<string>{"[node name=\""+name+"\" type=\""+type+"\" parent=\""+parentAttr+"\"]"};if(properties!=null){foreach(var pair in properties)newBlock.Add(pair.Key+" = "+pair.Value);}newBlock.Add("");// 1-based End line occupies index End-1; inserting at index End places the new block between the parent's last line and whatever follows.
lines.InsertRange(parentBlock.End,newBlock);newText=JoinLines(lines,crlf);return true;}/// <summary> Deletes the node's own section, every descendant section (path prefix) and [connection] headers pointing into the subtree. Trailing blank lines of each removed range are swallowed to avoid blank-line buildup. Returns false when no matching section exists. </summary>
public static bool TryRemoveNodeSubtree(string text,string nodePath,out string newText){newText=text;if(text==null||string.IsNullOrEmpty(nodePath))return false;var lines=SplitLines(text,out var crlf);var blocks=CollectBlocks(new TscnLexer().Tokenize(text));var subtreePrefix=nodePath+"/";// (startIndex, endExclusiveIndex) removal ranges in descending order later.
var ranges=new List<(int StartIdx,int EndIdx)>();var matched=false;foreach(var block in blocks){var inSubtree=false;if(block.IsNode&&block.Path!=null&&(block.Path==nodePath||block.Path.StartsWith(subtreePrefix,System.StringComparison.Ordinal)))inSubtree=true;if(block.IsConnection&&block.From!=null&&(block.From==nodePath||block.From.StartsWith(subtreePrefix,System.StringComparison.Ordinal)))inSubtree=true;if(!inSubtree)continue;matched=true;var startIdx=block.Start-1;var endIdx=block.End;// 0-based index of the first line AFTER the block
while(endIdx<lines.Count&&lines[endIdx].Trim().Length==0)endIdx++;ranges.Add((startIdx,endIdx));}if(!matched)return false;ranges.Sort((a,b)=>b.StartIdx.CompareTo(a.StartIdx));foreach(var range in ranges)lines.RemoveRange(range.StartIdx,range.EndIdx-range.StartIdx);newText=JoinLines(lines,crlf);return true;}private struct SectionInfo{public bool IsNode;public bool IsConnection;public string Path;public string From;public int Start;public int End;}/// <summary>Walks tokens once and produces every section block with its inclusive [Start..End] 1-based line span.</summary>
private static List<SectionInfo>CollectBlocks(List<TscnToken>tokens){var result=new List<SectionInfo>();TscnToken currentHeader=null;var lastLine= -1;for(var i=0;i<=tokens.Count;i++){var token=i<tokens.Count?tokens[i]:null;if(token==null||token.Type==TscnTokenType.SectionHeader){if(currentHeader!=null){var attrs=currentHeader.SectionAttrs??new Dictionary<string,string>();var info=new SectionInfo{Start=currentHeader.LineNumber,End=lastLine};if(currentHeader.SectionName=="node"){info.IsNode=true;info.Path=TscnNodeAddressing.ComputePath(attrs);}else if(currentHeader.SectionName=="connection"){info.IsConnection=true;attrs.TryGetValue("from",out info.From);}result.Add(info);}if(token==null)break;currentHeader=token;lastLine=token.LineNumber;}else{lastLine=token.LineNumber;}}return result;}private static bool TryFindBlock(List<SectionInfo>blocks,string nodePath,out SectionInfo block){block=default;foreach(var candidate in blocks){if(candidate.Path!=null&&candidate.Path==nodePath){block=candidate;return true;}}return false;}private static int GroupOrder(string key){if(key=="layout_mode")return 0;if(key=="anchors_preset")return 1;if(key.StartsWith("anchor_",System.StringComparison.Ordinal))return 2;if(key.StartsWith("grow_",System.StringComparison.Ordinal))return 3;if(key.StartsWith("offset_",System.StringComparison.Ordinal))return 4;return 5;}private static List<string>SplitLines(string text,out bool crlf){crlf=text.Contains("\r\n");var normalized=text.Replace("\r\n","\n").Replace('\r','\n');return new List<string>(normalized.Split('\n'));}private static string JoinLines(List<string>lines,bool crlf){var separator=crlf?"\r\n":"\n";return string.Join(separator,lines);}}}

===== FILE mods/editor_tools/src/SceneBuilder/TscnValueFormatter.cs =====
using System.Globalization;using Godot;namespace Cthangover.Core.UI.Tool.SceneBuilder{/// <summary> Serializes editor-side <see cref="Variant"/> values into .tscn literal syntax accepted by the runtime parser (<c>TscnPropertyParser</c>): floats always carry a decimal point, strings are quoted with Godot escape sequences, composite types use the <c>Type(a, b, ...)</c> constructor form. Returns null for values the text format cannot represent (non-finite floats, unsupported variants) so callers can skip the write-back instead of corrupting the scene file. </summary>
public static class TscnValueFormatter{/// <summary>Formats a Variant as a .tscn property value; null when not representable.</summary>
public static string Format(Variant value){switch(value.VariantType){case Variant.Type.Nil:return"null";case Variant.Type.Bool:return value.AsBool()?"true":"false";case Variant.Type.Int:return value.AsInt64().ToString(CultureInfo.InvariantCulture);case Variant.Type.Float:return FormatFloat(value.AsDouble());case Variant.Type.String:return Quote(value.AsString());case Variant.Type.Vector2:{var v=value.AsVector2();var x=FormatFloat(v.X);var y=FormatFloat(v.Y);return x==null||y==null?null:$"Vector2({x}, {y})";}case Variant.Type.Color:{var c=value.AsColor();var r=FormatFloat(c.R);var g=FormatFloat(c.G);var b=FormatFloat(c.B);var a=FormatFloat(c.A);return r==null||g==null||b==null||a==null?null:$"Color({r}, {g}, {b}, {a})";}default:return null;}}/// <summary> Formats a float in invariant culture with a guaranteed decimal point ("1" → "1.0"). Values pass through float precision first because every numeric property edited here originates from Godot float properties — that yields the short canonical form ("0.35" instead of the double expansion "0.34999999403953552"). Null for NaN/Infinity. </summary>
public static string FormatFloat(double v){var f=(float)v;if(!float.IsFinite(f))return null;var s=f.ToString("R",CultureInfo.InvariantCulture);if(!s.Contains('.')&& !s.Contains('e')&& !s.Contains('E'))s+=".0";return s;}private static string Quote(string s){var escaped=s.Replace("\\","\\\\").Replace("\"","\\\"").Replace("\n","\\n").Replace("\r","\\r").Replace("\t","\\t");return"\""+escaped+"\"";}}}

===== FILE mods/editor_tools/src/Services/MapFileService.cs =====
using System.Collections.Generic;using System.Linq;using System.Text.Json;using Cthangover.Core.Mods;using Cthangover.Mods.Map;using Cthangover.Core.Utils;namespace Cthangover.Tools.Services{/// <summary> (De)serialisation of world map zone files (<c>mods/&lt;mod&gt;/map/*.json</c>) for the map editor. File access itself goes through <see cref="ModFileService"/>. </summary>
public static class MapFileService{/// <summary>Folder of map zone files inside a mod.</summary>
public const string MapDir="map";/// <summary> Parses the standard <c>{"Items": [...]}</c> envelope into zones. Throws <see cref="JsonException"/> on malformed JSON so the editor can show the error instead of silently opening an empty map. </summary>
public static List<MapZone>ParseZones(string json){if(string.IsNullOrWhiteSpace(json))return new List<MapZone>();var data=JsonSerializer.Deserialize<FileData<MapZone>>(json,JsonOptionsProvider.ReadOptions);return data?.Items??new List<MapZone>();}/// <summary> Serialises zones into the standard envelope with indentation and without null fields, so hand-editing the result stays pleasant and optional properties such as <c>label_pos</c> only appear when used. </summary>
public static string BuildJson(IEnumerable<MapZone>zones){var data=new FileData<MapZone>{Items=zones?.ToList()??new List<MapZone>()};return JsonSerializer.Serialize(data,JsonOptionsProvider.WriteIndentedIgnoreNullOptions);}}}

===== FILE mods/editor_tools/src/Services/ModFileService.cs =====
using System;using System.Collections.Generic;using System.IO;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Providers;namespace Cthangover.Tools.Services{/// <summary> File access shared by the editors: listing a mod's files by folder and extension, reading them through the mod provider and writing them back. Reading works for every mod (folder, zip, test-suite subdir), writing only for folder-backed mods — the only ones with a real directory behind them. New files are created under the provider's <c>RootPath</c> because <c>GetFileSystemPath</c> reports <c>null</c> for files that do not exist yet, and an editor must be able to author a brand-new file. </summary>
public static class ModFileService{/// <summary>IDs of every loaded folder-backed (writable) mod, sorted.</summary>
public static List<string>ListWritableMods(){var result=new List<string>();var manager=ModManager.Instance;if(manager?.Mods==null)return result;foreach(var pair in manager.Mods){if(pair.Value?.FileProvider is FolderModFileProvider)result.Add(pair.Key);}result.Sort(StringComparer.OrdinalIgnoreCase);return result;}/// <summary>True when the mod is folder-backed, i.e. the editors can save into it.</summary>
public static bool IsWritable(string modId){return!string.IsNullOrEmpty(modId)&&ModManager.Instance.GetMod(modId)?.FileProvider is FolderModFileProvider;}/// <summary> Mod-relative paths of the files under <paramref name="directory"/> (recursively) ending with <paramref name="extension"/>, sorted. </summary>
public static List<string>ListFiles(string modId,string directory,string extension){var result=new List<string>();if(string.IsNullOrEmpty(modId))return result;Collect(modId,directory,extension,result);result.Sort(StringComparer.OrdinalIgnoreCase);return result;}/// <summary>Reads a file through the mod provider; <c>null</c> when missing.</summary>
public static string ReadText(string modId,string relativePath){return ModManager.Instance.ReadFileText(modId,relativePath);}/// <summary> Writes a file into a folder-backed mod, creating missing directories. Returns <c>false</c> for zip/virtual mods, unknown mods and empty paths. </summary>
public static bool WriteText(string modId,string relativePath,string text){var absolute=GetWritablePath(modId,relativePath);if(absolute==null)return false;var directory=Path.GetDirectoryName(absolute);if(!string.IsNullOrEmpty(directory))Directory.CreateDirectory(directory);File.WriteAllText(absolute,text);return true;}/// <summary> Absolute path a file of a folder-backed mod has (or would have once written); <c>null</c> for read-only mods and empty paths. </summary>
public static string GetWritablePath(string modId,string relativePath){if(string.IsNullOrEmpty(modId)||string.IsNullOrEmpty(relativePath))return null;if(ModManager.Instance.GetMod(modId)?.FileProvider is not FolderModFileProvider provider||string.IsNullOrEmpty(provider.RootPath))return null;return Path.GetFullPath(Path.Combine(provider.RootPath,relativePath.Replace('\\','/')));}private static void Collect(string modId,string directory,string extension,List<string>result){foreach(var entry in ModManager.Instance.ListFiles(modId,directory)){if(entry.EndsWith("/")){Collect(modId,entry.TrimEnd('/'),extension,result);continue;}if(entry.EndsWith(extension,StringComparison.OrdinalIgnoreCase))result.Add(entry.Replace('\\','/'));}}}}

===== FILE mods/editor_tools/src/Services/ModResourceService.cs =====
using System;using System.Collections.Generic;using System.IO;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Godot;namespace Cthangover.Tools.Services{/// <summary> Static service for discovering and loading mod background resources. Queries <see cref="Files.Collect"/> for files under <c>"backgrounds"</c>, strips extensions to produce IDs, and loads textures via <see cref="BackgroundFactory"/>. Used by the light editor, interactive editor, and scenario editor to populate background selection lists and render previews. </summary>
public static class ModResourceService{/// <summary> Returns all unique background IDs (filenames without extension) from the <c>"backgrounds"</c> file collection across all installed mods, sorted, without the <c>_albedo</c>/<c>_depth</c> lighting masks. </summary>
public static List<string>GetBackgroundIds(){var ids=new List<string>();var files=Files.Collect("backgrounds");var exts=GetExt();foreach(var kvp in files){var id=kvp.Key;var ext=Path.GetExtension(id);if(!string.IsNullOrWhiteSpace(id)&&exts.Contains(ext.ToLower())){var cleanId=id.Substring(0,id.LastIndexOf('.'));var bare=cleanId.TrimEnd('_');if(!bare.EndsWith("_albedo")&& !bare.EndsWith("_depth"))ids.Add(cleanId);}}ids.Sort(StringComparer.OrdinalIgnoreCase);return ids;}private static HashSet<string>GetExt(){var set=new HashSet<string>();foreach(var ext in ModConfig.Instance.TextureExtensions){if(string.IsNullOrWhiteSpace(ext))continue;set.Add(ext.ToLower());}return set;}/// <summary> Returns every texture logical name (filename without extension) from all configured texture groups, sorted for stable UI lists. Used by the map editor's marker icon picker: these are exactly the IDs that <c>MapZone.Image</c> accepts, because resolution also keys textures by basename. </summary>
public static List<string>GetTextureIds(){var ids=new List<string>();var textures=Textures.Collect();if(textures==null)return ids;foreach(var kvp in textures)ids.Add(kvp.Key);ids.Sort(StringComparer.OrdinalIgnoreCase);return ids;}/// <summary> Texture IDs that make sense as small icons (map markers): every texture except backgrounds and their <c>_depth</c>/<c>_albedo</c> masks. Textures are keyed by file name, so backgrounds are excluded by the file-name part of their IDs. </summary>
public static List<string>GetIconIds(){var backgrounds=new HashSet<string>(StringComparer.OrdinalIgnoreCase);foreach(var id in GetBackgroundIds())backgrounds.Add(id.Substring(id.LastIndexOf('/')+1));var result=new List<string>();foreach(var id in GetTextureIds()){if(backgrounds.Contains(id)||id.EndsWith("_depth",StringComparison.OrdinalIgnoreCase)||id.EndsWith("_albedo",StringComparison.OrdinalIgnoreCase))continue;result.Add(id);}return result;}/// <summary> Loads a background texture by ID via <see cref="BackgroundFactory.Instance.Get"/>. Returns <c>null</c> on any error (missing resource, load failure). </summary>
public static Texture2D LoadBackgroundTexture(string id){try{return BackgroundFactory.Instance.Get(id);}catch{return null;}}}}

===== FILE mods/editor_tools/src/Services/ScenarioFileService.cs =====
using System;using Cthangover.Mods.Dialog.Scenarios;using System.Collections.Generic;using System.IO;using System.Linq;using Cthangover.Core.Scenarios;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Utils;using Cthangover.Core.Mods;namespace Cthangover.Tools.Services{/// <summary>Parsed metadata from a scenario file header (before the <c>---</c> separator).</summary>
public sealed class ScenarioMetadata{/// <summary>Target scene name for the scenario.</summary>
public string Scene{get;set;}="-";/// <summary>Priority level of the scenario.</summary>
public string Priority{get;set;}="-";/// <summary>Condition string for the scenario to activate.</summary>
public string Condition{get;set;}="-";}/// <summary>Collected references extracted from scenario command lines for the info panel.</summary>
public sealed class ScenarioReferences{/// <summary>Scene switch targets referenced in commands.</summary>
public List<string>SwitchTargets{get;set;}=new();/// <summary>Localisation keys found in named parameters.</summary>
public List<string>LocaleKeys{get;set;}=new();/// <summary>Quest IDs referenced in named <c>quest_id</c> parameters.</summary>
public List<string>QuestRefs{get;set;}=new();/// <summary>Background IDs referenced in positional parameters of background-related commands.</summary>
public List<string>BackgroundRefs{get;set;}=new();}/// <summary> Describes a single scenario file found during scanning. The file is addressed by its owning mod and mod-relative path, so both folder mods and zip mods are covered; <see cref="Key"/> is the stable identity used by the editor UI. </summary>
public sealed class ScenarioFileInfo{/// <summary>Identifier of the mod that provides the file.</summary>
public string ModId{get;set;}/// <summary>Mod-relative path, e.g. <c>scenarios/start.scenario</c>.</summary>
public string RelativePath{get;set;}/// <summary>Filename without extension.</summary>
public string Name{get;set;}/// <summary>Directory path relative to the mod's <c>scenarios/</c> folder.</summary>
public string RelativeDir{get;set;}/// <summary>Stable UI identity: <c>modId:relativePath</c>.</summary>
public string Key=>ScenarioFileService.MakeKey(ModId,RelativePath);}/// <summary>A group of scenario files within the same subdirectory under <c>scenarios/</c>.</summary>
public sealed class ScenarioGroup{/// <summary>Relative directory path (empty for root-level files).</summary>
public string DirectoryPath{get;set;}/// <summary>Files in this group, sorted by name.</summary>
public List<ScenarioFileInfo>Files{get;set;}=new();}/// <summary>Represents all scenario files for one mod, grouped by subdirectory.</summary>
public sealed class ModScenarioTree{/// <summary>The mod's identifier.</summary>
public string ModId{get;set;}/// <summary>Groups of scenario files within this mod.</summary>
public List<ScenarioGroup>Groups{get;set;}=new();}/// <summary> Static service for discovering, reading, writing, and analysing scenario files. Scans every loaded mod's <c>scenarios/</c> folder through the mod file provider abstraction (<see cref="IModFileProvider"/>), so folder and zip mods are discovered identically. Writing is only possible for folder mods: zip mods are read-only, and <see cref="WriteAllText"/> reports that instead of touching the filesystem. Used by <see cref="ScenarioEditorWindow"/> for the file tree and info panel. </summary>
public static class ScenarioFileService{private const string ScenariosDir="scenarios";private const string ScenarioExtension=".scenario";private const string KeySeparator=":";/// <summary> Recursively scans every loaded mod's <c>scenarios/</c> folder for <c>.scenario</c> files via <see cref="IModFileProvider"/>. Groups results by mod ID and subdirectory. Returns an empty list when no mods are loaded or none provide scenarios. </summary>
public static List<ModScenarioTree>ScanScenarioFiles(){var result=new List<ModScenarioTree>();var manager=ModManager.Instance;foreach(var mod in manager.Mods.Values.OrderBy(m=>m.Id,StringComparer.OrdinalIgnoreCase)){var files=new List<ScenarioFileInfo>();CollectScenarioFiles(manager,mod.Id,ScenariosDir,files);if(files.Count==0)continue;var groups=files.GroupBy(f=>f.RelativeDir).OrderBy(g=>g.Key,StringComparer.OrdinalIgnoreCase).Select(g=>new ScenarioGroup{DirectoryPath=g.Key,Files=g.OrderBy(f=>f.Name,StringComparer.OrdinalIgnoreCase).ToList()}).ToList();result.Add(new ModScenarioTree{ModId=mod.Id,Groups=groups});}return result;}private static void CollectScenarioFiles(ModManager manager,string modId,string directory,List<ScenarioFileInfo>result){foreach(var entry in manager.ListFiles(modId,directory)){if(entry.EndsWith("/")){CollectScenarioFiles(manager,modId,entry.TrimEnd('/'),result);continue;}if(!entry.EndsWith(ScenarioExtension,StringComparison.OrdinalIgnoreCase))continue;var path=entry.Replace('\\','/');var lastSlash=path.LastIndexOf('/');var fileName=lastSlash>=0?path.Substring(lastSlash+1):path;var dirPath=lastSlash>=0?path.Substring(0,lastSlash):"";var relativeDir="";if(dirPath.StartsWith(ScenariosDir+"/",StringComparison.OrdinalIgnoreCase))relativeDir=dirPath.Substring(ScenariosDir.Length+1);else if(!dirPath.Equals(ScenariosDir,StringComparison.OrdinalIgnoreCase))relativeDir=dirPath;result.Add(new ScenarioFileInfo{ModId=modId,RelativePath=path,Name=Path.GetFileNameWithoutExtension(fileName),RelativeDir=relativeDir});}}/// <summary>Builds the stable UI identity for a scenario file.</summary>
public static string MakeKey(string modId,string relativePath)=>modId+KeySeparator+relativePath;/// <summary>Splits a key produced by <see cref="MakeKey"/> back into mod ID and mod-relative path.</summary>
public static bool TryParseKey(string key,out string modId,out string relativePath){modId=null;relativePath=null;if(string.IsNullOrEmpty(key))return false;var separator=key.IndexOf(KeySeparator,StringComparison.Ordinal);if(separator<=0||separator>=key.Length-1)return false;modId=key.Substring(0,separator);relativePath=key.Substring(separator+1);return true;}/// <summary> Parses the header block (before first <c>---</c> line) in YAML-like <c>key: value</c> format to extract scene, priority, and condition. </summary>
public static ScenarioMetadata ParseMetadata(string text){var meta=new ScenarioMetadata();var metaEnd=text.IndexOf("\n---");var metaText=metaEnd>=0?text.Substring(0,metaEnd):"";foreach(var line in metaText.Split('\n')){var t=line.Trim();if(t.Length==0||t.StartsWith("#"))continue;var ci=t.IndexOf(':');if(ci<=0)continue;var key=t.Substring(0,ci).Trim().ToLowerInvariant();var val=t.Substring(ci+1).Trim();switch(key){case"scene":meta.Scene=val;break;case"priority":meta.Priority=val;break;case"condition":meta.Condition=val;break;}}return meta;}/// <summary> Walks the body of the scenario (after <c>---</c>) and extracts references: background IDs, scene switch targets, locale keys, and quest IDs. Uses <see cref="ScenarioSyntaxService.ExtractCommandParams"/> to tokenise each line and <see cref="ScenarioCommandStrategyFactory"/> metadata to classify positional parameters. </summary> <param name="text">Full scenario file text including header.</param>
public static ScenarioReferences ExtractReferences(string text){var refs=new ScenarioReferences();var metaEnd=text.IndexOf("\n---");var content=metaEnd>=0?text.Substring(metaEnd+4):text;foreach(var rawLine in content.Split('\n')){var line=rawLine.Trim();if(line.Length==0||line.StartsWith("#")||line.StartsWith(":"))continue;ScenarioSyntaxService.ExtractCommandParams(line,out var cmd,out var positional,out var named);if(string.IsNullOrEmpty(cmd))continue;var meta=ScenarioCommandStrategyFactory.GetReferenceMetadata(cmd);if(meta!=null&&positional.Count>0){switch(meta.Positional0Kind){case PositionalReferenceKind.Background:refs.BackgroundRefs.Add(positional[0]);break;case PositionalReferenceKind.Scene:refs.SwitchTargets.Add(positional[0]);break;}}if(named.TryGetValue("key",out var lk))refs.LocaleKeys.Add(lk);if(named.TryGetValue("quest_id",out var qid))refs.QuestRefs.Add(qid);}return refs;}/// <summary>Reads a scenario file through its mod provider. Returns <c>null</c> when not found.</summary>
public static string ReadAllText(string modId,string relativePath){return ModManager.Instance.ReadFileText(modId,relativePath);}/// <summary> Writes a scenario file back to disk. Returns <c>false</c> when the owning mod is not filesystem-backed (zip mods are read-only). </summary>
public static bool WriteAllText(string modId,string relativePath,string text){var absolutePath=ModManager.Instance.GetFileSystemPath(modId,relativePath);if(string.IsNullOrEmpty(absolutePath))return false;File.WriteAllText(absolutePath,text);return true;}/// <summary>Absolute filesystem path of a scenario file, or <c>null</c> for zip-backed mods.</summary>
public static string GetAbsolutePath(string modId,string relativePath){return ModManager.Instance.GetFileSystemPath(modId,relativePath);}/// <summary>Project-style display path (<c>mods/&lt;modId&gt;/&lt;relativePath&gt;</c>).</summary>
public static string GetDisplayPath(string modId,string relativePath){return"mods/"+modId+"/"+relativePath;}/// <summary>Scans every mod's scenarios and builds the cross-scene graph (nodes per scene, edges per switch target).</summary>
public static SceneGraphData BuildSceneGraph(){var data=new SceneGraphData();var sceneNodes=new Dictionary<string,SceneGraphNode>(StringComparer.OrdinalIgnoreCase);var edgeSet=new HashSet<string>(StringComparer.OrdinalIgnoreCase);foreach(var mod in ScanScenarioFiles()){foreach(var group in mod.Groups){foreach(var file in group.Files){var text=ReadAllText(file.ModId,file.RelativePath);if(text==null)continue;var metadata=ParseMetadata(text);if(string.IsNullOrEmpty(metadata.Scene)||metadata.Scene=="-")continue;var sceneKey=metadata.Scene.ToLowerInvariant();if(!sceneNodes.TryGetValue(sceneKey,out var node)){node=new SceneGraphNode{SceneName=metadata.Scene,ModId=file.ModId,IsStartScene=string.Equals(metadata.Scene,"start_scene",StringComparison.OrdinalIgnoreCase)};sceneNodes[sceneKey]=node;}node.ScenarioCount++;node.Scenarios.Add(new ScenarioGraphLink{Name=file.Name,Priority=int.TryParse(metadata.Priority,out var p)?p:0,Key=file.Key});var refs=ExtractReferences(text);foreach(var target in refs.SwitchTargets){var srcKey=metadata.Scene.ToLowerInvariant();var dstKey=target.ToLowerInvariant();var edgeKey=srcKey+"|"+dstKey;if(!edgeSet.Contains(edgeKey)){edgeSet.Add(edgeKey);data.Edges.Add(new SceneGraphEdge{FromScene=metadata.Scene,ToScene=target});}}}}}foreach(var node in sceneNodes.Values)data.Nodes.Add(node);return data;}}}

===== FILE mods/editor_tools/src/Services/ScenarioGraphData.cs =====
using System.Collections.Generic;namespace Cthangover.Tools.Services{/// <summary>One scenario attached to a scene node (shown on the graph node and in its detail links).</summary>
public sealed class ScenarioGraphLink{public string Name{get;set;}public int Priority{get;set;}public string Key{get;set;}}/// <summary>One scene of the cross-scenario graph: owning mod, background list, start-scene flag and its scenarios.</summary>
public sealed class SceneGraphNode{public string SceneName{get;set;}public string ModId{get;set;}public int ScenarioCount{get;set;}public List<string>Backgrounds{get;set;}=new();public bool IsStartScene{get;set;}public List<ScenarioGraphLink>Scenarios{get;set;}=new();public bool HasErrors{get;set;}}/// <summary>Directed switch-target link between two scenes of the graph.</summary>
public sealed class SceneGraphEdge{public string FromScene{get;set;}public string ToScene{get;set;}}/// <summary>Complete graph model consumed by <c>ScenarioGraphView</c>.</summary>
public sealed class SceneGraphData{public List<SceneGraphNode>Nodes{get;set;}=new();public List<SceneGraphEdge>Edges{get;set;}=new();}}

===== FILE mods/editor_tools/src/Services/ScenarioSyntaxService.cs =====
using System.Collections.Generic;using Cthangover.Mods.Dialog.Scenarios;using Cthangover.Core.Scenarios;using Cthangover.Core.Factories.Impls;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Tools.Services{/// <summary> Static service providing scenario DSL syntax highlighting, tokenisation, and command-line parsing. <see cref="CreateHighlighter"/> produces a <see cref="CodeHighlighter"/> for the scenario editor's <see cref="TextEdit"/>. <see cref="ExtractCommandParams"/> tokenises a scenario command line into a command name, positional arguments, and named (<c>key=value</c>) parameters — used by <see cref="ScenarioFileService.ExtractReferences"/> for reference analysis. </summary>
public static class ScenarioSyntaxService{/// <summary> Builds a <see cref="CodeHighlighter"/> for scenario DSL syntax. Comments (lines starting with <c>#</c>) are green, strings are bright gold, command names are blue, and argument keywords (e.g. <c>key</c>, <c>wait</c>, <c>quest_id</c>) are amber — all from <see cref="UiTokens"/>. </summary>
public static CodeHighlighter CreateHighlighter(){var hl=new CodeHighlighter();hl.AddColorRegion("#",null,UiTokens.Success,true);hl.AddColorRegion("\"","\"",UiTokens.AccentBright,false);hl.NumberColor=UiTokens.Info;hl.SymbolColor=UiTokens.Muted;var cmdColor=UiTokens.Info;hl.KeywordColors=new Godot.Collections.Dictionary();foreach(var cmd in ScenarioCommandStrategyFactory.GetAllCommandNames())hl.KeywordColors[cmd]=cmdColor;foreach(var cmd in new[]{"option","select"})hl.KeywordColors[cmd]=cmdColor;var argColor=UiTokens.Accent;foreach(var kw in new[]{"key","first","second","hide_color","wait","speed","hidden","sprites","loop","target","quest_id","show","hide","duration"})hl.KeywordColors[kw]=argColor;return hl;}/// <summary> Splits a line into tokens, handling quoted strings (with backslash escaping) and whitespace. Returns all non-whitespace tokens in order. </summary>
public static List<string>Tokenize(string line){var result=new List<string>();var i=0;while(i<line.Length){if(char.IsWhiteSpace(line[i])){i++;continue;}if(line[i]=='"'){i++;var start=i;while(i<line.Length&&line[i]!='"'){if(line[i]=='\\'&&i+1<line.Length)i++;i++;}result.Add(line.Substring(start,i-start));if(i<line.Length)i++;continue;}var tok=i;while(i<line.Length&& !char.IsWhiteSpace(line[i]))i++;result.Add(line.Substring(tok,i-tok));}return result;}/// <summary>Strips surrounding double quotes from a value if present.</summary>
public static string StripQuotes(string val){if(val.Length>=2&&val.StartsWith("\"")&&val.EndsWith("\""))return val.Substring(1,val.Length-2);return val;}/// <summary> Parses a scenario command line into its components. Tokenises via <see cref="Tokenize"/>, then extracts the first token as the command name (lowercased), remaining bare tokens as positional arguments, and <c>key=value</c> pairs as named arguments. The <c>-></c> arrow token is consumed but discarded. </summary> <param name="line">A single scenario command line (trimmed).</param> <param name="cmd">The command name, lowercased.</param> <param name="positional">Positional arguments in order.</param> <param name="named">Named arguments keyed by lowercased name.</param>
public static void ExtractCommandParams(string line,out string cmd,out List<string>positional,out Dictionary<string,string>named){positional=new List<string>();named=new Dictionary<string,string>(System.StringComparer.OrdinalIgnoreCase);cmd="";var tokens=Tokenize(line);if(tokens.Count==0)return;cmd=tokens[0].ToLowerInvariant();for(int t=1;t<tokens.Count;t++){var tk=tokens[t];if(tk=="->"){t++;continue;}var eq=tk.IndexOf('=');if(eq>0)named[tk.Substring(0,eq).ToLowerInvariant()]=StripQuotes(tk.Substring(eq+1));else positional.Add(tk);}}}}

===== FILE mods/editor_tools/src/Services/SceneBackgroundService.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Mods.Resolvers;namespace Cthangover.Tools.Services{/// <summary> Finds which background an editor should show for a piece of content, so the author does not have to guess it from a list of hundreds: <list type="bullet"> <item>a scene's background is the first <c>defaultBackground</c> of its <c>scenes/*.json</c> definition;</item> <item>an interactive belongs to the scene of the scenario that adds it (<c>interactive_add &lt;id&gt;</c>).</item> </list> Both are best-effort hints: <c>null</c> means "no idea", and the author can always pick another background by hand. </summary>
public static class SceneBackgroundService{private const string InteractiveAddCommand="interactive_add";/// <summary>Default background ID of a logical scene, or <c>null</c>.</summary>
public static string ForScene(string sceneName){if(string.IsNullOrEmpty(sceneName)||sceneName=="-")return null;var defs=SceneDefinitions.Collect();if(defs==null)return null;foreach(var pair in defs){if(!string.Equals(pair.Key,sceneName,StringComparison.OrdinalIgnoreCase)||pair.Value==null)continue;// Several mods may define the scene; the last one wins at runtime.
for(var i=pair.Value.Count-1;i>=0;i--){if(pair.Value[i]?.DefaultBackground is{Count:>0}list)return list[0];}}return null;}/// <summary> Scans every scenario once and maps interactive IDs to the scene of the first scenario that adds them. </summary>
public static Dictionary<string,string>MapInteractivesToScenes(){var result=new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);foreach(var mod in ScenarioFileService.ScanScenarioFiles()){foreach(var group in mod.Groups){foreach(var file in group.Files){var text=ScenarioFileService.ReadAllText(file.ModId,file.RelativePath);if(string.IsNullOrEmpty(text)||text.IndexOf(InteractiveAddCommand,StringComparison.Ordinal)<0)continue;var scene=ScenarioFileService.ParseMetadata(text).Scene;foreach(var raw in text.Split('\n')){var tokens=ScenarioSyntaxService.Tokenize(raw.Trim());if(tokens.Count>=2&&tokens[0]==InteractiveAddCommand&& !result.ContainsKey(tokens[1]))result[tokens[1]]=scene;}}}}return result;}}}

===== FILE mods/editor_tools/test_suites/windows/src/ScenarioPlayReturnCheck.cs =====
using System;using System.Linq;using Cthangover.Core.Scenes;using Cthangover.Core.UI.Tool.ScenarioEditor;using Cthangover.Mods.Interface.Tool;using Cthangover.Tools.Common;using Cthangover.Tools.Hub;using Cthangover.Tools.Services;using Godot;namespace Cthangover.Tools.Tests.Windows{/// <summary> Play-test round trip of the scenario editor opened from the <see cref="NovelEditorHub"/>: plays this suite's own scenario (so the editor's save-before-play rewrites a test file, not game content), lets the play end the way a finished dialog ends it (<see cref="SceneManager.IsTestPlayActive"/> drops), then plays again and presses Stop. Both times the game must come back to the hub with the editor visible — not to the engine's main menu. Advanced one step per driver tick; <see cref="Tick"/> returns true when done. </summary>
public class ScenarioPlayReturnCheck{private const string SuiteModId="editor_tools__windows";private const string ScenarioPath="scenarios/playtest_return.scenario";private const int ShellSettleTicks=3;private readonly Action<bool,string>_check;private ScenarioEditorWindow _window;private int _step;private int _settle;/// <summary>Creates the check; <paramref name="check"/> records one assertion.</summary>
public ScenarioPlayReturnCheck(Action<bool,string>check){_check=check;}/// <summary>Advances the round trip by one step; true once finished.</summary>
public bool Tick(SceneTree tree){switch(_step){case 0:_window=(ScenarioEditorWindow)ToolWindow.ShowWindow(new ScenarioEditorWindow());var tabs=_window.FindChildren("*","",true,false).OfType<ScenarioEditTabs>().FirstOrDefault();tabs?.OpenFile(ScenarioFileService.MakeKey(SuiteModId,ScenarioPath));_check(tabs?.HasActiveFile==true,"scenario_editor: test scenario opened");break;case 1:case 4:PressButton(_window,"tools/scenario_editor/play");_settle=ShellSettleTicks;break;case 2:if(!InGame(tree))return false;SceneManager.IsTestPlayActive=false;break;case 5:if(!InGame(tree))return false;PressButton(tree.Root,"tools/scenario_editor/stop");break;case 3:case 6:if(tree.CurrentScene is not NovelEditorHub|| !_window.Visible)return false;_check(true,_step==3?"scenario_editor: the scenario's end returns to the editor hub":"scenario_editor: Stop returns to the editor hub");if(_step==6){_window.QueueFree();return true;}break;}_step++;return false;}/// <summary>The play-test left the hub for the game shell and the shell settled.</summary>
private bool InGame(SceneTree tree){if(tree.CurrentScene is NovelEditorHub||GodotSceneService.IsTransitioning|| --_settle>0)return false;_check(!_window.Visible,"scenario_editor: play hides the editor and enters the game");return true;}private static void PressButton(Node root,string textKey){var text=ToolUi.Tr(textKey);var button=root.FindChildren("*",nameof(Button),true,false).OfType<Button>().FirstOrDefault(b=>b.Text==text);button?.EmitSignal(BaseButton.SignalName.Pressed);}}}

===== FILE mods/editor_tools/test_suites/windows/src/ToolWindowsErrorLogger.cs =====
using System.Collections.Generic;using Godot;namespace Cthangover.Tools.Tests.Windows{/// <summary> Engine logger that records every error Godot reports while the test runs. An exception thrown inside a signal handler (a button toggled during construction, a draw callback) never reaches the code that opened the window — Godot only prints it — so without this the test would pass windows that are visibly broken. Errors may arrive from any thread. </summary>
public partial class ToolWindowsErrorLogger:Logger{private readonly object _lock=new();private readonly List<string>_errors=new();/// <summary>Records the error text (rationale or code, whichever is set).</summary>
public override void _LogError(string function,string file,int line,string code,string rationale,bool editorNotify,int errorType,Godot.Collections.Array<ScriptBacktrace>scriptBacktraces){if(errorType==(int)ErrorType.Warning)return;lock(_lock)_errors.Add($"{(string.IsNullOrEmpty(rationale) ? code : rationale)} ({file}:{line})");}/// <summary>Returns and clears the errors collected so far.</summary>
public List<string>Drain(){lock(_lock){var copy=new List<string>(_errors);_errors.Clear();return copy;}}}}

===== FILE mods/editor_tools/test_suites/windows/src/ToolWindowsExercises.cs =====
using System;using System.Linq;using Cthangover.Core.UI.Tool.LightEditor;using Cthangover.Core.UI.Tool.ScenarioEditor;using Cthangover.Tools.InteractiveEditor;using Cthangover.Tools.MapEditor;using Godot;namespace Cthangover.Tools.Tests.Windows{/// <summary> Per-tool interaction checks run on an open window, through the same public surface the UI uses: canvas input is delivered by emitting the canvas' <c>gui_input</c> signal (the editors subscribe to it), data is read back from the canvas' public controller. Nothing is saved — the window is closed without writing afterwards. </summary>
public static class ToolWindowsExercises{/// <summary>Runs the checks for <paramref name="toolId"/>; <paramref name="check"/> records each result.</summary>
public static void Run(string toolId,Window window,Action<bool,string>check){switch(toolId){case"map_editor":MapDrawing(window,check);break;case"interactive_editor":InteractivePolygon(window,check);break;case"light_editor":LightAdd(window,check);break;case"scenario_editor":var tree=Find<ScenarioFileTreePanel>(window)?.FindChildren("*",nameof(Tree),true,false).OfType<Tree>().FirstOrDefault();check(tree?.GetRoot()?.GetChildCount()>0,"scenario_editor: file tree lists mods with scenarios");break;}}/// <summary>Draws a triangle; the finishing double-click must not add a fourth vertex.</summary>
private static void MapDrawing(Window window,Action<bool,string>check){var canvas=Find<MapEditorCanvas>(window);if(canvas?.Controller==null){check(false,"map_editor: canvas found");return;}var zone=canvas.Controller.StartRegion();canvas.SetMode(MapEditorCanvas.ModeDraw);var rect=canvas.GetBackgroundRect();Click(canvas,rect.Position+rect.Size*new Vector2(0.2f,0.2f),false);Click(canvas,rect.Position+rect.Size*new Vector2(0.4f,0.2f),false);Click(canvas,rect.Position+rect.Size*new Vector2(0.3f,0.4f),false);Click(canvas,rect.Position+rect.Size*new Vector2(0.3f,0.4f),true);check(zone.Points.Count==3,$"map_editor: drawn region has 3 vertices (got {zone.Points.Count})");check(canvas.Mode==MapEditorCanvas.ModeSelect,"map_editor: double-click finishes drawing");check(Mathf.Abs(zone.Points[0].X-0.2f)<0.01f&&Mathf.Abs(zone.Points[0].Y-0.2f)<0.01f,$"map_editor: vertex lands at the clicked background point (got {zone.Points[0]})");}/// <summary>A new object switched to polygon gets 4 vertices; a click on empty background appends one.</summary>
private static void InteractivePolygon(Window window,Action<bool,string>check){var canvas=Find<InteractiveEditorCanvas>(window);if(canvas?.Controller==null){check(false,"interactive_editor: canvas found");return;}var def=canvas.Controller.Add();InteractiveEditorController.SetHitType(def,"polygon");canvas.RebuildHandles();check(def.HitArea.Vertices?.Length==4,"interactive_editor: rect converts to a 4-vertex polygon");var rect=canvas.GetBackgroundRect();Click(canvas,rect.Position+rect.Size*new Vector2(0.97f,0.97f),false);check(def.HitArea.Vertices?.Length==5,$"interactive_editor: click on empty background adds a vertex (got {def.HitArea.Vertices?.Length})");}/// <summary>A double-click on the background adds a light at that point.</summary>
private static void LightAdd(Window window,Action<bool,string>check){var canvas=Find<LightEditorCanvas>(window);if(canvas?.Controller==null){check(false,"light_editor: canvas found");return;}var before=canvas.Controller.Lights.Count;if(before>=LightEditorController.MaxLights)return;var rect=canvas.GetBackgroundRect();Click(canvas,rect.Position+rect.Size*new Vector2(0.75f,0.25f),true);var light=canvas.Controller.Selected;check(canvas.Controller.Lights.Count==before+1&&light!=null&&Mathf.Abs(light.X-0.75f)<0.01f&&Mathf.Abs(light.Y-0.25f)<0.01f,"light_editor: double-click adds a light at the clicked point");}private static void Click(Control target,Vector2 local,bool doubleClick){var ev=new InputEventMouseButton{ButtonIndex=MouseButton.Left,Pressed=true,DoubleClick=doubleClick,Position=local};target.EmitSignal(Control.SignalName.GuiInput,ev);}private static T Find<T>(Node root)where T:Node{foreach(var child in root.GetChildren()){if(child is T match)return match;var nested=Find<T>(child);if(nested!=null)return nested;}return null;}}}

===== FILE mods/editor_tools/test_suites/windows/src/ToolWindowsTestDriverNode.cs =====
using System.Collections.Generic;using System.IO;using Cthangover.Core.Bootstrap;using Cthangover.Core.Games;using Cthangover.Core.UI.Tool;using Cthangover.Core.Utils;using Cthangover.Mods.Interface.Menu;using Cthangover.Mods.Interface.Tool;using Cthangover.Tools.Hub;using Godot;namespace Cthangover.Tools.Tests.Windows{/// <summary> Opens every registered tool window one by one through the same path the toolbar button uses (<see cref="ToolFactory"/> → <see cref="ToolWindow.ShowWindow"/>), lets it lay out for a couple of ticks, checks that it entered the tree visible and inside the game viewport, optionally saves a screenshot and closes it. A window that throws while constructing or laying out fails only its own check, the others still run. Ends with the runner contract <c>[TEST_RESULT] name=tools_windows passed=N failed=M</c> and the exit code. <para/> Launched as the <c>novel_editor</c> game (<c>--game=novel_editor --new</c>) it first waits for the collection's entry scene, the <see cref="NovelEditorHub"/>, and checks it lists a launcher button for every registered tool before opening the windows; after the windows it plays a scenario from the editor and checks that both the scenario's end and Stop return to the hub (<see cref="ScenarioPlayReturnCheck"/>). </summary>
public partial class ToolWindowsTestDriverNode:Node{private const string ResultName="tools_windows";private const float TickSeconds=0.5f;private const float TimeoutSeconds=90f;private const int SettleTicks=4;private const int ExerciseTick=2;private readonly string _shotsDir;private readonly List<IToolProvider>_tools=new();private int _passed;private int _failed;private int _toolIndex= -1;private int _settle;private double _elapsed;private bool _finished;private Window _window;private bool _inHub;private ScenarioPlayReturnCheck _playCheck;private readonly ToolWindowsErrorLogger _errors=new();/// <summary>Creates the driver; <paramref name="shotsDir"/> null or empty disables screenshots.</summary>
public ToolWindowsTestDriverNode(string shotsDir){_shotsDir=shotsDir;OS.AddLogger(_errors);var timer=new Timer{WaitTime=TickSeconds,Autostart=true};AddChild(timer);timer.Timeout+=Tick;}private void Tick(){if(_finished)return;_elapsed+=TickSeconds;if(_elapsed>=TimeoutSeconds){Fail($"timeout ({TimeoutSeconds:0}s) at tool #{_toolIndex}");Finish();return;}if(_toolIndex<0){var tree=Godot.Engine.GetMainLoop()as SceneTree;if(!Boot.IsReady||tree?.CurrentScene==null)return;_inHub=ActiveGame.Id==NovelEditorHub.SceneName;if(_inHub&&tree.CurrentScene is not NovelEditorHub)return;_tools.AddRange(ToolFactory.Instance.GetAll());Check(_tools.Count>0,$"tools registered ({_tools.Count})");if(_inHub)CheckHub(tree.CurrentScene);OpenNext();return;}if(_playCheck!=null){if(_playCheck.Tick(GetTree()))Finish();return;}if(_window==null)return;if(--_settle==ExerciseTick){try{ToolWindowsExercises.Run(_tools[_toolIndex].Id,_window,Check);}catch(System.Exception ex){Fail($"{_tools[_toolIndex].Id}: interaction threw {ex.GetType().Name}: {ex.Message}");}}if(_settle>0)return;InspectAndClose();OpenNext();}private void OpenNext(){_toolIndex++;if(_toolIndex>=_tools.Count){if(_inHub)_playCheck=new ScenarioPlayReturnCheck(Check);else Finish();return;}var tool=_tools[_toolIndex];_errors.Drain();try{_window=ToolWindow.ShowWindow(tool.CreateWindow());_settle=SettleTicks;}catch(System.Exception ex){Fail($"{tool.Id}: open threw {ex.GetType().Name}: {ex.Message}");_window=null;OpenNext();}}/// <summary>Every registered tool has its launcher button on the hub screen.</summary>
private void CheckHub(Node hub){var keys=new HashSet<string>();CollectButtonKeys(hub,keys);foreach(var tool in _tools)Check(keys.Contains(tool.LocaleKey),$"hub: launcher button for {tool.Id}");SaveShot("novel_editor_hub");}private static void CollectButtonKeys(Node node,HashSet<string>keys){if(node is Button button&&button.HasMeta(MenuBuilder.LocaleMetaKey))keys.Add(button.GetMeta(MenuBuilder.LocaleMetaKey).AsString());foreach(var child in node.GetChildren())CollectButtonKeys(child,keys);}private void InspectAndClose(){var id=_tools[_toolIndex].Id;var window=_window;_window=null;var errors=_errors.Drain();Check(errors.Count==0,$"{id}: no engine errors while opening"+(errors.Count>0?": "+string.Join(" | ",errors):""));Check(GodotObject.IsInstanceValid(window)&&window.IsInsideTree()&&window.Visible,$"{id}: window visible in tree");if(!GodotObject.IsInstanceValid(window))return;var viewport=GetTree().Root.GetVisibleRect().Size;Check(window.Size.X<=viewport.X+1&&window.Size.Y<=viewport.Y+1,$"{id}: window {window.Size} fits viewport {viewport}");SaveShot(id);window.QueueFree();}private void SaveShot(string id){if(string.IsNullOrEmpty(_shotsDir)||DisplayServer.GetName()=="headless")return;var dir=ProjectSettings.GlobalizePath(_shotsDir);Directory.CreateDirectory(dir);var image=GetViewport().GetTexture().GetImage();var path=Path.Combine(dir,id+".png");image.SavePng(path);Log($"screenshot {path}");}private void Check(bool ok,string name){if(ok){_passed++;Log($"[PASS] {name}");}else{Fail(name);}}private void Fail(string message){_failed++;Log($"[FAIL] {message}");}private void Finish(){if(_finished)return;_finished=true;OS.RemoveLogger(_errors);GameLogger.Log("TEST",$"[TEST_RESULT] name={ResultName} passed={_passed} failed={_failed}");GetTree().Quit(_failed==0?0:1);}private static void Log(string message)=>GameLogger.Log("TEST",message);}}

===== FILE mods/editor_tools/test_suites/windows/src/ToolWindowsTestInitializer.cs =====
using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Tools.Tests.Windows{/// <summary> Entry point of the tool windows GUI test. Activated only by the exact user arg <c>--itest=tools_windows</c>; <c>--shots=&lt;dir&gt;</c> additionally saves a screenshot of every opened window (needs a real display, e.g. xvfb — the headless renderer produces no image). The driver is added to the tree deferred because resources-ready fires before the shell scene exists; the driver waits for the boot itself. </summary>
public class ToolWindowsTestInitializer:IModInitializer{private static bool driverQueued;/// <summary>Nothing to do per mod: the driver is queued on resources-ready.</summary>
public void OnModLoaded(string modId){}/// <summary>Queues the driver once when the activation flag is present.</summary>
public void OnModResourcesReady(){if(driverQueued)return;var args=OS.GetCmdlineUserArgs();if(!args.Any(a=>a=="--itest=tools_windows"))return;var shots=args.Where(a=>a.StartsWith("--shots=",System.StringComparison.Ordinal)).Select(a=>a.Substring("--shots=".Length)).FirstOrDefault();driverQueued=true;GameLogger.Log("TEST",$"ToolWindowsTestInitializer: activating tool windows test, shots='{shots ?? ""}'");(Godot.Engine.GetMainLoop()as SceneTree)?.Root?.CallDeferred("add_child",new ToolWindowsTestDriverNode(shots));}}}

===== FILE mods/effects_base/src/Animation/AnimationController.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.UI.Event;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Mods.Effects.Animation{/// <summary> Frame-by-frame sprite animation via crossfade between two TextureRect nodes. Rather than using a spritesheet, it keeps a "current" and "next" texture and lerps their modulate between transparent and opaque, creating smooth transitions. A built-in wait/waitPercent mechanism holds the "next" frame fully visible for a configurable interval before advancing, producing a paced flip-book effect. Exposes NextFrame/NextCycle events so external systems can sequence or react to animation milestones without polling. </summary>
public partial class AnimationController:Control,IOnUpdateEvent{[Export]private TextureRect current;[Export]private TextureRect next;[Export]private Texture2D empty;[Export]private bool isLoop=true;[Export]private float speed=1f;[Export]private float nextFrameSpeed=0.2f;/// <summary>Fires when the current frame transition completes and the next frame texture is assigned.</summary>
public event Action NextFrame;/// <summary>Fires when the animation wraps around (looping) or reaches the final frame (non-looping).</summary>
public event Action NextCycle;[Export]private Color clearColor=new(1f,1f,1f,0f);[Export]private Color completeColor=new(1f,1f,1f,1f);private int currentFrame;private double timestamp;private float percent;private bool wait;private float waitPercent;private bool isStarted;/// <summary>When true, the animation wraps from the last frame back to the first. When false, playback stops at the final frame.</summary>
public bool IsLoop{get=>isLoop;set=>isLoop=value;}/// <summary>Crossfade speed multiplier between frames. Higher values produce faster transitions.</summary>
public float Speed{get=>speed;set=>speed=value;}/// <summary>Hold duration multiplier for the "wait" phase after a frame transition completes.</summary>
public float NextFrameSpeed{get=>nextFrameSpeed;set=>nextFrameSpeed=value;}/// <summary>Whether playback is currently active. False on startup and after <see cref="Pause"/> or <see cref="Clear"/>.</summary>
public bool IsStarted=>isStarted;/// <summary>Ordered list of textures to cycle through. Set externally before calling <see cref="Play"/>.</summary>
public List<Texture2D>FrameSet{get;set;}/// <summary>Resets to idle state: stops playback, clears frame set, and blanks both texture nodes to transparent.</summary>
public void Clear(){isStarted=false;FrameSet=null;current.Texture=empty;current.Modulate=Colors.Transparent;next.Texture=empty;next.Modulate=Colors.Transparent;}/// <summary>Stops playback without clearing textures, freezing on the current frame.</summary>
public void Pause(){isStarted=false;}/// <summary>Starts or restarts playback from the first frame in <see cref="FrameSet"/>.</summary>
public void Play(){currentFrame=0;isStarted=true;timestamp=Godot.Time.GetTicksUsec()/1_000_000.0;current.Texture=empty;next.Modulate=clearColor;next.Texture=FrameSet[currentFrame];}/// <summary>Update priority. Lower numbers run earlier in the frame. Default 1.</summary>
public int Priority=>1;/// <summary>Advances the crossfade each frame. Handles both the transition phase (lerping modulate) and the wait phase (holding the next frame visible).</summary>
public void OnUpdate(){if(!isStarted)return;if(Lists.IsEmpty(FrameSet)){Clear();return;}double now=Godot.Time.GetTicksUsec()/1_000_000.0;if(!wait){float delta=(float)(now-timestamp)*speed;percent=Mathf.Min(percent+delta,1f);current.Modulate=completeColor.Lerp(Colors.Transparent,percent);next.Modulate=Colors.Transparent.Lerp(completeColor,percent);}else{float delta=(float)(now-timestamp)*nextFrameSpeed;waitPercent=Mathf.Min(waitPercent+delta,1f);}timestamp=now;if(percent>=1f&& !wait){wait=true;SwitchNextFrame();percent=0f;}if(waitPercent>=1f&&wait){wait=false;waitPercent=0f;}}private void SwitchNextFrame(){NextFrame?.Invoke();if(Lists.IsEmpty(FrameSet)){Clear();return;}var needNextCycle=false;currentFrame++;if(FrameSet.Count<=currentFrame){if(isLoop)currentFrame=0;else{currentFrame--;Pause();}needNextCycle=true;}current.Modulate=completeColor;current.Texture=next.Texture;next.Modulate=clearColor;next.Texture=FrameSet[currentFrame];if(needNextCycle)NextCycle?.Invoke();}}}

===== FILE mods/effects_base/src/Animation/EffectAnimNode.cs =====
using Godot;namespace Cthangover.Mods.Effects.Animation{/// <summary> Lightweight, code-only alternative to EffectAnimator that does NOT require a shader. Constructs an AtlasTexture and shifts its Region rect each frame to scroll through a horizontal spritesheet. Designed for runtime spawning via the static SpawnOn factory — it anchors to fill the target Control and self-destructs when playback finishes, requiring no scene file setup. </summary>
public partial class EffectAnimNode:TextureRect{private AtlasTexture atlas;private int frameCount;private float frameInterval;private float elapsed;private int currentFrame= -1;private int frameWidth;private float totalDuration;/// <summary>Factory method that creates the node, attaches it to <paramref name="target"/> filling the full rect, and initializes the spritesheet animation. Returns null if target or sheet is null.</summary> <param name="target">Parent Control to anchor the animation to.</param> <param name="sheet">Horizontal spritesheet texture. Frame width = sheet width / frameCount.</param> <param name="frameCount">Number of frames in the spritesheet.</param> <param name="totalDuration">Total playback time in seconds; frameInterval = totalDuration / frameCount.</param>
public static EffectAnimNode SpawnOn(Control target,Texture2D sheet,int frameCount,float totalDuration){if(target==null||sheet==null)return null;var node=new EffectAnimNode();target.AddChild(node);node.AnchorLeft=0;node.AnchorTop=0;node.AnchorRight=1;node.AnchorBottom=1;node.OffsetLeft=0;node.OffsetTop=0;node.OffsetRight=0;node.OffsetBottom=0;node.MouseFilter=MouseFilterEnum.Ignore;node.ZIndex=100;node.Init(sheet,frameCount,totalDuration);return node;}private void Init(Texture2D sheet,int frameCount,float totalDuration){this.frameCount=frameCount;this.totalDuration=totalDuration;frameInterval=totalDuration/frameCount;frameWidth=sheet.GetWidth()/frameCount;atlas=new AtlasTexture{Atlas=sheet,Region=new Rect2(0,0,frameWidth,sheet.GetHeight()),};Texture=atlas;ExpandMode=ExpandModeEnum.IgnoreSize;StretchMode=StretchModeEnum.KeepAspectCentered;SetFrame(0);}/// <summary>Steps the atlas region forward per frame interval and frees the node once the total duration has played.</summary>
public override void _Process(double delta){elapsed+=(float)delta;var frame=Mathf.Min((int)(elapsed/frameInterval),frameCount-1);if(frame!=currentFrame)SetFrame(frame);if(elapsed>=totalDuration)QueueFree();}private void SetFrame(int frame){currentFrame=frame;atlas.Region=new Rect2(frame*frameWidth,0,frameWidth,atlas.Region.Size.Y);}}}

===== FILE mods/effects_base/src/Animation/EffectAnimator.cs =====
using Cthangover.Core.UI.Event;using Godot;namespace Cthangover.Mods.Effects.Animation{/// <summary> Shader-driven frame animation: drives _FrameIndex and _FrameCount uniforms on the TextureRect's ShaderMaterial rather than swapping textures. This means all animation frames live in a single GPU texture atlas and the shader selects the visible frame. Auto-destroys (QueueFree) after totalDuration elapses, making it suitable for fire-and-forget VFX that don't need external cleanup. </summary>
public partial class EffectAnimator:Control,IOnUpdateEvent{[Export]private TextureRect image;[Export]private float totalDuration=5f;[Export]private int frameCount=5;private ShaderMaterial materialInstance;private double startTime;private float frameInterval;private int currentFrame;private static readonly StringName FrameIndexId="_FrameIndex";private static readonly StringName FrameCountId="_FrameCount";/// <summary>Precomputes the frame interval and pushes the frame count uniform into the shader material.</summary>
public override void _Ready(){frameInterval=totalDuration/frameCount;materialInstance=image.Material as ShaderMaterial;if(materialInstance!=null)materialInstance.SetShaderParameter(FrameCountId,frameCount);}/// <summary>Intentionally empty — the animator drives frames through the event controller instead of engine callbacks.</summary>
public override void _EnterTree(){}/// <summary>Starts the shader animation from frame 0, resetting the elapsed timer.</summary>
public void Play(){startTime=Godot.Time.GetTicksUsec()/1_000_000.0;currentFrame=0;if(materialInstance!=null)materialInstance.SetShaderParameter(FrameIndexId,0f);}/// <summary>Update priority. Default 1.</summary>
public int Priority=>1;/// <summary>Advances the shader's _FrameIndex uniform each frame based on elapsed time. Self-destructs when <c>totalDuration</c> elapses.</summary>
public void OnUpdate(){double now=Godot.Time.GetTicksUsec()/1_000_000.0;var elapsed=now-startTime;var newFrame=Mathf.Min((int)(elapsed/frameInterval),frameCount-1);if(newFrame!=currentFrame&&materialInstance!=null){currentFrame=newFrame;materialInstance.SetShaderParameter(FrameIndexId,currentFrame);}if(elapsed>=totalDuration)QueueFree();}}}

===== FILE mods/effects_base/src/Animation/EffectController.cs =====
namespace Cthangover.Mods.Effects.Animation{/// <summary> Specialisation of AnimationController where Pause() also calls Clear(), treating "pause" as a hard reset. This is intentional for one-shot effects that should clean up instantly rather than freezing on their last frame. The "new" keyword on Pause() shadows the base implementation on purpose. </summary>
public partial class EffectController:AnimationController{/// <summary>Shadows the base Pause() and calls <see cref="AnimationController.Clear"/> instead, instantly resetting one-shot effects.</summary>
public new void Pause(){Clear();}}}

===== FILE mods/effects_base/src/Animation/EffectDataSet.cs =====
using System.Collections.Generic;using Godot;namespace Cthangover.Mods.Effects.Animation{/// <summary> Editor-configured bridge between a frame Texture2D array and an AnimationController. Converts Godot's typed Array into a List, wires the NextCycle event, and starts playback. When the animation completes one cycle, it detaches the handler and self-destructs — the "fire and forget" glue between artist-authored data and the animation runtime. </summary>
public partial class EffectDataSet:Control{[Export]private AnimationController controller;[Export]private Godot.Collections.Array<Texture2D>frameSet;/// <summary>Converts the exported frame array, hands it to the controller and starts one playback cycle.</summary>
public override void _Ready(){AnchorLeft=0f;AnchorRight=1f;AnchorTop=0f;AnchorBottom=1f;var frames=new List<Texture2D>();foreach(var tex in frameSet)frames.Add(tex);controller.Clear();controller.NextCycle+=ControllerOnNextCycle;controller.FrameSet=frames;controller.Play();}private void ControllerOnNextCycle(){controller.NextCycle-=ControllerOnNextCycle;controller.Clear();QueueFree();}}}

===== FILE mods/effects_base/src/EffectFactory.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Factories;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Mods.Effects{/// <summary> Factory for <c>PackedScene</c> visual effects. Overrides <c>LoadFromMods</c> to use <c>ResourceLoader.Load</c> from the file system instead of <c>ConvertFromBytes</c>, because Godot's scene format is a proprietary binary blob that cannot be reconstructed from raw bytes in managed code. This imposes a hard constraint: effect scenes are only loadable from <b>folder mods</b> — zip mods skip them with a warning — so mod authors distributing visual effects must ship them unpacked. <para/> The single instance is a self-contained lazy singleton owned by this mod assembly; consumers resolve it through <see cref="Instance"/>. </summary>
public class EffectFactory:PrefabFactory<PackedScene>{private static readonly Lazy<EffectFactory>instance=new(()=>new EffectFactory());/// <summary>Process-wide instance; laziness matches the former AppServices slot.</summary>
public static EffectFactory Instance=>instance.Value;private EffectFactory():base("effects",64){}/// <summary>Mod asset group scanned by this factory — files live under <c>mods/*/effects</c>.</summary>
public override string GroupName=>"effects";/// <summary>Scene file extensions accepted during the mod scan.</summary>
protected override List<string>Extensions{get;}=new(){".tscn",".scn"};/// <summary>Always fails with a log — PackedScene is a proprietary Godot blob; use <see cref="LoadFromMods"/> (folder mods only).</summary>
protected override PackedScene ConvertFromBytes(string id,byte[]data,string extension){GameLogger.Log("FACTORY",$"PackedScene cannot be constructed from bytes; use folder mods for '{id}'",LogLevel.Error);return null;}/// <summary> Loads the scene via <c>ResourceLoader</c> from the mod's real file system path — which only folder mods can provide; zip mods are skipped with a warning. </summary>
protected override PackedScene LoadFromMods(string id,string extension){var files=Files.Collect(GroupName);string matchedKey=null;foreach(var key in files.Keys){if(key==id||key.Equals(id+extension,StringComparison.OrdinalIgnoreCase)){matchedKey=key;break;}}if(matchedKey==null|| !files.TryGetValue(matchedKey,out var entry)){GameLogger.Log("FACTORY",$"effect '{id}' - not found in group '{GroupName}'!",LogLevel.Error);return null;}var fsPath=ModManager.Instance.GetFileSystemPath(entry.ModId,entry.FullPath);if(fsPath==null){GameLogger.Log("FACTORY",$"effect '{id}' - only folder mods support PackedScene (zip mod '{entry.ModId}' skipped)",LogLevel.Warning);return null;}return ResourceLoader.Load<PackedScene>(fsPath);}}}

===== FILE mods/effects_base/src/EffectsModInitializer.cs =====
using Cthangover.Core.Mods;namespace Cthangover.Mods.Effects{/// <summary> Engine-mod initializer for the effects subsystem. The factory is a self-contained lazy singleton resolved by consumers through <c>EffectFactory.Instance</c>, so there is no core binding to re-establish on hot reload — the initializer exists to register the assembly with the mod pipeline and keep the migration shape uniform with the other engine mods. </summary>
public class EffectsModInitializer:IModInitializer{/// <summary>No per-mod setup is required.</summary>
public void OnModLoaded(string modId){}/// <summary>Nothing to bind: the factory lazy self-initializes on first use.</summary>
public void OnModResourcesReady(){}}}

===== FILE mods/effects_base/src/SubsystemRegistration.cs =====
namespace Cthangover.Mods.Effects{/// <summary> Migration marker for the coverage pipeline (<c>SubsystemsBridge</c> reflection scan): declares the effects subsystem fully moved out of the core assembly into this engine mod. The id must match the <c>DeclaredTests</c> entry in <c>SubsystemsCoverageTestSetup</c>. </summary>
public static class SubsystemRegistration{public static string SubsystemId=>"effects";}}

===== FILE mods/ff_battle/src/Actions/FFAbstractAnimation.cs =====
using Cthangover.Core.Battle.Actions;using Cthangover.Core.Characters;using Cthangover.FFBattle.UI;using Godot;namespace Cthangover.FFBattle.Actions{/// <summary> Abstract base for all FF battle animations. Implements a three-phase lifecycle (<see cref="DoStart"/>, <see cref="DoAction"/>, <see cref="DoEnd"/>) driven by the battle core's per-frame loop. Subclasses implement their animation logic in <c>DoInternalStart/DoInternalAction/DoInternalEnd</c> and are instantiated by <see cref="FFBattleCore"/> when a player or enemy action is executed. The <see cref="Speed"/> multiplier scales all timing, used e.g. to speed up enemy turn animations (<c>0.8f</c>). </summary>
public abstract class FFAbstractAnimation:IBattleAction{/// <summary>Start time of the current animation phase, in seconds (microsecond precision).</summary>
protected double Timestamp{get;set;}= -1;/// <summary>Animation speed multiplier; lower values produce faster animations.</summary>
protected float Speed{get;set;}=1f;/// <summary>The action descriptor being executed (determines which executor runs).</summary>
protected ActionCharacter Action{get;set;}/// <summary>The character widget performing the action.</summary>
protected FFCharacterWidget Source{get;set;}/// <summary>The character widget receiving the action.</summary>
protected FFCharacterWidget Target{get;set;}/// <summary>World position of <see cref="Source"/> captured at <see cref="DoStart"/>.</summary>
protected Vector2 SourcePos{get;set;}/// <summary>World position of <see cref="Target"/> captured at <see cref="DoStart"/>.</summary>
protected Vector2 TargetPos{get;set;}/// <summary>Creates an animation binding a source, target, and action with an optional speed override.</summary>
protected FFAbstractAnimation(FFCharacterWidget source,FFCharacterWidget target,ActionCharacter action,float speed=1f){Source=source;Target=target;Action=action;Speed=speed;}/// <summary> Advances the animation by one frame. Returns <c>true</c> when the animation sequence has completed. Called in a tight <c>while</c> loop by <see cref="FFBattleCore.RunAnimation"/> each process frame. </summary>
public bool DoAction(){if(Source==null||Target==null||Action==null)return true;// The target's death animation may free its widget mid-animation; finish early and let DoEnd snap the source back.
if(!IsAlive(Source)|| !IsAlive(Target))return true;return DoInternalAction();}/// <summary>True while the widget is still in the tree and not queued for deletion.</summary>
protected static bool IsAlive(FFCharacterWidget widget)=>GodotObject.IsInstanceValid(widget)&& !widget.IsQueuedForDeletion();/// <summary>Captures the initial world positions of source and target, then delegates to subclass setup.</summary>
public void DoStart(){if(Source==null||Target==null||Action==null)return;SourcePos=Source.GlobalPosition;TargetPos=Target.GlobalPosition;DoInternalStart();}/// <summary>Restores source to original position and delegates cleanup to subclass.</summary>
public void DoEnd(){if(Source==null||Target==null||Action==null)return;DoInternalEnd();}/// <summary>Frame tick of the animation; implementors advance phases and return true when finished.</summary>
protected abstract bool DoInternalAction();/// <summary>Phase setup called once by <see cref="DoStart"/> after positions are captured.</summary>
protected abstract void DoInternalStart();/// <summary>Cleanup called once by <see cref="DoEnd"/> — restore captured positions here.</summary>
protected abstract void DoInternalEnd();/// <summary>Quadratic ease-out: fast start, decelerating to the target. Used for return-to-start motion.</summary>
protected float EaseOutQuad(float t)=>1f-(1f-t)*(1f-t);/// <summary>Quadratic ease-in-out: slow start and end, fast in the middle. Used for approach motion.</summary>
protected float EaseInOutQuad(float t)=>t<0.5f?2f*t*t:1f-Mathf.Pow(-2f*t+2f,2f)/2f;}}

===== FILE mods/ff_battle/src/Actions/FFActionProvider.cs =====
using System.Collections.Generic;using Cthangover.Core.Battle.Actions;using Cthangover.Core.Utils;namespace Cthangover.FFBattle.Actions{/// <summary> Registry mapping action ID strings to their executor implementations. Created by <see cref="FFBattleCore"/> and registered with <see cref="ActionExecutorHub"/>. Maps <c>"physics/attack"</c> → <see cref="FFDamageExecutor"/>, <c>"physics/defence"</c> → <see cref="FFDefenceExecutor"/>, <c>"physics/stun"</c> → <see cref="FFStunExecutor"/>, and <c>"ff/item"</c> → <see cref="FFItemExecutor"/>. The lookup key comes from <see cref="ActionCharacter.ID"/>. </summary>
public class FFActionProvider:IActionExecutorProvider{private readonly Dictionary<string,IActionExecutor>_executors;/// <summary>Constructs the provider and registers the four built-in FF battle executors.</summary>
public FFActionProvider(){_executors=new Dictionary<string,IActionExecutor>{["physics/attack"]=new FFDamageExecutor(),["physics/defence"]=new FFDefenceExecutor(),["physics/stun"]=new FFStunExecutor(),["ff/item"]=new FFItemExecutor(),};}/// <summary> Retrieves the executor registered for <paramref name="actionId"/>. Logs a warning if no executor is found and returns <c>null</c>, which the animation system silently ignores. </summary> <param name="actionId">The <see cref="ActionCharacter.ID"/> string to look up.</param>
public IActionExecutor GetExecutor(string actionId){if(!_executors.TryGetValue(actionId,out var executor))GameLogger.Log("FF_BATTLE",$"No executor registered for action '{actionId}'",LogLevel.Warning);return executor;}}}

===== FILE mods/ff_battle/src/Actions/FFAttackAnimation.cs =====
using Cthangover.Core.Battle;using Cthangover.Core.Battle.Actions;using Cthangover.Core.Characters;using Cthangover.TurnBattle;using Cthangover.FFBattle.UI;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.FFBattle.Actions{/// <summary> Animation for offensive actions against enemies. Implements a three-phase sequence: (1) MoveForward — source character dashes toward the target with ease-in-out interpolation, stopping 30px short, (2) Impact — applies damage via <see cref="ActionExecutorHub"/> when 30% through the phase, triggering flash, shake, and floating damage text, (3) MoveBack — returns the source to its original position with ease-out. Used for player attacks and enemy attacks alike. </summary>
public class FFAttackAnimation:FFAbstractAnimation{private enum Phase{MoveForward,Impact,MoveBack,Done}private Phase _phase;private bool _damageApplied;private Vector2 _midPoint;/// <summary>Creates an attack animation for source→target with the given action descriptor and speed.</summary>
public FFAttackAnimation(FFCharacterWidget source,FFCharacterWidget target,ActionCharacter action,float speed=1f):base(source,target,action,speed){}/// <summary>Starts the attack sequence: compute the stop point short of the target and reset the phase clock.</summary>
protected override void DoInternalStart(){Timestamp=Godot.Time.GetTicksUsec()/1_000_000.0;_damageApplied=false;_phase=Phase.MoveForward;var dir=(TargetPos-SourcePos).Normalized();_midPoint=TargetPos-dir*30f;}/// <summary>Advances move-in → impact (damage via <see cref="ActionExecutorHub"/>, hit flash/shake, damage popup) → move-back.</summary>
protected override bool DoInternalAction(){var elapsed=(float)(Godot.Time.GetTicksUsec()/1_000_000.0-Timestamp)*Speed*(float)Godot.Engine.TimeScale;switch(_phase){case Phase.MoveForward:{var progress=Mathf.Clamp(elapsed/0.35f,0f,1f);Source.GlobalPosition=SourcePos.Lerp(_midPoint,EaseInOutQuad(progress));if(progress>=1f){_phase=Phase.Impact;Timestamp=Godot.Time.GetTicksUsec()/1_000_000.0;}break;}case Phase.Impact:{var progress=Mathf.Clamp(elapsed/0.2f,0f,1f);if(!_damageApplied&&progress>=0.3f){_damageApplied=true;var result=ActionExecutorHub.Instance.Execute(Action,Source.Card,Target.Card);Source.UpdateInfo();Target.UpdateInfo();if(result.Result){Target.Flash(UiTokens.Danger,0.2f);Target.Shake(4f,0.25f);if(result.Target.Damage>0)ShowDamageBehaviour.SpawnDamage(result.Target.Damage,Target,Target.GlobalPosition);var defenceLost= -result.Target.Defence;if(defenceLost>0)ShowDamageBehaviour.SpawnDefence(defenceLost,Target,Target.GlobalPosition);}}if(progress>=1f){_phase=Phase.MoveBack;Timestamp=Godot.Time.GetTicksUsec()/1_000_000.0;}break;}case Phase.MoveBack:{var progress=Mathf.Clamp(elapsed/0.4f,0f,1f);Source.GlobalPosition=_midPoint.Lerp(SourcePos,EaseOutQuad(progress));if(progress>=1f){_phase=Phase.Done;return true;}break;}}return false;}/// <summary>Returns the attacker to its original position and refreshes both widgets.</summary>
protected override void DoInternalEnd(){if(IsAlive(Source))Source.GlobalPosition=SourcePos;Source.UpdateInfo();Target.UpdateInfo();}}}

===== FILE mods/ff_battle/src/Actions/FFDamageExecutor.cs =====
using Cthangover.Core.Cards.StatusEffect;using Cthangover.Core.Characters;using Cthangover.Core.Utils;using Godot;namespace Cthangover.FFBattle.Actions{/// <summary> Executes a physical attack action. Calculates damage as <c>action.Attack × user.Attack</c>, then routes through <see cref="StatusEffectQueue.OnDealDamage"/> and <see cref="StatusEffectQueue.OnTakeDamage"/> for status-effect hooks. Subtraction is applied defence-first: <see cref="CharacterAttributes.Defence"/> absorbs damage point-for-point before health is reduced. Registered under ID <c>"physics/attack"</c>. </summary>
public class FFDamageExecutor:ActionBase{/// <summary>Hub registry key for this executor.</summary>
public override string ID=>"FFDamageExecutor";/// <summary>Applies attack damage with status hooks and defence absorption; consumes the action's required points.</summary>
public override ChangedAttributes Execute(ActionCharacter action,Character user,Character target){if(target==null||user==null|| !CheckRequiredAndUsePoint(action,user))return new ChangedAttributes{Result=false};var damageDelta=Mathf.RoundToInt(action.GetFloat("Attack",1f)*user.Attributes.Attack.Value);StatusEffectService.Of(target).OnTakeDamage(user,ref damageDelta);StatusEffectService.Of(user).OnDealDamage(target,ref damageDelta);var defenceDelta=0;var ignoreArmor=user.HasPassiveEffect("IgnoreArmor");if(!ignoreArmor&&target.Attributes.Defence.Value>0){defenceDelta=target.Attributes.Defence.Value>=damageDelta?damageDelta:target.Attributes.Defence.Value;damageDelta-=defenceDelta;}target.Attributes.Defence.Value-=defenceDelta;target.Attributes.Health.Value-=damageDelta;GameLogger.Log("FF_BATTLE",$"{user.Name} attacks {target.Name}: damage={damageDelta} defence={defenceDelta}",LogLevel.Debug);return new ChangedAttributes{Result=true,Target={Damage=damageDelta,Defence= -defenceDelta}};}}}

===== FILE mods/ff_battle/src/Actions/FFDefenceExecutor.cs =====
using Cthangover.Core.Characters;using Cthangover.Core.Utils;namespace Cthangover.FFBattle.Actions{/// <summary> Executes a defensive action (buff/barrier). Increases the target's <see cref="CharacterAttributes.Defence"/> by the value stored in the <c>"Defence"</c> property on the action (default 4). Defence acts as a shield against incoming physical damage in the <see cref="FFDamageExecutor"/>. Registered under ID <c>"physics/defence"</c>. </summary>
public class FFDefenceExecutor:ActionBase{/// <summary>Hub registry key for this executor.</summary>
public override string ID=>"FFDefenceExecutor";/// <summary>Adds the action's "Defence" value (default 4) to the target's defence shield.</summary>
public override ChangedAttributes Execute(ActionCharacter action,Character user,Character target){if(target==null||user==null|| !CheckRequiredAndUsePoint(action,user))return new ChangedAttributes{Result=false};var defenceDelta=action.GetInt("Defence",4);target.Attributes.Defence.Value+=defenceDelta;GameLogger.Log("FF_BATTLE",$"{user.Name} defends → +{defenceDelta} defence for {target?.Name}",LogLevel.Debug);return new ChangedAttributes{Result=true,Target={Defence=defenceDelta}};}}}

===== FILE mods/ff_battle/src/Actions/FFDefendAnimation.cs =====
using Cthangover.Core.Battle;using Cthangover.Core.Battle.Actions;using Cthangover.Core.Characters;using Cthangover.TurnBattle;using Cthangover.FFBattle.UI;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.FFBattle.Actions{/// <summary> Animation for defensive/support actions (buff, self-target, ally heal). Phases: (1) Bounce — source character bobs in place with a sine-based vertical oscillation, (2) Glow — applies the effect via <see cref="ActionExecutorHub"/> at 30% phase progress, triggering a green flash on the target and floating defence/damage indicators, (3) Recover — smoothly returns the source to its original position with ease-out interpolation. Unlike <see cref="FFAttackAnimation"/>, the source does not move toward the target. </summary>
public class FFDefendAnimation:FFAbstractAnimation{private enum Phase{Bounce,Glow,Recover,Done}private Phase _phase;private bool _effectApplied;private Vector2 _bounceOffset;/// <summary>Creates a defend/buff animation for source→target with the given action descriptor and speed.</summary>
public FFDefendAnimation(FFCharacterWidget source,FFCharacterWidget target,ActionCharacter action,float speed=1f):base(source,target,action,speed){}/// <summary>Starts the defend sequence from the bounce phase and captures the glow state.</summary>
protected override void DoInternalStart(){Timestamp=Godot.Time.GetTicksUsec()/1_000_000.0;_effectApplied=false;_phase=Phase.Bounce;_bounceOffset=new Vector2(0,-12f);}/// <summary>Advances bounce → glow (defence application with a green flash) → settle.</summary>
protected override bool DoInternalAction(){var elapsed=(float)(Godot.Time.GetTicksUsec()/1_000_000.0-Timestamp)*Speed*(float)Godot.Engine.TimeScale;switch(_phase){case Phase.Bounce:{var progress=Mathf.Clamp(elapsed/0.25f,0f,1f);var bounce=Mathf.Sin(progress*Mathf.Pi*2f)*8f;Source.GlobalPosition=SourcePos+new Vector2(0,-Mathf.Abs(bounce));if(progress>=1f){_phase=Phase.Glow;Timestamp=Godot.Time.GetTicksUsec()/1_000_000.0;}break;}case Phase.Glow:{var progress=Mathf.Clamp(elapsed/0.4f,0f,1f);if(!_effectApplied&&progress>=0.3f){_effectApplied=true;var result=ActionExecutorHub.Instance.Execute(Action,Source.Card,Target.Card);Source.UpdateInfo();Target.UpdateInfo();if(result.Result){Target.Flash(UiTokens.Success,0.4f);if(result.Target.Defence>0)ShowDamageBehaviour.SpawnDefence(result.Target.Defence,Target,Target.GlobalPosition);if(result.Target.Damage>0)ShowDamageBehaviour.SpawnDamage(result.Target.Damage,Target,Target.GlobalPosition,true);}}if(progress>=1f){_phase=Phase.Recover;Timestamp=Godot.Time.GetTicksUsec()/1_000_000.0;}break;}case Phase.Recover:{var progress=Mathf.Clamp(elapsed/0.3f,0f,1f);Source.GlobalPosition=(SourcePos+_bounceOffset).Lerp(SourcePos,EaseOutQuad(progress));if(progress>=1f){_phase=Phase.Done;return true;}break;}}return false;}/// <summary>Returns the defender to its original position and refreshes both widgets.</summary>
protected override void DoInternalEnd(){if(IsAlive(Source))Source.GlobalPosition=SourcePos;Source.UpdateInfo();Target.UpdateInfo();}}}

===== FILE mods/ff_battle/src/Actions/FFItemAnimation.cs =====
using Cthangover.Core.Characters;using Cthangover.Core.Items;using Cthangover.FFBattle.UI;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.FFBattle.Actions{/// <summary> Animation for item usage in battle. Creates a floating item icon (<see cref="TextureRect"/>) that rises above the source character and then flies toward the target with fading opacity. Phases: (1) RaiseItem — the item icon floats upward and the source bobs, (2) ApplyEffect — the icon lerps to the target's position and <see cref="FFItemExecutor.TryUseItem"/> is called at 30% phase, (3) Recover — source returns to position with ease-out. The icon is freed in <see cref="DoInternalEnd"/>. </summary>
public class FFItemAnimation:FFAbstractAnimation{private enum Phase{RaiseItem,ApplyEffect,Recover,Done}private Phase _phase;private IItem _item;private bool _effectApplied;private TextureRect _itemIcon;/// <summary>Creates an item-use animation. <paramref name="action"/> is a synthetic <c>"ff/item"</c> descriptor; actual logic runs via <paramref name="item"/>.</summary>
public FFItemAnimation(FFCharacterWidget source,FFCharacterWidget target,ActionCharacter action,IItem item,float speed=1f):base(source,target,action,speed){_item=item;}/// <summary>Spawns the raised item icon above the user and starts the item sequence.</summary>
protected override void DoInternalStart(){Timestamp=Godot.Time.GetTicksUsec()/1_000_000.0;_effectApplied=false;_phase=Phase.RaiseItem;if(_item?.Sprite!=null){_itemIcon=new TextureRect();_itemIcon.Texture=_item.Sprite;_itemIcon.ExpandMode=TextureRect.ExpandModeEnum.IgnoreSize;_itemIcon.StretchMode=TextureRect.StretchModeEnum.KeepAspect;_itemIcon.Size=new Vector2(32,32);_itemIcon.Position=Source.GlobalPosition-Source.Position+new Vector2(20,-40);Source.AddChild(_itemIcon);}}/// <summary>Advances raise-item → apply (item use with effect flash) → lower-icon.</summary>
protected override bool DoInternalAction(){var elapsed=(float)(Godot.Time.GetTicksUsec()/1_000_000.0-Timestamp)*Speed*(float)Godot.Engine.TimeScale;switch(_phase){case Phase.RaiseItem:{var progress=Mathf.Clamp(elapsed/0.3f,0f,1f);if(_itemIcon!=null){var iconStartPos=Source.GlobalPosition-Source.Position+new Vector2(20,-40);_itemIcon.Position=iconStartPos+new Vector2(0,-30f*EaseInOutQuad(progress));}var bounce=Mathf.Sin(progress*Mathf.Pi)*8f;Source.GlobalPosition=SourcePos+new Vector2(0,-Mathf.Abs(bounce));if(progress>=1f){_phase=Phase.ApplyEffect;Timestamp=Godot.Time.GetTicksUsec()/1_000_000.0;}break;}case Phase.ApplyEffect:{var progress=Mathf.Clamp(elapsed/0.35f,0f,1f);if(_itemIcon!=null&&Target!=null){var localTarget=Target.GlobalPosition-Source.Position;var iconStart=Source.GlobalPosition-Source.Position+new Vector2(20,-70);_itemIcon.Position=iconStart.Lerp(localTarget+new Vector2(20,-20),EaseInOutQuad(progress));_itemIcon.Modulate=UiTokens.Alpha(UiTokens.White,1f-progress*0.5f);}if(!_effectApplied&&progress>=0.3f){_effectApplied=true;if(_item!=null)FFItemExecutor.TryUseItem(_item,Source.Card,Target.Card);Source.UpdateInfo();Target.UpdateInfo();Target.Flash(UiTokens.Success,0.3f);}if(progress>=1f){_phase=Phase.Recover;Timestamp=Godot.Time.GetTicksUsec()/1_000_000.0;}break;}case Phase.Recover:{var progress=Mathf.Clamp(elapsed/0.25f,0f,1f);Source.GlobalPosition=Source.GlobalPosition.Lerp(SourcePos,EaseOutQuad(progress));if(progress>=1f){_phase=Phase.Done;return true;}break;}}return false;}/// <summary>Frees the item icon, returns the user to its original position and refreshes both widgets.</summary>
protected override void DoInternalEnd(){if(IsAlive(Source))Source.GlobalPosition=SourcePos;if(_itemIcon!=null){_itemIcon.QueueFree();_itemIcon=null;}Source.UpdateInfo();Target.UpdateInfo();}}}

===== FILE mods/ff_battle/src/Actions/FFItemExecutor.cs =====
using Cthangover.Core.Battle;using Cthangover.Core.Battle.Actions;using Cthangover.Core.Characters;using Cthangover.Core.Items;using Cthangover.Core.Settings;using Cthangover.Core.Utils;namespace Cthangover.FFBattle.Actions{/// <summary> Handles item usage during battle. Implements <see cref="IActionExecutor"/> but its <see cref="Execute"/> method is a no-op — actual item logic runs via <see cref="TryUseItem"/> which is called directly from <see cref="FFItemAnimation"/>. This separation exists because items carry their own <see cref="IItem.ItemAction"/> delegate for effects, unlike standard actions which use the executor pattern. Registered under ID <c>"ff/item"</c>. </summary>
public class FFItemExecutor:IActionExecutor{/// <summary>The action ID string that maps to this executor in <see cref="FFActionProvider"/>.</summary>
public string ActionId=>"ff/item";/// <summary>No-op — item effects are applied via <see cref="TryUseItem"/>.</summary>
public ChangedAttributes Execute(ActionCharacter action,Character user,Character target){return new ChangedAttributes{Result=false};}/// <summary> Consumes one action point from <paramref name="user"/>, removes the item from the global inventory, and invokes the item's <see cref="IItem.ItemAction"/>. Returns <c>false</c> if the user lacks points, the item is not in inventory, or preconditions fail. </summary> <param name="item">The item to use.</param> <param name="user">The character consuming the item (loses 1 point).</param> <param name="target">The character targeted by the item effect.</param>
public static bool TryUseItem(IItem item,Character user,Character target){if(item==null||user==null)return false;if(user.Attributes.Point.Value<1){GameLogger.Log("FF_BATTLE",$"Not enough points to use item {item.Name}",LogLevel.Warning);return false;}var inventory=GameData.Instance.Runtime.Inventory;if(!inventory.HasItem(item.ID)){GameLogger.Log("FF_BATTLE",$"No {item.ID} in inventory",LogLevel.Warning);return false;}user.Attributes.Point.Value-=1;inventory.Remove(item.ID,1);if(item.ItemAction!=null)item.ItemAction.UseAction(item);GameLogger.Log("FF_BATTLE",$"{user.Name} uses item {item.Name}",LogLevel.Debug);return true;}}}

===== FILE mods/ff_battle/src/Actions/FFStunExecutor.cs =====
using Cthangover.Core.Cards.StatusEffect;using Cthangover.Core.Characters;using Cthangover.Core.Utils;namespace Cthangover.FFBattle.Actions{/// <summary> Applies a stun status effect to the target for a configurable number of turns. Reads the turn count from the <c>"Turn"</c> property on the action (default 3). Stunned characters are skipped during turn iteration in <see cref="FFBattleCore.TryNextCharacterOrEndTurn"/> and <see cref="FFBattleCore.RunEnemyTurn"/>. Registered under ID <c>"physics/stun"</c>. </summary>
public class FFStunExecutor:ActionBase{/// <summary>Hub registry key for this executor.</summary>
public override string ID=>"FFStunExecutor";/// <summary>Adds the stun effect for the action's "Turn" count (default 3); stun-immune targets consume the points but stay un-stunned.</summary>
public override ChangedAttributes Execute(ActionCharacter action,Character user,Character target){if(target==null||user==null|| !CheckRequiredAndUsePoint(action,user))return new ChangedAttributes{Result=false};if(target.HasPassiveEffect("StunImmune"))return new ChangedAttributes{Result=true};var turns=action.GetInt("Turn",3);StatusEffectService.Of(target).Add("effect/physics/stun",turns);GameLogger.Log("FF_BATTLE",$"{user.Name} stuns {target.Name} for {turns} turns",LogLevel.Debug);return new ChangedAttributes{Result=true};}}}

===== FILE mods/ff_battle/src/FFBattleCore.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Threading.Tasks;using Cthangover.Core.Battle;using Cthangover.Core.Battle.Actions;using Cthangover.Core.Cards.StatusEffect;using Cthangover.Core.Characters;using Cthangover.Core.Items;using Cthangover.Core.Settings;using Cthangover.Core.Scenes;using Cthangover.Core.UI;using Cthangover.Core.UI.CharacterPanel;using Cthangover.Core.Utils;using Cthangover.FFBattle.UI;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.FFBattle{/// <summary> Core orchestration class for the Final Fantasy-style turn-based battle system. Manages the full battle lifecycle: player turn with menu-driven action selection, enemy AI turn execution, target selection mode, escape mechanics, and win/loss condition checks. Coordinates between <see cref="FFPlayerPanel"/>, <see cref="FFEnemyPanel"/>, <see cref="FFMenuPanel"/>, and <see cref="FFBattleController"/> for UI interaction. Registered via <see cref="IBattleCore.Id"/> as <c>"ff_battle"</c>. </summary>
public class FFBattleCore:IBattleCore,IBattleAutoPlayable{/// <summary>Unique identifier for this battle system implementation, used by the core engine to locate it.</summary>
public string Id=>"ff_battle";/// <summary>Provides the set of action executors (damage, defence, stun, item) registered for this battle system.</summary>
public IActionExecutorProvider ActionProvider{get;}=new Actions.FFActionProvider();private Character[]_playerChars;private Character[]_enemyChars;private IBattleContext _ctx;private FFPlayerPanel _playerPanel;private FFEnemyPanel _enemyPanel;private FFMenuPanel _menuPanel;private FFActionDetailPanel _detailPanel;private FFBattleHud _hud;private FFBattleLogPanel _logPanel;private FFBattleBanner _banner;private FFBattleTargetHint _targetHint;private FFBattleController _controller;private Control _toolPanel;private Button _endTurnButton;private FFCharacterWidget _currentCharacter;private FFMenuEntry _currentMenuAction;private bool _isPlayerTurn;private bool _targetSelectMode;private bool _targetAlly;private System.Action<FFCharacterWidget>_onTargetSelected;private int _round;private int _detailKind;private const int DetailMain=0;private const int DetailActions=1;private const int DetailItems=2;private const float PLAYER_SCALE=0.85f;/// <summary> Initialises the battle UI panels, controller, and event wiring. Copies enemy <see cref="Character"/> instances via <c>Copy()</c> so mutations during battle do not affect the original data. Registers the <see cref="ActionProvider"/> with <see cref="ActionExecutorHub"/> and constructs all UI widgets inside <paramref name="ctx"/>'s root node. </summary> <param name="playerChars">The player party characters, consumed directly (not copied).</param> <param name="enemyChars">Enemy characters — each is shallow-copied for battle isolation.</param> <param name="ctx">Battle context providing root node and end-battle callback.</param>
public void Init(Character[]playerChars,Character[]enemyChars,IBattleContext ctx){_playerChars=playerChars;_ctx=ctx;_enemyChars=new Character[enemyChars.Length];for(int i=0;i<enemyChars.Length;i++)_enemyChars[i]=enemyChars[i]?.Copy()??enemyChars[i];ActionExecutorHub.Instance.SetActiveProvider(ActionProvider);var root=ctx.RootNode as Node;var panel=root.GetNodeOrNull<Control>("Panel")??root;_toolPanel=root.GetNodeOrNull<Control>("ToolPanel");_playerPanel=new FFPlayerPanel{Name="FFPlayerPanel"};_playerPanel.EnsureConstructed();panel.AddChild(_playerPanel);_enemyPanel=new FFEnemyPanel{Name="FFEnemyPanel"};_enemyPanel.EnsureConstructed();panel.AddChild(_enemyPanel);_menuPanel=(FFMenuPanel)TscnScenes.LoadAndBuild("scenes/ff_menu_panel.tscn");_menuPanel.Name="FFMenuPanel";_menuPanel.EnsureConstructed();_menuPanel.HideMenu();panel.AddChild(_menuPanel);_detailPanel=(FFActionDetailPanel)TscnScenes.LoadAndBuild("scenes/ff_action_detail_panel.tscn");_detailPanel.Name="FFActionDetailPanel";_detailPanel.EnsureConstructed();_detailPanel.HidePassport();panel.AddChild(_detailPanel);_hud=(FFBattleHud)TscnScenes.LoadAndBuild("scenes/ff_battle_hud.tscn");_hud.Name="FFBattleHud";_hud.EnsureConstructed();panel.AddChild(_hud);_logPanel=(FFBattleLogPanel)TscnScenes.LoadAndBuild("scenes/ff_battle_log_panel.tscn");_logPanel.Name="FFBattleLogPanel";_logPanel.EnsureConstructed();panel.AddChild(_logPanel);_banner=(FFBattleBanner)TscnScenes.LoadAndBuild("scenes/ff_battle_banner.tscn");_banner.Name="FFBattleBanner";_banner.EnsureConstructed();panel.AddChild(_banner);_targetHint=(FFBattleTargetHint)TscnScenes.LoadAndBuild("scenes/ff_battle_target_hint.tscn");_targetHint.Name="FFBattleTargetHint";_targetHint.EnsureConstructed();_targetHint.HideHint();panel.AddChild(_targetHint);_endTurnButton=new Button();_endTurnButton.Text=TranslationServer.Translate("ff_battle/end_turn");_endTurnButton.Pressed+=OnEndTurnPressed;_endTurnButton.Visible=false;_endTurnButton.FocusMode=Control.FocusModeEnum.None;_toolPanel?.AddChild(_endTurnButton);_controller=new FFBattleController{Name="FFBattleController"};panel.AddChild(_controller);_controller.PlayerPanel=_playerPanel;_controller.EnemyPanel=_enemyPanel;_controller.MenuPanel=_menuPanel;_controller.SubMenuPanel=_menuPanel;_controller.OnCharacterSelected+=OnCharacterSelected;_controller.OnMenuCancel+=OnMenuCancel;_controller.OnTargetSelected+=OnTargetSelected;_playerPanel.OnWidgetClicked+=OnWidgetClicked;_enemyPanel.OnEnemyClicked+=OnEnemyClicked;_enemyPanel.OnEnemyDead+=OnEnemyDied;_menuPanel.OnItemSelected+=OnMenuItemSelected;_menuPanel.OnSelectionChanged+=OnMenuSelectionChanged;_menuPanel.OnCancelled+=OnMenuCancel;}/// <summary> Begins the battle sequence. Calculates layout positions and scales for player/enemy panels based on viewport size. Subscribes to <see cref="BattleSceneContext.OnBattleCleared"/> for cleanup. Immediately transitions into the first player turn. </summary>
public void Start(){var root=_ctx.RootNode as Node;var viewportSize=root.GetViewport().GetVisibleRect().Size;var enemyScale=FFEnemyPanel.CalculateScale(_enemyChars.Length,new Vector2(viewportSize.X*0.9f,viewportSize.Y*0.5f));_enemyPanel.Position=new Vector2(viewportSize.X*0.05f,0);_enemyPanel.Size=new Vector2(viewportSize.X*0.9f,viewportSize.Y*0.5f);_playerPanel.Position=new Vector2(0,viewportSize.Y*0.55f);_playerPanel.Size=new Vector2(viewportSize.X*0.65f,viewportSize.Y*0.45f);_menuPanel.Position=new Vector2(viewportSize.X*0.66f,viewportSize.Y*0.58f);_menuPanel.Size=new Vector2(viewportSize.X*0.3f,160f);_detailPanel.Position=new Vector2(viewportSize.X*0.66f-320f,viewportSize.Y*0.58f);_detailPanel.Size=new Vector2(300f,190f);_hud.Position=new Vector2(26f,20f);_logPanel.Position=new Vector2(viewportSize.X-466f,64f);_logPanel.Size=new Vector2(440f,440f);_banner.Position=new Vector2(0f,viewportSize.Y*0.43f);_banner.Size=new Vector2(viewportSize.X,70f);_targetHint.Position=new Vector2(0f,viewportSize.Y*0.48f);_targetHint.Size=new Vector2(viewportSize.X,40f);if(_endTurnButton!=null)_endTurnButton.Position=new Vector2(viewportSize.X-180f,viewportSize.Y*0.92f);_playerPanel.Init(_playerChars,PLAYER_SCALE);_enemyPanel.Init(_enemyChars,enemyScale);BattleSceneContext.Instance.OnBattleCleared+=OnBattleCleared;_isPlayerTurn=true;_round=0;UpdateHud();_logPanel.AddEntry(Translate("ff_battle/log_battle_start"),FFBattleLogKind.Turn);StartPlayerTurn();}private void StartPlayerTurn(){GameLogger.Log("FF_BATTLE","Player turn started",LogLevel.Debug);_isPlayerTurn=true;BattleSceneContext.Instance.IsWait=false;_currentCharacter=null;_targetSelectMode=false;_menuPanel.HideMenu();_detailPanel.HidePassport();_targetHint.HideHint();BattleSceneContext.Instance.ActiveCharacter=null;foreach(var widget in _playerPanel.Widgets){if(!widget.IsDead){StatusEffectService.Of(widget.Card).OnTurnStart();widget.Card.Attributes.Point.Value=widget.Card.Attributes.Point.BaseValue;}widget.UpdateInfo();}_endTurnButton.Visible=true;_controller.State=FFBattleState.Idle;foreach(var widget in _enemyPanel.Widgets)widget.ClearHighlight();foreach(var widget in _playerPanel.Widgets)widget.ClearHighlight();_round+=1;UpdateHud();_hud.SetPhase(true);_banner.Show(Translate("ff_battle/banner_player"));_logPanel.SetRound(_round);_logPanel.AddEntry(string.Format(Translate("ff_battle/log_turn_player"),_round),FFBattleLogKind.Turn);}private void OnWidgetClicked(FFCharacterWidget widget){if(!_isPlayerTurn)return;if(_targetSelectMode){if(widget.IsDead)return;if(_targetAlly&& !_playerPanel.Widgets.Contains(widget))return;if(!_targetAlly&&_playerPanel.Widgets.Contains(widget))return;_targetSelectMode=false;foreach(var w in _enemyPanel.Widgets)w.ClearHighlight();foreach(var w in _playerPanel.Widgets)w.ClearHighlight();_targetHint.HideHint();ExecuteMenuAction(widget);return;}_controller.SelectCharacter(widget);}private void OnCharacterSelected(FFCharacterWidget widget){if(widget.IsDead||widget.Card?.Attributes?.Point?.Value<=0)return;_currentCharacter=widget;BattleSceneContext.Instance.ActiveCharacter=widget.Card;ShowMainMenu();}private void ShowMainMenu(){var entries=new List<FFMenuEntry>{new FFMenuEntry{Key="attack",Label=TranslationServer.Translate("ff_battle/menu_attack"),Enabled=HasAvailableActions()},new FFMenuEntry{Key="items",Label=TranslationServer.Translate("ff_battle/menu_items"),Enabled=HasUsableItems()},new FFMenuEntry{Key="defend",Label=TranslationServer.Translate("ff_battle/menu_defend"),Enabled=HasDefendAction()},new FFMenuEntry{Key="escape",Label=TranslationServer.Translate("ff_battle/menu_escape"),Enabled=true}};_currentMenuAction=null;_detailKind=DetailMain;_menuPanel.ShowMenu(entries,string.Format(Translate("ff_battle/menu_caption"),ActorName(),_currentCharacter.Card.Attributes.Point.Value,_currentCharacter.Card.Attributes.Point.BaseValue));_controller.State=FFBattleState.MenuOpen;foreach(var widget in _playerPanel.Widgets)widget.ClearHighlight();_currentCharacter?.Highlight(UiTokens.Accent);}private void OnMenuItemSelected(int index){if(_menuPanel.Entries==null||index>=_menuPanel.Entries.Count)return;var entry=_menuPanel.Entries[index];if(!entry.Enabled)return;MatchMenuAction(entry);}private void MatchMenuAction(FFMenuEntry entry){switch(entry.Key){case"attack":ShowActionSubMenu();break;case"items":ShowItemSubMenu();break;case"defend":DoDefendAction();break;case"escape":DoEscapeAction();break;case"action":_currentMenuAction=entry;var action=entry.Data as ActionCharacter;if(action!=null){if(action.Type==ActionCharacterType.ToEnemy)StartTargetSelect(false);else if(action.Type==ActionCharacterType.ToAlias)StartTargetSelect(true);else if(action.Type==ActionCharacterType.ToSelf){_targetSelectMode=false;ExecuteMenuAction(_currentCharacter);}}break;case"item":_currentMenuAction=entry;var item=entry.Data as IItem;if(item!=null){if(item.ItemType.HasFlag(ItemType.TargetUsed))StartTargetSelect(true);else DoItemAction(item);}break;case"back":ShowMainMenu();break;case"_empty":break;}}private void ShowActionSubMenu(){if(_currentCharacter?.Card?.Actions==null)return;var entries=new List<FFMenuEntry>();foreach(var action in _currentCharacter.Card.Actions){if(!action.Type.UseInBattle())continue;var cost=action.GetInt("RequiredPoint",1);var canUse=_currentCharacter.Card.Attributes.Point.Value>=cost;entries.Add(new FFMenuEntry{Key="action",Label=$"{TranslationServer.Translate(action.Name)}  ({cost}P)",Enabled=canUse,Data=action});}if(entries.Count==0){entries.Add(new FFMenuEntry{Key="_empty",Label=TranslationServer.Translate("ff_battle/no_actions"),Enabled=false});}entries.Add(new FFMenuEntry{Key="back",Label=TranslationServer.Translate("ff_battle/back"),Enabled=true});_detailKind=DetailActions;_menuPanel.ShowMenu(entries,ActorName()+" · "+Translate("ff_battle/menu_actions"));_controller.State=FFBattleState.SubMenuOpen;}private void ShowItemSubMenu(){var inventory=GameData.Instance.Runtime.Inventory;var entries=new List<FFMenuEntry>();foreach(var container in inventory.Items){var item=container.Item;if(item.ItemType.HasFlag(ItemType.Used)&&container.Count>0){entries.Add(new FFMenuEntry{Key="item",Label=$"{TranslationServer.Translate(item.Name)}  x{container.Count}",Enabled=_currentCharacter.Card.Attributes.Point.Value>=1,Data=item});}}if(entries.Count==0){entries.Add(new FFMenuEntry{Key="_empty",Label=TranslationServer.Translate("ff_battle/no_items"),Enabled=false});}entries.Add(new FFMenuEntry{Key="back",Label=TranslationServer.Translate("ff_battle/back"),Enabled=true});_detailKind=DetailItems;_menuPanel.ShowMenu(entries,ActorName()+" · "+Translate("ff_battle/menu_items"));_controller.State=FFBattleState.SubMenuOpen;}private void StartTargetSelect(bool targetAlly){_targetSelectMode=true;_targetAlly=targetAlly;_menuPanel.HideMenu();_controller.State=FFBattleState.TargetSelect;_targetHint.ShowHint(Translate(targetAlly?"ff_battle/hint_ally":"ff_battle/hint_target"));foreach(var widget in _playerPanel.Widgets)widget.ClearHighlight();foreach(var widget in _enemyPanel.Widgets)widget.ClearHighlight();if(targetAlly){foreach(var widget in _playerPanel.Widgets)if(!widget.IsDead)widget.Highlight(UiTokens.Success);}else{foreach(var widget in _enemyPanel.Widgets)if(!widget.IsDead)widget.Highlight(UiTokens.Danger);}_onTargetSelected=null;}private void OnEnemyClicked(FFCharacterWidget widget){if(!_isPlayerTurn|| !_targetSelectMode||widget.IsDead)return;if(_targetAlly)return;_targetSelectMode=false;foreach(var w in _enemyPanel.Widgets)w.ClearHighlight();foreach(var w in _playerPanel.Widgets)w.ClearHighlight();_targetHint.HideHint();ExecuteMenuAction(widget);}private void OnTargetSelected(FFCharacterWidget widget){if(!_isPlayerTurn|| !_targetSelectMode||widget.IsDead)return;if(_targetAlly&&_enemyPanel.Widgets.Contains(widget))return;if(!_targetAlly&&_playerPanel.Widgets.Contains(widget))return;_targetSelectMode=false;foreach(var w in _enemyPanel.Widgets)w.ClearHighlight();foreach(var w in _playerPanel.Widgets)w.ClearHighlight();_targetHint.HideHint();ExecuteMenuAction(widget);}private async void ExecuteMenuAction(FFCharacterWidget target){if(_currentMenuAction==null)return;_controller.State=FFBattleState.AnimationPlaying;_endTurnButton.Visible=false;_detailPanel.HidePassport();_targetHint.HideHint();var source=_currentCharacter;var actionName=string.Empty;var hpBefore=target.Card.Attributes.Health.Value;var defBefore=target.Card.Attributes.Defence.Value;var stunBefore=CountStunTurns(target.Card);switch(_currentMenuAction.Key){case"action":{var action=_currentMenuAction.Data as ActionCharacter;actionName=TranslationServer.Translate(action.Name).ToString();LogActionDeclared(source,actionName,target);IBattleAction anim=null;if(action.Type==ActionCharacterType.ToEnemy)anim=new Actions.FFAttackAnimation(_currentCharacter,target,action);else if(action.Type==ActionCharacterType.ToAlias)anim=new Actions.FFDefendAnimation(_currentCharacter,target,action);else if(action.Type==ActionCharacterType.ToSelf)anim=new Actions.FFDefendAnimation(_currentCharacter,_currentCharacter,action);else GameLogger.Log("FF_BATTLE",$"Unknown action type: {action.Type}",LogLevel.Warning);await RunAnimation(anim);break;}case"item":{var item=_currentMenuAction.Data as IItem;actionName=TranslationServer.Translate(item.Name).ToString();LogActionDeclared(source,actionName,target);var dummyAction=new ActionCharacter{ID="ff/item"};var anim=new Actions.FFItemAnimation(_currentCharacter,target,dummyAction,item);await RunAnimation(anim);break;}}LogOutcome(source,target,actionName,hpBefore,defBefore,stunBefore);_currentMenuAction=null;_endTurnButton.Visible=true;CheckEndCondition();}private void DoDefendAction(){var defendAction=_currentCharacter?.Card?.Actions?.FirstOrDefault(a=>a.Type==ActionCharacterType.ToAlias);if(defendAction==null)return;_currentMenuAction=new FFMenuEntry{Key="action",Data=defendAction};_targetSelectMode=false;ExecuteMenuAction(_currentCharacter);}private void DoEscapeAction(){_menuPanel.HideMenu();_detailPanel.HidePassport();_controller.State=FFBattleState.Idle;var escapeRoll=(float)GD.RandRange(0,100);if(escapeRoll<70f){GameLogger.Log("FF_BATTLE","Escape succeeded",LogLevel.Debug);_banner.Show(Translate("ff_battle/escape_success"));_logPanel.AddEntry(Translate("ff_battle/log_escape_success"),FFBattleLogKind.Turn);_ctx.EndBattle(BattleSide.Player);}else{GameLogger.Log("FF_BATTLE","Escape failed",LogLevel.Debug);_currentCharacter.Card.Attributes.Point.Value-=1;_currentCharacter.UpdateInfo();_banner.Show(Translate("ff_battle/escape_fail"));_logPanel.AddEntry(string.Format(Translate("ff_battle/log_escape_failed"),NameOf(_currentCharacter)),FFBattleLogKind.Status);_currentCharacter=null;BattleSceneContext.Instance.ActiveCharacter=null;TryNextCharacterOrEndTurn();}}private void DoItemAction(IItem item){_currentMenuAction=new FFMenuEntry{Key="item",Data=item};_targetSelectMode=false;ExecuteMenuAction(_currentCharacter);}private async Task RunAnimation(IBattleAction anim){if(anim==null)return;var root=(Node)_ctx.RootNode;anim.DoStart();while(!anim.DoAction())await root.ToSignal(root.GetTree(),"process_frame");anim.DoEnd();}private void OnMenuCancel(){if(_targetSelectMode){_targetSelectMode=false;foreach(var w in _enemyPanel.Widgets)w.ClearHighlight();foreach(var w in _playerPanel.Widgets)w.ClearHighlight();_targetHint.HideHint();ShowMainMenu();return;}if(_controller.State==FFBattleState.SubMenuOpen){ShowMainMenu();return;}_menuPanel.HideMenu();_detailPanel.HidePassport();_controller.State=FFBattleState.Idle;_currentCharacter=null;_currentMenuAction=null;BattleSceneContext.Instance.ActiveCharacter=null;foreach(var w in _playerPanel.Widgets)w.ClearHighlight();}private void CheckEndCondition(){if(!_isPlayerTurn)return;if(!_enemyPanel.HasAlive()){GameLogger.Log("FF_BATTLE","All enemies dead — player wins",LogLevel.Debug);_ctx.EndBattle(BattleSide.Player);return;}if(!_playerPanel.HasAlive()){GameLogger.Log("FF_BATTLE","All players dead — enemy wins",LogLevel.Debug);_ctx.EndBattle(BattleSide.Enemy);return;}TryNextCharacterOrEndTurn();}private void TryNextCharacterOrEndTurn(){foreach(var widget in _playerPanel.Widgets){if(widget.IsDead||widget.Card?.Attributes?.Point?.Value<=0)continue;if(StatusEffectService.Of(widget.Card).HasStun()){GameLogger.Log("FF_BATTLE",$"{widget.Card.Name} is stunned, skipping",LogLevel.Debug);widget.Card.Attributes.Point.Value=0;widget.UpdateInfo();_logPanel.AddEntry(string.Format(Translate("ff_battle/log_stun_skip"),NameOf(widget)),FFBattleLogKind.Status);continue;}_currentCharacter=widget;BattleSceneContext.Instance.ActiveCharacter=widget.Card;ShowMainMenu();return;}_logPanel.AddEntry(Translate("ff_battle/log_no_points"),FFBattleLogKind.Info);OnPlayerTurnEnd();}/// <summary> Ends the player's turn, hides all UI, and transitions to the enemy turn. Runs a brief delay before invoking <c>RunEnemyTurn()</c> which iterates over all alive enemies, each picking a random <see cref="ActionCharacter"/> and a random valid target, executing the action via the appropriate animation subclass. Checks for player defeat after each enemy action. If all enemies are dead, calls <see cref="IBattleContext.EndBattle"/>. Otherwise starts a new player turn. </summary>
public async void OnPlayerTurnEnd(){GameLogger.Log("FF_BATTLE","Player turn ended",LogLevel.Debug);_isPlayerTurn=false;_endTurnButton.Visible=false;_menuPanel.HideMenu();_detailPanel.HidePassport();_targetHint.HideHint();_controller.State=FFBattleState.AnimationPlaying;BattleSceneContext.Instance.IsWait=true;foreach(var widget in _playerPanel.Widgets)widget.ClearHighlight();foreach(var widget in _enemyPanel.Widgets)widget.ClearHighlight();_hud.SetPhase(false);_banner.Show(Translate("ff_battle/banner_enemy"));_logPanel.AddEntry(string.Format(Translate("ff_battle/log_turn_enemy"),_round),FFBattleLogKind.Turn);var root=(Node)_ctx.RootNode;await root.ToSignal(root.GetTree(),"process_frame");await root.ToSignal(root.GetTree().CreateTimer(0.5f),"timeout");await RunEnemyTurn();}private async Task RunEnemyTurn(){GameLogger.Log("FF_BATTLE","Enemy turn started",LogLevel.Debug);var root=(Node)_ctx.RootNode;foreach(var enemyWidget in _enemyPanel.Widgets.ToList()){if(enemyWidget.IsDead||enemyWidget.Card?.Actions==null||enemyWidget.Card.Actions.Count==0)continue;StatusEffectService.Of(enemyWidget.Card).OnTurnStart();enemyWidget.UpdateInfo();if(StatusEffectService.Of(enemyWidget.Card).HasStun()){GameLogger.Log("FF_BATTLE",$"{enemyWidget.Card.Name} is stunned, skipping",LogLevel.Debug);_logPanel.AddEntry(string.Format(Translate("ff_battle/log_stun_skip"),NameOf(enemyWidget)),FFBattleLogKind.Status);continue;}enemyWidget.Card.Attributes.Point.Value=enemyWidget.Card.Attributes.Point.BaseValue;var action=PickEnemyAction(enemyWidget);if(action==null)continue;var target=PickEnemyTarget(enemyWidget,action);if(target==null)continue;GameLogger.Log("FF_BATTLE",$"Enemy: {enemyWidget.Card.Name} → {action.Name} → {target.Card.Name}",LogLevel.Debug);var actionName=TranslationServer.Translate(action.Name).ToString();LogActionDeclared(enemyWidget,actionName,target);var hpBefore=target.Card.Attributes.Health.Value;var defBefore=target.Card.Attributes.Defence.Value;var stunBefore=CountStunTurns(target.Card);var anim=CreateEnemyAnimation(enemyWidget,target,action);if(anim!=null){anim.DoStart();while(!anim.DoAction())await root.ToSignal(root.GetTree(),"process_frame");anim.DoEnd();}LogOutcome(enemyWidget,target,actionName,hpBefore,defBefore,stunBefore);await root.ToSignal(root.GetTree().CreateTimer(0.2f),"timeout");if(!_playerPanel.HasAlive()){GameLogger.Log("FF_BATTLE","All players dead — enemy wins",LogLevel.Debug);_ctx.EndBattle(BattleSide.Enemy);return;}}if(!_playerPanel.HasAlive()){_ctx.EndBattle(BattleSide.Enemy);return;}StartPlayerTurn();}private ActionCharacter PickEnemyAction(FFCharacterWidget widget){var actions=widget.Card.Actions;if(actions==null||actions.Count==0)return null;var random=new Random();return actions[random.Next(actions.Count)];}private FFCharacterWidget PickEnemyTarget(FFCharacterWidget source,ActionCharacter action){if(action.Type==ActionCharacterType.ToEnemy){var alivePlayers=_playerPanel.Widgets.Where(w=> !w.IsDead&&w.Card?.Attributes?.Health?.Value>0).ToList();if(alivePlayers.Count==0)return null;return alivePlayers[new Random().Next(alivePlayers.Count)];}if(action.Type==ActionCharacterType.ToAlias||action.Type==ActionCharacterType.ToSelf)return source;return null;}private Actions.FFAbstractAnimation CreateEnemyAnimation(FFCharacterWidget source,FFCharacterWidget target,ActionCharacter action){if(action.Type==ActionCharacterType.ToEnemy)return new Actions.FFAttackAnimation(source,target,action,0.8f);if(action.Type==ActionCharacterType.ToAlias||action.Type==ActionCharacterType.ToSelf)return new Actions.FFDefendAnimation(source,target,action,0.8f);GameLogger.Log("FF_BATTLE",$"Unknown action type for enemy: {action.Type}",LogLevel.Warning);return null;}private void OnEndTurnPressed(){if(!_isPlayerTurn)return;OnPlayerTurnEnd();}private void OnEnemyDied(FFCharacterWidget widget){var ctx=BattleSceneContext.Instance;if(ctx!=null&&widget?.Card!=null){ctx.RecordEnemyDefeated(widget.Card);ctx.NotifyCharacterDied(widget.Card);}UpdateHudCounts();if(!_enemyPanel.HasAlive()){GameLogger.Log("FF_BATTLE","All enemies dead — player wins",LogLevel.Debug);_ctx.EndBattle(BattleSide.Player);}}private bool HasAvailableActions(){if(_currentCharacter?.Card?.Actions==null)return false;foreach(var action in _currentCharacter.Card.Actions){if(!action.Type.UseInBattle())continue;var cost=action.GetInt("RequiredPoint",1);if(_currentCharacter.Card.Attributes.Point.Value>=cost)return true;}return false;}private bool HasUsableItems(){var inventory=GameData.Instance.Runtime.Inventory;foreach(var container in inventory.Items){if(container.Item.ItemType.HasFlag(ItemType.Used)&&container.Count>0)return true;}return false;}private bool HasDefendAction(){if(_currentCharacter?.Card?.Actions==null)return false;return _currentCharacter.Card.Actions.Any(a=>a.Type==ActionCharacterType.ToAlias&&_currentCharacter.Card.Attributes.Point.Value>=a.GetInt("RequiredPoint",1));}private void OnBattleCleared(){_playerPanel?.ClearAll();_enemyPanel?.ClearAll();_menuPanel?.HideMenu();_detailPanel?.HidePassport();_targetHint?.HideHint();_logPanel?.Clear();if(_endTurnButton!=null)_endTurnButton.Visible=false;}private void OnMenuSelectionChanged(int index){if(_menuPanel.Entries==null||index<0||index>=_menuPanel.Entries.Count){_detailPanel.HidePassport();return;}var entry=_menuPanel.Entries[index];_detailPanel.ShowPassport();if(_detailKind==DetailActions){if(entry.Data is ActionCharacter action)_detailPanel.ShowAction(action,BuildActionNote(action));else _detailPanel.ShowHint(Translate("ff_battle/detail_back"));return;}if(_detailKind==DetailItems){if(entry.Data is IItem item)_detailPanel.ShowItem(item,item.ItemType.HasFlag(ItemType.TargetUsed),BuildItemNote(item));else _detailPanel.ShowHint(Translate("ff_battle/detail_back"));return;}_detailPanel.ShowHint(MainHintFor(entry.Key));}private string BuildActionNote(ActionCharacter action){if(action.Properties==null)return TranslationServer.Translate("ff_battle/detail_action").ToString();var cost=action.GetInt("RequiredPoint",1);var attack=action.GetFloat("Attack",0f);if(attack>0f){var attacker=_currentCharacter?.Card?.Attributes?.Attack?.Value??0;return string.Format(Translate("ff_battle/detail_damage"),Mathf.RoundToInt(attack*attacker),attacker,attack.ToString("0.0"),cost);}var turns=action.GetInt("Turn",0);if(turns>0)return string.Format(Translate("ff_battle/detail_stun"),turns,cost);var defence=action.GetInt("Defence",0);if(defence>0)return string.Format(Translate("ff_battle/detail_defence"),defence,cost);var description=ActionCharacterUi.StripMarkup(ActionCharacterUi.Translate(action.Description));return!string.IsNullOrEmpty(description)?description:Translate("ff_battle/detail_action");}private static string BuildItemNote(IItem item){var description=ActionCharacterUi.StripMarkup(ActionCharacterUi.Translate(item.Description));return!string.IsNullOrEmpty(description)?description:TranslationServer.Translate("ff_battle/detail_item").ToString();}private static string MainHintFor(string key){switch(key){case"attack":return TranslationServer.Translate("ff_battle/detail_attack").ToString();case"items":return TranslationServer.Translate("ff_battle/detail_items").ToString();case"defend":return TranslationServer.Translate("ff_battle/detail_defend").ToString();case"escape":return TranslationServer.Translate("ff_battle/detail_escape").ToString();default:return TranslationServer.Translate("ff_battle/detail_back").ToString();}}private void LogActionDeclared(FFCharacterWidget source,string actionName,FFCharacterWidget target){var entry=source==target?string.Format(Translate("ff_battle/log_action_self"),NameOf(source),actionName):string.Format(Translate("ff_battle/log_action"),NameOf(source),actionName,NameOf(target));_logPanel.AddEntry(entry,FFBattleLogKind.Info);}private void LogOutcome(FFCharacterWidget source,FFCharacterWidget target,string actionName,int hpBefore,int defBefore,int stunBefore){var hpAfter=target.Card.Attributes.Health.Value;var defAfter=target.Card.Attributes.Defence.Value;var stunAfter=CountStunTurns(target.Card);if(defAfter>defBefore)_logPanel.AddEntry(string.Format(Translate("ff_battle/log_defence"),NameOf(target),defAfter-defBefore,defAfter),FFBattleLogKind.Buff);var shieldLost=defBefore-defAfter;if(shieldLost>0)_logPanel.AddEntry(string.Format(Translate("ff_battle/log_shield"),NameOf(target),shieldLost,defAfter),FFBattleLogKind.Shield);if(stunAfter>stunBefore)_logPanel.AddEntry(string.Format(Translate("ff_battle/log_stun"),NameOf(target),stunAfter),FFBattleLogKind.Status);if(hpAfter>hpBefore)_logPanel.AddEntry(string.Format(Translate("ff_battle/log_heal"),actionName,NameOf(target),hpAfter-hpBefore),FFBattleLogKind.Buff);if(hpAfter<hpBefore)_logPanel.AddEntry(string.Format(Translate("ff_battle/log_damage"),actionName,NameOf(source),NameOf(target),hpBefore-hpAfter),FFBattleLogKind.Damage);if(hpAfter<=0&&hpBefore>0)_logPanel.AddEntry(string.Format(Translate("ff_battle/log_death"),NameOf(target)),FFBattleLogKind.Dead);UpdateHudCounts();}private static int CountStunTurns(Character character){var stun=StatusEffectService.Of(character)?.ActiveStatusEffects?.FirstOrDefault(effect=>effect.EffectType==StatusEffectType.Stun);return stun?.RemainingTurns??0;}private string ActorName()=>_currentCharacter?.Card==null?string.Empty:TranslationServer.Translate(_currentCharacter.Card.Name).ToString();private static string NameOf(FFCharacterWidget widget)=>widget?.Card==null?string.Empty:TranslationServer.Translate(widget.Card.Name).ToString();private void UpdateHud(){if(_hud==null)return;_hud.SetRound(_round);UpdateHudCounts();}private void UpdateHudCounts(){if(_hud==null)return;_hud.SetCounts(_enemyPanel.Widgets.Count(w=> !w.IsDead),_playerPanel.Widgets.Count(w=> !w.IsDead));}private static string Translate(string key)=>TranslationServer.Translate(key).ToString();/// <summary> <c>true</c> while the core accepts a player turn advance; flips to <c>false</c> synchronously at the start of <see cref="OnPlayerTurnEnd"/>. </summary>
public bool IsWaitingForPlayerInput=>_isPlayerTurn;/// <summary> Automation hook: ends the player's turn through the same path the end-turn button uses. </summary>
public void AdvancePlayerTurn()=>OnPlayerTurnEnd();}}

===== FILE mods/ff_battle/src/UI/FFActionDetailPanel.cs =====
using Cthangover.Core.Characters;using Cthangover.Core.Items;using Cthangover.Core.UI;using Cthangover.Core.UI.CharacterPanel;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.FFBattle.UI{/// <summary> Action/item passport shown to the left of the command menu. It mirrors the action-card component of the unified style guide: a type accent stripe, the action icon, name and ID, type/cost/cooldown badges, property chips and a note line with the computed effect preview. The panel exists so the small FF-style menu can stay a compact list while the player still sees what the highlighted command actually does. Node structure lives in <c>scenes/ff_action_detail_panel.tscn</c>; the class fills it and reuses <see cref="ActionCharacterUi"/> for badges and properties so the battle and the character panel can never disagree about an action's presentation. </summary>
public partial class FFActionDetailPanel:ModWidget{private PanelContainer _card;private ColorRect _stripe;private TextureRect _icon;private Label _nameLabel;private Label _idLabel;private HFlowContainer _badges;private HFlowContainer _props;private PanelContainer _note;private Label _noteLabel;/// <summary>Builds the action detail passport panel (description plate shown when an action is inspected).</summary>
protected override void Construct(){CthangoverTheme.Apply(this);_card=GetNode<PanelContainer>("Box/Card");_stripe=GetNode<ColorRect>("Box/Card/CardRow/Stripe");_icon=GetNode<TextureRect>("Box/Card/CardRow/IconMargin/CardIcon");_nameLabel=GetNode<Label>("Box/Card/CardRow/Body/NameRow/NameLabel");_idLabel=GetNode<Label>("Box/Card/CardRow/Body/NameRow/IdLabel");_badges=GetNode<HFlowContainer>("Box/Card/CardRow/Body/Badges");_props=GetNode<HFlowContainer>("Box/Card/CardRow/Body/Props");_note=GetNode<PanelContainer>("Box/Note");_noteLabel=GetNode<Label>("Box/Note/NoteLabel");var cardStyle=UiKit.Frame(UiTokens.RadiusMd,UiTokens.Alpha(UiTokens.Surface1,0.92f),UiTokens.LineCard);cardStyle.ContentMarginLeft=0;cardStyle.ContentMarginRight=0;cardStyle.ContentMarginTop=0;cardStyle.ContentMarginBottom=0;_card.AddThemeStyleboxOverride("panel",cardStyle);var noteStyle=UiKit.Frame(UiTokens.RadiusMd,UiTokens.Alpha(UiTokens.Surface2,0.9f),UiTokens.Line);noteStyle.ContentMarginLeft=12;noteStyle.ContentMarginRight=12;noteStyle.ContentMarginTop=9;noteStyle.ContentMarginBottom=9;_note.AddThemeStyleboxOverride("panel",noteStyle);_nameLabel.AddThemeColorOverride("font_color",UiTokens.InkBright);_idLabel.AddThemeColorOverride("font_color",UiTokens.Faint);_noteLabel.AddThemeColorOverride("font_color",UiTokens.Muted);}/// <summary> Fills the card with an action passport. <paramref name="note"/> is a ready localised effect preview built by the core, because only the core knows the acting character's attack value for the damage estimate. </summary>
public void ShowAction(ActionCharacter action,string note){if(action==null){ShowHint(note);return;}_stripe.Color=ActionCharacterUi.AccentColor(action.Type);_icon.Texture=action.Image;_nameLabel.Text=ActionCharacterUi.Translate(action.Name);_idLabel.Text=action.ID??string.Empty;_card.Visible=true;ResetBody();AddBadge(ActionCharacterUi.Translate(ActionCharacterUi.TypeLabelKey(action.Type)),ActionCharacterUi.AccentColor(action.Type));var costText=ActionCharacterUi.CostText(action);if(!string.IsNullOrEmpty(costText))AddBadge(costText,UiTokens.TypeResource);var turns=action.GetInt("Turn",0);if(turns>0)AddBadge(string.Format(Translate("ff_battle/badge_turns"),turns),UiTokens.Cooldown);foreach(var pair in ActionCharacterUi.SortedProperties(action)){if(pair.Key=="RequiredPoint")continue;_props.AddChild(ActionCharacterUi.MakePropertyChip(pair.Key,pair.Value));}SetNote(note);}/// <summary>Fills the card with an inventory item passport for the item sub-menu.</summary>
public void ShowItem(IItem item,bool targetAlly,string note){if(item==null){ShowHint(note);return;}_stripe.Color=UiTokens.TypeUsed;_icon.Texture=item.Sprite;_nameLabel.Text=ActionCharacterUi.Translate(item.Name);_idLabel.Text=item.ID??string.Empty;_card.Visible=true;ResetBody();if(item.ItemType.HasFlag(ItemType.Used))AddBadge(ActionCharacterUi.Translate("ui/inventory/type_used"),UiTokens.TypeUsed);AddBadge(ActionCharacterUi.Translate(targetAlly?"ui/inventory/type_target":"ff_battle/badge_self"),targetAlly?UiTokens.TypeTarget:UiTokens.TypeSelf);SetNote(note);}/// <summary>Shows a plain directive note under the card, used for highlighted menu hints.</summary>
public void ShowHint(string note){ResetBody();_card.Visible=false;SetNote(note);}/// <summary> Hides the whole passport; the core calls it when the menu closes. The inherited <see cref="CanvasItem.Hide"/> semantics are enough, so the method is only a named alias for readability at call sites. </summary>
public void HidePassport()=>Visible=false;/// <summary>Shows the panel; the core calls it before filling any content.</summary>
public void ShowPassport()=>Visible=true;private void ResetBody(){foreach(var child in _badges.GetChildren()){_badges.RemoveChild(child);child.QueueFree();}foreach(var child in _props.GetChildren()){_props.RemoveChild(child);child.QueueFree();}}private void AddBadge(string text,Color color)=>_badges.AddChild(ActionCharacterUi.MakeBadge(text,color));private void SetNote(string note){var hasNote= !string.IsNullOrEmpty(note);_note.Visible=hasNote;_noteLabel.Text=note??string.Empty;}private static string Translate(string key)=>TranslationServer.Translate(key).ToString();}}

===== FILE mods/ff_battle/src/UI/FFBattleBanner.cs =====
using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.FFBattle.UI{/// <summary> Centered announcement strip ("Player turn", "Enemy turn", escape results). The node tree lives in <c>scenes/ff_battle_banner.tscn</c>; this class dresses it with tokens and owns the fade in/hold/fade out tween. The banner is purely informational, so it ignores the mouse and the core places it on its own anchored strip between the enemy grid and the party row. </summary>
public partial class FFBattleBanner:ModWidget{private Label _label;private Tween _tween;/// <summary>Builds the centre-screen turn/outcome banner label.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);_label=GetNode<Label>("Center/Panel/Label");_label.AddThemeColorOverride("font_color",UiTokens.InkBright);var style=UiKit.Frame(0,UiTokens.Alpha(UiTokens.Page,0.62f),new Color(0f,0f,0f,0f));style.BorderWidthTop=1;style.BorderWidthBottom=1;style.BorderColor=UiTokens.AccentLine;style.ContentMarginLeft=44;style.ContentMarginRight=44;style.ContentMarginTop=12;style.ContentMarginBottom=12;GetNode<PanelContainer>("Center/Panel").AddThemeStyleboxOverride("panel",style);Modulate=UiTokens.Alpha(UiTokens.White,0f);}/// <summary>Shows the given caption and restarts the 1.6s fade cycle.</summary>
public void Show(string text){_label.Text=text;_tween?.Kill();Modulate=UiTokens.Alpha(UiTokens.White,0f);_tween=CreateTween();_tween.TweenProperty(this,"modulate:a",1f,0.2f);_tween.TweenInterval(1.1f);_tween.TweenProperty(this,"modulate:a",0f,0.3f);}}}

===== FILE mods/ff_battle/src/UI/FFBattleController.cs =====
using Cthangover.Core.UI;using Godot;using Cthangover.Mods.Interface.Base;namespace Cthangover.FFBattle.UI{/// <summary> Defines the six states of the battle UI state machine, driving which input handler is active and what the player sees. </summary>
public enum FFBattleState{/// <summary>No menu open; player can select a character to act.</summary>
Idle,/// <summary>Main action menu (attack/items/defend/escape) is visible.</summary>
MenuOpen,/// <summary>Sub-menu (action list or item list) is visible.</summary>
SubMenuOpen,/// <summary>Player must click a valid target character widget.</summary>
TargetSelect,/// <summary>An animation is playing; all input is blocked.</summary>
AnimationPlaying,/// <summary>Battle has concluded.</summary>
Ended}/// <summary> Input dispatch layer for the FF battle UI. Extends <see cref="InputHandlerNode"/> so that keyboard events from Godot's input system are forwarded to this node even in mod assemblies. Routes arrow keys, Enter, Space, and Escape to the appropriate handler based on current <see cref="State"/>. Also provides mouse-driven character selection via <see cref="SelectCharacter"/>. Events are raised to <see cref="FFBattleCore"/> which wires up the actual logic. </summary>
public partial class FFBattleController:InputHandlerNode{private FFBattleState _state=FFBattleState.Idle;/// <summary>Reference to the player-side character panel for idle-state character selection.</summary>
public FFPlayerPanel PlayerPanel{get;set;}/// <summary>Reference to the enemy-side panel for visual context (not directly used for input).</summary>
public FFEnemyPanel EnemyPanel{get;set;}/// <summary>The main menu panel; <see cref="HandleMenuInput"/> delegates up/down/confirm to it.</summary>
public FFMenuPanel MenuPanel{get;set;}/// <summary>The sub-menu panel; <see cref="HandleSubMenuInput"/> delegates to it.</summary>
public FFMenuPanel SubMenuPanel{get;set;}/// <summary>Raised when a character widget is selected (via mouse click or keyboard).</summary>
public event System.Action<FFCharacterWidget>OnCharacterSelected;/// <summary>Raised when a menu item is confirmed. The int is the entry index.</summary>
public event System.Action<int>OnMenuAction;/// <summary>Raised when a sub-menu item is confirmed.</summary>
public event System.Action<int>OnSubMenuAction;/// <summary>Raised when Escape is pressed while the main menu is open.</summary>
public event System.Action OnMenuCancel;/// <summary>Raised when Escape is pressed while a sub-menu is open.</summary>
public event System.Action OnSubMenuCancel;/// <summary>Raised when a target widget is confirmed in target-select mode.</summary>
public event System.Action<FFCharacterWidget>OnTargetSelected;/// <summary> Current UI state. Setting this property logs the transition via <see cref="GameLogger"/> for debugging battle flow. </summary>
public FFBattleState State{get=>_state;set{_state=value;Cthangover.Core.Utils.GameLogger.Log("FF_BATTLE",$"Controller state → {value}",Cthangover.Core.Utils.LogLevel.Debug);}}/// <summary>Ensures the controller processes input even when the scene tree is paused.</summary>
public override void _Ready(){ProcessMode=ProcessModeEnum.Always;}/// <summary>Routes menu/confirm input into the FF battle state machine (target select, turn advance).</summary>
protected override void OnInput(InputEvent@event){if(State==FFBattleState.AnimationPlaying||State==FFBattleState.Ended)return;if(@event is InputEventKey key&&key.Pressed){HandleKeyboardInput(key);if(State!=FFBattleState.Idle)GetViewport().SetInputAsHandled();}}private void HandleKeyboardInput(InputEventKey key){switch(State){case FFBattleState.Idle:HandleIdleInput(key);break;case FFBattleState.MenuOpen:HandleMenuInput(key);break;case FFBattleState.SubMenuOpen:HandleSubMenuInput(key);break;case FFBattleState.TargetSelect:HandleTargetInput(key);break;}}private void HandleIdleInput(InputEventKey key){if(key.Keycode==Key.Enter||key.Keycode==Key.Space){SelectFirstAvailableCharacter();}}private void HandleMenuInput(InputEventKey key){switch(key.Keycode){case Key.Up:case Key.W:MenuPanel?.SelectPrevious();break;case Key.Down:case Key.S:MenuPanel?.SelectNext();break;case Key.Enter:case Key.Space:MenuPanel?.ConfirmSelection();break;case Key.Escape:OnMenuCancel?.Invoke();break;}}private void HandleSubMenuInput(InputEventKey key){switch(key.Keycode){case Key.Up:case Key.W:SubMenuPanel?.SelectPrevious();break;case Key.Down:case Key.S:SubMenuPanel?.SelectNext();break;case Key.Enter:case Key.Space:SubMenuPanel?.ConfirmSelection();break;case Key.Escape:OnSubMenuCancel?.Invoke();break;}}private void HandleTargetInput(InputEventKey key){if(key.Keycode==Key.Escape){OnMenuCancel?.Invoke();}}private void SelectFirstAvailableCharacter(){if(PlayerPanel==null)return;foreach(var widget in PlayerPanel.Widgets){if(!widget.IsDead&&widget.Card?.Attributes?.Point?.Value>0){OnCharacterSelected?.Invoke(widget);return;}}}/// <summary> Selects a character widget, raising <see cref="OnCharacterSelected"/>. Only valid in <see cref="FFBattleState.Idle"/> state. </summary>
public void SelectCharacter(FFCharacterWidget widget){if(State!=FFBattleState.Idle)return;OnCharacterSelected?.Invoke(widget);}}}

===== FILE mods/ff_battle/src/UI/FFBattleHud.cs =====
using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.FFBattle.UI{/// <summary> Top-left battle HUD: title, phase badge and the round / roster counters. The node tree comes from <c>scenes/ff_battle_hud.tscn</c>; this class only fetches nodes, dresses them with <see cref="UiTokens"/> styles and exposes narrow setters so <see cref="FFBattleCore"/> never assembles controls by hand. The phase badge reuses the type-accent palette: blue for the player turn, red for the enemy turn. </summary>
public partial class FFBattleHud:ModWidget{private Label _title;private PanelContainer _phaseBadge;private Label _phaseLabel;private Label _roundValue;private Label _enemiesValue;private Label _alliesValue;/// <summary>Builds the FF battle HUD frame: command menu host, speed control and log toggle.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);_title=GetNode<Label>("Panel/Column/TopRow/TitleLabel");_phaseBadge=GetNode<PanelContainer>("Panel/Column/TopRow/PhaseBadge");_phaseLabel=GetNode<Label>("Panel/Column/TopRow/PhaseBadge/PhaseLabel");_roundValue=GetNode<Label>("Panel/Column/MetaRow/RoundValue");_enemiesValue=GetNode<Label>("Panel/Column/MetaRow/EnemiesValue");_alliesValue=GetNode<Label>("Panel/Column/MetaRow/AlliesValue");var panelStyle=UiKit.Frame(UiTokens.RadiusLg,UiTokens.Alpha(UiTokens.Surface0,0.86f),UiTokens.Line);panelStyle.ContentMarginLeft=18;panelStyle.ContentMarginRight=18;panelStyle.ContentMarginTop=12;panelStyle.ContentMarginBottom=13;panelStyle.ShadowColor=new Color(0f,0f,0f,0.6f);panelStyle.ShadowSize=18;panelStyle.ShadowOffset=new Vector2(0f,8f);GetNode<PanelContainer>("Panel").AddThemeStyleboxOverride("panel",panelStyle);_title.AddThemeColorOverride("font_color",UiTokens.InkBright);foreach(var name in new[]{"RoundCaption","EnemiesCaption","AlliesCaption"})GetNode<Label>("Panel/Column/MetaRow/"+name).AddThemeColorOverride("font_color",UiTokens.Muted);foreach(var name in new[]{"RoundValue","EnemiesValue","AlliesValue"})GetNode<Label>("Panel/Column/MetaRow/"+name).AddThemeColorOverride("font_color",UiTokens.AccentBright);ApplyPhaseStyle(true);}/// <summary>Switches the phase badge between the player and enemy turns.</summary>
public void SetPhase(bool playerTurn){_phaseLabel.Text=Translate(playerTurn?"ff_battle/phase_player":"ff_battle/phase_enemy");ApplyPhaseStyle(playerTurn);}/// <summary>Updates the round counter.</summary>
public void SetRound(int round)=>_roundValue.Text=round.ToString();/// <summary>Updates living combatant counters.</summary>
public void SetCounts(int enemies,int allies){_enemiesValue.Text=enemies.ToString();_alliesValue.Text=allies.ToString();}private void ApplyPhaseStyle(bool playerTurn){var accent=playerTurn?UiTokens.TypeSelf:UiTokens.TypeEnemy;var style=UiKit.Frame(UiTokens.RadiusXs,UiTokens.BadgeFill(accent),UiTokens.BadgeLine(accent));style.ContentMarginLeft=7;style.ContentMarginRight=7;style.ContentMarginTop=2;style.ContentMarginBottom=2;_phaseBadge.AddThemeStyleboxOverride("panel",style);_phaseLabel.AddThemeColorOverride("font_color",accent);}private static string Translate(string key)=>TranslationServer.Translate(key).ToString();}}

===== FILE mods/ff_battle/src/UI/FFBattleLogKind.cs =====
namespace Cthangover.FFBattle.UI{/// <summary> Semantic categories of <see cref="FFBattleLogPanel"/> entries. The kind selects the row colour and the left border accent: turn separators are gold, damage red, absorbed shield blue, buffs green, statuses amber and deaths grey. <see cref="Info"/> is the neutral narration fallback. </summary>
public enum FFBattleLogKind{Info,Turn,Damage,Shield,Buff,Status,Dead}}

===== FILE mods/ff_battle/src/UI/FFBattleLogPanel.cs =====
using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.FFBattle.UI{/// <summary> Battle journal docked to the upper right: a window with a header carrying the round counter and a scrollable list of colour-coded rows. The panel is a proposal on top of the bare battle screen — it gives damage, shield absorption, status and death events a readable history that the original FF-style UI lacked. Node structure lives in <c>scenes/ff_battle_log_panel.tscn</c>; rows are created at runtime because they are data, not layout, and are capped to <see cref="MaxRows"/> so a long battle cannot grow unbounded. </summary>
public partial class FFBattleLogPanel:ModWidget{private const int MaxRows=80;private Label _title;private Label _roundLabel;private ScrollContainer _scroll;private VBoxContainer _rows;/// <summary>Builds the scrollable FF battle log with coloured entries per <see cref="FFBattleLogKind"/>.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);_title=GetNode<Label>("Window/Column/Header/HeaderRow/TitleLabel");_roundLabel=GetNode<Label>("Window/Column/Header/HeaderRow/RoundLabel");_scroll=GetNode<ScrollContainer>("Window/Column/Scroll");_rows=GetNode<VBoxContainer>("Window/Column/Scroll/Rows");var windowStyle=UiKit.Frame(UiTokens.RadiusLg,UiTokens.Alpha(UiTokens.Surface0,0.84f),UiTokens.Line);windowStyle.ContentMarginLeft=0;windowStyle.ContentMarginRight=0;windowStyle.ContentMarginTop=0;windowStyle.ContentMarginBottom=0;windowStyle.ShadowColor=new Color(0f,0f,0f,0.6f);windowStyle.ShadowSize=18;windowStyle.ShadowOffset=new Vector2(0f,8f);GetNode<PanelContainer>("Window").AddThemeStyleboxOverride("panel",windowStyle);_title.AddThemeColorOverride("font_color",UiTokens.Muted);_roundLabel.AddThemeColorOverride("font_color",UiTokens.Faint);}/// <summary>Updates the round caption in the header.</summary>
public void SetRound(int round)=>_roundLabel.Text=string.Format(Translate("ff_battle/log_round"),round);/// <summary>Drops every row; used when the battle is cleared or restarted.</summary>
public void Clear(){foreach(var child in _rows.GetChildren()){_rows.RemoveChild(child);child.QueueFree();}}/// <summary> Appends one coloured row and trims the oldest entries past the cap. The scroll is pushed to the bottom so the newest event is always visible. </summary>
public void AddEntry(string text,FFBattleLogKind kind){var style=UiKit.Frame(0,BackgroundOf(kind),new Color(0f,0f,0f,0f));style.BorderWidthLeft=2;style.BorderColor=BorderOf(kind);style.ContentMarginLeft=16;style.ContentMarginRight=12;style.ContentMarginTop=7;style.ContentMarginBottom=7;var row=new PanelContainer{MouseFilter=MouseFilterEnum.Ignore};row.AddThemeStyleboxOverride("panel",style);var label=UiKit.Label(text,UiTokens.FontBodySmall,TextOf(kind));label.AutowrapMode=TextServer.AutowrapMode.WordSmart;row.AddChild(label);_rows.AddChild(row);while(_rows.GetChildCount()>MaxRows){var first=_rows.GetChild(0);_rows.RemoveChild(first);first.QueueFree();}_scroll.ScrollVertical=(int)_scroll.GetVScrollBar().MaxValue;}private static Color BackgroundOf(FFBattleLogKind kind)=>kind==FFBattleLogKind.Turn?UiTokens.AccentSoft:new Color(0f,0f,0f,0f);private static Color BorderOf(FFBattleLogKind kind){switch(kind){case FFBattleLogKind.Turn:return UiTokens.Accent;case FFBattleLogKind.Damage:return UiTokens.Danger;case FFBattleLogKind.Shield:return UiTokens.TypeResource;case FFBattleLogKind.Buff:return UiTokens.TypePassive;case FFBattleLogKind.Status:return UiTokens.Cooldown;case FFBattleLogKind.Dead:return UiTokens.LineFaint;default:return UiTokens.Line;}}private static Color TextOf(FFBattleLogKind kind){switch(kind){case FFBattleLogKind.Turn:return UiTokens.AccentBright;case FFBattleLogKind.Damage:return UiTokens.DangerText;case FFBattleLogKind.Shield:return UiTokens.Info;case FFBattleLogKind.Buff:return UiTokens.SuccessText;case FFBattleLogKind.Status:return UiTokens.Cooldown;case FFBattleLogKind.Dead:return UiTokens.Faint;default:return UiTokens.Muted;}}private static string Translate(string key)=>TranslationServer.Translate(key).ToString();}}

===== FILE mods/ff_battle/src/UI/FFBattleTargetHint.cs =====
using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.FFBattle.UI{/// <summary> Toast-style directive shown while the player picks a battle target ("Select a target · RMB / Esc — cancel"). It is a deliberate addition over the bare FF UI: the original mod highlighted widgets without ever telling the player what the highlight meant, which is easy to miss on a large enemy grid. The node tree lives in <c>scenes/ff_battle_target_hint.tscn</c>; the class dresses it with tokens and toggles visibility. </summary>
public partial class FFBattleTargetHint:ModWidget{private Label _icon;private Label _text;/// <summary>Builds the floating hint shown above the hovered target.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);_icon=GetNode<Label>("Center/Toast/Row/IconLabel");_text=GetNode<Label>("Center/Toast/Row/TextLabel");_icon.AddThemeColorOverride("font_color",UiTokens.AccentBright);_text.AddThemeColorOverride("font_color",UiTokens.Ink);var style=UiKit.Frame(UiTokens.RadiusMd,UiTokens.ToastBg,UiTokens.Line);style.ContentMarginLeft=14;style.ContentMarginRight=14;style.ContentMarginTop=10;style.ContentMarginBottom=10;style.ShadowColor=new Color(0f,0f,0f,0.5f);style.ShadowSize=10;style.ShadowOffset=new Vector2(0f,4f);GetNode<PanelContainer>("Center/Toast").AddThemeStyleboxOverride("panel",style);}/// <summary>Shows the hint with the given caption.</summary>
public void ShowHint(string text){_text.Text=text;Visible=true;}/// <summary>Hides the hint; called when a target is confirmed or selection is cancelled.</summary>
public void HideHint()=>Visible=false;}}

===== FILE mods/ff_battle/src/UI/FFCharacterWidget.cs =====
using System;using System.Linq;using Cthangover.Core.Cards.StatusEffect;using Cthangover.Core.Characters;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.FFBattle.UI{/// <summary> Battle card of a single combatant, shared by the party and enemy panels. The node tree lives in <c>scenes/ff_character_widget.tscn</c>; this class only fetches the nodes, dresses them with <see cref="UiTokens"/> styles and refreshes their content. Visually the card follows the unified style guide: a team accent line on top, a sprite well, status badges in the upper right and an info block holding name, HP numbers, the layered health bar (<see cref="UiKit.StyleShieldedHealthBar"/>: track → health fill → blue defence segment drawn past the health edge while <see cref="CharacterAttributes.Defence"/> absorbs damage), action point pips (party) or attack caption (enemies) and the defence shield chip. <para/> The public surface is animation-facing, not layout-facing: <see cref="Highlight"/> paints the selection frame, <see cref="Flash"/> and <see cref="Shake"/> give impact feedback, <see cref="PlayDeathAnimation"/> dissolves the card. Battle code never touches labels directly — it mutates the <see cref="Character"/> and calls <see cref="UpdateInfo"/>, which re-reads health, defence, points and stun in one place. Panels must assign <see cref="IsPlayer"/> before <see cref="Init"/>, because the team colour is applied during initialisation. </summary>
public partial class FFCharacterWidget:ModWidget{/// <summary>Diameter of one action point pip.</summary>
private const int PipSize=9;private TextureRect _art;private ColorRect _teamLine;private Panel _selection;private Panel _teamDot;private Label _nameLabel;private Label _hpNumLabel;private ProgressBar _shieldBar;private ProgressBar _hpBar;private HBoxContainer _pipsBox;private Label _atkLabel;private PanelContainer _shieldChip;private Label _shieldValue;private VBoxContainer _statusBox;private PanelContainer _stunBadge;private TextureRect _stunIcon;private Label _stunLabel;private Tween _animTween;private static Shader _dissolveShader;/// <summary>Whether this widget belongs to the player party (affects highlight/targeting logic).</summary>
public bool IsPlayer{get;set;}/// <summary>Whether the character is dead; set by <see cref="PlayDeathAnimation"/>.</summary>
public bool IsDead{get;set;}/// <summary>The character data model backing this widget: stats, actions, status effects.</summary>
public Character Card{get;set;}/// <summary>Original scale captured at initialisation; used to restore after death animation.</summary>
public Vector2 BaseScale{get;private set;}/// <summary>Exposes this widget as a <see cref="Godot.Control"/> for layout calculations.</summary>
public Control ControlNode=>this;/// <summary>Builds the character widget: portrait frame, HP bar and status chips.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);_teamLine=GetNode<ColorRect>("TeamLine");_art=GetNode<TextureRect>("Art");_nameLabel=GetNode<Label>("Info/Column/NameRow/NameLabel");_teamDot=GetNode<Panel>("Info/Column/NameRow/TeamDot");_hpNumLabel=GetNode<Label>("Info/Column/NameRow/HpNumLabel");_shieldBar=GetNode<ProgressBar>("Info/Column/HpBarStack/ShieldBar");_hpBar=GetNode<ProgressBar>("Info/Column/HpBarStack/HpBar");_pipsBox=GetNode<HBoxContainer>("Info/Column/Foot/PipsBox");_atkLabel=GetNode<Label>("Info/Column/Foot/AtkLabel");_shieldChip=GetNode<PanelContainer>("Info/Column/Foot/ShieldChip");_shieldValue=GetNode<Label>("Info/Column/Foot/ShieldChip/ShieldRow/ShieldValue");_statusBox=GetNode<VBoxContainer>("StatusBox");_stunBadge=GetNode<PanelContainer>("StatusBox/StunBadge");_stunIcon=GetNode<TextureRect>("StatusBox/StunBadge/StunRow/StunIcon");_stunLabel=GetNode<Label>("StatusBox/StunBadge/StunRow/StunLabel");_selection=GetNode<Panel>("Selection");ApplyStyles();}/// <summary> Binds the widget to a <see cref="Character"/>: sprite, localised name, team colour and per-side info layout. The team colour is derived from <see cref="IsPlayer"/>, which is why callers set it before calling here. </summary>
public void Init(Character character){Card=character;BaseScale=Scale;IsDead=false;if(_art!=null&&character?.Image!=null)_art.Texture=character.Image;if(_nameLabel!=null)_nameLabel.Text=TranslationServer.Translate(character?.Name??string.Empty).ToString();var teamColor=IsPlayer?UiTokens.Info:UiTokens.Danger;if(_teamLine!=null)_teamLine.Color=UiTokens.Alpha(teamColor,0.55f);if(_teamDot!=null)_teamDot.AddThemeStyleboxOverride("panel",UiKit.Frame(4,UiTokens.Alpha(teamColor,0.9f)));if(_pipsBox!=null)_pipsBox.Visible=IsPlayer;if(_atkLabel!=null)_atkLabel.Visible= !IsPlayer;UpdateInfo();}/// <summary> Single refresh entry point for all mutable character state: layered HP/defence bar and numbers with threshold colours, action point pips, shield chip, attack caption and the stun badge with its remaining turns. Called by battle code and by animations after each resolved action. </summary>
public void UpdateInfo(){// A lethal hit frees the widget through the death animation while the attack animation and Health.OnChange subscribers still hold it.
if(!IsInstanceValid(this)||IsQueuedForDeletion()||Card?.Attributes==null)return;var health=Card.Attributes.Health;var percent=Mathf.Clamp(health.Percent,0f,1f);var defence=Card.Attributes.Defence?.Value??0;var fillColor=percent>0.5f?UiTokens.HpOk:percent>0.25f?UiTokens.Warning:UiTokens.HpLow;var textColor=percent>0.5f?UiTokens.HpText:percent>0.25f?UiTokens.Warning:UiTokens.DangerText;if(_hpBar!=null){_hpBar.Value=percent*100f;_hpBar.AddThemeStyleboxOverride("fill",UiKit.Frame(UiTokens.RadiusSm,fillColor));}// Blue defence segment on top of the health fill; collapses onto the health edge when the shield is gone.
UiKit.UpdateShieldedHealthBar(_shieldBar,percent,defence,health.BaseValue);if(_hpNumLabel!=null){_hpNumLabel.Text=$"{health.Value}/{health.BaseValue}";_hpNumLabel.AddThemeColorOverride("font_color",textColor);}if(_pipsBox!=null&&IsPlayer)RefreshPips();if(_atkLabel!=null&& !IsPlayer)_atkLabel.Text=string.Format("{0} {1}",Translate("ui/character_panel/stat_atk"),Card.Attributes.Attack.Value);if(_shieldChip!=null){_shieldChip.Visible=defence>0;if(_shieldValue!=null)_shieldValue.Text=defence.ToString();}RefreshStun();}/// <summary>Shows a coloured selection frame over the card with a 0.15s fade.</summary>
public void Highlight(Color color){if(_selection==null)return;_animTween?.Kill();_animTween=CreateTween();_animTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad);_animTween.TweenProperty(_selection,"modulate",color,0.15f);}/// <summary>Fades the selection frame out via a 0.15s tween.</summary>
public void ClearHighlight(){if(_selection==null)return;_animTween?.Kill();_animTween=CreateTween();_animTween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad);_animTween.TweenProperty(_selection,"modulate",UiTokens.Alpha(UiTokens.White,0f),0.15f);}/// <summary>Rapidly oscillates the widget with random offsets to simulate impact feedback.</summary>
public void Shake(float intensity,float duration){var originalPos=Position;_animTween?.Kill();_animTween=CreateTween();for(int i=0;i<6;i++){var offset=new Vector2((float)GD.RandRange(-intensity,intensity),(float)GD.RandRange(-intensity,intensity));_animTween.TweenProperty(this,"position",originalPos+offset,duration/12f);_animTween.TweenProperty(this,"position",originalPos,duration/12f);}}/// <summary>Briefly tints the whole card with a colour pulse (30% rise, 70% fall).</summary>
public void Flash(Color color,float duration){var originalModulate=Modulate;_animTween?.Kill();_animTween=CreateTween();_animTween.TweenProperty(this,"modulate",color,duration*0.3f);_animTween.TweenProperty(this,"modulate",originalModulate,duration*0.7f);}/// <summary> Plays a dissolve-and-shrink death sequence. The sprite gets the <c>scene_transition</c> shader with a growing dissolve progress while the card fades and squashes; the info block fades with it. Invokes <paramref name="onComplete"/> when finished so the parent panel can remove the widget and re-grid the survivors. </summary>
public void PlayDeathAnimation(Action onComplete){if(IsDead){onComplete?.Invoke();return;}IsDead=true;MouseFilter=MouseFilterEnum.Ignore;ClearHighlight();_animTween?.Kill();var duration=0.7f;if(_dissolveShader==null)_dissolveShader=Shaders.Resolve("scene_transition");if(_dissolveShader!=null&&_art?.Texture!=null){var material=new ShaderMaterial{Shader=_dissolveShader};material.SetShaderParameter("noise_scale",5f);material.SetShaderParameter("smoothness",0.25f);material.SetShaderParameter("distortion",0.092f);material.SetShaderParameter("glow_intensity",0f);material.SetShaderParameter("hue_shift",0f);material.SetShaderParameter("invert_direction",false);material.SetShaderParameter("progress",0f);_art.Material=material;CreateTween().TweenMethod(Callable.From<float>(t=>material.SetShaderParameter("progress",t)),0f,1f,duration);}var tween=CreateTween();tween.SetParallel(true);tween.TweenProperty(this,"modulate:a",0f,duration*0.5f);tween.TweenProperty(this,"scale",BaseScale*new Vector2(0.1f,1.2f),duration);tween.TweenProperty(_shieldBar,"modulate:a",0f,duration);tween.TweenProperty(_hpBar,"modulate:a",0f,duration);tween.TweenProperty(_hpNumLabel,"modulate:a",0f,duration);tween.TweenProperty(_nameLabel,"modulate:a",0f,duration);tween.TweenProperty(_pipsBox,"modulate:a",0f,duration);tween.TweenProperty(_atkLabel,"modulate:a",0f,duration);tween.TweenProperty(_shieldChip,"modulate:a",0f,duration);tween.TweenProperty(_statusBox,"modulate:a",0f,duration);tween.Chain().TweenProperty(this,"scale",Vector2.Zero,0.15f);tween.Finished+=()=>{onComplete?.Invoke();};}private void ApplyStyles(){_teamLine.Color=UiTokens.Alpha(UiTokens.Danger,0.55f);var infoStyle=UiKit.Frame(UiTokens.RadiusMd,UiTokens.Alpha(UiTokens.Surface2,0.93f),UiTokens.Line);infoStyle.CornerRadiusTopLeft=0;infoStyle.CornerRadiusTopRight=0;infoStyle.ContentMarginLeft=8;infoStyle.ContentMarginRight=8;infoStyle.ContentMarginTop=6;infoStyle.ContentMarginBottom=8;GetNode<PanelContainer>("Info").AddThemeStyleboxOverride("panel",infoStyle);_nameLabel.AddThemeColorOverride("font_color",UiTokens.InkBright);_hpNumLabel.AddThemeColorOverride("font_color",UiTokens.HpText);_atkLabel.AddThemeColorOverride("font_color",UiTokens.Muted);_teamDot.AddThemeStyleboxOverride("panel",UiKit.Frame(4,UiTokens.Danger));var hpFill=UiKit.Frame(UiTokens.RadiusSm,UiTokens.HpOk);_hpBar.AddThemeStyleboxOverride("fill",hpFill);// Layered pie: the shield bar owns the track and the blue defence fill; the health bar paints only its fill on top of it.
UiKit.StyleShieldedHealthBar(_shieldBar,_hpBar);var shieldStyle=UiKit.Frame(UiTokens.RadiusXs,UiTokens.StatBg,UiTokens.StatLine);shieldStyle.ContentMarginLeft=7;shieldStyle.ContentMarginRight=7;shieldStyle.ContentMarginTop=2;shieldStyle.ContentMarginBottom=2;_shieldChip.AddThemeStyleboxOverride("panel",shieldStyle);GetNode<Label>("Info/Column/Foot/ShieldChip/ShieldRow/ShieldKey").AddThemeColorOverride("font_color",UiTokens.Muted);_shieldValue.AddThemeColorOverride("font_color",UiTokens.Info);var stunStyle=UiKit.Frame(UiTokens.RadiusXs,UiTokens.CooldownSoft,UiTokens.CooldownLine);stunStyle.ContentMarginLeft=5;stunStyle.ContentMarginRight=8;stunStyle.ContentMarginTop=3;stunStyle.ContentMarginBottom=3;_stunBadge.AddThemeStyleboxOverride("panel",stunStyle);_stunLabel.AddThemeColorOverride("font_color",UiTokens.Cooldown);var selectionStyle=UiKit.Frame(UiTokens.RadiusMd,new Color(1f,1f,1f,0.07f),Colors.White,2);_selection.AddThemeStyleboxOverride("panel",selectionStyle);_selection.Modulate=UiTokens.Alpha(UiTokens.White,0f);}private void RefreshPips(){foreach(var child in _pipsBox.GetChildren()){_pipsBox.RemoveChild(child);child.QueueFree();}var basePoints=Mathf.Max(0,Card.Attributes.Point.BaseValue);var points=Card.Attributes.Point.Value;for(var i=0;i<basePoints;i++){var on=i<points;var pip=new Panel{MouseFilter=MouseFilterEnum.Ignore,CustomMinimumSize=new Vector2(PipSize,PipSize),};pip.AddThemeStyleboxOverride("panel",UiKit.Frame(PipSize/2,on?UiTokens.AccentBright:UiTokens.LineDisabled,on?UiTokens.Accent:UiTokens.LineFaint));_pipsBox.AddChild(pip);}}private void RefreshStun(){if(_stunBadge==null)return;var stun=StatusEffectService.Of(Card)?.ActiveStatusEffects?.FirstOrDefault(effect=>effect.EffectType==StatusEffectType.Stun);if(stun==null){_stunBadge.Visible=false;return;}_stunBadge.Visible=true;if(_stunIcon!=null)_stunIcon.Texture=stun.Icon;if(_stunLabel!=null)_stunLabel.Text=string.Format(Translate("ff_battle/status_stun"),stun.RemainingTurns);}private static string Translate(string key)=>TranslationServer.Translate(key).ToString();}}

===== FILE mods/ff_battle/src/UI/FFEnemyPanel.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Characters;using Cthangover.Core.Scenes;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;namespace Cthangover.FFBattle.UI{/// <summary> Panel that arranges enemy character widgets in a grid layout and handles their lifecycle. Calculates a uniform scale for all enemy widgets based on the panel dimensions and enemy count, centering rows horizontally. Listens to each widget's health change and triggers <see cref="FFCharacterWidget.PlayDeathAnimation"/> when HP reaches zero, then removes the dead widget and re-grids the survivors. Raises <see cref="OnEnemyDead"/> so <see cref="FFBattleCore"/> can check win conditions. </summary>
public partial class FFEnemyPanel:ModWidget{private const float BASE_W=180f;private const float BASE_H=260f;private const float MIN_SCALE=0.25f;private const float MAX_SCALE=1.0f;private const float CELL_PADDING=10f;/// <summary>All enemy character widgets currently in the panel, including dead ones until removed.</summary>
public List<FFCharacterWidget>Widgets{get;}=new();/// <summary>Raised when a widget's death animation completes and the widget is removed.</summary>
public event System.Action<FFCharacterWidget>OnEnemyDead;/// <summary>Raised when an enemy widget is clicked (used for target selection).</summary>
public event System.Action<FFCharacterWidget>OnEnemyClicked;/// <summary>Builds the enemy grid panel and lays out enemy widgets.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);}/// <summary>Number of enemy widget instances currently managed (including dead).</summary>
public int EnemyCount=>Widgets.Count;/// <summary> Creates <see cref="FFCharacterWidget"/> instances for each enemy, scales them uniformly, wires click and health-change handlers, and calls <see cref="GridLayout"/> to arrange them. </summary>
public void Init(Character[]enemies,float scale){foreach(var child in Widgets)child.QueueFree();Widgets.Clear();for(int i=0;i<enemies.Length;i++){var widget=(FFCharacterWidget)TscnScenes.LoadAndBuild("scenes/ff_character_widget.tscn");widget.EnsureConstructed();widget.Scale=new Vector2(scale,scale);widget.IsPlayer=false;widget.Init(enemies[i]);widget.MouseFilter=MouseFilterEnum.Stop;widget.GuiInput+=(evt)=>{if(evt is InputEventMouseButton mb&&mb.Pressed&&mb.ButtonIndex==MouseButton.Left)OnEnemyClicked?.Invoke(widget);};AddChild(widget);Widgets.Add(widget);enemies[i].Attributes.Health.OnChange+=(value,baseValue)=>{if(value<=0&& !widget.IsDead)HandleDeath(widget);};}GridLayout();}/// <summary> Computes a uniform scale factor so all enemies fit within <paramref name="panelSize"/> with padding. Uses <see cref="GetGridDimensions"/> to determine layout, then chooses the tighter of width-based and height-based scales, clamped to [<c>MIN_SCALE</c>, <c>MAX_SCALE</c>]. </summary>
public static float CalculateScale(int enemyCount,Vector2 panelSize){var(cols,rows)=GetGridDimensions(enemyCount);float availW=panelSize.X-CELL_PADDING*2;float availH=panelSize.Y-CELL_PADDING*2;float scaleByW=(availW-(cols-1)*CELL_PADDING)/(cols*BASE_W);float scaleByH=(availH-(rows-1)*CELL_PADDING)/(rows*BASE_H);float scale=Mathf.Min(scaleByW,scaleByH);scale=Mathf.Clamp(scale,MIN_SCALE,MAX_SCALE);return scale;}/// <summary> Determines grid dimensions for a given count: 1–4 → single row, 5–10 → 2 rows, 11–16 → 3 rows, 17+ → 4 rows. </summary>
public static(int cols,int rows)GetGridDimensions(int count){if(count<=4)return(count,1);if(count<=10)return((count+1)/2,2);if(count<=16)return((count+2)/3,3);return((count+3)/4,4);}/// <summary>Repositions all widgets into the calculated grid, with optional 0.3s cubic tween animation.</summary>
public void GridLayout(bool animate=true){var(cols,rows)=GetGridDimensions(Widgets.Count);for(int i=0;i<Widgets.Count;i++){var widget=Widgets[i];if(widget==null)continue;int row=i/cols;int col=i%cols;var totalColsInRow=row<rows-1?cols:(Widgets.Count-row*cols);if(totalColsInRow<=0)totalColsInRow=1;var effW=widget.Size.X*widget.Scale.X;var effH=widget.Size.Y*widget.Scale.Y;float startX=(Size.X-(totalColsInRow*effW+(totalColsInRow-1)*CELL_PADDING))/2f;float startY=CELL_PADDING;var targetPos=new Vector2(startX+col*(effW+CELL_PADDING),startY+row*(effH+CELL_PADDING));if(animate){var tween=CreateTween();tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);tween.TweenProperty(widget,"position",targetPos,0.3f);}else{widget.Position=targetPos;}}}private void HandleDeath(FFCharacterWidget widget){if(widget.IsDead)return;widget.PlayDeathAnimation(()=>{RemoveWidget(widget);GridLayout(true);OnEnemyDead?.Invoke(widget);});}/// <summary>Removes a widget from the panel, frees it, and re-grids the remaining widgets.</summary>
public void RemoveWidget(FFCharacterWidget widget){Widgets.Remove(widget);widget.QueueFree();GridLayout();}/// <summary>Returns <c>true</c> if any widget in the panel is not marked dead.</summary>
public bool HasAlive(){foreach(var w in Widgets)if(!w.IsDead)return true;return false;}/// <summary>Frees all widget instances and clears the widget list. Called on battle cleanup.</summary>
public void ClearAll(){foreach(var widget in Widgets.ToList()){if(GodotObject.IsInstanceValid(widget))widget.QueueFree();}Widgets.Clear();GridLayout(false);}}}

===== FILE mods/ff_battle/src/UI/FFMenuPanel.cs =====
using System.Collections.Generic;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.FFBattle.UI{/// <summary> Data object for a single menu entry. <see cref="Key"/> drives the action dispatch in <see cref="FFBattleCore.MatchMenuAction"/> (values like <c>"attack"</c>, <c>"action"</c>, <c>"item"</c>, <c>"back"</c>). <see cref="Data"/> carries arbitrary payload: an <see cref="ActionCharacter"/> for action entries or an <see cref="IItem"/> for item entries. </summary>
public class FFMenuEntry{/// <summary>Dispatch key used by <see cref="FFBattleCore"/> to determine the next step.</summary>
public string Key;/// <summary>Display label shown in the menu.</summary>
public string Label;/// <summary>Whether this entry can be selected (greyed out if false).</summary>
public bool Enabled=true;/// <summary>Payload data: <see cref="ActionCharacter"/> for actions, <see cref="IItem"/> for items.</summary>
public object Data;}/// <summary> Vertical scrolling menu panel with a blinking cursor, keyboard navigation, and mouse hover/click support. Used as both the main battle menu and the action/item sub-menu (a separate instance). Each menu is populated via <see cref="ShowMenu"/> with a list of <see cref="FFMenuEntry"/> objects. Selection wraps around but skips disabled entries. Raises <see cref="OnItemSelected"/> on confirm, <see cref="OnCancelled"/> on right-click or Escape (handled by <see cref="FFBattleController"/>). </summary>
public partial class FFMenuPanel:ModWidget{private ColorRect _background;private Panel _border;private Label _cursorLabel;private Label _caption;private StyleBoxFlat _rowActiveStyle;private StyleBoxFlat _rowIdleStyle;private List<Label>_itemLabels=new();private Tween _cursorTween;private int _selectedIndex;private bool _visible;private const float ITEM_HEIGHT=34f;private const float PADDING=18f;private const float CURSOR_OFFSET_X=6f;private const float CURSOR_OFFSET_Y=7f;/// <summary>The entries currently displayed in the menu.</summary>
public List<FFMenuEntry>Entries{get;private set;}=new();/// <summary> 0-based index of the highlighted entry. Setting it moves the cursor and clamps to the valid range. </summary>
public int SelectedIndex{get=>_selectedIndex;set{var clamped=Mathf.Clamp(value,0,Entries.Count-1);if(clamped==_selectedIndex)return;_selectedIndex=clamped;MoveCursor(_selectedIndex);}}/// <summary>Raised when an entry is confirmed (Enter/Space or left click).</summary>
public event System.Action<int>OnItemSelected;/// <summary>Raised when the highlighted entry changes (keyboard or mouse hover); carries the new index.</summary>
public event System.Action<int>OnSelectionChanged;/// <summary>Raised when the menu is cancelled (right click, or Escape handled externally).</summary>
public event System.Action OnCancelled;/// <summary>Builds the command menu list (Attack/Defence/Item/...) used during the player turn.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);_background=GetNode<ColorRect>("Background");_background.Color=UiTokens.Surface2;_border=GetNode<Panel>("Border");_border.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusSm,UiTokens.Alpha(UiTokens.White,0f),UiTokens.AccentLine,2));_cursorLabel=GetNode<Label>("Cursor");_cursorLabel.AddThemeColorOverride("font_color",UiTokens.AccentBright);_caption=GetNode<Label>("Caption");_caption.AddThemeColorOverride("font_color",UiTokens.Muted);_rowActiveStyle=UiKit.Frame(UiTokens.RadiusSm,UiTokens.AccentTrace,UiTokens.Alpha(UiTokens.White,0f));_rowIdleStyle=UiKit.Frame(UiTokens.RadiusSm,UiTokens.Alpha(UiTokens.White,0f),UiTokens.Alpha(UiTokens.White,0f));GuiInput+=OnPanelGuiInput;}/// <summary> Renders the menu with the given entries. Creates labels, wires hover/click handlers, sizes the panel to fit, positions the cursor at index 0, and makes the panel visible. </summary>
public void ShowMenu(List<FFMenuEntry>entries,string title=null){ClearEntries();Entries=entries;float width=220f;float totalHeight=PADDING*2+ITEM_HEIGHT*entries.Count;Size=new Vector2(width,totalHeight);for(int i=0;i<entries.Count;i++){var label=new Label();label.Text=entries[i].Label;label.AddThemeFontSizeOverride("font_size",17);label.AddThemeColorOverride("font_color",entries[i].Enabled?UiTokens.Ink:UiTokens.Faint);label.AddThemeStyleboxOverride("normal",_rowIdleStyle);label.MouseFilter=MouseFilterEnum.Stop;label.Position=new Vector2(PADDING+24,PADDING+i*ITEM_HEIGHT+CURSOR_OFFSET_Y);label.Size=new Vector2(width-PADDING*2-24,ITEM_HEIGHT);label.ClipText=true;int idx=i;label.GuiInput+=(evt)=>{if(evt is InputEventMouseButton mb&&mb.Pressed&&mb.ButtonIndex==MouseButton.Left){if(entries[idx].Enabled){_selectedIndex=idx;MoveCursor(idx);OnItemSelected?.Invoke(idx);}}};label.MouseEntered+=()=>{if(entries[idx].Enabled){SelectedIndex=idx;}};AddChild(label);_itemLabels.Add(label);}_selectedIndex=0;MoveCursor(0);_cursorLabel.Visible=true;_caption.Text=title??string.Empty;_caption.Visible= !string.IsNullOrEmpty(title);_visible=true;Visible=true;}/// <summary>Hides the menu, caption and cursor. Does not clear entries — call <see cref="ShowMenu"/> to repopulate.</summary>
public void HideMenu(){_visible=false;Visible=false;_cursorLabel.Visible=false;_caption.Visible=false;}/// <summary>Moves selection down, wrapping around and skipping disabled entries.</summary>
public void SelectNext(){if(!_visible||Entries.Count==0)return;var next=_selectedIndex;for(int i=0;i<Entries.Count;i++){next=(next+1)%Entries.Count;if(Entries[next].Enabled)break;}SelectedIndex=next;}/// <summary>Moves selection up, wrapping around and skipping disabled entries.</summary>
public void SelectPrevious(){if(!_visible||Entries.Count==0)return;var prev=_selectedIndex;for(int i=0;i<Entries.Count;i++){prev=(prev-1+Entries.Count)%Entries.Count;if(Entries[prev].Enabled)break;}SelectedIndex=prev;}/// <summary>Invokes <see cref="OnItemSelected"/> for the currently highlighted entry if it is enabled.</summary>
public void ConfirmSelection(){if(!_visible||Entries.Count==0)return;if(_selectedIndex>=0&&_selectedIndex<Entries.Count&&Entries[_selectedIndex].Enabled)OnItemSelected?.Invoke(_selectedIndex);}private void MoveCursor(int index){_cursorTween?.Kill();_cursorLabel.Position=new Vector2(CURSOR_OFFSET_X,PADDING+index*ITEM_HEIGHT+CURSOR_OFFSET_Y);_cursorTween=CreateTween();_cursorTween.SetLoops();_cursorTween.TweenProperty(_cursorLabel,"position:x",CURSOR_OFFSET_X+3f,0.3f);_cursorTween.TweenProperty(_cursorLabel,"position:x",CURSOR_OFFSET_X,0.3f);for(var i=0;i<_itemLabels.Count;i++){var enabled=i<Entries.Count&&Entries[i].Enabled;var selected=i==index&&enabled;_itemLabels[i].AddThemeStyleboxOverride("normal",selected?_rowActiveStyle:_rowIdleStyle);_itemLabels[i].AddThemeColorOverride("font_color",selected?UiTokens.AccentBright:enabled?UiTokens.Ink:UiTokens.Faint);}OnSelectionChanged?.Invoke(index);}private void OnPanelGuiInput(InputEvent evt){if(evt is InputEventMouseButton mb&&mb.Pressed&&mb.ButtonIndex==MouseButton.Right){OnCancelled?.Invoke();}}private void ClearEntries(){foreach(var label in _itemLabels)label.QueueFree();_itemLabels.Clear();}/// <summary>Frees the menu entries and disconnects selection handlers when the panel closes.</summary>
protected override void Destruct(){ClearEntries();_cursorTween?.Kill();}}}

===== FILE mods/ff_battle/src/UI/FFPlayerPanel.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Characters;using Cthangover.Core.Scenes;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;namespace Cthangover.FFBattle.UI{/// <summary> Panel that arranges player character widgets horizontally at the bottom of the battle screen. Creates one <see cref="FFCharacterWidget"/> per player character, wires click and health-change handlers, and positions them using fixed horizontal spacing via <see cref="Redraw"/>. When a player's HP drops to zero, the widget plays its death animation and is removed; the remaining widgets are repositioned. Exposes <see cref="HasAlive"/> for win/loss checks. </summary>
public partial class FFPlayerPanel:ModWidget{private const float CELL_SPACING=20f;/// <summary>All player character widgets, including dead ones until their death animation completes.</summary>
public List<FFCharacterWidget>Widgets{get;}=new();/// <summary>Raised when a player widget is clicked — routed to <see cref="FFBattleCore"/> for character selection.</summary>
public event System.Action<FFCharacterWidget>OnWidgetClicked;/// <summary>Builds the bottom player-party panel hosting one widget per party member.</summary>
protected override void Construct(){CthangoverTheme.Apply(this);}/// <summary> Creates <see cref="FFCharacterWidget"/> instances for each player, scales them uniformly, wires click and health-change handlers, and calls <see cref="Redraw"/> to position them horizontally. </summary>
public void Init(Character[]players,float scale){foreach(var child in Widgets)child.QueueFree();Widgets.Clear();for(int i=0;i<players.Length;i++){var widget=(FFCharacterWidget)TscnScenes.LoadAndBuild("scenes/ff_character_widget.tscn");widget.EnsureConstructed();widget.Scale=new Vector2(scale,scale);widget.IsPlayer=true;widget.Init(players[i]);widget.MouseFilter=MouseFilterEnum.Stop;widget.GuiInput+=(evt)=>{if(evt is InputEventMouseButton mb&&mb.Pressed&&mb.ButtonIndex==MouseButton.Left)OnWidgetClicked?.Invoke(widget);};AddChild(widget);Widgets.Add(widget);players[i].Attributes.Health.OnChange+=(value,baseValue)=>{widget.UpdateInfo();if(value<=0&& !widget.IsDead){widget.PlayDeathAnimation(()=>{RemoveWidget(widget);Redraw();});}};}Redraw();}/// <summary>Repositions all widgets in a horizontal row with fixed <c>CELL_SPACING</c> between them.</summary>
public void Redraw(){for(int i=0;i<Widgets.Count;i++){var widget=Widgets[i];if(widget==null)continue;var effWidth=widget.Size.X*widget.Scale.X;var effHeight=widget.Size.Y*widget.Scale.Y;widget.Position=new Vector2(i*(effWidth+CELL_SPACING)+CELL_SPACING,Size.Y-effHeight-CELL_SPACING);}}/// <summary>Returns <c>true</c> if any widget is alive and has positive health.</summary>
public bool HasAlive(){return Widgets.Any(w=> !w.IsDead&&w.Card?.Attributes?.Health?.Value>0);}/// <summary>Frees all widget instances and clears the widget list. Called on battle cleanup.</summary>
public void ClearAll(){foreach(var widget in Widgets.ToList()){if(GodotObject.IsInstanceValid(widget))widget.QueueFree();}Widgets.Clear();Redraw();}/// <summary>Removes a widget from the panel, frees it, and repositions remaining widgets.</summary>
public void RemoveWidget(FFCharacterWidget widget){Widgets.Remove(widget);widget.QueueFree();Redraw();}}}

===== FILE mods/interactive_base/src/HitAreaType.cs =====
namespace Cthangover.Core.Interactive{/// <summary>Enumeration of supported hit-area shapes for interactive objects.</summary>
public enum HitAreaType{/// <summary>Full rectangular area matching the control bounds.</summary>
Rect,/// <summary>Circular area centred within the control bounds.</summary>
Circle,/// <summary>Custom polygon defined by vertex array.</summary>
Polygon}}

===== FILE mods/interactive_base/src/InteractiveDefinition.cs =====
using System.Text.Json.Serialization;using Cthangover.Core.Factories;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Core.Interactive{/// <summary> JSON-driven definition of an interactive scene object loaded from mods. The texture is always full-screen (alpha determines visibility outside the collider). The collider defines the clickable region in normalised 0..1 viewport coordinates. Interactives keep their own resolver and <c>{"Items": [...]}</c> envelope; unlike other entity kinds they are not part of the kind-routed pipeline. </summary>
public class InteractiveDefinition:IIdentifiable{/// <summary>Unique identifier used in scenario commands and registry lookups.</summary>
[JsonPropertyName("id")]public string ID{get;set;}/// <summary>Owning mod identifier set by ModManager during collection.</summary>
[JsonIgnore]public string ModId{get;set;}/// <summary>Texture key resolved via <c>ModManager.ResolveTexture</c>. Full-screen with alpha.</summary>
[JsonPropertyName("texture")]public string Texture{get;set;}/// <summary>Visual layer: "background", "foreground" or "ui". Defaults to "foreground".</summary>
[JsonPropertyName("layer")]public string Layer{get;set;}="foreground";/// <summary>Z-index for ordering within the layer. Higher = on top.</summary>
[JsonPropertyName("zIndex")]public int ZIndex{get;set;}/// <summary>Whether the object is initially enabled (responds to input). Default true.</summary>
[JsonPropertyName("enabled")]public bool Enabled{get;set;}=true;/// <summary>Whether the object is initially visible. Default true.</summary>
[JsonPropertyName("visible")]public bool Visible{get;set;}=true;/// <summary>Mouse cursor override when hovering (Godot CursorShape enum name, e.g. "PointingHand").</summary>
[JsonPropertyName("cursor")]public string Cursor{get;set;}/// <summary>Collider shape and position in normalised viewport coordinates (0..1).</summary>
[JsonPropertyName("hitArea")]public HitAreaDefinition HitArea{get;set;}/// <summary>Visual highlight settings applied on hover.</summary>
[JsonPropertyName("highlight")]public HighlightDefinition Highlight{get;set;}/// <summary>Actions triggered by pointer events.</summary>
[JsonPropertyName("actions")]public InteractiveActionDefinition Actions{get;set;}}/// <summary> Shape and position of the clickable collider in normalised viewport coordinates (0..1), where (0,0) is the top-left corner and (1,1) is bottom-right. </summary>
public class HitAreaDefinition{/// <summary><c>"rect"</c>, <c>"circle"</c> or <c>"polygon"</c>. Defaults to <c>"rect"</c>.</summary>
[JsonPropertyName("type")]public string Type{get;set;}="rect";/// <summary>X position of the collider origin (top-left for rect, centre for circle). Normalised 0..1.</summary>
[JsonPropertyName("x")]public float X{get;set;}/// <summary>Y position of the collider origin. Normalised 0..1.</summary>
[JsonPropertyName("y")]public float Y{get;set;}/// <summary>Width for rect type. Normalised 0..1.</summary>
[JsonPropertyName("width")]public float Width{get;set;}=0.1f;/// <summary>Height for rect type. Normalised 0..1.</summary>
[JsonPropertyName("height")]public float Height{get;set;}=0.1f;/// <summary>Radius for circle type. Normalised 0..1 (relative to viewport min dimension).</summary>
[JsonPropertyName("radius")]public float Radius{get;set;}=0.05f;/// <summary>Polygon vertices as an array of normalised Vector2 points. Only for "polygon" type.</summary>
[JsonPropertyName("vertices")][JsonConverter(typeof(Vector2ArrayJsonConverter))]public Vector2[]Vertices{get;set;}}/// <summary> Highlight effect applied when the pointer hovers over the collider. Uses a shader-based colour modulation on the full-screen texture. </summary>
public class HighlightDefinition{/// <summary>Modulate colour as a hex string (e.g. "#FFFF0033"). Parsed to Godot.Color at runtime.</summary>
[JsonPropertyName("color")]public string ColorHex{get;set;}="#FFFF0033";/// <summary>Pulse scale multiplier applied during hover (e.g. 1.02 = 2% bigger).</summary>
[JsonPropertyName("scale")]public float Scale{get;set;}=1.02f;/// <summary>Duration in seconds of the hover/unhover animation.</summary>
[JsonPropertyName("duration")]public float Duration{get;set;}=0.15f;/// <summary>Parsed Godot.Color from ColorHex. Computed at load time, not serialised.</summary>
[JsonIgnore]public Color Color=>ParseColor(ColorHex);private static Color ParseColor(string hex){if(string.IsNullOrEmpty(hex))return UiTokens.Alpha(UiTokens.Accent,0.3f);try{return new Color(hex);}catch{return UiTokens.Alpha(UiTokens.Accent,0.3f);}}}/// <summary> Actions dispatched on pointer events. </summary>
public class InteractiveActionDefinition{/// <summary>Action executed on left-click.</summary>
[JsonPropertyName("onClick")]public ClickAction OnClick{get;set;}/// <summary>Inline DSL commands executed on mouse enter.</summary>
[JsonPropertyName("onHoverEnter")]public string OnHoverEnter{get;set;}/// <summary>Inline DSL commands executed on mouse leave.</summary>
[JsonPropertyName("onHoverLeave")]public string OnHoverLeave{get;set;}}/// <summary> Click action descriptor. Supports a referenced <c>.scenario</c> file and/or inline DSL commands. </summary>
public class ClickAction{/// <summary>Path to a <c>.scenario</c> file within a mod.</summary>
[JsonPropertyName("scenario")]public string Scenario{get;set;}/// <summary>Inline DSL commands to execute (one command per array element).</summary>
[JsonPropertyName("commands")]public string[]Commands{get;set;}}}

===== FILE mods/interactive_base/src/InteractiveFactory.cs =====
using Cthangover.Core.Bootstrap;using Cthangover.Core.Interactive;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;namespace Cthangover.Core.Factories.Impls{/// <summary> Factory for <c>InteractiveDefinition</c> lookups. Wraps <c>ModManager.CollectInteractives()</c> with a thread-safe singleton accessor. Since the underlying collection is already cached by <c>ModManager</c>, this factory is a thin forwarding layer that provides the same <c>Instance.Get(id)</c> API convention as every other factory in the project. <para/> The single instance lives in this mod assembly as a static lazy singleton; the static facade stays as the public API. </summary>
public class InteractiveFactory{private static InteractiveFactory _instance;/// <summary>Static lazy singleton; kept as the public API.</summary>
public static InteractiveFactory Instance=>_instance??=new InteractiveFactory();private InteractiveFactory(){}/// <summary> Looks up an interactive definition by its unique ID. Returns <c>null</c> if no definition with that ID exists in any mod. </summary> <param name="id">The <c>ID</c> from the definition JSON.</param>
public InteractiveDefinition Get(string id){if(string.IsNullOrEmpty(id))return null;var dict=Interactives.Collect();if(dict!=null&&dict.TryGetValue(id,out var def))return def;GameLogger.Log("FACTORY",$"InteractiveFactory: definition '{id}' not found",LogLevel.Error);return null;}}}

===== FILE mods/interactive_base/src/InteractiveManager.cs =====
using System.Collections.Generic;using Cthangover.Core.Events;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;using Cthangover.Core.UI.Dialog;using Cthangover.Mods.Interface.View;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Interactive{/// <summary> Central registry for active interactive objects on the current scene. Manages the full lifecycle: creation from <c>InteractiveDefinition</c>, placement into <c>ViewBox</c> layer containers, callback wiring for hover/click actions, and cleanup on scene change. Created and owned by <c>SceneContextNode</c> so its state is automatically cleared when the scene changes. Access via <c>InteractiveManager.Instance</c> (set during <c>SceneContextNode._Ready</c>). </summary>
public class InteractiveManager{private static InteractiveManager _instance;/// <summary> Singleton instance, valid only while a scene is active. Set by <c>SceneContextNode</c> on scene enter, cleared on scene exit. </summary>
public static InteractiveManager Instance=>_instance;private readonly Dictionary<string,InteractiveObject>_objects=new();private bool _showDebugBounds;/// <summary> Initialises the singleton and performs one-time setup. Called by <c>SceneContextNode._Ready</c>. </summary>
public static void Initialize(){_instance=new InteractiveManager();}/// <summary> Tears down active objects but keeps the singleton alive. Called by <c>SceneContextNode.ClearData</c> on scene change. The manager instance persists for the full game session; only the tracked objects are cleaned up on each scene transition. </summary>
public static void Shutdown(){_instance?.ClearAll();}/// <summary> Toggles debug visualisation of hit-area bounds and object IDs. When enabled, every active object draws a coloured overlay. </summary>
public bool ShowDebugBounds{get=>_showDebugBounds;set{_showDebugBounds=value;foreach(var kv in _objects)kv.Value.UpdateDebugVisual(value);}}/// <summary> Number of currently active interactive objects. </summary>
public int Count=>_objects.Count;/// <summary> Instantiates an interactive object from its definition ID and adds it to the appropriate <c>InteractiveLayer</c> in the <c>ViewBox</c>. If an object with the same ID is already active, returns the existing one. </summary> <param name="definitionId">The <c>ID</c> from the <c>InteractiveDefinition</c> JSON.</param> <returns>The created or existing <c>InteractiveObject</c>, or <c>null</c> if the definition is not found.</returns>
public InteractiveObject Add(string definitionId){if(string.IsNullOrEmpty(definitionId))return null;if(_objects.TryGetValue(definitionId,out var existing))return existing;var def=InteractiveFactory.Instance.Get(definitionId);if(def==null){GameLogger.Log("INTERACTIVE",$"Add: definition '{definitionId}' not found",LogLevel.Error);return null;}var viewBox=SceneContextNode.FindNode<ViewBox>("ViewBox");if(viewBox==null){GameLogger.Log("INTERACTIVE","Add: ViewBox not found",LogLevel.Error);return null;}var layerContainer=viewBox.GetInteractiveLayer(def.Layer);if(layerContainer==null){GameLogger.Log("INTERACTIVE",$"Add: layer '{def.Layer}' not found, trying foreground",LogLevel.Warning);layerContainer=viewBox.GetInteractiveLayer("foreground");}if(layerContainer==null){GameLogger.Log("INTERACTIVE","Add: no interactive layer containers available (ViewBox layers not ready?)",LogLevel.Error);return null;}var contentSize=viewBox.LogicalSize!=Vector2I.Zero?new Vector2(viewBox.LogicalSize.X,viewBox.LogicalSize.Y):viewBox.Content?.Size??new Vector2(1920f,1024f);var obj=new InteractiveObject{Name="Interactive_"+def.ID,ID=def.ID};obj.OnHoverEnterCallback=ExecuteHoverDsl;obj.OnHoverLeaveCallback=ExecuteHoverDsl;obj.OnClickCallback=ExecuteClick;layerContainer.AddChild(obj);obj.Configure(def,contentSize);_objects[definitionId]=obj;GameLogger.Log("INTERACTIVE",$"Add: '{definitionId}' created on layer '{def.Layer}' fullscreen={contentSize.X:F0}x{contentSize.Y:F0} hitType={def.HitArea?.Type ?? "rect"}");return obj;}/// <summary> Removes and destroys the interactive object with the given definition ID. Publishes an <see cref="InteractiveStateChangedEvent"/> only if the object actually existed and was removed, so condition caches invalidate on real removal rather than on redundant calls. </summary> <param name="definitionId">The definition ID to remove.</param>
public void Remove(string definitionId){if(_objects.TryGetValue(definitionId,out var obj)){GameLogger.Log("INTERACTIVE",$"Remove: '{definitionId}'");ModEventBus.Publish(new InteractiveStateChangedEvent{Id=definitionId});obj.Destruct();_objects.Remove(definitionId);}}/// <summary> Removes and destroys all active interactive objects. Called automatically on scene change via <c>Shutdown</c>. Publishes one <see cref="InteractiveStateChangedEvent"/> per removed object (before destruct/clear) so condition caches invalidate for each disappearance. </summary>
public void ClearAll(){GameLogger.Log("INTERACTIVE",$"ClearAll: destroying {_objects.Count} object(s)");if(_objects.Count>0){foreach(var kv in _objects)ModEventBus.Publish(new InteractiveStateChangedEvent{Id=kv.Key});}// Objects live inside the scene: leaving it to the main menu (or switching games) frees them with the scene before this runs.
foreach(var kv in _objects){if(GodotObject.IsInstanceValid(kv.Value))kv.Value.Destruct();}_objects.Clear();}/// <summary> Looks up an active interactive object by its definition ID. </summary> <param name="definitionId">The definition ID to find.</param> <returns>The <c>InteractiveObject</c> or <c>null</c>.</returns>
public InteractiveObject Get(string definitionId){_objects.TryGetValue(definitionId,out var obj);return obj;}/// <summary> Enables or disables an active interactive object. </summary> <param name="definitionId">The definition ID.</param> <param name="enabled">New enabled state.</param>
public void SetEnabled(string definitionId,bool enabled){if(_objects.TryGetValue(definitionId,out var obj))obj.IsEnabled=enabled;}/// <summary> Shows or hides an active interactive object. </summary> <param name="definitionId">The definition ID.</param> <param name="visible">New visibility state.</param>
public void SetVisible(string definitionId,bool visible){if(_objects.TryGetValue(definitionId,out var obj))obj.Visible=visible;}private void ExecuteHoverDsl(string dsl){if(string.IsNullOrEmpty(dsl))return;GameLogger.Log("INTERACTIVE",$"ExecuteHoverDsl: '{dsl}'");GetDialogRunner()?.ExecuteInlineCommands(dsl);}private void ExecuteClick(string definitionId){if(string.IsNullOrEmpty(definitionId))return;var def=InteractiveFactory.Instance.Get(definitionId);if(def?.Actions?.OnClick==null){GameLogger.Log("INTERACTIVE",$"ExecuteClick: no action defined for '{definitionId}'");return;}var clickAction=def.Actions.OnClick;var hasScenario= !string.IsNullOrEmpty(clickAction.Scenario);var hasCommands=clickAction.Commands!=null&&clickAction.Commands.Length>0;if(!hasScenario&& !hasCommands)return;if(!hasScenario&&hasCommands){var inlineDsl=string.Join("\n",clickAction.Commands);GetDialogRunner()?.ExecuteInlineCommands(inlineDsl);return;}var runner=GetDialogRunner();if(runner==null){GameLogger.Log("INTERACTIVE","ExecuteClick: dialog runner not available (dialog_base mod not loaded)",LogLevel.Error);return;}runner.TryExecuteClickDialog(definitionId);}private IInteractiveDialogRunner GetDialogRunner(){var tree=Godot.Engine.GetMainLoop()as SceneTree;return tree?.Root?.GetNodeOrNull<Node>("/root/DialogRunner")as IInteractiveDialogRunner;}private readonly Dictionary<string,string>_hoverVariables=new();/// <summary> Simple variable store for hover-event DSL commands (e.g. <c>set cursor_hint=door</c>). UI elements can subscribe or poll these values to show contextual hints. </summary>
public string GetHoverVariable(string name){_hoverVariables.TryGetValue(name,out var val);return val;}/// <summary> Creates a custom interactive object programmatically using a fluent builder. For use in wrapper-template C# code. </summary> <param name="id">Unique instance ID.</param> <returns>An <c>InteractiveObjectBuilder</c> for fluent configuration.</returns>
public InteractiveObjectBuilder CreateCustom(string id){return new InteractiveObjectBuilder(id,this);}/// <summary>Tracks a programmatically built object (from <see cref="CreateCustom"/>) and wires default callbacks where none were set.</summary>
internal void RegisterBuiltObject(InteractiveObject obj){if(obj==null)return;_objects[obj.ID]=obj;obj.OnHoverEnterCallback??=ExecuteHoverDsl;obj.OnHoverLeaveCallback??=ExecuteHoverDsl;obj.OnClickCallback??=ExecuteClick;}}}

===== FILE mods/interactive_base/src/InteractiveModInitializer.cs =====
using Cthangover.Core.Events;using Cthangover.Core.Interactive;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Mods.Interactive{/// <summary> Engine-mod initializer for the interactive subsystem. Takes over the lifecycle <c>SceneContextNode</c> owned before the migration: on resources-ready it initializes the <c>InteractiveManager</c> singleton and subscribes the per-scene teardown (<c>InteractiveManager.Shutdown</c>) to <c>SceneExitedEvent</c>, so tracked interactive objects are destroyed on every scene change. All operations are idempotent: <c>ModManager.Reload()</c> re-runs <c>OnModResourcesReady</c>, so repeat invocations must not corrupt state. </summary>
public class InteractiveModInitializer:IModInitializer{private static bool initialized;/// <summary>No per-mod setup is required; lifecycle happens on resources-ready.</summary>
public void OnModLoaded(string modId){}/// <summary> Initializes the manager singleton and wires the scene-exit teardown. Safe to call again after a mod reload. </summary>
public void OnModResourcesReady(){if(initialized)return;initialized=true;InteractiveManager.Initialize();GameLogger.Log("INTERACTIVE","InteractiveModInitializer: manager initialized");ModEventBus.Subscribe<SceneExitedEvent>(_=>InteractiveManager.Shutdown());}}}

===== FILE mods/interactive_base/src/InteractiveObject.cs =====
using System;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Scenes;using Cthangover.Core.UI.Executable;using Cthangover.Core.Utils;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Core.Interactive{/// <summary> Runtime node for an interactive scene object. The Control fills its parent (the interactive layer container) and displays a full-screen texture with alpha. The collider (rect, circle, or polygon) defines the clickable region in normalised viewport coordinates, converted to pixels at runtime. Hit detection uses <c>_HasPoint</c> overrides verified against global mouse position and the pixel-space collider geometry. </summary>
public partial class InteractiveObject:Control,IEventObject{private static readonly Color _transparent=new(0f,0f,0f,0f);/// <summary>Unique instance identifier, used for registry and <c>IEventObject</c> tracking.</summary>
public string ID{get;set;}/// <summary>Reference back to the <c>InteractiveDefinition.ID</c> that spawned this object.</summary>
public string DefinitionId{get;private set;}/// <summary>Whether the object responds to mouse input.</summary>
public bool IsEnabled{get;set;}=true;/// <summary>True while the pointer is within the collider.</summary>
public bool IsHovered{get;private set;}/// <summary>Invoked on hover enter with the definition's hover-enter DSL (wired by <see cref="InteractiveManager"/>).</summary>
internal Action<string>OnHoverEnterCallback{get;set;}/// <summary>Invoked on hover leave with the definition's hover-leave DSL.</summary>
internal Action<string>OnHoverLeaveCallback{get;set;}/// <summary>Invoked on click with the definition id (wired by <see cref="InteractiveManager"/>).</summary>
internal Action<string>OnClickCallback{get;set;}private TextureRect _visual;private ShaderMaterial _highlightMaterial;private Tween _highlightTween;private HitAreaType _hitType=HitAreaType.Rect;private float _hitRadiusPx;private Vector2[]_hitPolygonPx;private Rect2 _hitRectPx;private Vector2 _contentSize;private Color _highlightColor=_transparent;private float _highlightScale=1f;private float _highlightDuration=0.15f;private string _onHoverEnterDsl;private string _onHoverLeaveDsl;private ColorRect _debugRect;private Label _debugLabel;private bool _visualReady;/// <summary>Prepares the full-screen visual, highlight material and mouse handlers.</summary>
public override void _Ready(){MouseFilter=MouseFilterEnum.Stop;ClipContents=true;EnsureVisualReady();MouseEntered+=OnMouseEntered;MouseExited+=OnMouseExited;GameLogger.Log("INTERACTIVE",$"_Ready: id='{ID}' size=({Size.X:F0}x{Size.Y:F0}) visible={Visible} enabled={IsEnabled}");}/// <summary> Applies a definition to this instance. The Control is made full-screen within its parent, the texture fills the entire area, and the collider geometry is computed from normalised coordinates multiplied by the logical viewport size. </summary>
public void Configure(InteractiveDefinition def,Vector2 contentSize){if(def==null)return;DefinitionId=def.ID;_contentSize=contentSize;EnsureVisualReady();AnchorLeft=0f;AnchorTop=0f;AnchorRight=1f;AnchorBottom=1f;ZIndex=def.ZIndex;Visible=def.Visible;IsEnabled=def.Enabled;if(!string.IsNullOrEmpty(def.Texture)){var tex=Textures.Resolve(def.Texture);if(tex!=null)_visual.Texture=tex;else GameLogger.Log("INTERACTIVE",$"Configure: texture '{def.Texture}' for '{def.ID}' not found in any mod",LogLevel.Warning);}ApplyHitArea(def.HitArea,contentSize);if(def.Highlight!=null){_highlightColor=def.Highlight.Color;_highlightScale=def.Highlight.Scale;_highlightDuration=def.Highlight.Duration;}_onHoverEnterDsl=def.Actions?.OnHoverEnter;_onHoverLeaveDsl=def.Actions?.OnHoverLeave;SetCursor(def.Cursor);GameLogger.Log("INTERACTIVE",$"Configure: id='{def.ID}' fullScreen contentSize=({contentSize.X:F0},{contentSize.Y:F0}) hitType={_hitType}");}/// <summary> Checks whether the global mouse position falls within the collider geometry in pixel space. Bypasses Godot's local coordinate issues. </summary>
public override bool _HasPoint(Vector2 point){if(!IsEnabled|| !Visible)return false;var globalMouse=GetGlobalMousePosition();var globalPos=GlobalPosition;switch(_hitType){case HitAreaType.Rect:{var rect=new Rect2(globalPos+_hitRectPx.Position,_hitRectPx.Size);return rect.HasPoint(globalMouse);}case HitAreaType.Circle:{var center=globalPos+new Vector2(_hitRectPx.Position.X+_hitRectPx.Size.X/2f,_hitRectPx.Position.Y+_hitRectPx.Size.Y/2f);return(globalMouse-center).Length()<=_hitRadiusPx;}case HitAreaType.Polygon:{if(_hitPolygonPx==null||_hitPolygonPx.Length<3)return false;var local=globalMouse-globalPos;return Geometry2D.IsPointInPolygon(local,_hitPolygonPx);}}return false;}/// <summary> Unregisters from the <c>SceneContextNode</c> event-object tracker and frees the Godot node. Called by <c>InteractiveManager.ClearAll</c> on scene transition and by <c>InteractiveManager.Remove</c> for targeted cleanup, ensuring no dangling references survive scene teardown. </summary>
public void Destruct(){SceneContextNode.Instance?.RemoveEventObject(ID);QueueFree();}/// <summary> Creates or hides a debug overlay showing the hit-area bounds rectangle and the object's <c>ID</c> label. The rectangle colour changes to yellow when the pointer is inside the collider. Used by <c>InteractiveManager.ShowDebugBounds</c> to toggle all objects at once. </summary> <param name="show">Whether to create/show or hide the debug visuals.</param>
public void UpdateDebugVisual(bool show){if(show){if(_debugRect==null){_debugRect=new ColorRect{Name="DebugRect",MouseFilter=MouseFilterEnum.Ignore,ZIndex=100};AddChild(_debugRect);}if(_debugLabel==null){_debugLabel=new Label{Name="DebugLabel",MouseFilter=MouseFilterEnum.Ignore,ZIndex=101,Position=new Vector2(2f,2f)};AddChild(_debugLabel);}_debugRect.Position=_hitRectPx.Position;_debugRect.Size=_hitRectPx.Size;_debugRect.Visible=true;_debugRect.Color=IsHovered?UiTokens.Alpha(UiTokens.Accent,0.35f):UiTokens.Alpha(UiTokens.Success,0.2f);_debugLabel.Visible=true;_debugLabel.Text=DefinitionId??ID;}else{if(_debugRect!=null)_debugRect.Visible=false;if(_debugLabel!=null)_debugLabel.Visible=false;}}private void SetupHighlightMaterial(){var shader=Shaders.Resolve("interactive_highlight");if(shader==null){GameLogger.Log("INTERACTIVE","highlight shader 'interactive_highlight' not found",LogLevel.Warning);return;}_highlightMaterial=new ShaderMaterial{Shader=shader};_highlightMaterial.SetShaderParameter("modulate_color",_transparent);_visual.Material=_highlightMaterial;}private void EnsureVisualReady(){if(_visualReady)return;_visualReady=true;_visual=new TextureRect{Name="Visual",ExpandMode=TextureRect.ExpandModeEnum.IgnoreSize,StretchMode=TextureRect.StretchModeEnum.Scale,MouseFilter=MouseFilterEnum.Ignore,AnchorRight=1f,AnchorBottom=1f};AddChild(_visual);SetupHighlightMaterial();}private void ApplyHitArea(HitAreaDefinition hitDef,Vector2 contentSize){_hitType=HitAreaType.Rect;var vpW=contentSize.X;var vpH=contentSize.Y;if(hitDef==null){_hitRectPx=new Rect2(0,0,vpW,vpH);return;}switch(hitDef.Type?.ToLowerInvariant()){case"circle":_hitType=HitAreaType.Circle;_hitRadiusPx=hitDef.Radius*Mathf.Min(vpW,vpH);var cx=hitDef.X*vpW;var cy=hitDef.Y*vpH;_hitRectPx=new Rect2(cx-_hitRadiusPx,cy-_hitRadiusPx,_hitRadiusPx*2f,_hitRadiusPx*2f);break;case"polygon":_hitType=HitAreaType.Polygon;if(hitDef.Vertices!=null&&hitDef.Vertices.Length>=3){_hitPolygonPx=new Vector2[hitDef.Vertices.Length];float minX=float.MaxValue,minY=float.MaxValue,maxX=float.MinValue,maxY=float.MinValue;for(var i=0;i<hitDef.Vertices.Length;i++){var v=new Vector2(hitDef.Vertices[i].X*vpW,hitDef.Vertices[i].Y*vpH);_hitPolygonPx[i]=v;minX=Mathf.Min(minX,v.X);minY=Mathf.Min(minY,v.Y);maxX=Mathf.Max(maxX,v.X);maxY=Mathf.Max(maxY,v.Y);}_hitRectPx=new Rect2(minX,minY,maxX-minX,maxY-minY);}break;default:_hitType=HitAreaType.Rect;var rx=hitDef.X*vpW;var ry=hitDef.Y*vpH;var rw=hitDef.Width*vpW;var rh=hitDef.Height*vpH;_hitRectPx=new Rect2(rx,ry,rw,rh);break;}}private void SetCursor(string cursorName){if(string.IsNullOrEmpty(cursorName))return;if(Enum.TryParse<CursorShape>(cursorName,true,out var shape))MouseDefaultCursorShape=shape;}private void OnMouseEntered(){GameLogger.Log("INTERACTIVE",$"OnMouseEntered: id='{ID}' enabled={IsEnabled} hitType={_hitType}");if(!IsEnabled)return;IsHovered=true;ApplyHighlight(true);UpdateDebugVisual(InteractiveManager.Instance?.ShowDebugBounds??false);OnHoverEnterCallback?.Invoke(_onHoverEnterDsl);}private void OnMouseExited(){GameLogger.Log("INTERACTIVE",$"OnMouseExited: id='{ID}'");if(!IsEnabled)return;IsHovered=false;ApplyHighlight(false);UpdateDebugVisual(InteractiveManager.Instance?.ShowDebugBounds??false);OnHoverLeaveCallback?.Invoke(_onHoverLeaveDsl);}private void ApplyHighlight(bool active){if(_highlightMaterial==null)return;_highlightTween?.Kill();var targetScale=active?_highlightScale:1f;_highlightTween=CreateTween();_highlightTween.SetParallel(true);_highlightTween.TweenProperty(this,"scale",Vector2.One*targetScale,_highlightDuration);if(_highlightColor.A>0f){_highlightTween.TweenMethod(Callable.From<float>(f=>{var c=new Color(_highlightColor.R,_highlightColor.G,_highlightColor.B,_highlightColor.A*f);_highlightMaterial.SetShaderParameter("modulate_color",c);}),active?0f:1f,active?1f:0f,_highlightDuration);}}/// <summary>Handles left-click press: fires the click callback and consumes the event.</summary>
public override void _GuiInput(InputEvent@event){if(!IsEnabled)return;if(@event is InputEventMouseButton mb&&mb.ButtonIndex==MouseButton.Left&&mb.Pressed){GameLogger.Log("INTERACTIVE",$"clicked: '{DefinitionId}'");OnClickCallback?.Invoke(DefinitionId);AcceptEvent();}}}}

===== FILE mods/interactive_base/src/InteractiveObjectBuilder.cs =====
using System;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;using Cthangover.Mods.Interface.View;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Interactive{/// <summary> Fluent builder for creating interactive objects programmatically from wrapper-template C# code. The texture is always full-screen with alpha; the collider defines the clickable region in normalised viewport coordinates. </summary>
public class InteractiveObjectBuilder{private readonly string _id;private readonly InteractiveManager _manager;private string _layer="foreground";private string _texture;private Texture2D _textureDirect;private int _zIndex;private bool _enabled=true;private bool _visible=true;private string _cursor;private HitAreaType _hitType=HitAreaType.Rect;private float _hitX,_hitY,_hitW=0.1f,_hitH=0.1f;private float _hitRadius=0.05f;private Vector2[]_hitVertices;private Color _highlightColor=new(1f,1f,0f,0.3f);private float _highlightScale=1.02f;private float _highlightDuration=0.15f;private Action<string>_onClick;private Action<string>_onHoverEnter;private Action<string>_onHoverLeave;private string _onHoverEnterDsl;private string _onHoverLeaveDsl;internal InteractiveObjectBuilder(string id,InteractiveManager manager){_id=id;_manager=manager;}/// <summary>Sets the visual layer ("background", "foreground" or "ui"). Default is "foreground".</summary>
public InteractiveObjectBuilder SetLayer(string layer){_layer=layer;return this;}/// <summary>Sets the full-screen texture key (resolved via ModManager.ResolveTexture).</summary>
public InteractiveObjectBuilder SetTexture(string textureKey){_texture=textureKey;return this;}/// <summary>Sets the full-screen texture directly.</summary>
public InteractiveObjectBuilder SetTexture(Texture2D texture){_textureDirect=texture;return this;}/// <summary>Sets the Z-index for ordering within the layer.</summary>
public InteractiveObjectBuilder SetZIndex(int z){_zIndex=z;return this;}/// <summary>Sets whether the object responds to input. Default true.</summary>
public InteractiveObjectBuilder SetEnabled(bool enabled){_enabled=enabled;return this;}/// <summary>Sets whether the object is visible. Default true.</summary>
public InteractiveObjectBuilder SetVisible(bool visible){_visible=visible;return this;}/// <summary>Sets the mouse cursor shape (Godot CursorShape enum name, e.g. "PointingHand").</summary>
public InteractiveObjectBuilder SetCursor(string cursor){_cursor=cursor;return this;}/// <summary>Sets a rectangular collider at normalised viewport coordinates (0..1).</summary>
public InteractiveObjectBuilder SetHitRect(float x,float y,float width,float height){_hitType=HitAreaType.Rect;_hitX=x;_hitY=y;_hitW=width;_hitH=height;return this;}/// <summary>Sets a circular collider centred at normalised coordinates with normalised radius.</summary>
public InteractiveObjectBuilder SetHitCircle(float x,float y,float radius){_hitType=HitAreaType.Circle;_hitX=x;_hitY=y;_hitRadius=radius;return this;}/// <summary>Sets a polygon collider from an array of normalised vertices (0..1).</summary>
public InteractiveObjectBuilder SetHitPolygon(Vector2[]vertices){_hitType=HitAreaType.Polygon;_hitVertices=vertices;return this;}/// <summary>Configures the highlight effect.</summary>
public InteractiveObjectBuilder WithHighlight(Color color,float scale=1.02f,float duration=0.15f){_highlightColor=color;_highlightScale=scale;_highlightDuration=duration;return this;}/// <summary>Sets the click callback. Receives the object's ID.</summary>
public InteractiveObjectBuilder OnClick(Action<string>callback){_onClick=callback;return this;}/// <summary>Sets the hover-enter callback.</summary>
public InteractiveObjectBuilder OnHoverEnter(Action<string>callback){_onHoverEnter=callback;return this;}/// <summary>Sets the hover-leave callback.</summary>
public InteractiveObjectBuilder OnHoverLeave(Action<string>callback){_onHoverLeave=callback;return this;}/// <summary>Sets inline DSL command executed on hover enter.</summary>
public InteractiveObjectBuilder SetHoverEnterDsl(string dsl){_onHoverEnterDsl=dsl;return this;}/// <summary>Sets inline DSL command executed on hover leave.</summary>
public InteractiveObjectBuilder SetHoverLeaveDsl(string dsl){_onHoverLeaveDsl=dsl;return this;}/// <summary> Finalises configuration, creates the Godot node, registers it with <c>InteractiveManager</c>, and returns the built <c>InteractiveObject</c>. </summary>
public InteractiveObject Build(){var viewBox=SceneContextNode.FindNode<ViewBox>("ViewBox");if(viewBox==null){GameLogger.Log("INTERACTIVE","InteractiveObjectBuilder.Build: ViewBox not found",LogLevel.Error);return null;}var layerContainer=viewBox.GetInteractiveLayer(_layer);if(layerContainer==null){GameLogger.Log("INTERACTIVE",$"InteractiveObjectBuilder.Build: layer '{_layer}' not found",LogLevel.Error);return null;}var contentSize=viewBox.LogicalSize!=Vector2I.Zero?new Vector2(viewBox.LogicalSize.X,viewBox.LogicalSize.Y):viewBox.Content?.Size??new Vector2(1920f,1024f);var obj=new InteractiveObject{Name="Interactive_"+_id,ID=_id};layerContainer.AddChild(obj);var def=BuildDefinition();obj.Configure(def,contentSize);if(_textureDirect!=null){var visual=obj.GetNodeOrNull<TextureRect>("Visual");if(visual!=null)visual.Texture=_textureDirect;}if(_onClick!=null)obj.OnClickCallback=_onClick;if(_onHoverEnter!=null)obj.OnHoverEnterCallback=_onHoverEnter;if(_onHoverLeave!=null)obj.OnHoverLeaveCallback=_onHoverLeave;_manager?.RegisterBuiltObject(obj);GameLogger.Log("INTERACTIVE",$"InteractiveObjectBuilder.Build: '{_id}' created");return obj;}private InteractiveDefinition BuildDefinition(){var hitArea=new HitAreaDefinition{Type=_hitType.ToString().ToLowerInvariant()};switch(_hitType){case HitAreaType.Rect:hitArea.X=_hitX;hitArea.Y=_hitY;hitArea.Width=_hitW;hitArea.Height=_hitH;break;case HitAreaType.Circle:hitArea.X=_hitX;hitArea.Y=_hitY;hitArea.Radius=_hitRadius;break;case HitAreaType.Polygon:hitArea.Vertices=_hitVertices;break;}return new InteractiveDefinition{ID=_id,Texture=_texture,Layer=_layer,ZIndex=_zIndex,Enabled=_enabled,Visible=_visible,Cursor=_cursor,HitArea=hitArea,Highlight=new HighlightDefinition{ColorHex=$"#{_highlightColor.ToHtml(false)}",Scale=_highlightScale,Duration=_highlightDuration}};}}}

===== FILE mods/interactive_base/src/InteractiveResolver.cs =====
using System.Collections.Generic;using System.Text.Json;using Cthangover.Core.Bootstrap;using Cthangover.Core.Interactive;using Cthangover.Core.Utils;namespace Cthangover.Core.Mods.Resolvers{/// <summary> Collects interactive object definitions from mods. Interactives are environment objects that the player can interact with in scenes — they are defined in <c>.json</c> files under <c>interactives/</c> directories. Each definition specifies an interaction type, position, appearance, and behaviour. Collected in mod load order — later mods override earlier ones for the same interactive ID, enabling mods to replace or extend base game interactives. </summary>
public class InteractiveResolver:ModAssetResolver{/// <inheritdoc />
public override string Id=>"interactives";private Dictionary<string,InteractiveDefinition>_cache;/// <summary> Collects all interactive definitions from every loaded mod, keyed by interactive ID. Later mods in load order override earlier ones for the same ID. Caches the result. </summary>
public Dictionary<string,InteractiveDefinition>Collect(){Context.Initialize();if(_cache!=null)return _cache;var result=new Dictionary<string,InteractiveDefinition>();foreach(var modId in Context.GetOrderedModIds()){var provider=Context.Mods[modId].FileProvider;CollectInteractiveDefsRecursive(provider,modId,"interactives",result);}_cache=result;GameLogger.Log("MODS",$"CollectInteractives -> {result.Count} definition(s)");return result;}/// <inheritdoc />
public override void ClearCache(){_cache=null;}private void CollectInteractiveDefsRecursive(IModFileProvider provider,string modId,string dir,Dictionary<string,InteractiveDefinition>result){foreach(var entry in provider.ListFiles(dir)){if(entry.EndsWith("/")){var subDir=entry.TrimEnd('/');CollectInteractiveDefsRecursive(provider,modId,subDir,result);continue;}if(!entry.EndsWith(".json",System.StringComparison.OrdinalIgnoreCase))continue;var text=Context.ReadResolvedText(modId,entry,provider);if(text==null)continue;try{var fileData=JsonSerializer.Deserialize<FileData<InteractiveDefinition>>(text,JsonOptionsProvider.ReadOptions);if(fileData?.Items==null||fileData.Items.Count==0)continue;foreach(var item in fileData.Items){if(string.IsNullOrEmpty(item.ID))continue;item.ModId=modId;if(result.ContainsKey(item.ID)){GameLogger.Log("MODS",$"interactive '{item.ID}' from mod '{modId}' overrides previous definition",LogLevel.Warning);AppServices.Current.Conflicts.RecordConflict("interactive",item.ID,modId,result[item.ID].ModId,false);}result[item.ID]=item;GameLogger.Log("MODS",$"interactive '{item.ID}' from mod '{modId}' ({entry})");}}catch(JsonException ex){GameLogger.Log("MODS",$"JSON parse failed for interactive '{modId}/{entry}': {ex.Message}",LogLevel.Error);}}}}/// <summary> Static convenience accessor for <see cref="InteractiveResolver"/>, proxying through <see cref="ModManager.Instance.GetResolver{T}"/>. </summary>
public static class Interactives{/// <summary>Collects every interactive definition from all mods, keyed by id (later mods override).</summary>
public static Dictionary<string,InteractiveDefinition>Collect()=>ModManager.Instance.GetResolver<InteractiveResolver>()?.Collect();}}

===== FILE mods/interactive_base/src/SubsystemRegistration.cs =====
namespace Cthangover.Mods.Interactive{/// <summary> Subsystem migration marker: declares that the interactive subsystem now lives in this engine mod. Consumed by <c>SubsystemsBridge</c> for the coverage registry (see SubsystemsCoverageTestSetup.DeclaredTests). </summary>
public static class SubsystemRegistration{/// <summary>Stable subsystem identifier used by the coverage registry.</summary>
public static string SubsystemId=>"interactive";}}

===== FILE mods/interactive_base/src/Vector2JsonConverter.cs =====
using System;using System.Text.Json;using System.Text.Json.Serialization;using Godot;namespace Cthangover.Core.Interactive{/// <summary> Custom JSON converter for <c>Godot.Vector2</c>. System.Text.Json cannot natively deserialise Godot's Vector2 from <c>{"x":0.72,"y":0.45}</c> because its X/Y members are fields, not properties. This converter handles the <c>{"x":...,"y":...}</c> object format used throughout interactive definition JSON files. </summary>
public class Vector2JsonConverter:JsonConverter<Vector2>{/// <summary>Reads a JSON object with "x" and "y" number members into a Vector2.</summary>
public override Vector2 Read(ref Utf8JsonReader reader,Type typeToConvert,JsonSerializerOptions options){if(reader.TokenType!=JsonTokenType.StartObject)throw new JsonException("Expected object for Vector2");float x=0f;float y=0f;while(reader.Read()){if(reader.TokenType==JsonTokenType.EndObject)break;if(reader.TokenType==JsonTokenType.PropertyName){var prop=reader.GetString();reader.Read();if(string.Equals(prop,"x",StringComparison.OrdinalIgnoreCase))x=reader.GetSingle();else if(string.Equals(prop,"y",StringComparison.OrdinalIgnoreCase))y=reader.GetSingle();else reader.Skip();}}return new Vector2(x,y);}/// <summary>Writes a Vector2 as {"x":...,"y":...}.</summary>
public override void Write(Utf8JsonWriter writer,Vector2 value,JsonSerializerOptions options){writer.WriteStartObject();writer.WriteNumber("x",value.X);writer.WriteNumber("y",value.Y);writer.WriteEndObject();}}/// <summary> JSON converter for <c>Vector2[]</c> arrays. Each element uses the <c>Vector2JsonConverter</c> for deserialisation. </summary>
public class Vector2ArrayJsonConverter:JsonConverter<Vector2[]>{/// <summary>Reads a JSON array of <c>{"x":...,"y":...}</c> objects into a Vector2[].</summary>
public override Vector2[]Read(ref Utf8JsonReader reader,Type typeToConvert,JsonSerializerOptions options){if(reader.TokenType!=JsonTokenType.StartArray)throw new JsonException("Expected array for Vector2[]");var list=new System.Collections.Generic.List<Vector2>();var vecConverter=new Vector2JsonConverter();while(reader.Read()){if(reader.TokenType==JsonTokenType.EndArray)break;list.Add(vecConverter.Read(ref reader,typeof(Vector2),options));}return list.ToArray();}/// <summary>Writes a Vector2[] as an array of {"x":...,"y":...} objects.</summary>
public override void Write(Utf8JsonWriter writer,Vector2[]value,JsonSerializerOptions options){var vecConverter=new Vector2JsonConverter();writer.WriteStartArray();foreach(var v in value)vecConverter.Write(writer,v,options);writer.WriteEndArray();}}}

===== FILE mods/interactive_base/test_suites/interactives/src/InteractivesTest.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Bootstrap;using Cthangover.Core.Scenes;using Cthangover.Core.Interactive;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;using Cthangover.Mods.Dialog;using Godot;namespace Mods.InteractiveBase.Tests{/// <summary> Entry point of the interactives itest: the suite is mounted (and this initializer compiled) only with <c>--itest=interactives</c>; run it on <c>--scene=interactives_test</c>, where the suite scenario adds the lamp. </summary>
public class InteractivesTestInitializer:IModInitializer{private static bool queued;public void OnModLoaded(string modId){}public void OnModResourcesReady(){if(queued|| !OS.GetCmdlineUserArgs().Contains("--itest=interactives"))return;queued=true;GameLogger.Log("TEST","InteractivesTest: activating");(Godot.Engine.GetMainLoop()as SceneTree)?.Root?.CallDeferred("add_child",new InteractivesTestDriverNode());}}/// <summary> Drives the lamp like a player: waits until the scenario command <c>interactive_add kitchen_lamp</c> placed it, hovers it, clicks it through the object's own input handler and expects the <c>onClick</c> scenario to run in the shared DialogBox (its <c>set lamp_clicked=yes</c> lands in the box runtime). Prints <c>[TEST_RESULT] name=interactives</c>. </summary>
public partial class InteractivesTestDriverNode:Node{private const string ResultName="interactives";private const string LampId="kitchen_lamp";private const double TimeoutSeconds=60;private int passed;private int failed;private double elapsed;private double stepStarted;private int step;private InteractiveObject lamp;public override void _Process(double delta){if(step<0)return;elapsed+=delta;if(elapsed>TimeoutSeconds){Check($"finished in {TimeoutSeconds:0}s (stuck at step {step})",false);Finish();return;}try{Step();}catch(Exception ex){Check($"step {step}: no exception ({ex.GetType().Name}: {ex.Message})",false);Finish();}}private void Step(){switch(step){case 0:if(!Boot.IsReady||(GetTree().CurrentScene==null))return;Check("definition kitchen_lamp is collected",Interactives.Collect().ContainsKey(LampId));Next();return;case 1:lamp=InteractiveManager.Instance?.Get(LampId);if(lamp==null){if(Waited(15)){Check("interactive_add placed kitchen_lamp",false);Finish();}return;}Check("interactive_add placed kitchen_lamp",true);Check("lamp is enabled and inside the tree",lamp.IsEnabled&&lamp.IsInsideTree());lamp.EmitSignal(Control.SignalName.MouseEntered);Next();return;case 2:// Hover DSL runs in a throw-away runtime: the observable part is that it does not break the object.
lamp.EmitSignal(Control.SignalName.MouseExited);Check("hover enter/exit handled, lamp still enabled",lamp.IsEnabled&&lamp.IsInsideTree());lamp._GuiInput(new InputEventMouseButton{ButtonIndex=MouseButton.Left,Pressed=true,Position=lamp.Size/2});Next();return;case 3:var box=SceneContextNode.FindNode<DialogBox>("DialogBox");var clicked=box?.Runtime.GetVariable("lamp_clicked");if(clicked!="yes"&& !Waited(5))return;Check("click started the onClick dialog (waits on its text)",box is{IsDialogEnded:false});Check("onClick scenario ran (lamp_clicked=yes)"+(clicked=="yes"?string.Empty:$" (got '{clicked}')"),clicked=="yes");Finish();return;}}private bool Waited(double seconds)=>elapsed-stepStarted>seconds;private void Next(){step++;stepStarted=elapsed;}private void Check(string what,bool ok){if(ok)passed++;else failed++;GameLogger.Log("TEST",$"  [{(ok ? "PASS" : "FAIL")}] {what}");}private void Finish(){step= -1;GameLogger.Log("TEST",$"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");GetTree().Quit(failed==0?0:1);}}}

===== FILE mods/items_base/src/Ingredient.cs =====
namespace Cthangover.Core.Items{/// <summary> Plain POCO implementing <c>IIngredient</c>. Used by <c>RecipeFactory</c> to materialise ingredient entries from <c>IngredientInfo</c> DTOs after resolving the item reference through <c>ItemFactory</c>. </summary>
public class Ingredient:IIngredient{/// <summary>The resolved item this formula entry references.</summary>
public IItem Item{get;set;}/// <summary>How many copies the recipe consumes or produces.</summary>
public int Count{get;set;}}}

===== FILE mods/items_base/src/IngredientInfo.cs =====
using System;using System.Text.Json.Serialization;using Cthangover.Core.Factories;namespace Cthangover.Core.Items{/// <summary> JSON deserialization DTO for a recipe ingredient entry. Carries only the item <c>ID</c> string and <c>Count</c> — not the resolved <c>IItem</c> — because recipe JSON files are loaded before item definitions may be fully populated across mods. The actual item reference is wired up later by <c>RecipeFactory.CreateIngredient</c>, which looks up the ID through <c>ItemFactory</c> and validates that the referenced item exists. </summary>
[Serializable]public class IngredientInfo:IIdentifiable{/// <summary>Item ID this entry references; serialized as "Id" in JSON.</summary>
[JsonPropertyName("Id")]public string ID{get;set;}/// <summary>Quantity consumed or produced by the recipe.</summary>
public int Count{get;set;}}}

===== FILE mods/items_base/src/Inventory.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Events;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Utils;namespace Cthangover.Core.Items{/// <summary> Concrete player inventory backed by an ordered <c>List&lt;IItemContainer&gt;</c>. Uses LINQ scans (<c>FirstOrDefault</c>, <c>Any</c>) rather than a <c>Dictionary</c> because inventory sizes in this game are small enough that O(n) lookups are imperceptible, and list ordering directly drives UI slot layout — the first container appears in the first grid cell. On <c>Remove</c>, when a stack count reaches zero the entire container is deleted from the list rather than kept as an empty slot, preventing the UI from rendering gaps. The constructor seeds wolf meat and branches for testing convenience — in production, save data would override this initial state. The <c>Change</c> event fires with <c>(itemId, delta)</c> so subscribers receive full context. Additionally, each mutation publishes an <c>InventoryChangedEvent</c> to the global <c>ModEventBus</c>. </summary>
public class Inventory:IInventory{/// <summary>Ordered slot list; order drives UI layout and empty slots are removed on zero stacks.</summary>
public List<IItemContainer>Items{get;set;}=new();/// <summary>Fires on every successful add/remove with (itemId, signed delta); also mirrored as <see cref="Events.InventoryChangedEvent"/> on the ModEventBus.</summary>
public event Action<string,int>Change;/// <summary> Creates an inventory pre-seeded with the starter kit (10 wolf meat, 10 branches) so a new game always has basic supplies. </summary>
public Inventory(){AddStarterItem("food/wolf_meat",10);AddStarterItem("resource/branches",10);}/// <summary> Seeds one starter stack, skipping the entry when the item definition cannot be resolved. This can legitimately happen when the inventory is constructed while the launcher has not finished loading mods; adding a container with a <c>null</c> item would otherwise crash every later <see cref="HasItem(string)"/> call. </summary>
private void AddStarterItem(string id,int count){var item=ItemFactory.Instance.Get(id);if(item==null){GameLogger.Log("INVENTORY",$"starter item '{id}' not found, skipping seed",LogLevel.Warning);return;}Items.Add(new ItemContainer{Item=item,Count=count});}/// <summary>True when any slot holds the given item (null-safe).</summary>
public bool HasItem(IItem item){if(item==null)return false;return HasItem(item.ID);}/// <summary>True when any slot holds the item with the given id; null/empty ids return false.</summary>
public bool HasItem(string id){if(string.IsNullOrWhiteSpace(id))return false;return Items.Any(o=>o.Item.ID==id);}/// <summary>Returns the slot holding the given item, or <c>null</c> (null-safe).</summary>
public IItemContainer TryGet(IItem item){if(item==null)return null;return TryGet(item.ID);}/// <summary>Returns the slot holding the given id, or <c>null</c> for null/empty ids.</summary>
public IItemContainer TryGet(string id){if(string.IsNullOrWhiteSpace(id))return null;return Items.FirstOrDefault(o=>o.Item!=null&&o.Item.ID==id);}/// <summary>Total count across slots for the given item (0 when absent or null).</summary>
public int CheckCount(IItem item){if(item==null)return 0;return CheckCount(item.ID);}/// <summary>Total count across slots for the given id (0 when absent).</summary>
public int CheckCount(string id){var container=TryGet(id);return container?.Count??0;}/// <summary>Adds the item (delegates to the id overload); null items are ignored.</summary>
public void Add(IItem item,int count=1){if(item==null)return;Add(item.ID,count);}/// <summary> Stacks onto an existing slot or creates a new one by resolving the id through <see cref="Factories.Impls.ItemFactory"/>; unknown ids are silently ignored. Fires <see cref="Change"/> and publishes <see cref="Events.InventoryChangedEvent"/> on success. </summary>
public void Add(string id,int count=1){var container=TryGet(id);if(container!=null){container.Count+=count;Change?.Invoke(id,count);PublishInventoryChanged(id,count,container.Count);return;}var item=ItemFactory.Instance.Get(id);if(item!=null){container=new ItemContainer{Item=item,Count=count,};Items.Add(container);Change?.Invoke(id,count);PublishInventoryChanged(id,count,count);}}/// <summary>Removes the item (delegates to the id overload); null items return false.</summary>
public bool Remove(IItem item,int count=1){if(item==null)return false;return Remove(item.ID,count);}/// <summary> Removes <paramref name="count"/> items from the slot, deleting the container when the stack reaches zero. Returns <c>false</c> without changes when the slot holds fewer items than requested. </summary>
public bool Remove(string id,int count=1){var container=TryGet(id);if(container==null)return false;var delta=container.Count-count;if(delta<0)return false;if(delta==0)Items.Remove(container);else container.Count-=count;var newCount=delta==0?0:delta;Change?.Invoke(id,-count);PublishInventoryChanged(id,-count,newCount);return true;}private static void PublishInventoryChanged(string itemId,int delta,int newCount){ModEventBus.Publish(new InventoryChangedEvent{ItemId=itemId,Delta=delta,NewCount=newCount,});}}}

===== FILE mods/items_base/src/Item.cs =====
using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Items{/// <summary> Concrete item instance produced by <see cref="Factories.Impls.ItemFactory"/> from an <see cref="ItemInfo"/> DTO. The factory builds a fresh instance per <c>Get</c> call (resolving the sprite and the on-use action by id), so callers may mutate their copy freely — inventories share the definition fields but each slot only tracks its own count. </summary>
public class Item:IItem{/// <summary>Unique item ID (path-style, e.g. <c>"food/ration"</c>); the factory lookup key.</summary>
public string ID{get;set;}/// <summary>Display name (resolved locale string).</summary>
public string Name{get;set;}/// <summary>Tooltip/description text (resolved locale string).</summary>
public string Description{get;set;}/// <summary>Base purchase price in the game economy.</summary>
public int Cost{get;set;}/// <summary>Icon texture resolved from the mod's <c>items</c> group via <see cref="Factories.Impls.ItemSpriteFactory"/>.</summary>
public Texture2D Sprite{get;set;}/// <summary><see cref="ItemType"/> flags describing the item's categories.</summary>
public ItemType ItemType{get;set;}/// <summary>On-use behaviour resolved via <see cref="ItemActionFactory"/>; <c>null</c> means no use effect.</summary>
public IItemAction ItemAction{get;set;}/// <summary>String-keyed extension bag imported from extra JSON keys, for mod-defined item data.</summary>
public PropertyData Properties{get;set;}=new();}}

===== FILE mods/items_base/src/ItemActionConsumer.cs =====
using Cthangover.Core.Factories.Impls;using Cthangover.Core.Items;using Cthangover.Core.Mods.Consumers;namespace Cthangover.Mods.Items{/// <summary> Feeds <see cref="ItemActionFactory"/> with every mod's C# <see cref="IItemAction"/> implementations as the mods load (for example cooking's recipe actions) and removes them when their mod unloads, so a hot-reloaded mod's actions run its fresh code and the dying assembly is not pinned. GDScript-declared actions do not come from an assembly and still arrive through <see cref="ItemActionBridge"/>. </summary>
public class ItemActionConsumer:ModTypeConsumer<IItemAction>{/// <inheritdoc />
public override string Id=>"items.actions";/// <inheritdoc />
protected override void Add(IItemAction instance,string modId)=>ItemActionFactory.Instance.Register(instance);/// <inheritdoc />
protected override void Remove(IItemAction instance,string modId)=>ItemActionFactory.Instance.Unregister(instance);}}

===== FILE mods/items_base/src/ItemActionFactory.cs =====
using System.Collections.Generic;using Cthangover.Core.Items;namespace Cthangover.Core.Factories.Impls{/// <summary> Registry of item action behaviours, indexed by <c>ID</c>. It discovers nothing itself: C# actions of every mod arrive through <c>ItemActionConsumer</c> as the kernel registers their assembly and leave with their mod, and GDScript-declared actions arrive through <c>ItemActionBridge</c>. A mod therefore introduces a new item action simply by containing a public class that implements the interface. The <c>"null"</c> ID string is treated as a no-op action (the fallback for items that don't have an on-use effect), avoiding the need for a sentinel <c>NullItemAction</c> class. <para/> The single instance lives in this mod assembly as a static lazy singleton; the static facade stays as the public API. </summary>
public class ItemActionFactory{private static ItemActionFactory _instance;/// <summary>Static lazy singleton; kept as the public API.</summary>
public static ItemActionFactory Instance=>_instance??=new ItemActionFactory();private readonly IDictionary<string,IItemAction>data=new Dictionary<string,IItemAction>();internal ItemActionFactory(){}/// <summary>Registers a single action under its <see cref="IItemAction.ID"/>; the first action of an id wins, null/empty ids are ignored.</summary>
public void Register(IItemAction action){if(action!=null&& !string.IsNullOrEmpty(action.ID)&& !data.ContainsKey(action.ID))data[action.ID]=action;}/// <summary> Removes exactly <paramref name="action"/> (its mod is being unloaded); an id owned by another action instance is left alone. </summary>
public void Unregister(IItemAction action){if(action!=null&& !string.IsNullOrEmpty(action.ID)&&data.TryGetValue(action.ID,out var registered)&&registered==action)data.Remove(action.ID);}/// <summary> Resolves the behaviour registered under <paramref name="id"/>. The special id <c>"null"</c> (or an empty value) is an explicit no-action marker and returns <c>null</c>, as does an unknown id. </summary>
public IItemAction Get(string id){if(string.IsNullOrWhiteSpace(id)||id=="null")return null;return data[id];}}}

===== FILE mods/items_base/src/ItemContainer.cs =====
namespace Cthangover.Core.Items{/// <summary> Concrete inventory slot holding a shared <c>IItem</c> reference and a mutable stack count. The item reference is shared across every container that holds the same item type — only the count is slot-specific — which keeps memory footprint low when multiple stacks of the same item exist (e.g. 3 stacks of arrows in different inventory rows). </summary>
public class ItemContainer:IItemContainer{/// <summary>The shared item definition held by this slot (one instance per item type, not per stack).</summary>
public IItem Item{get;set;}/// <summary>Stack size; <see cref="Inventory"/> deletes the container when it reaches zero.</summary>
public int Count{get;set;}}}

===== FILE mods/items_base/src/ItemFactory.cs =====
using System.Collections.Generic;using Cthangover.Core.Items;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;namespace Cthangover.Core.Factories.Impls{/// <summary> The single instance lives in this mod assembly as a static lazy singleton; the static facade stays as the public API. </summary>
public class ItemFactory{private static ItemFactory _instance;/// <summary>Static lazy singleton; kept as the public API.</summary>
public static ItemFactory Instance=>_instance??=new ItemFactory();private Dictionary<string,ItemInfo>items;/// <summary> Builds a fresh <see cref="Item"/> from the collected <see cref="ItemInfo"/>, resolving the sprite through <see cref="ItemSpriteFactory"/>, the on-use action through <see cref="ItemActionFactory"/>, and importing extra JSON keys into <see cref="Item.Properties"/>. Unknown or empty ids return <c>null</c>. </summary>
public IItem Get(string id){if(string.IsNullOrEmpty(id))return null;var infos=items??Entities.Collect<ItemInfo>();// The registry refuses mod-data access until Boot.IsReady, so this snapshot can never be collected from a half-built mod set; the cached field is reset when ModManager.Reload rebuilds the registry.
if(ModManager.Instance.IsInitialized)items??=infos;if(!infos.TryGetValue(id,out var info)){GameLogger.Log("FACTORY",$"item '{id}' not found",LogLevel.Error);return null;}var properties=new PropertyData();PropertyData.ImportJsonProperties(properties,info.ExtraProperties);return new Item{ID=info.ID,Name=info.Name,Description=info.Description,Cost=info.Cost,Sprite=ItemSpriteFactory.Instance.Get(info.Sprite),ItemType=info.ItemType,ItemAction=ItemActionFactory.Instance.Get(info.ItemAction),Properties=properties,};}}}

===== FILE mods/items_base/src/ItemInfo.cs =====
using System;using System.Collections.Generic;using System.Text.Json.Serialization;using Cthangover.Core.Factories;using Cthangover.Core.Utils;namespace Cthangover.Core.Items{/// <summary> JSON-serializable item descriptor collected by the entity resolver (<c>[EntityKind("item")]</c> — files declare <c>"Kind": "item"</c>). Typed fields cover the stock item schema; any additional JSON keys are captured by <see cref="ExtraProperties"/> and imported by <see cref="Factories.Impls.ItemFactory"/> into <see cref="Item.Properties"/>, so mods can attach custom data to items without schema changes. </summary>
[Serializable][EntityKind("item")]public class ItemInfo:IIdentifiable{/// <summary>Unique item ID, serialized as "Id" in JSON; path-style ids (e.g. "food/ration") double as save-data references.</summary>
[JsonPropertyName("Id")]public string ID{get;set;}/// <summary>Locale key for the item's display name, resolved from .properties files.</summary>
public string Name{get;set;}/// <summary>Locale key for the item's tooltip/description text.</summary>
public string Description{get;set;}/// <summary>Base purchase price in the game economy.</summary>
public int Cost{get;set;}/// <summary>Sprite path inside the mod's <c>items</c> texture group, resolved by <see cref="Factories.Impls.ItemSpriteFactory"/>.</summary>
public string Sprite{get;set;}/// <summary>Category flags parsed from a comma-separated string (e.g. "Resource,Food") via the flags enum converter.</summary>
[JsonConverter(typeof(FlagsStringEnumConverter<ItemType>))]public ItemType ItemType{get;set;}/// <summary>ID of the on-use behaviour, resolved through <see cref="Factories.Impls.ItemActionFactory"/>; "null"/empty means no use effect.</summary>
public string ItemAction{get;set;}/// <summary>Captures every JSON key not matched by the typed fields; imported into <see cref="Item.Properties"/> at factory time.</summary>
[JsonExtensionData]public Dictionary<string,object>ExtraProperties{get;set;}}}

===== FILE mods/items_base/src/ItemsModInitializer.cs =====
using Cthangover.Core.Factories.Impls;using Cthangover.Core.Items;using Cthangover.Core.Mods;using Cthangover.Core.Settings;namespace Cthangover.Mods.Items{/// <summary> Lifecycle entry point of the items mod. The core no longer owns the inventory/recipe state or the item factories, so this initializer: <list type="number"> <item>installs the <see cref="RuntimeData"/> factory hooks, so the core state bag lazily produces the mod-owned <c>Inventory</c> and <c>RecipeData</c> on first access; and</item> <item>binds <see cref="ItemActionBridge"/> to this mod's <c>ItemActionFactory</c>, flushing every GDScript-declared action buffered so far.</item> </list> C# item actions of every mod reach the factory through <see cref="ItemActionConsumer"/>, not through this initializer. Persistence lives in <see cref="ItemsSaveable"/>, discovered automatically by <c>ModSaveableRegistry</c>. </summary>
public class ItemsModInitializer:IModInitializer{/// <summary>No per-mod work is needed on load; discovery is reflection-based.</summary>
public void OnModLoaded(string modId){}/// <summary>Installs the RuntimeData factory hooks and binds the item-action bridge (both idempotent).</summary>
public void OnModResourcesReady(){RuntimeData.InventoryFactory??=()=>new Inventory();RuntimeData.RecipeDataFactory??=()=>new RecipeData();ItemActionBridge.Bind(action=>ItemActionFactory.Instance.Register(action));}}}

===== FILE mods/items_base/src/ItemsSaveable.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Items;using Cthangover.Core.Mods;using Cthangover.Core.Settings;namespace Cthangover.Mods.Items{/// <summary> Persists the player inventory and unlocked recipes in the items mod's own save slot. The core used to carry the <c>"Inventory"</c> and <c>"Recipes"</c> blobs inside the <c>__core__</c> slot; the blob format (CItem id/count pairs, recipe id list) is unchanged — only the owning slot moved. Backward compatibility with old saves' core blobs is intentionally not provided (precedent: recruiting) — old saves start with an empty inventory and no unlocked recipes. </summary>
public class ItemsSaveable:IModSaveable{private const string InventoryBlob="Inventory";private const string RecipesBlob="Recipes";/// <summary>Writes item stacks and unlocked recipe IDs into the mod slot.</summary>
public void OnSave(ModSlot slot){var runtime=GameData.Instance?.Runtime;if(runtime==null)return;slot.Data[InventoryBlob]=new DataBlob(1,runtime.Inventory.Items.Select(o=>new CItem{ID=o.Item.ID,Count=o.Count}).ToList());slot.Data[RecipesBlob]=new DataBlob(1,runtime.RecipeData.Data.ToList());}/// <summary>Restores inventory and recipes; absent blobs leave the current state untouched.</summary>
public bool OnLoad(ModSlot slot,out string errorMessage){errorMessage=null;var runtime=GameData.Instance?.Runtime;if(runtime==null||slot?.Data==null)return true;if(slot.Data.TryGetValue(InventoryBlob,out var invBlob)){var items=invBlob.ReadJson<List<CItem>>();runtime.Inventory.Items=items?.Select(o=>(IItemContainer)new ItemContainer{Item=ItemFactory.Instance.Get(o.ID),Count=o.Count}).ToList()??new List<IItemContainer>();}if(slot.Data.TryGetValue(RecipesBlob,out var recipeBlob))runtime.RecipeData.Data=recipeBlob.ReadJson<List<string>>()?.ToHashSet()??new HashSet<string>();return true;}}}

===== FILE mods/items_base/src/Recipe.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Items{/// <summary> Concrete recipe fulfilling <c>IRecipe</c>. Populated by <c>RecipeFactory.CreateRecipe</c> from a <c>RecipeInfo</c> DTO after resolving every ingredient entry's item ID through <c>ItemFactory</c>. Recipes are treated as immutable value objects by the crafting UI — <c>RecipeFactory.Get</c> returns the cached instance directly without copying. </summary>
public class Recipe:IRecipe{/// <summary>Unique recipe ID (path-style, e.g. <c>"recipe/wolf_meat_to_ration"</c>).</summary>
public string ID{get;set;}/// <summary>Locale key for the recipe display name.</summary>
public string Name{get;set;}/// <summary>Locale key for the recipe description text.</summary>
public string Description{get;set;}/// <summary>Crafting time in in-game minutes.</summary>
public int Time{get;set;}/// <summary>Required crafting station (only <see cref="WorkbenchType.Cooking"/> exists).</summary>
public WorkbenchType WorkbenchType{get;set;}/// <summary>Ingredients consumed on a successful craft; item references resolved by <see cref="Factories.Impls.RecipeFactory"/>.</summary>
public List<IIngredient>Input{get;set;}/// <summary>Items produced on a successful craft.</summary>
public List<IIngredient>Output{get;set;}}}

===== FILE mods/items_base/src/RecipeData.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Items;namespace Cthangover.Core.Settings{/// <summary> Tracks which crafting recipes the player has unlocked. Internally a <see cref="HashSet{String}"/> of recipe IDs that provides <c>O(1)</c> lookup. Recipes can be queried by <see cref="IRecipe.WorkbenchType"/> via <see cref="GetRecipesByType"/>, which resolves each ID through <see cref="Cthangover.Core.Factories.Impls.RecipeFactory"/> and filters the resulting <see cref="IRecipe"/> objects. </summary>
public class RecipeData:IRecipeData{/// <summary>Set of known recipe IDs (duplicate insertion is silently ignored).</summary>
public HashSet<string>Data{get;set;}=new();/// <summary> Unlocks a recipe by ID. Returns <c>false</c> if the recipe was already known (duplicate). </summary>
public bool Add(string id){return Data.Add(id);}/// <summary><c>true</c> if the recipe with the given ID is already unlocked.</summary>
public bool Has(string id){return Data.Contains(id);}/// <summary> Returns all known recipes that belong to the specified <see cref="WorkbenchType"/>. Each ID is resolved via <see cref="Cthangover.Core.Factories.Impls.RecipeFactory.Instance"/> and filtered; IDs that resolve to <c>null</c> are silently dropped. </summary>
public List<IRecipe>GetRecipesByType(WorkbenchType type){return Data.Select(o=>RecipeFactory.Instance.Get(o)).Where(o=>o.WorkbenchType==type).ToList();}}}

===== FILE mods/items_base/src/RecipeFactory.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Items;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;namespace Cthangover.Core.Factories.Impls{/// <summary> Factory for crafting recipes. Uses <c>BoundedCache</c> because the crafting UI may repeatedly query recipes during scrolling and the ingredient resolution (through <c>ItemFactory</c>) is non-trivial. Recipes are treated as immutable value objects — <c>Get</c> returns the cached instance directly without copying. Missing ingredient items emit an error and skip that ingredient rather than failing the whole recipe, so a mod author's typo in one input doesn't silently break the crafting UI for unrelated recipes. <para/> The single instance lives in this mod assembly as a static lazy singleton; the static facade stays as the public API. </summary>
public class RecipeFactory:ICacheLoader<string,IRecipe>{private static RecipeFactory _instance;/// <summary>Static lazy singleton; kept as the public API.</summary>
public static RecipeFactory Instance=>_instance??=new RecipeFactory();private Dictionary<string,RecipeInfo>_allInfos;private readonly BoundedCache<string,IRecipe>_cache;internal RecipeFactory(){var size=ModConfig.Instance.Cache.GetCacheSize("recipes",128);_cache=new BoundedCache<string,IRecipe>(size,this);}IRecipe ICacheLoader<string,IRecipe>.Load(string id){var infos=EnsureInfos();if(infos.TryGetValue(id,out var info))return CreateRecipe(info);return null;}/// <summary> Returns the recipe definitions, collecting them on first use. Publication to <c>_allInfos</c> waits until the mod registry is finalized so an early lookup from the launcher boot window cannot freeze an empty snapshot for the session. </summary>
private Dictionary<string,RecipeInfo>EnsureInfos(){if(_allInfos!=null)return _allInfos;var infos=Entities.Collect<RecipeInfo>();if(ModManager.Instance.IsInitialized)_allInfos=infos;return infos;}/// <summary> Returns the cached recipe for <paramref name="id"/> (resolved lazily through <see cref="RecipeFactory.Load"/>), or <c>null</c> for unknown or empty ids. The shared instance is returned without copying — recipes are treated as immutable. </summary>
public IRecipe Get(string id){if(string.IsNullOrEmpty(id))return null;EnsureInfos();return _cache.Get(id);}private static Recipe CreateRecipe(RecipeInfo info){return new Recipe{ID=info.ID,Name=info.Name,Description=info.Description,Time=info.Time,WorkbenchType=info.Type,Input=info.Input.Select(i=>CreateIngredient(i)).Where(i=>i!=null).ToList(),Output=info.Output.Select(i=>CreateIngredient(i)).Where(i=>i!=null).ToList()};}private static IIngredient CreateIngredient(IngredientInfo info){var item=ItemFactory.Instance.Get(info.ID);if(item==null){GameLogger.Log("MOD_TEST",$"RecipeFactory: item '{info.ID}' not found in ItemFactory",LogLevel.Error);return null;}return new Ingredient{Item=item,Count=info.Count};}}}

===== FILE mods/items_base/src/RecipeInfo.cs =====
using System;using System.Collections.Generic;using System.Text.Json.Serialization;using Cthangover.Core.Factories;namespace Cthangover.Core.Items{/// <summary> JSON DTO for recipe definitions in mod files. Uses a plain <c>JsonStringEnumConverter</c> for <c>WorkbenchType</c> (simple enum, no flags needed) rather than the <c>FlagsStringEnumConverter</c> used by <c>ItemInfo</c>. <c>Input</c> and <c>Output</c> are raw <c>IngredientInfo</c> lists — the actual <c>IItem</c> references are resolved by <c>RecipeFactory</c> at cache population time, which also validates that every referenced item exists in <c>ItemFactory</c>. </summary>
[Serializable][EntityKind("recipe")]public class RecipeInfo:IIdentifiable{/// <summary>Unique recipe ID, serialized as "Id" in JSON.</summary>
[JsonPropertyName("Id")]public string ID{get;set;}/// <summary>Locale key for the recipe display name.</summary>
public string Name{get;set;}/// <summary>Locale key for the recipe description text.</summary>
public string Description{get;set;}/// <summary>Required crafting station, serialized as its enum name ("Cooking").</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]public WorkbenchType Type{get;set;}/// <summary>Crafting time in in-game minutes.</summary>
public int Time{get;set;}/// <summary>Raw ingredient entries; item references are resolved and validated by <see cref="Factories.Impls.RecipeFactory"/>.</summary>
public List<IngredientInfo>Input{get;set;}/// <summary>Raw output entries resolved at factory time.</summary>
public List<IngredientInfo>Output{get;set;}}}

===== FILE mods/items_base/src/SubsystemRegistration.cs =====
namespace Cthangover.Mods.Items{/// <summary> Migration marker for the coverage pipeline (<c>SubsystemsBridge</c> reflection scan): declares the items subsystem fully moved out of the core assembly into this engine mod. The id must match the <c>DeclaredTests</c> entry in <c>SubsystemsCoverageTestSetup</c>. </summary>
public static class SubsystemRegistration{public static string SubsystemId=>"items";}}

===== FILE mods/items_base/src/ui/CharacterTargetPanel.cs =====
using System;using Cthangover.Core.Characters;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Settings;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Core.UI.Inventory{/// <summary> Inline target picker for <see cref="ItemType.TargetUsed"/> items, embedded directly into the item passport instead of replacing the whole right column. Each party member is a button row with a health bar, current/max HP and the translated name; a cancel button closes the picker. Extends <see cref="VBoxContainer"/> because the previous Control-based version left its children unmanaged — a plain Control never laid them out, so title, rows and cancel button collapsed into the top-left corner. </summary>
public partial class CharacterTargetPanel:VBoxContainer{/// <summary>Raised when the player picks a target; the effect is applied by the inventory panel.</summary>
public event Action<CharacterInfoData>CharacterSelected;/// <summary>Raised when the player aborts target selection.</summary>
public event Action Cancelled;/// <summary>Builds the picker inline: title, one row per party member, and a cancel button.</summary>
public CharacterTargetPanel(){SizeFlagsVertical=SizeFlags.ExpandFill;MouseFilter=MouseFilterEnum.Stop;AddThemeConstantOverride("separation",7);var title=new Label();title.Text=InventoryItemUi.Translate("ui/inventory/select_target").ToUpperInvariant();title.AddThemeFontSizeOverride("font_size",10);title.AddThemeColorOverride("font_color",UiTokens.TypeTarget);title.MouseFilter=MouseFilterEnum.Ignore;AddChild(title);var characterData=GameData.Instance.Runtime.CharacterData;foreach(var id in characterData.BattleSet){if(!characterData.Characters.TryGetValue(id,out var info))continue;AddChild(BuildTargetRow(info));}var cancel=new Button();cancel.Text=InventoryItemUi.Translate("ui/inventory/cancel");cancel.FocusMode=FocusModeEnum.None;cancel.AddThemeFontSizeOverride("font_size",13);cancel.AddThemeStyleboxOverride("normal",MakeCancelStyle(false));cancel.AddThemeStyleboxOverride("hover",MakeCancelStyle(true));cancel.AddThemeStyleboxOverride("pressed",MakeCancelStyle(true));cancel.AddThemeStyleboxOverride("focus",new StyleBoxEmpty());cancel.AddThemeColorOverride("font_color",UiTokens.Body);cancel.AddThemeColorOverride("font_hover_color",Colors.White);cancel.Pressed+=OnCancelPressed;AddChild(cancel);}private Button BuildTargetRow(CharacterInfoData info){var template=CharacterTemplateRegistry.Get(info.ID);var displayName=InventoryItemUi.Translate(template?.Name??info.ID);var health=info.Attributes?.Health;var value=health?.Value??0;var max=health?.BaseValue??0;var button=new Button();button.FocusMode=FocusModeEnum.None;button.CustomMinimumSize=new Vector2(0,46);button.SizeFlagsHorizontal=SizeFlags.ExpandFill;button.AddThemeStyleboxOverride("normal",MakeRowStyle(false));button.AddThemeStyleboxOverride("hover",MakeRowStyle(true));button.AddThemeStyleboxOverride("pressed",MakeRowStyle(true));button.AddThemeStyleboxOverride("focus",new StyleBoxEmpty());var row=new HBoxContainer();row.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);row.OffsetLeft=12;row.OffsetRight= -12;row.AddThemeConstantOverride("separation",10);row.MouseFilter=MouseFilterEnum.Ignore;button.AddChild(row);var name=new Label();name.Text=displayName;name.AddThemeFontSizeOverride("font_size",14);name.AddThemeColorOverride("font_color",UiTokens.Ink);name.TextOverrunBehavior=TextServer.OverrunBehavior.TrimEllipsis;name.ClipText=true;name.SizeFlagsHorizontal=SizeFlags.ExpandFill;name.VerticalAlignment=VerticalAlignment.Center;name.MouseFilter=MouseFilterEnum.Ignore;row.AddChild(name);row.AddChild(BuildHealthBar(value,max));var hp=new Label();hp.Text=value+"/"+max;hp.AddThemeFontSizeOverride("font_size",12);hp.AddThemeColorOverride("font_color",UiTokens.Muted);hp.VerticalAlignment=VerticalAlignment.Center;hp.MouseFilter=MouseFilterEnum.Ignore;row.AddChild(hp);var captured=info;button.Pressed+=()=>CharacterSelected?.Invoke(captured);return button;}private void OnCancelPressed(){Cancelled?.Invoke();}private static ProgressBar BuildHealthBar(int value,int max){var ratio=max>0?(float)value/max:1f;var bar=new ProgressBar();bar.CustomMinimumSize=new Vector2(96,7);bar.MaxValue=max>0?max:1;bar.Value=value;bar.ShowPercentage=false;bar.MouseFilter=MouseFilterEnum.Ignore;bar.AddThemeStyleboxOverride("background",MakeBarStyle(UiTokens.LineDisabled));bar.AddThemeStyleboxOverride("fill",MakeBarStyle(ratio<0.4f?UiTokens.HpLow:UiTokens.HpOk));return bar;}private static StyleBoxFlat MakeRowStyle(bool hover){var style=new StyleBoxFlat();style.BgColor=hover?UiTokens.TargetHover:UiTokens.Surface2;style.BorderColor=hover?UiTokens.TargetLine:UiTokens.Line;style.BorderWidthLeft=1;style.BorderWidthRight=1;style.BorderWidthTop=1;style.BorderWidthBottom=1;style.CornerRadiusTopLeft=6;style.CornerRadiusTopRight=6;style.CornerRadiusBottomLeft=6;style.CornerRadiusBottomRight=6;return style;}private static StyleBoxFlat MakeBarStyle(Color color){var style=new StyleBoxFlat();style.BgColor=color;style.CornerRadiusTopLeft=4;style.CornerRadiusTopRight=4;style.CornerRadiusBottomLeft=4;style.CornerRadiusBottomRight=4;return style;}private static StyleBoxFlat MakeCancelStyle(bool hover){var style=new StyleBoxFlat();style.BgColor=hover?UiTokens.BtnHover:UiTokens.Surface3;style.BorderColor=hover?UiTokens.Alpha(UiTokens.Accent,0.55f):UiTokens.Line;style.BorderWidthLeft=1;style.BorderWidthRight=1;style.BorderWidthTop=1;style.BorderWidthBottom=1;style.CornerRadiusTopLeft=4;style.CornerRadiusTopRight=4;style.CornerRadiusBottomLeft=4;style.CornerRadiusBottomRight=4;style.ContentMarginLeft=12;style.ContentMarginRight=12;style.ContentMarginTop=9;style.ContentMarginBottom=9;return style;}}}

===== FILE mods/items_base/src/ui/InventoryBagBehaviour.cs =====
using System.Collections.Generic;using Cthangover.Core.Items;using Cthangover.Mods.Interface.Base.Lists.Impls;namespace Cthangover.Core.UI.Inventory{/// <summary> Grid-based inventory display using ColumnCellListWidget. Binds an external List&lt;IItemContainer&gt;. The List property setter auto-refreshes the UI — intended for data-driven updates where the inventory source pushes a new list reference rather than items mutating in place. </summary>
public partial class InventoryBagBehaviour:ColumnCellListWidget<InventoryItemBehaviour,IItemContainer>{private List<IItemContainer>list;/// <summary> Bound inventory data source. Setting this property immediately rebuilds the grid display via <see cref="Refresh"/>. The entire grid is reconstructed from the new list reference. </summary>
public List<IItemContainer>List{get=>list;set{list=value;Refresh();}}/// <summary> Provides the current <see cref="List"/> as the data collection for the <see cref="ColumnCellListWidget{TItem, TModel}"/> base. </summary>
public override ICollection<IItemContainer>CreateModels(){return list;}}}

===== FILE mods/items_base/src/ui/InventoryItemBehaviour.cs =====
using System;using Cthangover.Core.Items;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Core.UI.Inventory{/// <summary> Interactive cell of the inventory grid. Unlike the old thin cell that only drew an icon and a stack counter, this one owns the whole visual language of a slot: the frame (normal/hover/selected), the type accent dot, the short action tag and the dimmed state for inert items. It is built entirely in code because the grid needs pixel-accurate <see cref="StyleBoxFlat"/> frame swapping that theme overrides in the tscn pipeline cannot express. Click handling lives inside the cell: it raises <see cref="Clicked"/> after consuming the mouse event, instead of the panel subscribing to <c>GuiInput</c> from outside, so event propagation is stopped by the control that actually owns the click. </summary>
public partial class InventoryItemBehaviour:ListItem<IItemContainer>{/// <summary>Raised on a left-click after the cell consumed the event. The payload is the cell itself.</summary>
public event Action<InventoryItemBehaviour>Clicked;private static readonly StyleBoxFlat NormalFrame=MakeFrame(UiTokens.LineSoft,UiTokens.Surface1);private static readonly StyleBoxFlat HoverFrame=MakeFrame(UiTokens.LineStrong,UiTokens.SurfaceHover);private static readonly StyleBoxFlat SelectedFrame=MakeFrame(UiTokens.Accent,UiTokens.SurfaceSelected);private static readonly StyleBoxFlat TagStyle=MakeTagStyle();private static readonly Color IconDimmed=UiTokens.Alpha(UiTokens.White,0.38f);private Panel _frame;private TextureRect _icon;private Label _count;private Panel _dot;private StyleBoxFlat _dotStyle;private PanelContainer _tag;private Label _tagLabel;private bool _built;private bool _selected;private bool _hovered;/// <summary>Stops mouse propagation and wires hover/click handling before any model is bound.</summary>
public InventoryItemBehaviour(){MouseFilter=MouseFilterEnum.Stop;GuiInput+=OnGuiInput;MouseEntered+=OnMouseEntered;MouseExited+=OnMouseExited;}/// <summary> Binds the item stack and lazily builds the visual tree on first use; repeated binds only refresh the rendered data. </summary>
public override void Construct(IItemContainer model){base.Construct(model);EnsureBuilt();UpdateInfo();}/// <summary> Pushes the bound stack into the visuals: icon, stack counter (hidden for singletons), type accent dot, action tag and the dimmed state for items without a usable action. </summary>
public void UpdateInfo(){if(!_built||Model?.Item==null)return;var item=Model.Item;_icon.Texture=item.Sprite;_count.Text=Model.Count>1?"×"+Model.Count:string.Empty;_count.Visible=Model.Count>1;_dotStyle.BgColor=InventoryItemUi.AccentColor(item.ItemType);var tagKey=InventoryItemUi.TagKey(item);_tag.Visible=tagKey!=null;if(tagKey!=null)_tagLabel.Text=InventoryItemUi.Translate(tagKey);var dimmed= !InventoryItemUi.IsUsable(item);_icon.Modulate=dimmed?IconDimmed:Colors.White;_count.Modulate=dimmed?IconDimmed:Colors.White;}/// <summary>Switches the frame to the gold selected state; the panel owns this flag.</summary>
public void SetSelected(bool selected){_selected=selected;ApplyFrame();}/// <summary>Removes the cell from the scene tree; the grid rebuilds cells instead of recycling them.</summary>
public override void Destruct(){QueueFree();}private void EnsureBuilt(){if(_built)return;_built=true;_frame=new Panel();_frame.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);_frame.AddThemeStyleboxOverride("panel",NormalFrame);_frame.MouseFilter=MouseFilterEnum.Ignore;AddChild(_frame);_dot=new Panel();_dot.SetAnchorsAndOffsetsPreset(LayoutPreset.TopLeft);_dot.OffsetLeft=6;_dot.OffsetTop=6;_dot.OffsetRight=13;_dot.OffsetBottom=13;_dot.MouseFilter=MouseFilterEnum.Ignore;_dotStyle=new StyleBoxFlat();_dotStyle.CornerRadiusTopLeft=2;_dotStyle.CornerRadiusTopRight=2;_dotStyle.CornerRadiusBottomLeft=2;_dotStyle.CornerRadiusBottomRight=2;_dot.AddThemeStyleboxOverride("panel",_dotStyle);AddChild(_dot);_icon=new TextureRect();_icon.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);_icon.OffsetLeft=12;_icon.OffsetTop=12;_icon.OffsetRight= -12;_icon.OffsetBottom= -12;_icon.ExpandMode=TextureRect.ExpandModeEnum.IgnoreSize;_icon.StretchMode=TextureRect.StretchModeEnum.KeepAspectCentered;_icon.MouseFilter=MouseFilterEnum.Ignore;AddChild(_icon);_count=new Label();_count.SetAnchorsAndOffsetsPreset(LayoutPreset.BottomRight);_count.OffsetLeft= -70;_count.OffsetTop= -26;_count.OffsetRight= -6;_count.OffsetBottom= -3;_count.HorizontalAlignment=HorizontalAlignment.Right;_count.VerticalAlignment=VerticalAlignment.Center;_count.AddThemeFontSizeOverride("font_size",13);_count.AddThemeColorOverride("font_color",UiTokens.Stack);_count.AddThemeColorOverride("font_outline_color",new Color(0f,0f,0f,0.9f));_count.AddThemeConstantOverride("outline_size",4);_count.MouseFilter=MouseFilterEnum.Ignore;AddChild(_count);var tagRow=new HBoxContainer();tagRow.SetAnchorsAndOffsetsPreset(LayoutPreset.TopWide);tagRow.OffsetLeft=5;tagRow.OffsetTop=4;tagRow.OffsetRight= -5;tagRow.OffsetBottom=24;tagRow.Alignment=BoxContainer.AlignmentMode.End;tagRow.MouseFilter=MouseFilterEnum.Ignore;AddChild(tagRow);_tag=new PanelContainer();_tag.AddThemeStyleboxOverride("panel",TagStyle);_tag.MouseFilter=MouseFilterEnum.Ignore;tagRow.AddChild(_tag);_tagLabel=new Label();_tagLabel.AddThemeFontSizeOverride("font_size",9);_tagLabel.AddThemeColorOverride("font_color",UiTokens.AccentBright);_tagLabel.MouseFilter=MouseFilterEnum.Ignore;_tag.AddChild(_tagLabel);}private void OnGuiInput(InputEvent@event){if(@event is InputEventMouseButton mb&&mb.Pressed&&mb.ButtonIndex==MouseButton.Left){Clicked?.Invoke(this);AcceptEvent();}}private void OnMouseEntered(){_hovered=true;ApplyFrame();}private void OnMouseExited(){_hovered=false;ApplyFrame();}private void ApplyFrame(){if(!_built||_frame==null)return;_frame.AddThemeStyleboxOverride("panel",_selected?SelectedFrame:(_hovered?HoverFrame:NormalFrame));}private static StyleBoxFlat MakeFrame(Color border,Color background){var style=new StyleBoxFlat();style.BgColor=background;style.BorderColor=border;style.BorderWidthLeft=1;style.BorderWidthRight=1;style.BorderWidthTop=1;style.BorderWidthBottom=1;style.CornerRadiusTopLeft=6;style.CornerRadiusTopRight=6;style.CornerRadiusBottomLeft=6;style.CornerRadiusBottomRight=6;return style;}private static StyleBoxFlat MakeTagStyle(){var style=new StyleBoxFlat();style.BgColor=UiTokens.SlotTagBg;style.BorderColor=UiTokens.AccentLine;style.BorderWidthLeft=1;style.BorderWidthRight=1;style.BorderWidthTop=1;style.BorderWidthBottom=1;style.CornerRadiusTopLeft=3;style.CornerRadiusTopRight=3;style.CornerRadiusBottomLeft=3;style.CornerRadiusBottomRight=3;style.ContentMarginLeft=5;style.ContentMarginRight=5;style.ContentMarginTop=1;style.ContentMarginBottom=1;return style;}}}

===== FILE mods/items_base/src/ui/InventoryItemUi.cs =====
using System.Collections.Generic;using Cthangover.Core.Items;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Core.UI.Inventory{/// <summary> Shared presentation rules for inventory items, used by both the grid cell and the item passport so the two can never disagree about an item's affordances. <see cref="ItemType"/> is a flags enum, so every question ("can this be used?", "what badge colour?") has to pick a priority among possibly overlapping flags — the order in <see cref="BadgeOrder"/> defines that priority consistently. A custom verb can be supplied by mods through the item's <c>Properties</c> bag under the <c>verb</c> key; it is treated as a locale key when it contains a slash and as a literal otherwise. </summary>
public static class InventoryItemUi{/// <summary>Badge order from most narrative to most technical flag.</summary>
public static readonly IReadOnlyList<ItemType>BadgeOrder=new[]{ItemType.Quest,ItemType.Used,ItemType.TargetUsed,ItemType.Food,ItemType.Resource,ItemType.Recipe,ItemType.CantDrop,};/// <summary> Mirrors <c>InventoryPanelBehaviour</c> use-button rule: an item is usable only when it carries an action and one of the "used" flags. Food or resources without an <see cref="IItemAction"/> are inert. </summary>
public static bool IsUsable(IItem item){if(item==null||item.ItemAction==null)return false;return item.ItemType.HasFlag(ItemType.Used)||item.ItemType.HasFlag(ItemType.TargetUsed);}/// <summary>False only for <see cref="ItemType.CantDrop"/> items (quest objectives, key items).</summary>
public static bool CanDrop(IItem item){return item!=null&& !item.ItemType.HasFlag(ItemType.CantDrop);}/// <summary> Locale key (or literal) of the verb shown on the action button. Resolution order: mod-provided <c>verb</c> property, recipe, targeted, food, generic use. </summary>
public static string VerbKey(IItem item){if(item==null)return"ui/inventory/verb_use";var custom=item.Properties?.GetStr("verb");if(!string.IsNullOrWhiteSpace(custom))return custom;if(item.ItemType.HasFlag(ItemType.Recipe))return"ui/inventory/verb_recipe";if(item.ItemType.HasFlag(ItemType.TargetUsed))return"ui/inventory/verb_target";if(item.ItemType.HasFlag(ItemType.Food))return"ui/inventory/verb_eat";return"ui/inventory/verb_use";}/// <summary> Short corner tag for the grid cell. Only actionable items get a tag — an inert resource must not advertise a verb it cannot run. </summary>
public static string TagKey(IItem item){if(item?.ItemAction==null)return null;if(item.ItemType.HasFlag(ItemType.Recipe))return"ui/inventory/tag_recipe";if(item.ItemType.HasFlag(ItemType.TargetUsed))return"ui/inventory/tag_target";if(item.ItemType.HasFlag(ItemType.Food))return"ui/inventory/tag_food";return"ui/inventory/tag_use";}/// <summary> Accent colour of an item: quest gold dominates, then recipe, food, targeted, used and finally plain resources. </summary>
public static Color AccentColor(ItemType type){if(type.HasFlag(ItemType.Quest))return UiTokens.TypeQuest;if(type.HasFlag(ItemType.Recipe))return UiTokens.TypeRecipe;if(type.HasFlag(ItemType.Food))return UiTokens.TypeFood;if(type.HasFlag(ItemType.TargetUsed))return UiTokens.TypeTarget;if(type.HasFlag(ItemType.Used))return UiTokens.TypeUsed;if(type.HasFlag(ItemType.Resource))return UiTokens.TypeResource;return UiTokens.Muted;}/// <summary>Locale key of the badge caption for a single type flag.</summary>
public static string TypeBadgeKey(ItemType type){if(type==ItemType.Quest)return"ui/inventory/type_quest";if(type==ItemType.Used)return"ui/inventory/type_used";if(type==ItemType.TargetUsed)return"ui/inventory/type_target";if(type==ItemType.Food)return"ui/inventory/type_food";if(type==ItemType.Resource)return"ui/inventory/type_resource";if(type==ItemType.Recipe)return"ui/inventory/type_recipe";if(type==ItemType.CantDrop)return"ui/inventory/type_cant_drop";return null;}/// <summary>Translates a locale key, falling back to the key itself for unknown keys and literals.</summary>
public static string Translate(string keyOrLiteral){if(string.IsNullOrEmpty(keyOrLiteral))return string.Empty;return TranslationServer.Translate(keyOrLiteral).ToString();}}}

===== FILE mods/items_base/src/ui/InventoryPanelBehaviour.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Audio;using Cthangover.Core.Characters;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Items;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.Core.UI.Inventory{/// <summary> Full-screen inventory panel following mockup 1: a header with type tabs and battle action-point pips, a search/sort toolbar, a six-column item grid on the left and an "item passport" on the right. The passport shows the translated name, type badges, description, a stats table, the mod-defined <c>PropertyData</c> bag and the contextual actions for the selected item (use/apply/learn/drop) with a quantity slider. Affordances are centralised in <see cref="InventoryItemUi"/> so the grid cell, the passport buttons and the target list never disagree. Actions that fail (<c>UseAction</c> returning <c>false</c>) do not consume the item and surface the reason in the passport message area instead. During battle every use/apply/drop costs one action point from the active character; outside battle actions are free. The grid rebuilds on every <see cref="IInventory.Change"/>, preserving the selected item by ID when it still exists. </summary>
public partial class InventoryPanelBehaviour:Widget{private enum BagFilter{All,Food,Resource,Recipe,Quest,}private enum SortMode{Type,Name,Cost,Count,}private enum MessageKind{None,Ok,Error,}private const int Columns=6;private const float CellGap=10f;private const float PassportWidth=430f;private static readonly(BagFilter Filter,string Key)[]FilterTabs={(BagFilter.All,"ui/inventory/tab_all"),(BagFilter.Food,"ui/inventory/tab_food"),(BagFilter.Resource,"ui/inventory/tab_resource"),(BagFilter.Recipe,"ui/inventory/tab_recipe"),(BagFilter.Quest,"ui/inventory/tab_quest"),};private static readonly Color PanelColor=UiTokens.Surface0;private static readonly Color CellColor=UiTokens.Surface1;private static readonly Color StatColor=UiTokens.Surface2;private static readonly Color LineColor=UiTokens.Line;private static readonly Color CellLineColor=UiTokens.LineSoft;private static readonly Color InkColor=UiTokens.Ink;private static readonly Color InkBrightColor=UiTokens.InkBright;private static readonly Color BodyColor=UiTokens.Body;private static readonly Color MutedColor=UiTokens.Muted;private static readonly Color FaintColor=UiTokens.Faint;private static readonly Color GoldColor=UiTokens.Accent;private static readonly Color GoldBrightColor=UiTokens.AccentBright;private static readonly Color DangerColor=UiTokens.Danger;private static readonly Color OkColor=UiTokens.Ok;private static readonly Color ErrorColor=UiTokens.DangerText;private BagFilter _filter=BagFilter.All;private SortMode _sort=SortMode.Type;private string _search=string.Empty;private IItemContainer _selectedContainer;private InventoryItemBehaviour _selectedCell;private int _dropQuantity=1;private bool _dropOpen;private bool _targetOpen;private MessageKind _messageKind=MessageKind.None;private string _messageText;private readonly List<InventoryItemBehaviour>_itemCells=new();private Control _gridContent;private HBoxContainer _tabsRow;private LineEdit _searchEdit;private OptionButton _sortOption;private HBoxContainer _apBox;private HBoxContainer _apPips;private Label _shownLabel;private VBoxContainer _passport;private Label _dropQuantityLabel;private System.Action<string,int>_onInventoryChange;// Scene nodes (inventory_panel.tscn): static layout lives in the scene, the behaviour resolves them once and owns only the dynamic layer.
private Label _title;private Button _closeButton;private Label _hintLabel;private Label _apLabel;/// <summary>Subscribes to inventory changes so the grid refreshes live while the panel is open.</summary>
public override void _Ready(){base._Ready();_onInventoryChange=(_,_)=>Refresh();GameData.Instance.Runtime.Inventory.Change+=_onInventoryChange;MouseFilter=MouseFilterEnum.Stop;}/// <summary>Unsubscribes from inventory change events so a closed panel stops refreshing.</summary>
public override void _ExitTree(){base._ExitTree();GameData.Instance.Runtime.Inventory.Change-=_onInventoryChange;}protected override void OnceConstruct(){MouseFilter=MouseFilterEnum.Stop;// Scene nodes (inventory_panel.tscn); static layout lives in the scene.
_title=GetNode<Label>("Panel/Margin/Root/Header/Title");_tabsRow=GetNode<HBoxContainer>("Panel/Margin/Root/Header/Tabs");_apBox=GetNode<HBoxContainer>("Panel/Margin/Root/Header/ApBox");_apLabel=GetNode<Label>("Panel/Margin/Root/Header/ApBox/ApLabel");_apPips=GetNode<HBoxContainer>("Panel/Margin/Root/Header/ApBox/ApPips");_closeButton=GetNode<Button>("Panel/Margin/Root/Header/CloseButton");_searchEdit=GetNode<LineEdit>("Panel/Margin/Root/Toolbar/Search");_sortOption=GetNode<OptionButton>("Panel/Margin/Root/Toolbar/SortOption");_gridContent=GetNode<Control>("Panel/Margin/Root/Body/Left/GridScroll/GridContent");_shownLabel=GetNode<Label>("Panel/Margin/Root/Body/Left/GridFooter/ShownLabel");_hintLabel=GetNode<Label>("Panel/Margin/Root/Body/Left/GridFooter/HintLabel");_passport=GetNode<VBoxContainer>("Panel/Margin/Root/Body/Right/PassportScroll/Passport");ApplyTheme();ApplyCaptions();_searchEdit.TextChanged+=OnSearchChanged;_sortOption.ItemSelected+=OnSortSelected;_closeButton.Pressed+=OnClosePressed;}/// <summary>Theme overrides for scene nodes that depend on UiKit tokens (styles stay code-side like the rest of the toolkit).</summary>
private void ApplyTheme(){var panel=GetNode<Panel>("Panel");panel.AddThemeStyleboxOverride("panel",MakePanelStyle(PanelColor,LineColor,8,0f,0f,true));_title.AddThemeColorOverride("font_color",InkBrightColor);_apLabel.AddThemeColorOverride("font_color",MutedColor);_shownLabel.AddThemeColorOverride("font_color",FaintColor);_hintLabel.AddThemeColorOverride("font_color",FaintColor);_searchEdit.PlaceholderText=InventoryItemUi.Translate("ui/inventory/search");_searchEdit.AddThemeFontSizeOverride("font_size",13);_searchEdit.AddThemeStyleboxOverride("normal",MakeInputStyle(false));_searchEdit.AddThemeStyleboxOverride("focus",MakeInputStyle(true));_searchEdit.AddThemeColorOverride("font_color",InkColor);_searchEdit.AddThemeColorOverride("font_placeholder_color",FaintColor);_searchEdit.AddThemeColorOverride("caret_color",GoldColor);_sortOption.AddThemeFontSizeOverride("font_size",13);_sortOption.AddThemeStyleboxOverride("normal",MakeInputStyle(false));_sortOption.AddThemeStyleboxOverride("hover",MakeInputStyle(true));_sortOption.AddThemeStyleboxOverride("pressed",MakeInputStyle(true));_sortOption.AddThemeStyleboxOverride("focus",new StyleBoxEmpty());_sortOption.AddThemeColorOverride("font_color",BodyColor);_sortOption.AddThemeColorOverride("font_hover_color",InkBrightColor);var popup=_sortOption.GetPopup();popup.AddThemeStyleboxOverride("panel",MakePanelStyle(StatColor,LineColor,6,4f,4f,true));popup.AddThemeStyleboxOverride("hover",MakeHoverRowStyle());popup.AddThemeColorOverride("font_color",BodyColor);popup.AddThemeColorOverride("font_hover_color",GoldBrightColor);popup.AddThemeConstantOverride("v_separation",8);var right=GetNode<PanelContainer>("Panel/Margin/Root/Body/Right");right.AddThemeStyleboxOverride("panel",MakeRightColumnStyle());var backdrop=GetNode<ColorRect>("Backdrop");UiKit.ApplyPanelBackdrop(backdrop);CthangoverTheme.ApplyIcon(_closeButton);}/// <summary>Localized captions for all static labels of the scene.</summary>
private void ApplyCaptions(){_title.Text=InventoryItemUi.Translate("ui/inventory/title").ToUpperInvariant();_apLabel.Text=InventoryItemUi.Translate("ui/inventory/ap").ToUpperInvariant();_hintLabel.Text=InventoryItemUi.Translate("ui/inventory/hint_select");_sortOption.AddItem(InventoryItemUi.Translate("ui/inventory/sort_type"),(int)SortMode.Type);_sortOption.AddItem(InventoryItemUi.Translate("ui/inventory/sort_name"),(int)SortMode.Name);_sortOption.AddItem(InventoryItemUi.Translate("ui/inventory/sort_cost"),(int)SortMode.Cost);_sortOption.AddItem(InventoryItemUi.Translate("ui/inventory/sort_count"),(int)SortMode.Count);_sortOption.Selected=0;}protected override void ShowConstruct(){_filter=BagFilter.All;_sort=SortMode.Type;_search=string.Empty;_messageKind=MessageKind.None;_messageText=null;if(_searchEdit!=null)_searchEdit.Text=string.Empty;if(_sortOption!=null)_sortOption.Selected=0;ClearGrid();ClearSelection();RebuildTabs();RebuildGrid();UpdateActiveActorState();SelectFirst();}protected override void HideDestruct(){ClearGrid();ClearSelection();ClearChildren(_passport);}/// <summary> Rebuilds the grid and passport from the current inventory state, preserving the selected item by ID when it is still present. Called automatically by <see cref="IInventory.Change"/>. </summary>
public void Refresh(){if(!Visible||_gridContent==null)return;var previousId=_selectedContainer?.Item?.ID;ClearGrid();ClearSelection();RebuildGrid();if(previousId!=null){var container=GameData.Instance.Runtime.Inventory.TryGet(previousId);if(container!=null){SelectItem(container);return;}}SelectFirst();}private void RebuildTabs(){ClearChildren(_tabsRow);var inventory=GameData.Instance.Runtime.Inventory;var total=inventory.Items.Count;foreach(var tab in FilterTabs){var count=tab.Filter==BagFilter.All?total:CountByFilter(tab.Filter);var button=new Button();button.Text=InventoryItemUi.Translate(tab.Key)+" "+count;button.FocusMode=FocusModeEnum.None;StyleTabButton(button,_filter==tab.Filter);var filter=tab.Filter;button.Pressed+=()=>OnTabPressed(filter);_tabsRow.AddChild(button);}}private int CountByFilter(BagFilter filter){var flag=FilterFlag(filter);var count=0;foreach(var container in GameData.Instance.Runtime.Inventory.Items){if(container?.Item!=null&&container.Item.ItemType.HasFlag(flag))count++;}return count;}private void RebuildGrid(){ClearGrid();if(_gridContent==null)return;var inventory=GameData.Instance.Runtime.Inventory;var containers=GetFilteredContainers();var cellSize=CalculateCellSize();for(var i=0;i<containers.Count;i++){var container=containers[i];var cell=new InventoryItemBehaviour();cell.Construct(container);cell.Clicked+=OnCellClicked;_gridContent.AddChild(cell);PlaceCell(cell,i,cellSize);_itemCells.Add(cell);}if(_itemCells.Count>0){var last=_itemCells[_itemCells.Count-1];var height=last.Position.Y+last.Size.Y+CellGap;_gridContent.CustomMinimumSize=new Vector2(0,height);}else{_gridContent.CustomMinimumSize=Vector2.Zero;}if(_shownLabel!=null){_shownLabel.Text=string.Format(InventoryItemUi.Translate("ui/inventory/shown"),containers.Count,inventory.Items.Count);}}private List<IItemContainer>GetFilteredContainers(){var result=new List<IItemContainer>();foreach(var container in GameData.Instance.Runtime.Inventory.Items){if(container?.Item==null)continue;if(!MatchesFilter(container.Item)|| !MatchesSearch(container.Item))continue;result.Add(container);}SortContainers(result);return result;}private bool MatchesFilter(IItem item){return _filter==BagFilter.All||item.ItemType.HasFlag(FilterFlag(_filter));}private bool MatchesSearch(IItem item){if(string.IsNullOrWhiteSpace(_search))return true;var needle=_search.Trim().ToLowerInvariant();if(InventoryItemUi.Translate(item.Name).ToLowerInvariant().Contains(needle))return true;return item.ID!=null&&item.ID.ToLowerInvariant().Contains(needle);}private void SortContainers(List<IItemContainer>containers){switch(_sort){case SortMode.Name:containers.Sort((a,b)=>string.Compare(TranslateName(a),TranslateName(b),StringComparison.OrdinalIgnoreCase));break;case SortMode.Cost:containers.Sort((a,b)=>b.Item.Cost.CompareTo(a.Item.Cost));break;case SortMode.Count:containers.Sort((a,b)=>b.Count.CompareTo(a.Count));break;default:containers.Sort(CompareByType);break;}}private static int CompareByType(IItemContainer a,IItemContainer b){var rankA=TypeRank(a.Item.ItemType);var rankB=TypeRank(b.Item.ItemType);if(rankA!=rankB)return rankA-rankB;return string.Compare(TranslateName(a),TranslateName(b),StringComparison.OrdinalIgnoreCase);}private static int TypeRank(ItemType type){if(type.HasFlag(ItemType.Quest))return 0;if(type.HasFlag(ItemType.Recipe))return 1;if(type.HasFlag(ItemType.Food))return 2;return 3;}private static string TranslateName(IItemContainer container){return InventoryItemUi.Translate(container.Item.Name);}private static ItemType FilterFlag(BagFilter filter){switch(filter){case BagFilter.Food:return ItemType.Food;case BagFilter.Resource:return ItemType.Resource;case BagFilter.Recipe:return ItemType.Recipe;case BagFilter.Quest:return ItemType.Quest;default:return ItemType.None;}}private float CalculateCellSize(){var viewportWidth=GetViewportRect().Size.X;if(viewportWidth<=0)viewportWidth=1920f;var panelWidth=viewportWidth-80f-48f;var leftWidth=Mathf.Max(320f,panelWidth-PassportWidth-14f);var cellSize=(leftWidth-(Columns-1)*CellGap)/Columns;return Mathf.Clamp(cellSize,72f,220f);}private void PlaceCell(InventoryItemBehaviour cell,int index,float cellSize){var column=index%Columns;var row=index/Columns;cell.Position=new Vector2(column*(cellSize+CellGap),row*(cellSize+CellGap));cell.Size=new Vector2(cellSize,cellSize);}private void SelectItem(IItemContainer container){if(_selectedCell!=null){_selectedCell.SetSelected(false);_selectedCell=null;}_selectedContainer=container;_dropOpen=false;_dropQuantity=1;_targetOpen=false;if(container!=null){foreach(var cell in _itemCells){if(ReferenceEquals(cell.Model,container)){_selectedCell=cell;cell.SetSelected(true);break;}}}BuildPassport();}private void SelectFirst(){if(_itemCells.Count>0)SelectItem(_itemCells[0].Model);else SelectItem(null);}private void ClearGrid(){foreach(var cell in _itemCells)cell.QueueFree();_itemCells.Clear();_selectedCell=null;}private void ClearSelection(){if(_selectedCell!=null){_selectedCell.SetSelected(false);_selectedCell=null;}_selectedContainer=null;_dropOpen=false;_dropQuantity=1;_targetOpen=false;}private void BuildPassport(){ClearChildren(_passport);var container=_selectedContainer;if(container?.Item==null){var hasAny=GameData.Instance.Runtime.Inventory.Items.Count>0;var placeholder=MakeLabel(InventoryItemUi.Translate(hasAny?"ui/inventory/select":"ui/inventory/empty"),14,FaintColor);placeholder.AutowrapMode=TextServer.AutowrapMode.WordSmart;placeholder.HorizontalAlignment=HorizontalAlignment.Center;_passport.AddChild(placeholder);return;}var item=container.Item;_passport.AddChild(BuildPassportHeader(container));var description=MakeLabel(InventoryItemUi.Translate(item.Description),14,BodyColor);description.AutowrapMode=TextServer.AutowrapMode.WordSmart;_passport.AddChild(description);_passport.AddChild(BuildStatsBlock(container));_passport.AddChild(BuildPropertiesBlock(item));_passport.AddChild(BuildActionsBlock(container));}private Control BuildPassportHeader(IItemContainer container){var item=container.Item;var row=new HBoxContainer();row.AddThemeConstantOverride("separation",16);var iconPanel=new PanelContainer();iconPanel.CustomMinimumSize=new Vector2(96,96);iconPanel.AddThemeStyleboxOverride("panel",MakePanelStyle(CellColor,CellLineColor,8));var icon=new TextureRect();icon.Texture=item.Sprite;icon.ExpandMode=TextureRect.ExpandModeEnum.IgnoreSize;icon.StretchMode=TextureRect.StretchModeEnum.KeepAspectCentered;icon.CustomMinimumSize=new Vector2(80,80);icon.MouseFilter=MouseFilterEnum.Ignore;iconPanel.AddChild(icon);row.AddChild(iconPanel);var titleColumn=new VBoxContainer();titleColumn.SizeFlagsHorizontal=SizeFlags.ExpandFill;titleColumn.AddThemeConstantOverride("separation",8);var name=MakeLabel(InventoryItemUi.Translate(item.Name),25,InkBrightColor);name.AutowrapMode=TextServer.AutowrapMode.WordSmart;titleColumn.AddChild(name);var badges=new HFlowContainer();badges.AddThemeConstantOverride("h_separation",5);badges.AddThemeConstantOverride("v_separation",5);foreach(var type in InventoryItemUi.BadgeOrder){if(item.ItemType.HasFlag(type))badges.AddChild(MakeBadge(type));}titleColumn.AddChild(badges);row.AddChild(titleColumn);var costColumn=new VBoxContainer();costColumn.SizeFlagsVertical=SizeFlags.ShrinkCenter;var cost=MakeLabel(item.Cost.ToString(),22,GoldBrightColor);cost.HorizontalAlignment=HorizontalAlignment.Right;var costLabel=MakeLabel(InventoryItemUi.Translate("ui/inventory/cost").ToUpperInvariant(),10,FaintColor);costLabel.HorizontalAlignment=HorizontalAlignment.Right;costColumn.AddChild(cost);costColumn.AddChild(costLabel);row.AddChild(costColumn);return row;}private static Control MakeBadge(ItemType type){return UiKit.Badge(InventoryItemUi.Translate(InventoryItemUi.TypeBadgeKey(type)),InventoryItemUi.AccentColor(type));}private static Control BuildStatsBlock(IItemContainer container){var item=container.Item;var wrapper=new PanelContainer();wrapper.AddThemeStyleboxOverride("panel",MakePanelStyle(LineColor,LineColor,6));var grid=new GridContainer();grid.Columns=2;grid.AddThemeConstantOverride("h_separation",1);grid.AddThemeConstantOverride("v_separation",1);grid.SizeFlagsHorizontal=SizeFlags.ExpandFill;wrapper.AddChild(grid);grid.AddChild(MakeStatCell(InventoryItemUi.Translate("ui/inventory/stack"),"×"+container.Count));grid.AddChild(MakeStatCell(InventoryItemUi.Translate("ui/inventory/id"),item.ID));grid.AddChild(MakeStatCell(InventoryItemUi.Translate("ui/inventory/action"),item.ItemAction?.ID??"—"));grid.AddChild(MakeStatCell(InventoryItemUi.Translate("ui/inventory/verb"),InventoryItemUi.Translate(InventoryItemUi.VerbKey(item))));return wrapper;}private static Control MakeStatCell(string key,string value){var cell=new PanelContainer();cell.SizeFlagsHorizontal=SizeFlags.ExpandFill;cell.AddThemeStyleboxOverride("panel",MakePanelStyle(StatColor,StatColor,0,12f,8f));var column=new VBoxContainer();column.AddThemeConstantOverride("separation",3);column.AddChild(MakeLabel(key.ToUpperInvariant(),10,FaintColor));var valueLabel=MakeLabel(value,13,InkColor);valueLabel.AutowrapMode=TextServer.AutowrapMode.WordSmart;column.AddChild(valueLabel);cell.AddChild(column);return cell;}private static Control BuildPropertiesBlock(IItem item){var block=new VBoxContainer();block.AddThemeConstantOverride("separation",7);block.AddChild(MakeLabel(InventoryItemUi.Translate("ui/inventory/props_header").ToUpperInvariant(),10,FaintColor));var values=item.Properties?.Values;if(values==null||values.Count==0){block.AddChild(MakePropertyRow(InventoryItemUi.Translate("ui/inventory/props_none"),string.Empty));return block;}var keys=new List<string>(values.Keys);keys.Sort(StringComparer.Ordinal);foreach(var key in keys)block.AddChild(MakePropertyRow(key,values[key]));return block;}private static Control MakePropertyRow(string key,string value){var row=new PanelContainer();row.AddThemeStyleboxOverride("panel",MakePanelStyle(StatColor,StatColor,0,12f,7f));var box=new HBoxContainer();box.AddThemeConstantOverride("separation",12);var keyLabel=MakeLabel(key,12,MutedColor);keyLabel.SizeFlagsHorizontal=SizeFlags.ExpandFill;box.AddChild(keyLabel);var valueLabel=MakeLabel(value,12,GoldBrightColor);box.AddChild(valueLabel);row.AddChild(box);return row;}private Control BuildActionsBlock(IItemContainer container){var item=container.Item;var block=new VBoxContainer();block.AddThemeConstantOverride("separation",9);var usable=InventoryItemUi.IsUsable(item);var verb=InventoryItemUi.Translate(InventoryItemUi.VerbKey(item));var useButton=new Button();useButton.Text=HasActiveActor?verb+"  "+InventoryItemUi.Translate("ui/inventory/ap_cost"):verb;useButton.Disabled= !usable;useButton.FocusMode=FocusModeEnum.None;StyleButton(useButton,true,false);useButton.Pressed+=OnUseClicked;block.AddChild(useButton);if(!usable){var hint=MakeLabel(InventoryItemUi.Translate("ui/inventory/no_action"),12,FaintColor);hint.AutowrapMode=TextServer.AutowrapMode.WordSmart;block.AddChild(hint);}var canDrop=InventoryItemUi.CanDrop(item);var dropButton=new Button();dropButton.Text=InventoryItemUi.Translate(canDrop?"ui/inventory/drop":"ui/inventory/drop_cant");dropButton.Disabled= !canDrop;dropButton.FocusMode=FocusModeEnum.None;StyleButton(dropButton,false,true);dropButton.Pressed+=OnDropClicked;block.AddChild(dropButton);if(_dropOpen)block.AddChild(BuildDropQuantity(container));if(_targetOpen&&item.ItemType.HasFlag(ItemType.TargetUsed))block.AddChild(BuildTargetPanel());if(_messageKind!=MessageKind.None)block.AddChild(BuildMessage());return block;}private Control BuildDropQuantity(IItemContainer container){var wrapper=new PanelContainer();wrapper.AddThemeStyleboxOverride("panel",MakePanelStyle(StatColor,LineColor,6,12f,12f));var column=new VBoxContainer();column.AddThemeConstantOverride("separation",8);var head=new HBoxContainer();var title=MakeLabel(InventoryItemUi.Translate("ui/inventory/drop_quantity"),12,MutedColor);title.SizeFlagsHorizontal=SizeFlags.ExpandFill;head.AddChild(title);_dropQuantityLabel=MakeLabel(_dropQuantity+" / "+container.Count,12,GoldBrightColor);head.AddChild(_dropQuantityLabel);column.AddChild(head);var slider=new HSlider();slider.MinValue=1;slider.MaxValue=Mathf.Max(1,container.Count);slider.Step=1;slider.Value=Mathf.Clamp(_dropQuantity,1,Mathf.Max(1,container.Count));slider.SizeFlagsHorizontal=SizeFlags.ExpandFill;slider.AddThemeStyleboxOverride("slider",MakeSliderTrack(UiTokens.LineDisabled));slider.AddThemeStyleboxOverride("grabber_area",MakeSliderTrack(GoldColor));slider.AddThemeStyleboxOverride("grabber_area_highlight",MakeSliderTrack(GoldBrightColor));slider.ValueChanged+=OnDropSliderChanged;column.AddChild(slider);var row=new HBoxContainer();row.AddThemeConstantOverride("separation",6);row.AddChild(MakeSmallButton("−",OnDropMinus));row.AddChild(MakeSmallButton("+",OnDropPlus));row.AddChild(MakeSmallButton(InventoryItemUi.Translate("ui/inventory/drop_all"),OnDropMax));var confirm=new Button();confirm.Text=InventoryItemUi.Translate("ui/inventory/drop_confirm");confirm.FocusMode=FocusModeEnum.None;confirm.SizeFlagsHorizontal=SizeFlags.ExpandFill;StyleButton(confirm,false,true);confirm.Pressed+=OnDropConfirm;row.AddChild(confirm);column.AddChild(row);wrapper.AddChild(column);return wrapper;}private Control BuildTargetPanel(){var targetPanel=new CharacterTargetPanel();targetPanel.CharacterSelected+=OnTargetSelected;targetPanel.Cancelled+=OnTargetCancelled;return targetPanel;}private Control BuildMessage(){var accent=_messageKind==MessageKind.Ok?OkColor:ErrorColor;var wrapper=new PanelContainer();wrapper.AddThemeStyleboxOverride("panel",MakePanelStyle(UiTokens.Alpha(accent,0.1f),UiTokens.Alpha(accent,0.5f),5,12f,9f));var label=MakeLabel(_messageText,12,accent);label.AutowrapMode=TextServer.AutowrapMode.WordSmart;wrapper.AddChild(label);return wrapper;}private Button MakeSmallButton(string text,Action handler){var button=new Button();button.Text=text;button.FocusMode=FocusModeEnum.None;button.SizeFlagsHorizontal=SizeFlags.ExpandFill;StyleButton(button,false,false);button.AddThemeFontSizeOverride("font_size",13);button.Pressed+=handler;return button;}private bool HasActiveActor=>ActiveCharacterRegistry.ActiveCharacter!=null;private void UpdateActiveActorState(){if(_apBox==null)return;_apBox.Visible=HasActiveActor;if(!HasActiveActor||_apPips==null)return;ClearChildren(_apPips);var point=ActiveCharacterRegistry.ActiveCharacter.Attributes.Point;var total=point.BaseValue>0?point.BaseValue:point.Value;total=Mathf.Clamp(total,1,6);for(var i=0;i<total;i++){var pip=new Panel();pip.CustomMinimumSize=new Vector2(11,11);var style=new StyleBoxFlat();style.CornerRadiusTopLeft=6;style.CornerRadiusTopRight=6;style.CornerRadiusBottomLeft=6;style.CornerRadiusBottomRight=6;if(i<point.Value){style.BgColor=GoldBrightColor;style.BorderColor=GoldColor;}else{style.BgColor=UiTokens.LineDisabled;style.BorderColor=UiTokens.LineFaint;}style.BorderWidthLeft=1;style.BorderWidthRight=1;style.BorderWidthTop=1;style.BorderWidthBottom=1;pip.AddThemeStyleboxOverride("panel",style);pip.MouseFilter=MouseFilterEnum.Ignore;_apPips.AddChild(pip);}}private bool HasActionPoint(){if(!HasActiveActor)return true;var point=ActiveCharacterRegistry.ActiveCharacter.Attributes.Point;if(point.Value>=1)return true;SetMessage(MessageKind.Error,InventoryItemUi.Translate("ui/inventory/no_ap"));BuildPassport();PlaySound("ui/close_click");return false;}private void SpendActionPoint(){if(!HasActiveActor)return;ActiveCharacterRegistry.ActiveCharacter.Attributes.Point.Value-=1;UpdateActiveActorState();}private void OnUseClicked(){var container=_selectedContainer;var item=container?.Item;if(item?.ItemAction==null|| !InventoryItemUi.IsUsable(item))return;if(item.ItemType.HasFlag(ItemType.TargetUsed)){_targetOpen=true;_dropOpen=false;BuildPassport();return;}if(!HasActionPoint())return;if(!item.ItemAction.UseAction(item)){SetMessage(MessageKind.Error,InventoryItemUi.Translate("ui/inventory/use_failed"));BuildPassport();return;}SpendActionPoint();var messageKey=item.ItemType.HasFlag(ItemType.Recipe)?"ui/inventory/used_recipe":"ui/inventory/used";SetMessage(MessageKind.Ok,string.Format(InventoryItemUi.Translate(messageKey),InventoryItemUi.Translate(item.Name)));GameData.Instance.Runtime.Inventory.Remove(item,1);PlaySound("items/use");}private void OnDropClicked(){if(_selectedContainer?.Item==null)return;_dropQuantity=1;_dropOpen= !_dropOpen;_targetOpen=false;BuildPassport();}private void OnDropSliderChanged(double value){_dropQuantity=Mathf.RoundToInt((float)value);if(_dropQuantityLabel!=null&&_selectedContainer!=null)_dropQuantityLabel.Text=_dropQuantity+" / "+_selectedContainer.Count;}private void OnDropMinus(){if(_selectedContainer==null)return;_dropQuantity=Mathf.Max(_dropQuantity-1,1);RefreshDropQuantity();}private void OnDropPlus(){if(_selectedContainer==null)return;_dropQuantity=Mathf.Min(_dropQuantity+1,_selectedContainer.Count);RefreshDropQuantity();}private void OnDropMax(){if(_selectedContainer==null)return;_dropQuantity=_selectedContainer.Count;RefreshDropQuantity();}private void RefreshDropQuantity(){BuildPassport();}private void OnDropConfirm(){var container=_selectedContainer;var item=container?.Item;if(item==null|| !InventoryItemUi.CanDrop(item))return;if(!HasActionPoint())return;SpendActionPoint();var quantity=Mathf.Clamp(_dropQuantity,1,container.Count);SetMessage(MessageKind.Ok,string.Format(InventoryItemUi.Translate("ui/inventory/dropped"),InventoryItemUi.Translate(item.Name),quantity));GameData.Instance.Runtime.Inventory.Remove(item,quantity);PlaySound("ui/close_click");}private void OnTargetSelected(CharacterInfoData info){var item=_selectedContainer?.Item;if(item?.ItemAction==null)return;if(!HasActionPoint())return;if(!item.ItemAction.UseAction(item)){SetMessage(MessageKind.Error,InventoryItemUi.Translate("ui/inventory/applied_failed"));_targetOpen=false;BuildPassport();return;}SpendActionPoint();var template=info!=null?CharacterTemplateRegistry.Get(info.ID):null;var targetName=InventoryItemUi.Translate(template?.Name??info?.ID??"?");SetMessage(MessageKind.Ok,string.Format(InventoryItemUi.Translate("ui/inventory/applied"),InventoryItemUi.Translate(item.Name),targetName));GameData.Instance.Runtime.Inventory.Remove(item,1);PlaySound("items/target");}private void OnTargetCancelled(){_targetOpen=false;BuildPassport();}private void OnCellClicked(InventoryItemBehaviour cell){_messageKind=MessageKind.None;_messageText=null;SelectItem(cell.Model);PlaySound("ui/skip");}private void OnTabPressed(BagFilter filter){_filter=filter;_messageKind=MessageKind.None;_messageText=null;ClearSelection();RebuildTabs();RebuildGrid();SelectFirst();}private void OnSearchChanged(string text){_search=text;ClearSelection();RebuildGrid();SelectFirst();}private void OnSortSelected(long index){_sort=(SortMode)index;ClearSelection();RebuildGrid();SelectFirst();}private void OnClosePressed(){PlaySound("ui/close_click");Hide();}private void SetMessage(MessageKind kind,string text){_messageKind=kind;_messageText=text;}private void PlaySound(string id){var audio=SceneContextNode.FindNode<Node>("AudioService")as IAudioService;audio?.PlaySound(id,SoundType.UI);}private static Label MakeLabel(string text,int size,Color color){var label=new Label();label.Text=text??string.Empty;label.AddThemeFontSizeOverride("font_size",size);label.AddThemeColorOverride("font_color",color);label.MouseFilter=MouseFilterEnum.Ignore;return label;}private static StyleBoxFlat MakePanelStyle(Color background,Color border,int radius,float paddingX=0f,float paddingY=0f,bool shadow=false){var style=new StyleBoxFlat();style.BgColor=background;style.BorderColor=border;style.BorderWidthLeft=1;style.BorderWidthRight=1;style.BorderWidthTop=1;style.BorderWidthBottom=1;style.CornerRadiusTopLeft=radius;style.CornerRadiusTopRight=radius;style.CornerRadiusBottomLeft=radius;style.CornerRadiusBottomRight=radius;style.ContentMarginLeft=paddingX;style.ContentMarginRight=paddingX;style.ContentMarginTop=paddingY;style.ContentMarginBottom=paddingY;if(shadow){style.ShadowColor=new Color(0f,0f,0f,0.6f);style.ShadowSize=18;style.ShadowOffset=new Vector2(0f,8f);}return style;}private static StyleBoxFlat MakeRightColumnStyle(){var style=new StyleBoxFlat();style.BgColor=UiTokens.SideBg;style.BorderColor=LineColor;style.BorderWidthLeft=1;style.ContentMarginLeft=20;style.ContentMarginRight=18;style.ContentMarginTop=16;style.ContentMarginBottom=16;return style;}private static StyleBoxFlat MakeInputStyle(bool focus){var style=new StyleBoxFlat();style.BgColor=UiTokens.Well;style.BorderColor=focus?UiTokens.AccentRing:LineColor;style.BorderWidthLeft=1;style.BorderWidthRight=1;style.BorderWidthTop=1;style.BorderWidthBottom=1;style.CornerRadiusTopLeft=4;style.CornerRadiusTopRight=4;style.CornerRadiusBottomLeft=4;style.CornerRadiusBottomRight=4;style.ContentMarginLeft=10;style.ContentMarginRight=10;style.ContentMarginTop=8;style.ContentMarginBottom=8;return style;}private static StyleBoxFlat MakeHoverRowStyle(){var style=new StyleBoxFlat();style.BgColor=UiTokens.AccentTrace;style.CornerRadiusTopLeft=4;style.CornerRadiusTopRight=4;style.CornerRadiusBottomLeft=4;style.CornerRadiusBottomRight=4;style.ContentMarginLeft=8;style.ContentMarginRight=8;style.ContentMarginTop=4;style.ContentMarginBottom=4;return style;}private static StyleBoxFlat MakeSliderTrack(Color color){var style=new StyleBoxFlat();style.BgColor=color;style.CornerRadiusTopLeft=3;style.CornerRadiusTopRight=3;style.CornerRadiusBottomLeft=3;style.CornerRadiusBottomRight=3;style.ContentMarginTop=2;style.ContentMarginBottom=2;return style;}private static StyleBoxFlat MakeButtonStyle(bool primary,bool danger,bool hover){var style=new StyleBoxFlat();if(primary)style.BgColor=hover?UiTokens.BtnPrimaryHover:UiTokens.BtnPrimary;else style.BgColor=hover?UiTokens.BtnHover:UiTokens.Surface3;if(danger&&hover)style.BorderColor=DangerColor;else if(primary)style.BorderColor=hover?UiTokens.AccentLineStrong:UiTokens.Alpha(UiTokens.Accent,0.55f);else style.BorderColor=hover?UiTokens.Alpha(UiTokens.Accent,0.55f):LineColor;style.BorderWidthLeft=1;style.BorderWidthRight=1;style.BorderWidthTop=1;style.BorderWidthBottom=1;style.CornerRadiusTopLeft=4;style.CornerRadiusTopRight=4;style.CornerRadiusBottomLeft=4;style.CornerRadiusBottomRight=4;style.ContentMarginLeft=12;style.ContentMarginRight=12;style.ContentMarginTop=11;style.ContentMarginBottom=11;return style;}private static void StyleButton(Button button,bool primary,bool danger){button.AddThemeFontSizeOverride("font_size",14);button.AddThemeStyleboxOverride("normal",MakeButtonStyle(primary,danger,false));button.AddThemeStyleboxOverride("hover",MakeButtonStyle(primary,danger,true));button.AddThemeStyleboxOverride("pressed",MakeButtonStyle(primary,danger,true));var disabled=MakeButtonStyle(primary,danger,false);disabled.BgColor=UiTokens.BtnDisabled;disabled.BorderColor=UiTokens.LineDisabled;button.AddThemeStyleboxOverride("disabled",disabled);button.AddThemeStyleboxOverride("focus",new StyleBoxEmpty());var normalColor=primary?GoldBrightColor:BodyColor;var hoverColor=danger?ErrorColor:(primary?UiTokens.AccentHover:InkBrightColor);button.AddThemeColorOverride("font_color",normalColor);button.AddThemeColorOverride("font_hover_color",hoverColor);button.AddThemeColorOverride("font_pressed_color",hoverColor);button.AddThemeColorOverride("font_disabled_color",FaintColor);}private static void StyleTabButton(Button button,bool active){button.AddThemeFontSizeOverride("font_size",13);button.AddThemeStyleboxOverride("normal",MakeTabStyle(active,false));button.AddThemeStyleboxOverride("hover",MakeTabStyle(active,true));button.AddThemeStyleboxOverride("pressed",MakeTabStyle(active,true));button.AddThemeStyleboxOverride("focus",new StyleBoxEmpty());button.AddThemeColorOverride("font_color",active?GoldBrightColor:MutedColor);button.AddThemeColorOverride("font_hover_color",active?GoldBrightColor:InkColor);button.AddThemeColorOverride("font_pressed_color",active?GoldBrightColor:InkColor);}private static StyleBoxFlat MakeTabStyle(bool active,bool hover){var style=new StyleBoxFlat();if(active)style.BgColor=UiTokens.AccentSoft;else style.BgColor=hover?UiTokens.WhiteTrace:Colors.Transparent;style.BorderColor=active?UiTokens.AccentLine:Colors.Transparent;style.BorderWidthLeft=1;style.BorderWidthRight=1;style.BorderWidthTop=1;style.BorderWidthBottom=1;style.CornerRadiusTopLeft=4;style.CornerRadiusTopRight=4;style.CornerRadiusBottomLeft=4;style.CornerRadiusBottomRight=4;style.ContentMarginLeft=13;style.ContentMarginRight=13;style.ContentMarginTop=7;style.ContentMarginBottom=7;return style;}private static void ClearChildren(Node container){if(container==null)return;foreach(var child in container.GetChildren()){container.RemoveChild(child);child.QueueFree();}}}}

===== FILE mods/items_base/test_suites/inventory/src/InventoryTestDriverNode.cs =====
using System.Collections.Generic;using Cthangover.Core.Bootstrap;using Cthangover.Core.Settings;using Cthangover.Core.UI;using Cthangover.Core.Utils;using Godot;namespace Mods.InventoryTest{/// <summary> Драйвер интеграционного GUI-теста инвентаря. Проходит по реальному UI теми же контрактами, что и игрок: открывает панель через публичный <see cref="UiRegistry.Toggle"/> (тот же путь, что кнопка тулбара), сеет детерминированные предметы через публичный <c>IInventory.Add</c>, кликает ячейку эмуляцией Godot-сигнала <c>gui_input</c> (обработчик ячейки подписан на него — мод не может переопределять виртуальные методы, но сигналы из мод-сборок работают), фильтрует через <c>LineEdit</c>-сигнал <c>text_changed</c> и закрывает панель. Машина состояний: один шаг за тик таймера (0.4с), чтобы отложенные пересборки сетки успевали примениться. Каждый шаг — отдельная проверка: исход фиксируется, провал не прерывает остальные. Итог — контракт раннера <c>[TEST_RESULT] name=inventory_test passed=N failed=M</c> и код выхода процесса. Общий watchdog (<c>--timeout=</c>, по умолчанию 60с) гасит зависание с failed=1 — тем же контрактом, что и у DialogAutoDriverNode. </summary>
public partial class InventoryTestDriverNode:Node{private const string ResultName="inventory_test";private const string PanelEntryId="inventory";private const float TickSeconds=0.4f;private const float DefaultTimeoutSeconds=60f;private readonly float?timeoutSeconds;private readonly List<string>failures=new();private int passed;private int failed;private int step;private double elapsed;private Control panel;private Control gridContent;private LineEdit search;/// <summary>Создаёт драйвер с watchdog-таймаутом (null — дефолт 60с) и ставит тикающий таймер-ребёнок.</summary>
public InventoryTestDriverNode(float?timeoutSeconds){this.timeoutSeconds=timeoutSeconds;var timer=new Timer{Name="TickTimer",WaitTime=TickSeconds,Autostart=true,OneShot=false};AddChild(timer);timer.Timeout+=Tick;}/// <summary>Один шаг машины состояний: ожидание готовности, затем проверки по очереди.</summary>
private void Tick(){if(step<0)return;// тест уже завершён
elapsed+=TickSeconds;var limit=timeoutSeconds??DefaultTimeoutSeconds;if(elapsed>=limit){Finish($"timeout ({limit:0}s) reached at step {step}");return;}try{RunStep();}catch(System.Exception ex){failures.Add($"step {step}: unhandled {ex.GetType().Name}: {ex.Message}");failed++;Log($"[FAIL] step {step}: {ex.Message}");Finish(null);}}private void RunStep(){// Шаг 0: ждём готовности — бут завершён и сцена вошла в дерево.
if(step==0){var ready=Boot.IsReady&&GameData.Instance?.Runtime!=null&&(Godot.Engine.GetMainLoop()as SceneTree)?.CurrentScene!=null;if(!ready)return;// ждём дальше, watchdog страхует
Log("environment ready (boot complete, scene entered)");Next();return;}// Шаг 1: UI-запись инвентаря зарегистрирована модом cthangover_game.
if(step==1){Check("entry registered",()=>{var entry=AppServices.Current.Ui.GetById(PanelEntryId);if(entry==null)throw new CheckException($"ui entry '{PanelEntryId}' not found");});Next();return;}// Шаг 2: сеем детерминированные предметы через публичный API инвентаря (в этой сессии; сохранения тест не пишет).
if(step==2){Check("inventory seed via IInventory.Add",()=>{var inv=GameData.Instance.Runtime.Inventory;if(inv==null)throw new CheckException("Runtime.Inventory is null (items mod factory missing?)");inv.Add("food/ration",3);inv.Add("food/wolf_meat",2);if(inv.CheckCount("food/ration")<3)throw new CheckException("food/ration count < 3 after Add");if(inv.CheckCount("food/wolf_meat")<2)throw new CheckException("food/wolf_meat count < 2 after Add");});Next();return;}// Шаг 3: открываем панель тем же вызовом, что и кнопка тулбара.
if(step==3){Check("open via UiRegistry.Toggle",()=>{UiRegistry.Toggle(PanelEntryId);if(!UiHost.Instance.TryGetInstance(PanelEntryId,out panel)||panel==null)throw new CheckException("panel instance not created by Toggle");if(!panel.Visible)throw new CheckException("panel not visible after Toggle");});Next();return;}// Шаг 4: сетка отрисовала засеянные предметы.
if(step==4){Check("grid renders seeded items",()=>{gridContent=panel.FindChild("GridContent",true,false)as Control;if(gridContent==null)throw new CheckException("GridContent node not found");// Один кадр на пересборку после Add: если ячейки ещё не успели, тик-ждать нельзя (шаг уже выполняется), поэтому сравниваем с запасом на асинхронность через событие Change, которое панель обрабатывает синхронно.
if(gridContent.GetChildCount()<2)throw new CheckException($"grid has {gridContent.GetChildCount()} cell(s), expected >= 2");});Next();return;}// Шаг 5: клик по первой ячейке — эмуляция gui_input, как будто щёлкнул игрок; панель выбирает предмет и наполняет паспорт.
if(step==5){Check("cell click fills passport",()=>{var cell=gridContent.GetChildCount()>0?gridContent.GetChild(0)as Control:null;if(cell==null)throw new CheckException("no cell to click");var click=new InputEventMouseButton{ButtonIndex=MouseButton.Left,Pressed=true};cell.EmitSignal("gui_input",click);var passport=panel.FindChild("Passport",true,false);if(passport==null)throw new CheckException("Passport node not found");var hasText=false;foreach(var child in passport.FindChildren("*","Label",true,false)){if(child is Label l&& !string.IsNullOrWhiteSpace(l.Text)){hasText=true;break;}}if(!hasText)throw new CheckException("passport has no non-empty label after selection");});Next();return;}// Шаг 6: поиск-фильтр через сигнал LineEdit. Старые ячейки сетка удаляет через QueueFree — они исчезают лишь в конце кадра, поэтому в этом же тике проверяем синхронный индикатор ShownLabel ("показано 0"), а количество нод — на следующем тике.
if(step==6){Check("no-match filter zeroes the shown counter",()=>{search=panel.FindChild("Search",true,false)as LineEdit;if(search==null)throw new CheckException("Search LineEdit not found");search.Text="zzz_no_such_item";search.EmitSignal("text_changed",search.Text);var shown=panel.FindChild("ShownLabel",true,false)as Label;var text=shown?.Text??"";if(!text.Contains("0"))throw new CheckException($"ShownLabel '{text}' does not show zero after no-match filter");});Next();return;}// Шаг 7: кадр прошёл — ячейки после no-match фильтра реально удалены из дерева; затем возвращаем фильтр в пустое значение.
if(step==7){Check("grid empties after filter, restores after reset",()=>{var after=gridContent.GetChildCount();if(after!=0)throw new CheckException($"grid has {after} cell(s) after no-match filter, expected 0");search.Text="";search.EmitSignal("text_changed",search.Text);});Next();return;}// Шаг 8: сетка восстановлена; повторный Toggle закрывает панель.
if(step==8){Check("grid restored and second Toggle closes",()=>{var restored=gridContent.GetChildCount();if(restored<2)throw new CheckException($"grid has {restored} cell(s) after filter reset, expected >= 2");UiRegistry.Toggle(PanelEntryId);if(panel.Visible)throw new CheckException("panel still visible after second Toggle");});Finish(null);}}private void Next(){step++;}private void Check(string title,System.Action body){var sw=Godot.Time.GetTicksUsec();try{body();passed++;Log($"[PASS] {title} ({(Godot.Time.GetTicksUsec() - sw) / 1000.0:0.0} ms)");}catch(CheckException ex){failed++;failures.Add($"{title}: {ex.Message}");Log($"[FAIL] {title}: {ex.Message}");}}/// <summary>Печатает итог и завершает процесс; после вызова шаги не выполняются.</summary>
private void Finish(string reason){if(step<0)return;step= -1;if(reason!=null){failed++;Log($"[FAIL] {reason}");}foreach(var f in failures)Log($"       failed: {f}");Log($"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");GetTree().Quit(failed==0?0:1);}private void Log(string message){GameLogger.Log("TEST",$"InventoryTest: {message}");}/// <summary>Отличает ожидаемую проверку от неожиданного исключения шага.</summary>
private sealed class CheckException:System.Exception{public CheckException(string message):base(message){}}}}

===== FILE mods/items_base/test_suites/inventory/src/InventoryTestInitializer.cs =====
using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Mods.InventoryTest{/// <summary> Точка входа интеграционного теста инвентаря (прецедент live2d_test: мод сам везёт драйвер и сам печатает [TEST_RESULT]). Активация — только по явному пользовательскому флагу <c>--itest=inventory</c> (user-args после --), чтобы мод никогда не вмешивался в обычную игру и в другие смоук-прогоны. Само переключение сцен выполняет ядро (<c>--scene=</c>), драйверу остаётся только дождаться готового шелла. Драйвер ставится в корень дерева отложенно: <c>OnModResourcesReady</c> срабатывает в фазе Scenes, до окончания бута и до входа в сцену. </summary>
public class InventoryTestInitializer:IModInitializer{private static bool driverQueued;/// <summary>Per-mod инициализация не требуется — драйвер ставится на resources-ready.</summary>
public void OnModLoaded(string modId){}/// <summary> Разбирает user-args; при активационном флаге добавляет драйвер теста в корень дерева. Повторный <c>ModManager.Reload()</c> не должен плодить драйверы — защищено статическим флагом. </summary>
public void OnModResourcesReady(){if(driverQueued)return;var args=OS.GetCmdlineUserArgs();// Ровно свой флаг: широкая проверка StartsWith("--itest=") активировала бы этот тест и при запуске чужого itest-мода, а его драйвер квитит процесс в конце — второй тест не успевал бы отработать.
if(!args.Any(a=>a=="--itest=inventory"))return;var timeout=ParseTimeout(args);driverQueued=true;GameLogger.Log("TEST",$"InventoryTestInitializer: activating inventory GUI test, timeout={timeout?.ToString() ?? "none"}s");// Deferred: сцена ещё не готова; драйвер сам ждёт Boot.IsReady и появления CurrentScene, поэтому порядок с загрузкой сцены не важен.
(Godot.Engine.GetMainLoop()as SceneTree)?.Root?.CallDeferred("add_child",new InventoryTestDriverNode(timeout));}private static float?ParseTimeout(string[]args){var raw=args.Where(a=>a.StartsWith("--timeout=",System.StringComparison.Ordinal)).Select(a=>a.Substring("--timeout=".Length)).FirstOrDefault();return float.TryParse(raw,System.Globalization.CultureInfo.InvariantCulture,out var t)&&t>0?t:null;}}}

===== FILE mods/live2d_base/source_code/Cubism/Core/CubismCoreBindings.cs =====
using System;using System.Runtime.InteropServices;namespace Cthangover.Live2D.Cubism.Core{/// <summary> P/Invoke bindings for the Live2D CubismCore native library (Live2DCubismCore.dll). Provides direct access to model loading, updating, and data querying at the C level. All methods are unsafe and return raw pointers into CubismCore's internal arrays. <see cref="CubismNativeModel"/> wraps this layer into a managed API with proper memory alignment and lifetime management. </summary>
public static class CubismCoreBindings{/// <summary>Name of the native DLL loaded at runtime.</summary>
public const string DllName="Live2DCubismCore";/// <summary>Required byte alignment for .moc3 data buffers before revival.</summary>
public const int AlignofMoc=64;/// <summary>Required byte alignment for model instance memory.</summary>
public const int AlignofModel=16;/// <summary>Flag: drawable uses additive blending.</summary>
public const byte BlendAdditive=1<<0;/// <summary>Flag: drawable uses multiplicative blending.</summary>
public const byte BlendMultiplicative=1<<1;/// <summary>Flag: drawable is double-sided (no back-face culling).</summary>
public const byte IsDoubleSided=1<<2;/// <summary>Flag: drawable's mask is inverted.</summary>
public const byte IsInvertedMask=1<<3;/// <summary>Dynamic flag: drawable is currently visible.</summary>
public const byte IsVisible=1<<0;/// <summary>Dynamic flag: drawable visibility changed this frame.</summary>
public const byte VisibilityDidChange=1<<1;/// <summary>Dynamic flag: drawable opacity changed this frame.</summary>
public const byte OpacityDidChange=1<<2;/// <summary>Dynamic flag: drawable draw order changed this frame.</summary>
public const byte DrawOrderDidChange=1<<3;/// <summary>Dynamic flag: drawable render order changed this frame.</summary>
public const byte RenderOrderDidChange=1<<4;/// <summary>Dynamic flag: vertex positions changed this frame.</summary>
public const byte VertexPositionsDidChange=1<<5;/// <summary>Dynamic flag: blend color changed this frame.</summary>
public const byte BlendColorDidChange=1<<6;/// <summary>2D vector used in CubismCore struct layouts.</summary>
[StructLayout(LayoutKind.Sequential)]public struct csmVector2{public float X;public float Y;}/// <summary>4D vector used in CubismCore color struct layouts.</summary>
[StructLayout(LayoutKind.Sequential)]public struct csmVector4{public float X;public float Y;public float Z;public float W;}/// <summary>Returns the CubismCore SDK version number.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern uint csmGetVersion();/// <summary>Revives a .moc3 binary from an aligned memory buffer. Returns a moc handle or IntPtr.Zero.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe IntPtr csmReviveMocInPlace(void*address,uint size);/// <summary>Native log callback: receives a pointer to a NUL-terminated csmChar* message.</summary>
[UnmanagedFunctionPointer(CallingConvention.StdCall)]public delegate void CsmLogDelegate(IntPtr messagePtr);/// <summary> Installs the Core log handler. Without it every native-side failure (invalid moc3, unsupported version, alignment errors) is invisible. Keep the delegate in a static field for the process lifetime — the Core stores the raw function pointer. </summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern void csmSetLogFunction(CsmLogDelegate logFunction);/// <summary>Queries the memory size needed to initialize a model from the given moc.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe uint csmGetSizeofModel(IntPtr moc);/// <summary>Creates a model instance inside the provided aligned buffer. Returns model handle or IntPtr.Zero.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe IntPtr csmInitializeModelInPlace(IntPtr moc,void*address,uint size);/// <summary>Advance the model by one frame — updates physics, parameter-to-vertex mapping, etc.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe void csmUpdateModel(IntPtr model);/// <summary>Reads the model's canvas dimensions and pixels-per-unit scale.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe void csmReadCanvasInfo(IntPtr model,csmVector2*outSizeInPixels,csmVector2*outOriginInPixels,float*outPixelsPerUnit);/// <summary>Returns the number of parameters in the model.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe int csmGetParameterCount(IntPtr model);/// <summary>Returns a pointer array of null-terminated parameter ID strings.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe IntPtr csmGetParameterIds(IntPtr model);/// <summary>Returns a float array of current parameter values.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe float*csmGetParameterValues(IntPtr model);/// <summary>Returns a float array of parameter minimum bounds.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe float*csmGetParameterMinimumValues(IntPtr model);/// <summary>Returns a float array of parameter maximum bounds.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe float*csmGetParameterMaximumValues(IntPtr model);/// <summary>Returns a float array of parameter default values.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe float*csmGetParameterDefaultValues(IntPtr model);/// <summary>Returns the number of drawables (renderable mesh parts) in the model.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe int csmGetDrawableCount(IntPtr model);/// <summary>Returns byte flags array for constant (immutable) drawable properties.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe byte*csmGetDrawableConstantFlags(IntPtr model);/// <summary>Returns byte flags array for per-frame dynamic drawable state.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe byte*csmGetDrawableDynamicFlags(IntPtr model);/// <summary>Returns an int array mapping each drawable to a texture index.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe int*csmGetDrawableTextureIndices(IntPtr model);/// <summary>Returns an int array of vertex counts per drawable.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe int*csmGetDrawableVertexCounts(IntPtr model);/// <summary>Returns a pointer array of csmVector2 arrays (vertex positions per drawable).</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe csmVector2**csmGetDrawableVertexPositions(IntPtr model);/// <summary>Returns a pointer array of csmVector2 arrays (UVs per drawable).</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe csmVector2**csmGetDrawableVertexUvs(IntPtr model);/// <summary>Returns an int array of index counts per drawable.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe int*csmGetDrawableIndexCounts(IntPtr model);/// <summary>Returns a pointer array of ushort index arrays per drawable.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe ushort**csmGetDrawableIndices(IntPtr model);/// <summary>Returns a float array of per-drawable opacities.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe float*csmGetDrawableOpacities(IntPtr model);/// <summary>Returns draw order indices — maps render order slot to drawable index.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe int*csmGetDrawableDrawOrders(IntPtr model);/// <summary>Returns render order indices (newer Cubism format).</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe int*csmGetRenderOrders(IntPtr model);/// <summary>Resets dynamic flags for all drawables, preparing for the next frame.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe void csmResetDrawableDynamicFlags(IntPtr model);/// <summary>Returns an int array of mask counts per drawable.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe int*csmGetDrawableMaskCounts(IntPtr model);/// <summary>Returns a pointer array of int arrays (mask drawable indices per drawable).</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe int**csmGetDrawableMasks(IntPtr model);/// <summary>Returns a csmVector4 array of per-drawable multiply colors.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe csmVector4*csmGetDrawableMultiplyColors(IntPtr model);/// <summary>Returns a csmVector4 array of per-drawable screen colors.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe csmVector4*csmGetDrawableScreenColors(IntPtr model);/// <summary>Returns the number of parts in the model.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe int csmGetPartCount(IntPtr model);/// <summary>Returns a pointer array of part ID strings.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe IntPtr csmGetPartIds(IntPtr model);/// <summary>Returns a float array of per-part opacities.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe float*csmGetPartOpacities(IntPtr model);/// <summary>Checks if the given .moc3 data passes consistency validation.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe int csmHasMocConsistency(void*address,uint size);/// <summary>Returns the latest .moc3 format version supported by the DLL.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe uint csmGetLatestMocVersion();/// <summary>Returns the .moc3 format version of the given binary data.</summary>
[DllImport(DllName,CallingConvention=CallingConvention.StdCall)]public static extern unsafe uint csmGetMocVersion(void*address,uint size);}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/ACubismMotion.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Abstract base for all Live2D Cubism motions (animations and expressions). Порт ACubismMotion из Cubism SDK for Native 5-r.5: владеет временами фейда, весом, цикличностью и колбэками; очередь (<see cref="CubismMotionQueueManager"/>) вызывает <see cref="UpdateParameters"/> раз в кадр, тот в свою очередь — <see cref="SetupMotionQueueEntry"/> (при старте), <see cref="UpdateFadeWeight"/> и виртуальный <see cref="DoUpdateParameters"/>. Завершение выставляется по EndTime: сам моушен ставит IsFinished в DoUpdateParameters (незацикленный), а обёртка <see cref="UpdateParameters"/> после применения параметров закрывает запись, когда текущее время прошло EndTime (например, после запущенного через очередь fade-out). Колбэки при этом не дублируются: OnFinishedMotion вызывается только самим моушеном. </summary>
public abstract class ACubismMotion{/// <summary> Callback invoked when the motion finishes (non-looping only). The finished motion is passed as the argument. </summary>
public delegate void FinishedMotionCallback(ACubismMotion motion);/// <summary> Callback invoked when the motion begins playback. Called from <see cref="SetupMotionQueueEntry"/> right after the entry's times are configured. </summary>
public delegate void BeganMotionCallback(ACubismMotion motion);protected float _fadeInSeconds= -1f;protected float _fadeOutSeconds= -1f;protected float _weight=1f;protected float _offsetSeconds;protected bool _isLoop;protected bool _isLoopFadeIn=true;protected bool _previousLoopState;/// <summary>Сработавшие события (UserData); наполняется в <see cref="GetFiredEvent"/>.</summary>
protected readonly List<string>_firedEventValues=new();/// <summary> Duration of the fade-in transition in seconds. A negative value means the default (1 second) will be used by <see cref="SetupMotionQueueEntry"/> and <see cref="UpdateFadeWeight"/>. </summary>
public float FadeInSeconds{get=>_fadeInSeconds;set=>_fadeInSeconds=value;}/// <summary> Duration of the fade-out transition in seconds. A negative value means the default (1 second) will be used. </summary>
public float FadeOutSeconds{get=>_fadeOutSeconds;set=>_fadeOutSeconds=value;}/// <summary> Overall motion weight (0..1). Multiplied with the fade-in/fade-out factors in <see cref="UpdateFadeWeight"/> to produce the final blend weight. </summary>
public float Weight{get=>_weight;set=>_weight=value;}/// <summary> Time offset in seconds applied to the motion's start time. Allows starting playback from an arbitrary point. </summary>
public float OffsetSeconds{get=>_offsetSeconds;set=>_offsetSeconds=value;}/// <summary> Whether the motion loops. When enabled, <see cref="CubismMotion"/> wraps timeOffset back to the beginning after the duration elapses. </summary>
public bool IsLoop{get=>_isLoop;set=>_isLoop=value;}/// <summary> When true and looping, the motion performs a fade-in at each loop restart. When false, the fade-in is skipped on subsequent loops. </summary>
public bool IsLoopFadeIn{get=>_isLoopFadeIn;set=>_isLoopFadeIn=value;}/// <summary> Invoked when the motion finishes (non-looping only). Автоматически вызывается самим моушеном в DoUpdateParameters по достижении длительности; очередь этот колбэк не дублирует. </summary>
public FinishedMotionCallback OnFinishedMotion{get;set;}/// <summary> Invoked when the motion begins. Fires at the start of <see cref="SetupMotionQueueEntry"/> after the entry is initialized. </summary>
public BeganMotionCallback OnBeganMotion{get;set;}/// <summary> Returns the total motion duration in seconds. Override in <see cref="CubismMotion"/> to return the motion3.json duration, or -1 for looping motions. </summary>
public virtual float GetDuration()=> -1f;/// <summary> Returns the loop cycle duration in seconds. Override in <see cref="CubismMotion"/> to return the full duration even when looping is active. </summary>
public virtual float GetLoopDuration()=> -1f;/// <summary> Порт ACubismMotion::UpdateParameters — кадровая обёртка, вызываемая очередью. Проверяет доступность записи, инициализирует её при старте, считает fade-вес, применяет параметры и после этого закрывает запись, если время прошло EndTime. </summary>
public void UpdateParameters(CubismNativeModel model,CubismMotionQueueEntry entry,float userTimeSeconds){if(!entry.IsAvailable||entry.IsFinished){return;}SetupMotionQueueEntry(entry,userTimeSeconds);float fadeWeight=UpdateFadeWeight(entry,userTimeSeconds);DoUpdateParameters(model,userTimeSeconds,fadeWeight,entry);// Пост-проверка SDK: EndTime > 0 и уже пройден — запись завершена.
if((entry.EndTime>0f)&&(entry.EndTime<userTimeSeconds)){entry.IsFinished=true;}}/// <summary> Порт ACubismMotion::SetupMotionQueueEntry: при первом кадре записывает StartTime (с учётом OffsetSeconds), FadeInStartTime и EndTime (если EndTime ещё не выставлен извне, например фейдаутом от очереди). </summary>
public void SetupMotionQueueEntry(CubismMotionQueueEntry entry,float userTimeSeconds){if(!entry.IsAvailable||entry.IsFinished){return;}if(entry.IsStarted){return;}entry.IsStarted=true;entry.StartTime=userTimeSeconds-_offsetSeconds;entry.FadeInStartTime=userTimeSeconds;// EndTime мог быть выставлен заранее (SetFadeout до старта).
if(entry.EndTime<0f){AdjustEndTime(entry);}OnBeganMotion?.Invoke(this);}/// <summary> Порт ACubismMotion::UpdateFadeWeight: вес = Weight x fadeIn x fadeOut с синус-эйзингом. При fadeIn/fadeOut == 0 соответствующий множитель = 1. </summary>
public float UpdateFadeWeight(CubismMotionQueueEntry entry,float userTimeSeconds){if(entry==null){return-1f;}float fadeWeight=_weight;float fadeIn=_fadeInSeconds==0f?1f:CubismMath.GetEasingSine((userTimeSeconds-entry.FadeInStartTime)/_fadeInSeconds);float fadeOut=(_fadeOutSeconds==0f||entry.EndTime<0f)?1f:CubismMath.GetEasingSine((entry.EndTime-userTimeSeconds)/_fadeOutSeconds);fadeWeight=fadeWeight*fadeIn*fadeOut;entry.SetState(userTimeSeconds,fadeWeight);return fadeWeight;}/// <summary> Порт ACubismMotion::AdjustEndTime: EndTime = StartTime + GetDuration(); для цикличных моушенов (GetDuration() <= 0) EndTime = -1 (бесконечность). </summary>
protected void AdjustEndTime(CubismMotionQueueEntry entry){float duration=GetDuration();float endTime=duration<=0f? -1f:entry.StartTime+duration;entry.EndTime=endTime;}/// <summary> Порт ACubismMotion::GetFiredEvent: базовая реализация не содержит событий. <see cref="CubismMotion"/> переопределяет и наполняет список по UserData. </summary>
public virtual List<string>GetFiredEvent(float beforeCheckTimeSeconds,float motionTimeSeconds){return _firedEventValues;}/// <summary> Applies motion parameter values to the model for the current frame. Called every frame by <see cref="ACubismMotion.UpdateParameters"/>. </summary> <param name="model">The native Cubism model to modify.</param> <param name="userTimeSeconds">Elapsed user time in seconds.</param> <param name="weight">Blend weight already multiplied by fade factors.</param> <param name="entry">The queue entry containing start/end timing.</param>
public abstract void DoUpdateParameters(CubismNativeModel model,float userTimeSeconds,float weight,CubismMotionQueueEntry entry);}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismBreath.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Defines a sinusoidal breathing parameter. Each breath parameter oscillates a model parameter value around an offset with a given peak amplitude, cycle period, and weight. </summary>
public struct BreathParameterData{/// <summary>Model parameter ID to apply the breath to.</summary>
public string ParameterId;/// <summary>Base offset added to the sinusoidal value.</summary>
public float Offset;/// <summary>Amplitude of the sine wave.</summary>
public float Peak;/// <summary>Full cycle duration in seconds.</summary>
public float Cycle;/// <summary>Multiplier applied to the final breath value.</summary>
public float Weight;}/// <summary> Applies sinusoidal breathing animation to model parameters. Operates additively on top of existing parameter values — the breath oscillation is multiplied by <see cref="BreathParameterData.Weight"/> and added to the current parameter value every frame. Configured with a list of <see cref="BreathParameterData"/> entries, each targeting a specific parameter ID. Called from <see cref="CubismModelNode.OnUpdate"/> alongside other effect layers (eye blink, pose, physics). </summary>
public class CubismBreath{private readonly List<BreathParameterData>_parameters=new();private float _currentTime;/// <summary> Replaces the current breath parameter set. Previously configured parameters are cleared. </summary>
public void SetParameters(List<BreathParameterData>parameters){_parameters.Clear();_parameters.AddRange(parameters);}/// <summary> Applies the breath oscillation to matching model parameters. Accumulates internal time and evaluates a sine wave per parameter: <c>offset + peak * sin(2 * pi * time / cycle)</c>, then adds the weighted result to the native parameter value. </summary>
public unsafe void UpdateParameters(CubismNativeModel model,float deltaTimeSeconds){_currentTime+=deltaTimeSeconds;var idsPtr=model.GetParameterIds();var values=model.GetParameterValues();for(int i=0;i<model.ParameterCount;i++){var id=CubismNativeModel.ReadStringFromPtrArray(idsPtr,i);foreach(var bp in _parameters){if(bp.ParameterId!=id)continue;var value=bp.Offset+bp.Peak*MathF.Sin(2f*MathF.PI*_currentTime/bp.Cycle);values[i]+=bp.Weight*value;break;}}}}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismExpressionMotion.cs =====
using System.Collections.Generic;using System.Text.Json;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Blend mode for expression parameters. Determines how the expression value is combined with the existing parameter value during animation. </summary>
public enum ExpressionBlendType{Additive=0,Multiply=1,Overwrite=2}/// <summary> A single parameter entry in an expression motion. Targets a model parameter by ID with a specific blend type and value. </summary>
public struct ExpressionParameter{/// <summary>Target model parameter ID.</summary>
public string ParameterId;/// <summary>How to combine this value with existing parameter data.</summary>
public ExpressionBlendType BlendType;/// <summary>The target value (interpretation depends on <see cref="BlendType"/>).</summary>
public float Value;}/// <summary> Expression motion, parsed from a Cubism .exp3.json file. Inherits fade and weight management from <see cref="ACubismMotion"/>. Формулы применения — точный порт CubismExpressionMotion::DoUpdateParameters через Set/Add/MultiplyParameterValue из CubismModel (SDK 5-r.5), weight — fade-вес: <list type="bullet"> <item><see cref="ExpressionBlendType.Additive"/> — <c>value + expValue * weight</c></item> <item><see cref="ExpressionBlendType.Multiply"/> — <c>value * (1 + (expValue - 1) * weight)</c></item> <item><see cref="ExpressionBlendType.Overwrite"/> — <c>value * (1 - weight) + expValue * weight</c></item> </list> Как и в SDK, результат зажимается в [min, max] параметра (порт CubismModel::SetParameterValue), а параметры применяются в порядке их перечисления в exp3.json: при нескольких записях с одним Id побеждает последняя. Fade-время по умолчанию — 1 c (DefaultFadeTime), если в файле не задано. Загружается и запускается через <see cref="CubismModelNode.StartExpression"/>. </summary>
public class CubismExpressionMotion:ACubismMotion{/// <summary>Порт DefaultFadeTime из CubismExpressionMotion.cpp.</summary>
private const float DefaultFadeTime=1f;private readonly List<ExpressionParameter>_parameters=new();/// <summary> Parses an .exp3.json byte buffer into an expression motion. Reads fade-in/out times and the list of target parameters with their blend types and values. </summary>
public CubismExpressionMotion(byte[]jsonBytes){var json=JsonDocument.Parse(jsonBytes);var root=json.RootElement;// Порт CubismExpressionMotion::Parse: root[FadeInTime].ToFloat(DefaultFadeTime).
_fadeInSeconds=root.TryGetProperty("FadeInTime",out var fi)&&fi.ValueKind==JsonValueKind.Number?fi.GetSingle():DefaultFadeTime;_fadeOutSeconds=root.TryGetProperty("FadeOutTime",out var fo)&&fo.ValueKind==JsonValueKind.Number?fo.GetSingle():DefaultFadeTime;if(root.TryGetProperty("Parameters",out var paramsArr)){foreach(var p in paramsArr.EnumerateArray()){// "Blend": null or absent -> Additive (SDK ToBlendFunction default).
var blend=p.TryGetProperty("Blend",out var b)&&b.ValueKind==JsonValueKind.String?ParseBlendType(b.GetString()):ExpressionBlendType.Additive;_parameters.Add(new ExpressionParameter{ParameterId=p.GetProperty("Id").GetString(),Value=p.TryGetProperty("Value",out var val)&&val.ValueKind==JsonValueKind.Number?val.GetSingle():0f,BlendType=blend});}}}private static ExpressionBlendType ParseBlendType(string value)=>value switch{"Multiply"=>ExpressionBlendType.Multiply,"Overwrite"=>ExpressionBlendType.Overwrite,_=>ExpressionBlendType.Additive};/// <summary> Applies all expression parameters to the model using their respective blend modes. Called every frame by the queue manager. </summary>
public override unsafe void DoUpdateParameters(CubismNativeModel model,float userTimeSeconds,float weight,CubismMotionQueueEntry entry){var idsPtr=model.GetParameterIds();var values=model.GetParameterValues();var minValues=model.GetParameterMinimumValues();var maxValues=model.GetParameterMaximumValues();var indexById=new Dictionary<string,int>(model.ParameterCount);for(int i=0;i<model.ParameterCount;i++)indexById[CubismNativeModel.ReadStringFromPtrArray(idsPtr,i)]=i;// Порт: цикл по параметрам выражения (в порядке exp3.json).
foreach(var ep in _parameters){if(!indexById.TryGetValue(ep.ParameterId,out int pi))continue;switch(ep.BlendType){case ExpressionBlendType.Additive:// AddParameterValue: value + (expValue * weight)
values[pi]=ClampParameterValue(values[pi]+ep.Value*weight,minValues[pi],maxValues[pi]);break;case ExpressionBlendType.Multiply:// MultiplyParameterValue: value * (1 + (expValue - 1) * weight)
values[pi]=ClampParameterValue(values[pi]*(1f+(ep.Value-1f)*weight),minValues[pi],maxValues[pi]);break;case ExpressionBlendType.Overwrite:// SetParameterValue(id, value, weight): expValue зажимается, затем лерп
values[pi]=values[pi]*(1f-weight)+ClampParameterValue(ep.Value,minValues[pi],maxValues[pi])*weight;break;}}}/// <summary>Порт GetParameterClampValue — зажим значения параметра в [min, max].</summary>
private static float ClampParameterValue(float value,float min,float max){if(value<min)return min;if(value>max)return max;return value;}}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismEyeBlink.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Drives a state-machine based eye blink animation on target model parameters. Cycles through five states: First -> Interval -> Closing -> Closed -> Opening, modulating the specified parameter values from 1 (open) to 0 (closed) and back. Blink timing is randomized per cycle: each interval between blinks is a random value in <c>(0, 2 * BlinkIntervalSeconds)</c>. The actual blink consists of configurable closing, closed, and opening phase durations. Used by <see cref="CubismModelNode"/> when <see cref="CubismModelNode.EnableEyeBlink"/> is true and the model3.json defines EyeBlink parameter group. </summary>
public class CubismEyeBlink{private enum EyeState{First,Interval,Closing,Closed,Opening}private EyeState _state=EyeState.First;private float _nextBlinkTime;private float _stateStartTime;private float _userTimeSeconds;private readonly List<string>_parameterIds=new();/// <summary> Average interval between successive blinks, in seconds. Actual interval is randomized in range (0, 2 * this value). Default is 4 seconds. </summary>
public float BlinkIntervalSeconds{get;set;}=4f;/// <summary> Duration of the eyelid closing transition, in seconds. Default 0.1. </summary>
public float ClosingSeconds{get;set;}=0.1f;/// <summary> Duration the eyes stay fully closed, in seconds. Default 0.05. </summary>
public float ClosedSeconds{get;set;}=0.05f;/// <summary> Duration of the eyelid opening transition, in seconds. Default 0.15. </summary>
public float OpeningSeconds{get;set;}=0.15f;/// <summary> Sets the parameter IDs that represent eye open/close. Typically sourced from the model3.json EyeBlink group. Clears any previously set IDs. </summary>
public void SetParameterIds(IEnumerable<string>ids){_parameterIds.Clear();_parameterIds.AddRange(ids);}/// <summary> Advances the blink state machine and writes eye openness to model parameters. 1.0 = fully open, 0.0 = fully closed. Linear interpolation between phases. Call every frame from <see cref="CubismModelNode.OnUpdate"/>. </summary>
public void UpdateParameters(CubismNativeModel model,float deltaTimeSeconds){_userTimeSeconds+=deltaTimeSeconds;switch(_state){case EyeState.First:_state=EyeState.Interval;_nextBlinkTime=_userTimeSeconds+Random.Shared.NextSingle()*(2f*BlinkIntervalSeconds-1f);break;case EyeState.Interval:SetParameterValues(model,1f);if(_userTimeSeconds>=_nextBlinkTime){_state=EyeState.Closing;_stateStartTime=_userTimeSeconds;}break;case EyeState.Closing:{var elapsed=_userTimeSeconds-_stateStartTime;var value=1f-Math.Clamp(elapsed/ClosingSeconds,0f,1f);SetParameterValues(model,value);if(elapsed>=ClosingSeconds){_state=EyeState.Closed;_stateStartTime=_userTimeSeconds;}break;}case EyeState.Closed:SetParameterValues(model,0f);if(_userTimeSeconds-_stateStartTime>=ClosedSeconds){_state=EyeState.Opening;_stateStartTime=_userTimeSeconds;}break;case EyeState.Opening:{var elapsed=_userTimeSeconds-_stateStartTime;var value=Math.Clamp(elapsed/OpeningSeconds,0f,1f);SetParameterValues(model,value);if(elapsed>=OpeningSeconds){_state=EyeState.Interval;_nextBlinkTime=_userTimeSeconds+Random.Shared.NextSingle()*(2f*BlinkIntervalSeconds-1f);}break;}}}private unsafe void SetParameterValues(CubismNativeModel model,float value){var idsPtr=model.GetParameterIds();var values=model.GetParameterValues();for(int i=0;i<model.ParameterCount;i++){var id=CubismNativeModel.ReadStringFromPtrArray(idsPtr,i);if(_parameterIds.Contains(id))values[i]=value;}}}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismMath.cs =====
using System;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Math utilities shared across the Cubism animation pipeline. Includes easing functions (sine), angle utilities, linear interpolation helpers, and bezier curve evaluators with both analytic (Cardano) and binary search approaches. The bezier methods handle Cubism's segmented animation curves: <see cref="BezierEvaluate"/> calculates a value at a given time, <see cref="CardanoAlgorithmForBezier"/> solves for the exact bezier parameter t, and <see cref="BezierEvaluateBinarySearch"/> is a fallback when beziers are restricted (non-standard). </summary>
public static class CubismMath{/// <summary> Sine-based easing function mapping [0,1] -> [0,1] with smooth acceleration and deceleration. Used for fade-in/fade-out curves in motion transitions. <c>0.5 - 0.5 * cos(pi * t)</c>, clamped to [0,1]. </summary>
public static float GetEasingSine(float t){if(t<0f)return 0f;if(t>1f)return 1f;return 0.5f-0.5f*MathF.Cos(MathF.PI*t);}/// <summary>Converts degrees to radians.</summary>
public static float DegreesToRadian(float degrees){return degrees*(MathF.PI/180f);}/// <summary>Converts radians to degrees.</summary>
public static float RadianToDegrees(float radian){return radian*(180f/MathF.PI);}/// <summary> Computes the signed angle in radians from vector (fromX,fromY) to vector (toX,toY). Uses Atan2(det, dot) for the angle between two vectors. </summary>
public static float DirectionToRadian(float fromX,float fromY,float toX,float toY){var dot=fromX*toX+fromY*toY;var det=fromX*toY-fromY*toX;return MathF.Atan2(det,dot);}/// <summary> Converts a radian angle to a unit direction vector (sin, cos). </summary>
public static void RadianToDirection(float radian,out float x,out float y){x=MathF.Sin(radian);y=MathF.Cos(radian);}/// <summary>Standard linear interpolation between two float values.</summary>
public static float LinearEvaluation(float startValue,float endValue,float time){return startValue+(endValue-startValue)*time;}/// <summary> Linear interpolation between two <see cref="CubismMotionPoint"/> values, using their stored Time properties to compute the interpolation factor. </summary>
public static float LinearEvaluation(ref CubismMotionPoint p0,ref CubismMotionPoint p1,float time){var t=(time-p0.Time)/(p1.Time-p0.Time);return p0.Value+(p1.Value-p0.Value)*t;}/// <summary> De Casteljau cubic bezier evaluation. Given 4 control points (cp0..cp3), each with a time and value, returns the bezier value at the specified time. </summary>
public static float BezierEvaluate(float cp0Time,float cp0Value,float cp1Time,float cp1Value,float cp2Time,float cp2Value,float cp3Time,float cp3Value,float time){var t=(time-cp0Time)/(cp3Time-cp0Time);var t1=1f-t;var t2=t*t;var t12=t1*t1;var value=t12*t1*cp0Value+3f*t12*t*cp1Value+3f*t1*t2*cp2Value+t2*t*cp3Value;return value;}/// <summary> Cardano cubic formula to solve for the exact bezier parameter t given a target time. Used when beziers are not restricted — the analytic solution is faster than binary search. Returns the t value that, when applied to the bezier equation, produces the targetTime (or very close to it). </summary>
public static float CardanoAlgorithmForBezier(float cp0Time,float cp1Time,float cp2Time,float cp3Time,float targetTime){var dx=cp0Time-targetTime;var cx=cp1Time-cp0Time;var bx=cp2Time-cp1Time;var ax=cp3Time-cp2Time;var a2=ax-cx;var ab=2f*(ax-2f*bx+cx);var a0=3f*(bx-cx);var ad= -cx;var a=a2/dx;var b=ab/dx;var c=a0/dx;var d=ad/dx;var r=(-2f*b*b*b+9f*a*b*c-27f*a*a*d)/(54f*a*a*a);var q=(b*b-3f*a*c)/(9f*a*a);var q3=q*q*q;var floorTerm=r*r-q3;if(MathF.Abs(floorTerm)<1e-7f)floorTerm=0f;if(floorTerm<0f){var theta=MathF.Acos(r/MathF.Sqrt(q3));var sqrtQ=MathF.Sqrt(q);return-2f*sqrtQ*MathF.Cos(theta/3f)-b/(3f*a);}var sqrtTerm=MathF.Sqrt(floorTerm);var aTerm=MathF.Cbrt(MathF.Abs(r+sqrtTerm));var bTerm=r>0f?MathF.Cbrt(MathF.Abs(r-sqrtTerm)):-MathF.Cbrt(MathF.Abs(r-sqrtTerm));return aTerm-bTerm-b/(3f*a);}/// <summary> Binary search bezier evaluation — fallback for when <see cref="CubismMotionJson.AreBeziersRestricted"/> is true. Iteratively narrows t in [0,1] until the bezier time at t is within epsilon of the target time, then evaluates the value. Capped at 20 iterations with 1e-5 tolerance. </summary>
public static float BezierEvaluateBinarySearch(float cp0Time,float cp1Time,float cp2Time,float cp3Time,float cp0Value,float cp1Value,float cp2Value,float cp3Value,float targetTime){const int maxIterations=20;const float epsilon=1e-5f;var tMin=0f;var tMax=1f;for(var i=0;i<maxIterations;i++){var t=(tMin+tMax)*0.5f;var t1=1f-t;var t2=t*t;var t12=t1*t1;var timeAtT=t12*t1*cp0Time+3f*t12*t*cp1Time+3f*t1*t2*cp2Time+t2*t*cp3Time;if(MathF.Abs(timeAtT-targetTime)<epsilon)return t12*t1*cp0Value+3f*t12*t*cp1Value+3f*t1*t2*cp2Value+t2*t*cp3Value;if(timeAtT<targetTime)tMin=t;else tMax=t;}var tf=(tMin+tMax)*0.5f;var tf1=1f-tf;var tf2=tf*tf;var tf12=tf1*tf1;return tf12*tf1*cp0Value+3f*tf12*tf*cp1Value+3f*tf1*tf2*cp2Value+tf2*tf*cp3Value;}}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismModelMatrix.cs =====
using System;using System.Collections.Generic;using Godot;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Port of CubismModelMatrix.cpp (Cubism SDK for Native 5-r.5). Manages the 2D transformation (scale, translation, alignment) of a Cubism model inside its logical coordinate space. SDK semantics to be aware of: <list type="bullet"> <item><see cref="SetWidth"/> and <see cref="SetHeight"/> MULTIPLY the current scale by target / canvas-size (they do not assign it), exactly like the C++ original.</item> <item>The (width, height) constructor performs <c>SetHeight(2.0f)</c> like the SDK, i.e. the model is initially scaled to occupy 2 logical units of height.</item> <item>Translation methods are INCREMENTAL in the SDK: SetX/SetY, SetPosition, Top, Left (via TranslateX/Y) add to the current translation. SetupFromLayout is unaffected because it starts from a fresh matrix (translation 0).</item> <item>CenterX/CenterY/Bottom/Right compute their offset from the full scaled size (canvas size × current scale).</item> </list> <see cref="SetupFromLayout"/> reads positioning keys from a Cubism .model3.json "Layout" section; <see cref="ApplyToNode"/> writes the final transform to a Godot <see cref="Node2D"/>. </summary>
public class CubismModelMatrix{private float _width;private float _height;private float _scaleX=1f;private float _scaleY=1f;private float _translateX;private float _translateY;/// <summary>Creates an identity matrix with no known canvas size.</summary>
public CubismModelMatrix(){}/// <summary> Creates a matrix referenced to the given canvas dimensions. Like the SDK constructor, this calls <see cref="SetHeight"/>(2.0f), so the model initially occupies 2 logical units of height. </summary>
public CubismModelMatrix(float width,float height){_width=width;_height=height;SetHeight(2.0f);}/// <summary>Current scale on the X axis.</summary>
public float ScaleX=>_scaleX;/// <summary>Current scale on the Y axis.</summary>
public float ScaleY=>_scaleY;/// <summary>Current X translation offset.</summary>
public float TranslateX=>_translateX;/// <summary>Current Y translation offset.</summary>
public float TranslateY=>_translateY;/// <summary>SDK CubismModelMatrix::Scale — multiplies the current scale.</summary>
public void Scale(float scaleX,float scaleY){_scaleX*=scaleX;_scaleY*=scaleY;}/// <summary> SDK SetWidth: multiplies the current uniform scale by w / canvasWidth. Ignored when the canvas width is zero or unknown (the SDK would divide by zero). </summary>
public void SetWidth(float w){if(_width<=0f)return;var scaleX=w/_width;var scaleY=scaleX;Scale(scaleX,scaleY);}/// <summary> SDK SetHeight: multiplies the current uniform scale by h / canvasHeight. Ignored when the canvas height is zero or unknown (the SDK would divide by zero). </summary>
public void SetHeight(float h){if(_height<=0f)return;var scaleX=h/_height;var scaleY=scaleX;Scale(scaleX,scaleY);}/// <summary>SDK Translate — adds x and y to the current translation (the SDK's TranslateX/TranslateY helpers are folded in here; the TranslateX/TranslateY properties above are the read accessors).</summary>
public void Translate(float x,float y){_translateX+=x;_translateY+=y;}/// <summary>SDK SetPosition — incremental (adds to the current translation).</summary>
public void SetPosition(float x,float y)=>Translate(x,y);/// <summary>SDK SetX — incremental (adds to the current X translation).</summary>
public void SetX(float x)=>_translateX+=x;/// <summary>SDK SetY — incremental (adds to the current Y translation).</summary>
public void SetY(float y)=>_translateY+=y;/// <summary> Centers the model horizontally and vertically at the given point. Equivalent to calling <see cref="CenterX"/> then <see cref="CenterY"/>. </summary>
public void SetCenterPosition(float x,float y){CenterX(x);CenterY(y);}/// <summary>SDK CenterX — adds x - (width × scaleX) / 2 to the X translation.</summary>
public void CenterX(float x){var w=_width*_scaleX;_translateX+=x-(w/2f);}/// <summary>SDK CenterY — adds y - (height × scaleY) / 2 to the Y translation.</summary>
public void CenterY(float y){var h=_height*_scaleY;_translateY+=y-(h/2f);}/// <summary>SDK Top — adds y to the Y translation (via SetY).</summary>
public void Top(float y)=>SetY(y);/// <summary>SDK Bottom — adds y - (height × scaleY) to the Y translation.</summary>
public void Bottom(float y){var h=_height*_scaleY;_translateY+=y-h;}/// <summary>SDK Left — adds x to the X translation (via SetX).</summary>
public void Left(float x)=>SetX(x);/// <summary>SDK Right — adds x - (width × scaleX) to the X translation.</summary>
public void Right(float x){var w=_width*_scaleX;_translateX+=x-w;}/// <summary> SDK SetupFromLayout — configures scale and position from a layout dictionary (lowercased keys). Width/height are applied first (they affect scale), then the position keys: "x", "y", "center_x", "center_y", "top", "bottom", "left", "right". </summary>
public void SetupFromLayout(Dictionary<string,float>layout){foreach(var kvp in layout){switch(kvp.Key){case"width":SetWidth(kvp.Value);break;case"height":SetHeight(kvp.Value);break;}}foreach(var kvp in layout){switch(kvp.Key){case"x":SetX(kvp.Value);break;case"y":SetY(kvp.Value);break;case"center_x":CenterX(kvp.Value);break;case"center_y":CenterY(kvp.Value);break;case"top":Top(kvp.Value);break;case"bottom":Bottom(kvp.Value);break;case"left":Left(kvp.Value);break;case"right":Right(kvp.Value);break;}}}/// <summary> Writes the current scale and translation to a Godot <see cref="Node2D"/>. Sets both <c>Scale</c> and <c>Position</c> properties. </summary>
public void ApplyToNode(Node2D node){node.Scale=new Vector2(_scaleX,_scaleY);node.Position=new Vector2(_translateX,_translateY);}}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismModelNode.cs =====
using Cthangover.Core.Mods;using Cthangover.Core.UI.Event;using Cthangover.Core.Utils;using Godot;using System.Collections.Generic;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Defines how the Live2D model scales to fit its parent container. </summary>
public enum FitMode{None,FitWidth,FitHeight,FitAuto}/// <summary> The main Godot node that loads, updates, and renders a Live2D Cubism model. Represents a single character/avatar in the scene tree. Orchestrates the full pipeline: <list type="number"> <item>Loads .model3.json and .moc3 via <see cref="CubismNativeModel"/></item> <item>Sets up <see cref="CubismParameter"/> and <see cref="CubismPartOpacity"/> arrays</item> <item>Creates optional effect layers: <see cref="CubismEyeBlink"/>, <see cref="CubismBreath"/>, <see cref="CubismPose"/>, <see cref="CubismPhysics"/></item> <item>Builds 2D meshes through <see cref="CubismRenderer2D"/></item> <item>Every frame (<see cref="OnUpdate"/>): target point -> parameter injection -> motion/expression evaluation -> effect layers -> model update -> renderer update</item> </list> The update order matters: motion values are applied first, then expression values blend on top, and finally effect layers (eye blink, breath, pose, physics) modify the result. The target point drives face-tracking parameters (ParamAngleX/Y, ParamEyeBallX/Y). </summary>
public partial class CubismModelNode:Node2D,IOnUpdateEvent{public int Priority=>0;private CubismNativeModel _nativeModel;private CubismRenderer2D _renderer;private CubismModelSettingJson _modelSetting;// The autoload event controller this node registered its OnUpdate with. Kept so _ExitTree can unregister: the controller outlives the scene, and a stale registration would keep dispatching into a freed node.
private SceneEventController _eventController;private CubismEyeBlink _eyeBlink;private CubismBreath _breath;private CubismPose _pose;private CubismPhysics _physics;private CubismTargetPoint _targetPoint=new();private Vector2 _contentSize;private Vector2 _contentCenter;private bool _contentBoundsReady;private CubismMotionQueueManager _motionManager=new();private CubismMotionQueueManager _expressionManager=new();private float _userTimeSeconds;private string _modelDir;private IModFileProvider _fileProvider;private bool _layoutLogged;// Parameters claimed by SetLiveParameter with their last written values. Re-applied after the Load/Save snapshot bracket every frame, so manual control survives the per-frame parameter restore.
private readonly System.Collections.Generic.Dictionary<string,float>_manualValues=new();// Cache of parameter index by id, built once after model load — hot loops (face-track injection, SetLiveParameter) resolve by index without per-frame Marshal.PtrToStringAnsi / string comparisons.
private readonly Dictionary<string,int>_parameterIndexById=new();private FaceTrackEntry[]_faceTrackCache=System.Array.Empty<FaceTrackEntry>();private readonly List<string>_eyeBlinkIds=new();private readonly List<string>_lipSyncIds=new();/// <summary>One precomputed face-track injection: values[index] = targetAxis * scale.</summary>
private struct FaceTrackEntry{public int Index;public string Id;public bool FromTargetX;public float Scale;}/// <summary> All model parameters with their IDs, bounds, and current values. Directly references the native parameter arrays — modifying <see cref="CubismParameter.Value"/> and calling <see cref="CubismParameter.ApplyToModel"/> writes back to CubismCore. </summary>
public CubismParameter[]Parameters{get;private set;}=System.Array.Empty<CubismParameter>();/// <summary> All model part opacities, indexed by part index. Useful for showing/hiding parts (clothing layers, accessories, etc.). </summary>
public CubismPartOpacity[]PartOpacities{get;private set;}=System.Array.Empty<CubismPartOpacity>();/// <summary>Canvas pixel width from the model's .moc3 data. Available after loading.</summary>
public float CanvasWidth=>_nativeModel?.CanvasWidth??0f;/// <summary>Canvas pixel height from the model's .moc3 data. Available after loading.</summary>
public float CanvasHeight=>_nativeModel?.CanvasHeight??0f;/// <summary> Horizontal anchor point for layout positioning, 0..1. 0 = left edge, 0.5 = center, 1 = right edge. Default 0.5. Serialized as a Godot export property. </summary>
[Export]public float AnchorX{get;set;}=0.5f;/// <summary> Vertical anchor point for layout positioning, 0..1. 0 = top edge, 0.5 = center, 1 = bottom edge. Default 0.5. </summary>
[Export]public float AnchorY{get;set;}=0.5f;/// <summary> How the model scales to fit its parent container. Default <see cref="FitMode.FitAuto"/>. Used by <see cref="DeferredApplyLayout"/> on the first frame with a valid parent size. </summary>
[Export]public FitMode ScaleFit{get;set;}=FitMode.FitAuto;/// <summary> Enables automatic eye blink animation. Requires EyeBlink parameter groups in the model3.json. Default true. </summary>
[Export]public bool EnableEyeBlink{get;set;}=true;/// <summary> Enables breathing animation. Default false. Note: breath parameters are not currently loaded from model settings — enable this only if parameters are set externally via <see cref="CubismBreath.SetParameters"/>. </summary>
[Export]public bool EnableBreath{get;set;}/// <summary> Enables physics simulation (hair, cloth, etc.) from the .physics3.json file. Default true. </summary>
[Export]public bool EnablePhysics{get;set;}=true;/// <summary> Enables pose-based part opacity management from the .pose3.json file. Default true. </summary>
[Export]public bool EnablePose{get;set;}=true;private bool _loaded;/// <summary> Loads a Cubism model from within a registered mod. Reads the .model3.json file, resolves the .moc3 and textures, then calls <see cref="FinishSetup"/> to build parameters, parts, effects, and meshes. When <paramref name="modId"/> is <c>null</c>, all mods are searched in priority order for the first match. </summary> <param name="relativePath">Path to .model3.json relative to the mod root.</param> <param name="modId">Optional registered mod ID containing the model assets.</param>
public void LoadModelFromMod(string relativePath,string modId=null){var mods=ModManager.Instance?.Mods;if(mods==null){GameLogger.Log("LIVE2D",$"LoadModelFromMod: no mods loaded",LogLevel.Error);return;}IModInfo modInfo=null;if(modId!=null){mods.TryGetValue(modId,out modInfo);}else{var orderedIds=ModRegistry.Instance.GetOrderedModIds();foreach(var id in orderedIds){if(mods.TryGetValue(id,out var candidate)&&candidate.FileProvider?.FileExists(relativePath)==true){modInfo=candidate;break;}}}if(modInfo==null){GameLogger.Log("LIVE2D",$"LoadModelFromMod: file '{relativePath}' not found in any mod",LogLevel.Error);return;}_fileProvider=modInfo.FileProvider;var jsonBytes=_fileProvider.ReadFileBinary(relativePath);if(jsonBytes==null){GameLogger.Log("LIVE2D",$"LoadModelFromMod: file not found '{relativePath}' in mod '{modId ?? "?"}'",LogLevel.Error);return;}_modelDir=relativePath.Contains('/')?relativePath.Substring(0,relativePath.LastIndexOf('/')):"";_nativeModel=new CubismNativeModel();_nativeModel.LoadFromProvider(jsonBytes,_modelDir,_fileProvider);if(!_nativeModel.IsLoaded){// LoadFromProvider logs failures (missing moc, invalid JSON) and returns without a native model — FinishSetup would P/Invoke on IntPtr.Zero.
GameLogger.Log("LIVE2D",$"LoadModelFromMod: native model load failed for '{relativePath}'",LogLevel.Error);return;}FinishSetup(jsonBytes);}private void FinishSetup(byte[]model3JsonBytes){_modelSetting=new CubismModelSettingJson(model3JsonBytes);SetupParameters();SetupParts();SetupEffects();_renderer=new CubismRenderer2D(this);_nativeModel.UpdateModel();_renderer.BuildModel(_nativeModel);var bounds=_renderer.GetContentBounds();_contentSize=new Vector2(bounds.Size.X,bounds.Size.Y);_contentCenter=new Vector2(bounds.GetCenter().X,bounds.GetCenter().Y);_contentBoundsReady=true;GameLogger.Log("LIVE2D",$"contentBounds: center={_contentCenter} size={_contentSize}");_loaded=true;var ec=GetNodeOrNull<SceneEventController>("/root/EventController");if(ec!=null){_eventController=ec;ec.AddUpdateEventListener(this);}GameLogger.Log("LIVE2D",$"Model loaded: {_nativeModel.ParameterCount} params, {_nativeModel.DrawableCount} drawables, canvas={_nativeModel.CanvasWidth}x{_nativeModel.CanvasHeight}");}private ulong _lastTickMsec;/// <summary> Per-frame update called by the SceneEventController. Execution order (SDK LAppModel::Update): <list type="number"> <item>Apply deferred layout (first valid frame only)</item> <item>Update <see cref="_targetPoint"/> for face tracking</item> <item>Restore the parameter snapshot (<c>LoadParameters</c>)</item> <item>Evaluate motion queue (character animations)</item> <item>Save the parameter snapshot (<c>SaveParameters</c>) — additive layers (expressions, breath, face-track) start every frame from the same baseline and never compound frame-over-frame</item> <item>Run effect layers in SDK 5 scheduler order (ICubismUpdater.hpp): eye blink → expression → look/face-track → breath → physics → pose</item> <item>Call <c>csmUpdateModel</c> on the native model</item> <item>Update the renderer (visibility, opacity, vertex positions)</item> </list> </summary>
public void OnUpdate(){if(!_loaded)return;// If the node was freed while detached from the tree, _ExitTree never ran and the stale registration survives: bail out before any Godot API call on the disposed meshes (ObjectDisposedException).
if(!GodotObject.IsInstanceValid(this)|| !IsInsideTree())return;var now=Time.GetTicksMsec();var dt=_lastTickMsec==0?0.016f:(now-_lastTickMsec)/1000f;_lastTickMsec=now;DeferredApplyLayout();_userTimeSeconds+=dt;_targetPoint.Update(dt);// LAppModel.cpp:388-398 — Load/SaveParameters bracket around the motion phase. Without it, Additive/Multiply expressions and breath deltas accumulate frame-over-frame and drive parameters into their clamps.
_nativeModel.LoadParameters();_motionManager.DoUpdateMotion(_nativeModel,_userTimeSeconds);_nativeModel.SaveParameters();// Manual values survive the snapshot bracket: LoadParameters restores the pre-motion baseline and would otherwise silently revert SetLiveParameter.
ReapplyManualParameters();// SDK 5 update-scheduler order (ICubismUpdater.hpp): EyeBlink(200) → Expression(300) → Look(400) → Breath(500) → Physics(600) → Pose(800).
_eyeBlink?.UpdateParameters(_nativeModel,dt);_expressionManager.DoUpdateMotion(_nativeModel,_userTimeSeconds);InjectFaceTrackParameters();_breath?.UpdateParameters(_nativeModel,dt);_physics?.Evaluate(_nativeModel,dt);_pose?.UpdateParameters(_nativeModel,dt);_nativeModel.UpdateModel();_renderer.UpdateModel();}/// <summary> Frees the pinned native model memory when the node leaves the scene tree. Without this the pinned GCHandles (moc + model buffers) leak for the rest of the process — <see cref="CubismNativeModel.Dispose"/> is unreachable otherwise. </summary>
public override void _ExitTree(){// Invalidate before anything else: the autoload controller may dispatch OnUpdate between tree exit and the deferred delete pass, and the meshes die together with this subtree.
_loaded=false;if(_eventController!=null){_eventController.RemoveUpdateEventListener(this);_eventController=null;}_nativeModel?.Dispose();_nativeModel=null;}/// <summary> Adds the face-tracking target to the native parameter values via the precomputed index cache — no string reads or comparisons in the per-frame path. Parameters claimed through <see cref="SetLiveParameter"/> are skipped. Mirrors SDK CubismLook::UpdateParameters (AddParameterValue): AngleX/Y = target × 30, EyeBallX/Y = target — layered on top of the motion result, never replacing it. The Load/Save bracket in <see cref="OnUpdate"/> keeps the addition from accumulating across frames. </summary>
private unsafe void InjectFaceTrackParameters(){if(_faceTrackCache.Length==0)return;var values=_nativeModel.GetParameterValues();var tx=_targetPoint.X;var ty=_targetPoint.Y;for(int i=0;i<_faceTrackCache.Length;i++){var entry=_faceTrackCache[i];if(_manualValues.ContainsKey(entry.Id))continue;values[entry.Index]+=(entry.FromTargetX?tx:ty)*entry.Scale;}}/// <summary> Restores values set through <see cref="SetLiveParameter"/> after <c>LoadParameters</c> resets the native parameter arrays to the snapshot. </summary>
private unsafe void ReapplyManualParameters(){if(_manualValues.Count==0)return;var values=_nativeModel.GetParameterValues();foreach(var kv in _manualValues){if(!_parameterIndexById.TryGetValue(kv.Key,out var index))continue;values[index]=kv.Value;}}private void DeferredApplyLayout(){if(_layoutLogged)return;if(!_contentBoundsReady)return;// Layout from model3.json takes precedence over the Fit logic (same as LAppModel::SetupModel -> modelMatrix->SetupFromLayout).
var layout=_modelSetting?.GetLayoutMap();if(layout!=null&&layout.Count>0){ApplyLayoutFromModelSetting(layout);return;}var parentSize=GetParentSize();if(parentSize.X<=0f||parentSize.Y<=0f)return;var cw=_contentSize.X;var ch=_contentSize.Y;if(cw<=0f||ch<=0f)return;float scale=1f;switch(ScaleFit){case FitMode.FitWidth:scale=parentSize.X/cw;break;case FitMode.FitHeight:scale=parentSize.Y/ch;break;case FitMode.FitAuto:scale=Mathf.Min(parentSize.X/cw,parentSize.Y/ch);break;}Scale=new Vector2(scale,scale);Position=new Vector2(parentSize.X*AnchorX-_contentCenter.X*scale,parentSize.Y*AnchorY-_contentCenter.Y*scale);_layoutLogged=true;GameLogger.Log("LIVE2D",$"ApplyLayout: parentSize={parentSize} contentSize={cw}x{ch} contentCenter={_contentCenter} scale={scale} pos={Position}");}/// <summary> Applies the "Layout" section of model3.json through <see cref="CubismModelMatrix.SetupFromLayout"/> (SDK semantics: canvas logical units, model height defaults to 2 units via the matrix constructor). The logical view scale maps 2 logical units to the parent height (like the SDK samples' view matrix); Y is flipped because Godot's Y axis points down. </summary>
private void ApplyLayoutFromModelSetting(Dictionary<string,float>layout){var ppu=_nativeModel.PixelsPerUnit;if(ppu<=0f)ppu=1f;var matrix=new CubismModelMatrix(CanvasWidth/ppu,CanvasHeight/ppu);matrix.SetupFromLayout(layout);var parentSize=GetParentSize();var logicalPixelsPerUnit=parentSize.Y>0f?parentSize.Y/2f:ppu;Scale=new Vector2(matrix.ScaleX*logicalPixelsPerUnit/ppu,matrix.ScaleY*logicalPixelsPerUnit/ppu);Position=new Vector2(matrix.TranslateX*logicalPixelsPerUnit,-matrix.TranslateY*logicalPixelsPerUnit);_layoutLogged=true;GameLogger.Log("LIVE2D",$"ApplyModelSettingLayout: matrixScale=({matrix.ScaleX:F3},{matrix.ScaleY:F3}) translate=({matrix.TranslateX:F3},{matrix.TranslateY:F3}) nodeScale={Scale} pos={Position}");}private Vector2 GetParentSize(){var parent=GetParent();if(parent is Control c&&c.Size.X>0f&&c.Size.Y>0f)return c.Size;return GetViewportRect().Size;}private unsafe void SetupParameters(){var count=_nativeModel.ParameterCount;Parameters=new CubismParameter[count];var idsPtr=_nativeModel.GetParameterIds();var mins=_nativeModel.GetParameterMinimumValues();var maxs=_nativeModel.GetParameterMaximumValues();var defaults=_nativeModel.GetParameterDefaultValues();for(int i=0;i<count;i++){Parameters[i]=new CubismParameter(_nativeModel){Id=CubismNativeModel.ReadStringFromPtrArray(idsPtr,i),Index=i,MinimumValue=mins[i],MaximumValue=maxs[i],DefaultValue=defaults[i],Value=defaults[i]};}// Parameter index cache is built once after load — hot loops resolve by index only.
_parameterIndexById.Clear();for(int i=0;i<count;i++)_parameterIndexById[Parameters[i].Id]=i;_faceTrackCache=BuildFaceTrackCache();}/// <summary> Precomputes the face-track injection list once after load: ParamAngleX/Y (×30) and ParamEyeBallX/Y (×1) — LAppModel semantics. Parameters absent from the model are skipped. </summary>
private FaceTrackEntry[]BuildFaceTrackCache(){var specs=new(string Id,bool FromTargetX,float Scale)[]{("ParamAngleX",true,30f),("ParamAngleY",false,30f),("ParamEyeBallX",true,1f),("ParamEyeBallY",false,1f),};var result=new List<FaceTrackEntry>(specs.Length);foreach(var spec in specs){if(!_parameterIndexById.TryGetValue(spec.Item1,out var index))continue;result.Add(new FaceTrackEntry{Index=index,Id=spec.Item1,FromTargetX=spec.Item2,Scale=spec.Item3});}return result.ToArray();}private unsafe void SetupParts(){var count=_nativeModel.GetPartCount();PartOpacities=new CubismPartOpacity[count];var idsPtr=_nativeModel.GetPartIds();var opacities=_nativeModel.GetPartOpacities();for(int i=0;i<count;i++){PartOpacities[i]=new CubismPartOpacity(_nativeModel){Id=CubismNativeModel.ReadStringFromPtrArray(idsPtr,i),Index=i,Value=opacities[i]};}}private void SetupEffects(){// Collect the Groups (EyeBlink/LipSync) parameter ids once — they are wired into every motion via SetEffectIds (see StartMotion).
_eyeBlinkIds.Clear();for(int i=0;i<_modelSetting.GetEyeBlinkParameterCount();i++)_eyeBlinkIds.Add(_modelSetting.GetEyeBlinkParameterId(i));_lipSyncIds.Clear();for(int i=0;i<_modelSetting.GetLipSyncParameterCount();i++)_lipSyncIds.Add(_modelSetting.GetLipSyncParameterId(i));if(EnableEyeBlink&&_eyeBlinkIds.Count>0){_eyeBlink=new CubismEyeBlink();_eyeBlink.SetParameterIds(_eyeBlinkIds);}var physicsPath=_modelSetting.GetPhysicsFileName();if(EnablePhysics&& !string.IsNullOrEmpty(physicsPath)){var physBytes=ReadModFile(physicsPath);if(physBytes!=null)_physics=new CubismPhysics(physBytes);else GameLogger.Log("LIVE2D",$"SetupEffects: failed to read physics '{physicsPath}'",LogLevel.Warning);}var posePath=_modelSetting.GetPoseFileName();if(EnablePose&& !string.IsNullOrEmpty(posePath)){var poseBytes=ReadModFile(posePath);if(poseBytes!=null)_pose=new CubismPose(poseBytes);else GameLogger.Log("LIVE2D",$"SetupEffects: failed to read pose '{posePath}'",LogLevel.Warning);}}private byte[]ReadModFile(string relativePath){var normalized=relativePath.Replace('\\','/');var combined=string.IsNullOrEmpty(_modelDir)?normalized:$"{_modelDir}/{normalized}";var result=_fileProvider.ReadFileBinary(combined);if(result==null)GameLogger.Log("LIVE2D",$"ReadModFile: provider returned null for '{combined}'",LogLevel.Warning);return result;}/// <summary> Sets the face-tracking target. Drives eye/head movement parameters (ParamAngleX/Y, ParamEyeBallX/Y) through a smooth damped approach in <see cref="CubismTargetPoint"/>. </summary>
public void SetTargetPoint(float x,float y)=>_targetPoint.Set(x,y);/// <summary> Finds a parameter by its Cubism ID string (e.g. "ParamAngleX"). Returns null if not found. </summary>
public CubismParameter FindParameter(string id){foreach(var p in Parameters)if(p.Id==id)return p;return null;}/// <summary> Finds a part by its Cubism ID string. Returns null if not found. </summary>
public CubismPartOpacity FindPart(string id){foreach(var p in PartOpacities)if(p.Id==id)return p;return null;}/// <summary> Sets a parameter value directly in the native model, bypassing the per-frame face-tracking injection in <see cref="OnUpdate"/>. Once a parameter is set via this method, <see cref="OnUpdate"/> stops overwriting it — the caller takes full control of that parameter's value. Safe to call before the model is loaded — silently ignored. </summary> <param name="id">Cubism parameter ID (e.g. "ParamAngleX").</param> <param name="value">Raw parameter value. Caller should clamp to the parameter's bounds.</param>
public void SetLiveParameter(string id,float value){if(!_loaded){GameLogger.Log("LIVE2D",$"SetLiveParameter: model not loaded, ignoring id='{id}'",LogLevel.Warning);return;}unsafe{if(!_parameterIndexById.TryGetValue(id,out var index)){GameLogger.Log("LIVE2D",$"SetLiveParameter: parameter '{id}' not found",LogLevel.Warning);return;}_nativeModel.GetParameterValues()[index]=value;_manualValues[id]=value;if(index<Parameters.Length)Parameters[index].Value=value;}}/// <summary> Starts a motion animation by group name and index. Loads the motion3.json from the model directory, applies the model-specified fade-in/fade-out times, and enqueues it. Safe to call before the model is loaded — the call is silently ignored. </summary> <param name="group">Motion group name from model3.json (e.g. "Idle", "TapBody").</param> <param name="no">Zero-based index within the group.</param>
public void StartMotion(string group,int no){if(!_loaded)return;if(no<0||no>=_modelSetting.GetMotionCount(group))return;var motionFileName=_modelSetting.GetMotionFileName(group,no);var motionBytes=ReadModFile(motionFileName);if(motionBytes==null)return;var motion=new CubismMotion(motionBytes);var fadeIn=_modelSetting.GetMotionFadeInTimeValue(group,no);var fadeOut=_modelSetting.GetMotionFadeOutTimeValue(group,no);if(fadeIn>=0f)motion.FadeInSeconds=fadeIn;if(fadeOut>=0f)motion.FadeOutSeconds=fadeOut;// Wire the motion's model curves (EyeBlink/LipSync) to the parameter ids from model3.json Groups — same as LAppModel.StartMotion.
motion.SetEffectIds(_eyeBlinkIds,_lipSyncIds);_motionManager.StartMotion(motion,true,_userTimeSeconds);}/// <summary> Starts an expression by its name (as defined in the model3.json Expressions array). Looks up the expression file, loads the .exp3.json, and enqueues it in the expression manager. Safe to call before model loaded — silently ignored. </summary>
public void StartExpression(string expressionId){if(!_loaded)return;for(int i=0;i<_modelSetting.GetExpressionCount();i++){if(_modelSetting.GetExpressionName(i)==expressionId){var expBytes=ReadModFile(_modelSetting.GetExpressionFileName(i));if(expBytes==null)return;var expr=new CubismExpressionMotion(expBytes);_expressionManager.StartMotion(expr,true,_userTimeSeconds);return;}}}/// <summary>Stops all currently playing motion animations.</summary>
public void StopMotion()=>_motionManager.StopAllMotions();/// <summary>Stops all currently playing expression animations.</summary>
public void StopExpression()=>_expressionManager.StopAllMotions();}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismModelSettingJson.cs =====
using System.Collections.Generic;using System.Linq;using System.Text.Json;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Interface for reading a Cubism model3.json setting file. All accessors return default/empty values when the corresponding data is absent from the JSON. </summary>
public interface ICubismModelSetting{/// <summary>Returns the relative path to the .moc3 file.</summary>
string GetModelFileName();/// <summary>Returns the number of texture references in the model.</summary>
int GetTextureCount();/// <summary>Returns the relative path to the texture at the given index.</summary>
string GetTextureFileName(int index);/// <summary>Returns the relative path to the physics3.json file, or null.</summary>
string GetPhysicsFileName();/// <summary>Returns the relative path to the pose3.json file, or null.</summary>
string GetPoseFileName();/// <summary>Returns the number of expressions defined in the model.</summary>
int GetExpressionCount();/// <summary>Returns the display name of the expression at the given index.</summary>
string GetExpressionName(int index);/// <summary>Returns the relative path to the .exp3.json file for the expression at the given index.</summary>
string GetExpressionFileName(int index);/// <summary>Returns the number of motion groups.</summary>
int GetMotionGroupCount();/// <summary>Returns the name of the motion group at the given index.</summary>
string GetMotionGroupName(int index);/// <summary>Returns the number of motions within the named group.</summary>
int GetMotionCount(string groupName);/// <summary>Returns the relative path to the motion3.json file for the given group and index.</summary>
string GetMotionFileName(string groupName,int index);/// <summary>Returns the relative path to the associated sound file, or null.</summary>
string GetMotionSoundFileName(string groupName,int index);/// <summary>Returns the fade-in time in seconds for the motion, or -1 if not specified.</summary>
float GetMotionFadeInTimeValue(string groupName,int index);/// <summary>Returns the fade-out time in seconds for the motion, or -1 if not specified.</summary>
float GetMotionFadeOutTimeValue(string groupName,int index);/// <summary>Returns the number of parameters in the EyeBlink group.</summary>
int GetEyeBlinkParameterCount();/// <summary>Returns the parameter ID at the given index within the EyeBlink group.</summary>
string GetEyeBlinkParameterId(int index);/// <summary>Returns the number of parameters in the LipSync group.</summary>
int GetLipSyncParameterCount();/// <summary>Returns the parameter ID at the given index within the LipSync group.</summary>
string GetLipSyncParameterId(int index);/// <summary>Returns the number of hit area definitions.</summary>
int GetHitAreasCount();/// <summary>Returns the hit area ID at the given index.</summary>
string GetHitAreaId(int index);/// <summary>Returns the hit area display name at the given index.</summary>
string GetHitAreaName(int index);/// <summary> Returns the Layout section as a dictionary of lowercased key -> float value. Supported keys: width, height, x, y, center_x, center_y, top, bottom, left, right. </summary>
Dictionary<string,float>GetLayoutMap();}/// <summary> JSON parser for the Cubism .model3.json format (Live2D model settings). Implements <see cref="ICubismModelSetting"/>. Provides typed access to all sections: FileReferences (moc, textures, expressions, motions), Groups (EyeBlink, LipSync), HitAreas, and Layout. All string path values are returned as-is (relative to the model directory); it is the caller's responsibility to resolve them through a file provider. Instances hold a <see cref="JsonDocument"/> and should be disposed via <see cref="Dispose"/> when no longer needed. </summary>
public class CubismModelSettingJson:ICubismModelSetting{private readonly JsonDocument _json;private readonly JsonElement _root;private readonly JsonElement?_fileRefs;private readonly JsonElement?_groups;private readonly JsonElement?_hitAreas;private readonly JsonElement?_layout;/// <summary> Parses the given .model3.json bytes. </summary>
public CubismModelSettingJson(byte[]jsonBytes){_json=JsonDocument.Parse(jsonBytes);_root=_json.RootElement;if(_root.TryGetProperty("FileReferences",out var fr))_fileRefs=fr;if(_root.TryGetProperty("Groups",out var gr))_groups=gr;if(_root.TryGetProperty("HitAreas",out var ha))_hitAreas=ha;if(_root.TryGetProperty("Layout",out var lo))_layout=lo;}public string GetModelFileName()=>_fileRefs?.GetProperty("Moc").GetString();public int GetTextureCount()=>_fileRefs?.TryGetProperty("Textures",out var arr)==true?arr.GetArrayLength():0;public string GetTextureFileName(int index)=>_fileRefs?.GetProperty("Textures")[index].GetString();public string GetPhysicsFileName()=>TryGetString(_fileRefs,"Physics");public string GetPoseFileName()=>TryGetString(_fileRefs,"Pose");public int GetExpressionCount()=>_fileRefs?.TryGetProperty("Expressions",out var arr)==true?arr.GetArrayLength():0;public string GetExpressionName(int index)=>_fileRefs?.GetProperty("Expressions")[index].GetProperty("Name").GetString();public string GetExpressionFileName(int index)=>_fileRefs?.GetProperty("Expressions")[index].GetProperty("File").GetString();public int GetMotionGroupCount()=>_fileRefs?.TryGetProperty("Motions",out var motions)==true?motions.EnumerateObject().Count():0;public string GetMotionGroupName(int index){int i=0;foreach(var prop in _fileRefs?.GetProperty("Motions").EnumerateObject()??default)if(i++ ==index)return prop.Name;return null;}public int GetMotionCount(string groupName)=>_fileRefs?.TryGetProperty("Motions",out var m)==true&&m.TryGetProperty(groupName,out var grp)?grp.GetArrayLength():0;public string GetMotionFileName(string groupName,int index)=>_fileRefs?.GetProperty("Motions").GetProperty(groupName)[index].GetProperty("File").GetString();public string GetMotionSoundFileName(string groupName,int index)=>TryGetString(_fileRefs?.GetProperty("Motions").GetProperty(groupName)[index],"Sound");public float GetMotionFadeInTimeValue(string groupName,int index)=>TryGetFloat(_fileRefs?.GetProperty("Motions").GetProperty(groupName)[index],"FadeInTime");public float GetMotionFadeOutTimeValue(string groupName,int index)=>TryGetFloat(_fileRefs?.GetProperty("Motions").GetProperty(groupName)[index],"FadeOutTime");public int GetEyeBlinkParameterCount(){foreach(var group in _groups?.EnumerateArray()??default)if(group.GetProperty("Name").GetString()=="EyeBlink")return group.GetProperty("Ids").GetArrayLength();return 0;}public string GetEyeBlinkParameterId(int index){foreach(var group in _groups?.EnumerateArray()??default)if(group.GetProperty("Name").GetString()=="EyeBlink")return group.GetProperty("Ids")[index].GetString();return null;}public int GetLipSyncParameterCount(){foreach(var group in _groups?.EnumerateArray()??default)if(group.GetProperty("Name").GetString()=="LipSync")return group.GetProperty("Ids").GetArrayLength();return 0;}public string GetLipSyncParameterId(int index){foreach(var group in _groups?.EnumerateArray()??default)if(group.GetProperty("Name").GetString()=="LipSync")return group.GetProperty("Ids")[index].GetString();return null;}public int GetHitAreasCount()=>_hitAreas?.GetArrayLength()??0;public string GetHitAreaId(int index)=>_hitAreas?[index].GetProperty("Id").GetString();public string GetHitAreaName(int index)=>_hitAreas?[index].GetProperty("Name").GetString();public Dictionary<string,float>GetLayoutMap(){var map=new Dictionary<string,float>();if(_layout==null)return map;foreach(var prop in _layout.Value.EnumerateObject())if(prop.Value.ValueKind==JsonValueKind.Number)map[prop.Name.ToLowerInvariant()]=prop.Value.GetSingle();return map;}private static string TryGetString(JsonElement?parent,string key)=>parent?.TryGetProperty(key,out var val)==true?val.GetString():null;private static float TryGetFloat(JsonElement?parent,string key)=>parent?.TryGetProperty(key,out var val)==true&&val.ValueKind==JsonValueKind.Number?val.GetSingle():-1f;/// <summary> Releases the underlying <see cref="JsonDocument"/>. </summary>
public void Dispose()=>_json?.Dispose();}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismMotion.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Live2D.Cubism.Framework{public delegate float MotionSegmentFunction(float[]segments,int baseIndex,float time);/// <summary> Версия обработки зацикленного времени — порт CubismMotion::MotionBehavior (SDK 5-r.5). V2 (по умолчанию): длительность цикла расширяется на один кадр (1/Fps), а переход между циклами интерполируется через коррекцию конечной точки (CorrectEndPoint). V1: время цикла жёстко перезаписывается без кадра-хвоста (поведение старых SDK). </summary>
public enum MotionBehavior{V1=0,V2=1,}/// <summary> Parses and plays a Cubism .motion3.json animation. Manages three categories of curves: <list type="bullet"> <item><b>Model curves</b> — eye blink, lip sync, and opacity</item> <item><b>Parameter curves</b> — arbitrary model parameter animations</item> <item><b>Part opacity curves</b> — visibility/opacity of model parts</item> </list> Данные хранятся как в нативном CubismMotionData: один общий плоский массив точек (<see cref="CubismMotionPoint"/>) и общий массив сегментов (<see cref="MotionSegmentData"/>); каждая кривая ссылается на свой диапазон сегментов через BaseSegmentIndex/SegmentCount. Оценка кривой сначала ищет сегмент по времени ПЕРВОЙ точки следующего сегмента, а за пределами последнего сегмента возвращает значение последней точки (при зацикливании V2 — интерполирует переход конца в начало). Обновление параметров (<see cref="DoUpdateParameters"/>) — четыре прохода, порт SDK: <list type="number"> <item>Model-кривые: EyeBlink/LipSync значения и ModelOpacity</item> <item>Parameter-кривые: модуляция на EyeBlink (* значение) и LipSync (+ значение)</item> <item>Не анимированные параметры групп EyeBlink/LipSync: лерп с fade-весом</item> <item>PartOpacity-кривые</item> </list> Фейд применяется по кривой: если у кривой заданы собственные FadeInTime/FadeOutTime — используется они (база — фейд самого моушена), иначе общий fade-вес моушена. </summary>
public class CubismMotion:ACubismMotion{private const string ModelCurveIdEyeBlink="EyeBlink";private const string ModelCurveIdLipSync="LipSync";private const string ModelCurveIdOpacity="Opacity";/// <summary>Порт SDK-константы MaxTargetSize: лимит целей EyeBlink/LipSync (ширина битовой маски).</summary>
private const int MaxTargetSize=64;/// <summary>Порт CubismMath::Epsilon — порог сравнения в алгоритме Кардано.</summary>
private const float MathEpsilon=0.00001f;private readonly float _sourceFrameRate;private float _lastWeight;private float _loopDurationSeconds= -1f;private MotionBehavior _motionBehavior=MotionBehavior.V2;private readonly CubismMotionJson _motionJson;private readonly bool _areBeziersRestricted;private readonly List<MotionCurveData>_modelCurves=new();private readonly List<MotionCurveData>_parameterCurves=new();private readonly List<MotionCurveData>_partOpacityCurves=new();// Общие плоские хранилища точек и сегментов (порт CubismMotionData.Points/Segments).
private CubismMotionPoint[]_points=Array.Empty<CubismMotionPoint>();private MotionSegmentData[]_segments=Array.Empty<MotionSegmentData>();private readonly List<string>_eyeBlinkParameterIds=new();private readonly List<string>_lipSyncParameterIds=new();private readonly List<CubismMotionEvent>_events=new();private float _modelOpacity=1f;/// <summary> Parses a .motion3.json byte buffer: метаданные (длительность, loop, fps, фейды, признак restricted-безье) и кривые/события через <see cref="ParseCurves"/>/<see cref="ParseEvents"/>. Отсутствующее или отрицательное время фейда заменяется на 1 c — порт CubismMotion::Parse. </summary>
public CubismMotion(byte[]motion3JsonBytes){_motionJson=new CubismMotionJson(motion3JsonBytes);_sourceFrameRate=_motionJson.GetFps();_areBeziersRestricted=_motionJson.AreBeziersRestricted();_isLoop=_motionJson.IsLoop();_loopDurationSeconds=_motionJson.GetDuration();var fadeIn=_motionJson.GetFadeInTime();var fadeOut=_motionJson.GetFadeOutTime();_fadeInSeconds=fadeIn<0f?1f:fadeIn;_fadeOutSeconds=fadeOut<0f?1f:fadeOut;ParseCurves();ParseEvents();}/// <summary> Configures which parameters are affected by eye blink and lip sync modulation (обычно из Groups модели в model3.json). Кривая параметра из этих групп умножается на значение EyeBlink-кривой / складывается с LipSync в проходе 2; параметры групп без собственной кривой получают лерп в проходе 3. </summary>
public void SetEffectIds(List<string>eyeBlinkIds,List<string>lipSyncIds){_eyeBlinkParameterIds.Clear();_lipSyncParameterIds.Clear();if(eyeBlinkIds!=null)_eyeBlinkParameterIds.AddRange(eyeBlinkIds);if(lipSyncIds!=null)_lipSyncParameterIds.AddRange(lipSyncIds);}/// <summary>Returns -1 for looping motions (they never end), иначе полную длительность.</summary>
public override float GetDuration()=>_isLoop? -1f:_loopDurationSeconds;/// <summary>Полная длительность одного цикла, даже если моушен зациклен.</summary>
public override float GetLoopDuration()=>_loopDurationSeconds;/// <summary>Порт CubismMotion::SetMotionBehavior — версия обработки зацикленного времени.</summary>
public void SetMotionBehavior(MotionBehavior behavior)=>_motionBehavior=behavior;/// <summary>Порт CubismMotion::GetMotionBehavior.</summary>
public MotionBehavior GetMotionBehavior()=>_motionBehavior;/// <summary> true, если среди Model-кривых есть кривая "Opacity" — порт CubismMotion::IsExistModelOpacity: наличие кривой, а не текущее значение прозрачности. </summary>
public bool IsExistModelOpacity(){foreach(var curve in _modelCurves)if(curve.Id==ModelCurveIdOpacity)return true;return false;}/// <summary> Текущая ModelOpacity из Model-кривой "Opacity" (1, если кривой нет). Рендерер должен умножать на неё прозрачность drawable'ов — SDK применяет её в CubismModel::SetModelOpacity, здесь значение отдаётся наружу. </summary>
public float GetModelOpacity()=>_modelOpacity;/// <summary> События (UserData), сработавшие в интервале (beforeCheckTime, motionTime]. Список очищается при каждом вызове; очередь вызывает метод раз в кадр с lastCheckTime..currentTime, поэтому события не теряются между кадрами. </summary>
public override List<string>GetFiredEvent(float beforeCheckTimeSeconds,float motionTimeSeconds){_firedEventValues.Clear();foreach(var evt in _events)if(evt.FireTime>beforeCheckTimeSeconds&&evt.FireTime<=motionTimeSeconds)_firedEventValues.Add(evt.Value);return _firedEventValues;}/// <summary>Совместимая обёртка над <see cref="GetFiredEvent"/> с тем же списком результатов.</summary>
public List<string>GetFiredEventValue(float beforeTime,float motionTime)=>GetFiredEvent(beforeTime,motionTime);/// <summary> Four-pass parameter update. Called by <see cref="CubismMotionQueueManager"/> every frame (через <see cref="ACubismMotion.UpdateParameters"/>). </summary>
public override unsafe void DoUpdateParameters(CubismNativeModel model,float userTimeSeconds,float fadeWeight,CubismMotionQueueEntry entry){if(model==null||entry==null)return;// Порт CubismMotion::DoUpdateParameters: пересчёт EndTime при смене loop-состояния (V2).
if(_motionBehavior==MotionBehavior.V2&&_previousLoopState!=_isLoop){AdjustEndTime(entry);_previousLoopState=_isLoop;}float timeOffsetSeconds=userTimeSeconds-entry.StartTime;if(timeOffsetSeconds<0f)timeOffsetSeconds=0f;// защита от старта в "будущем"
float lipSyncValue=float.MaxValue;// FLT_MAX — сигнатура "кривой нет"
float eyeBlinkValue=float.MaxValue;ulong lipSyncFlags=0UL;ulong eyeBlinkFlags=0UL;// Базовые (моушен-уровень) фейды для кривых без собственных fade-настроек.
float tmpFadeIn=_fadeInSeconds<=0f?1f:CubismMath.GetEasingSine((userTimeSeconds-entry.FadeInStartTime)/_fadeInSeconds);float tmpFadeOut=(_fadeOutSeconds<=0f||entry.EndTime<0f)?1f:CubismMath.GetEasingSine((entry.EndTime-userTimeSeconds)/_fadeOutSeconds);// 'Repeat' time as necessary.
float time=timeOffsetSeconds;float duration=_loopDurationSeconds;bool isCorrection=_motionBehavior==MotionBehavior.V2&&_isLoop;if(_isLoop){if(_motionBehavior==MotionBehavior.V2)duration+=1f/_sourceFrameRate;// кадр-хвост для интерполяции цикла
while(time>duration)time-=duration;}var idsPtr=model.GetParameterIds();var values=model.GetParameterValues();var minValues=model.GetParameterMinimumValues();var maxValues=model.GetParameterMaximumValues();// Индексы параметров модели: порт model->GetParameterIndex(curveId).
var paramIndexById=new Dictionary<string,int>(model.ParameterCount);for(int i=0;i<model.ParameterCount;i++)paramIndexById[CubismNativeModel.ReadStringFromPtrArray(idsPtr,i)]=i;// Pass 1 — model curves (EyeBlink / LipSync / Opacity).
foreach(var curve in _modelCurves){float value=EvaluateCurveInternal(curve,_points,_segments,time,isCorrection,duration,_areBeziersRestricted);if(curve.Id==ModelCurveIdEyeBlink)eyeBlinkValue=value;else if(curve.Id==ModelCurveIdLipSync)lipSyncValue=value;else if(curve.Id==ModelCurveIdOpacity){// Порт: _modelOpacity = value; model->SetModelOpacity(...). Значение отдаётся наружу через GetModelOpacity().
_modelOpacity=value;}}// Pass 2 — parameter curves с модуляцией EyeBlink/LipSync и пер-кривым фейдом.
foreach(var curve in _parameterCurves){if(!paramIndexById.TryGetValue(curve.Id,out int parameterIndex))continue;float sourceValue=values[parameterIndex];float value=EvaluateCurveInternal(curve,_points,_segments,time,isCorrection,duration,_areBeziersRestricted);if(eyeBlinkValue!=float.MaxValue){for(int i=0;i<_eyeBlinkParameterIds.Count&&i<MaxTargetSize;i++){if(_eyeBlinkParameterIds[i]==curve.Id){value*=eyeBlinkValue;eyeBlinkFlags|=1UL<<i;break;}}}if(lipSyncValue!=float.MaxValue){for(int i=0;i<_lipSyncParameterIds.Count&&i<MaxTargetSize;i++){if(_lipSyncParameterIds[i]==curve.Id){value+=lipSyncValue;lipSyncFlags|=1UL<<i;break;}}}float v;if(curve.FadeInTime<0f&&curve.FadeOutTime<0f){// Собственных фейдов у кривой нет — применяется общий fade-вес моушена.
v=sourceValue+(value-sourceValue)*fadeWeight;}else{// Порт: база — фейд самого моушена (tmpFadeIn/tmpFadeOut), не 1.
float fin=curve.FadeInTime<0f?tmpFadeIn:curve.FadeInTime==0f?1f:CubismMath.GetEasingSine((userTimeSeconds-entry.FadeInStartTime)/curve.FadeInTime);float fout=curve.FadeOutTime<0f?tmpFadeOut:(curve.FadeOutTime==0f||entry.EndTime<0f)?1f:CubismMath.GetEasingSine((entry.EndTime-userTimeSeconds)/curve.FadeOutTime);float paramWeight=_weight*fin*fout;v=sourceValue+(value-sourceValue)*paramWeight;}// Порт model->SetParameterValue: значение зажимается в [min, max] параметра.
values[parameterIndex]=ClampParameterValue(v,minValues[parameterIndex],maxValues[parameterIndex]);}// Pass 3 — EyeBlink/LipSync для параметров, не анимированных кривыми: лерп с fade-весом.
if(eyeBlinkValue!=float.MaxValue){for(int i=0;i<_eyeBlinkParameterIds.Count&&i<MaxTargetSize;i++){// Моушен уже записал этот параметр в проходе 2 — моргание не применяем.
if((eyeBlinkFlags>>i&1UL)!=0UL)continue;if(!paramIndexById.TryGetValue(_eyeBlinkParameterIds[i],out int pi))continue;float sourceValue=values[pi];float v=sourceValue+(eyeBlinkValue-sourceValue)*fadeWeight;values[pi]=ClampParameterValue(v,minValues[pi],maxValues[pi]);}}if(lipSyncValue!=float.MaxValue){for(int i=0;i<_lipSyncParameterIds.Count&&i<MaxTargetSize;i++){if((lipSyncFlags>>i&1UL)!=0UL)continue;if(!paramIndexById.TryGetValue(_lipSyncParameterIds[i],out int pi))continue;float sourceValue=values[pi];float v=sourceValue+(lipSyncValue-sourceValue)*fadeWeight;values[pi]=ClampParameterValue(v,minValues[pi],maxValues[pi]);}}// Pass 4 — part opacity curves (пишутся напрямую в массив прозрачностей частей).
var partOpacities=model.GetPartOpacities();var partIdsPtr=model.GetPartIds();int partCount=model.GetPartCount();var partIndexById=new Dictionary<string,int>(partCount);for(int i=0;i<partCount;i++)partIndexById[CubismNativeModel.ReadStringFromPtrArray(partIdsPtr,i)]=i;foreach(var curve in _partOpacityCurves){if(!partIndexById.TryGetValue(curve.Id,out int pi))continue;partOpacities[pi]=EvaluateCurveInternal(curve,_points,_segments,time,isCorrection,duration,_areBeziersRestricted);}// Завершение/перезапуск: моушен сам выставляет IsFinished и зовёт колбэк (очередь только удаляет finished-записи — порт CubismMotionQueueManager).
if(timeOffsetSeconds>=duration){if(_isLoop)UpdateForNextLoop(entry,userTimeSeconds,time);else{OnFinishedMotion?.Invoke(this);entry.IsFinished=true;}}_lastWeight=fadeWeight;}/// <summary> Порт CubismMotion::UpdateForNextLoop: сдвиг StartTime назад на время внутри цикла; фейд-ин перезапускается только при включённом _isLoopFadeIn (поведение V2). </summary>
private void UpdateForNextLoop(CubismMotionQueueEntry entry,float userTimeSeconds,float time){switch(_motionBehavior){case MotionBehavior.V2:default:entry.StartTime=userTimeSeconds-time;if(_isLoopFadeIn)entry.FadeInStartTime=userTimeSeconds-time;OnFinishedMotion?.Invoke(this);break;case MotionBehavior.V1:entry.StartTime=userTimeSeconds;if(_isLoopFadeIn)entry.FadeInStartTime=userTimeSeconds;break;}}/// <summary> Разбор кривых: порт CubismMotion::Parse. Все точки и сегменты складываются в общие плоские списки; кривая хранит BaseSegmentIndex и количество сегментов. </summary>
private void ParseCurves(){var points=new List<CubismMotionPoint>();var segments=new List<MotionSegmentData>();for(int ci=0;ci<_motionJson.GetCurveCount();ci++){var target=ParseTarget(_motionJson.GetCurveTarget(ci));var curveData=new MotionCurveData{Type=target,Id=_motionJson.GetCurveId(ci),BaseSegmentIndex=segments.Count,// -1 = время фейда кривой не задано в json (как в SDK).
FadeInTime=_motionJson.GetCurveFadeInTime(ci),FadeOutTime=_motionJson.GetCurveFadeOutTime(ci),};_motionJson.GetCurveSegments(ci,out var rawSegments);curveData.SegmentCount=ParseSegmentsInternal(rawSegments,points,segments);switch(target){case CubismMotionCurveTarget.Model:_modelCurves.Add(curveData);break;case CubismMotionCurveTarget.Parameter:_parameterCurves.Add(curveData);break;case CubismMotionCurveTarget.PartOpacity:_partOpacityCurves.Add(curveData);break;}}_points=points.ToArray();_segments=segments.ToArray();}/// <summary> Разбор массива Segments одной кривой — порт 1:1 цикла CubismMotion::Parse. Формат motion3.json: [t0, v0, type, t, v, type, t, v, v, v(bezier)...]: первые два элемента — стартовая точка кривой, дальше для каждого сегмента маркер типа и его точки (Linear/Stepped/InverseStepped — 1 точка, Bezier — 3). Возвращает число сегментов кривой; точки и сегменты дописываются в общие списки. </summary>
internal static int ParseSegmentsInternal(float[]raw,List<CubismMotionPoint>points,List<MotionSegmentData>segments){int count=0;int segmentPosition=0;while(segmentPosition<raw.Length){int basePointIndex;if(segmentPosition==0){// Первая точка кривой: время и значение — не маркер типа.
basePointIndex=points.Count;points.Add(new CubismMotionPoint{Time=raw[segmentPosition],Value=raw[segmentPosition+1]});segmentPosition+=2;}else{// Сегменты продолжаются от последней точки предыдущего сегмента.
basePointIndex=points.Count-1;}var segmentType=(CubismMotionSegmentType)(int)raw[segmentPosition];switch(segmentType){case CubismMotionSegmentType.Linear:case CubismMotionSegmentType.Stepped:case CubismMotionSegmentType.InverseStepped:points.Add(new CubismMotionPoint{Time=raw[segmentPosition+1],Value=raw[segmentPosition+2]});segmentPosition+=3;break;case CubismMotionSegmentType.Bezier:points.Add(new CubismMotionPoint{Time=raw[segmentPosition+1],Value=raw[segmentPosition+2]});points.Add(new CubismMotionPoint{Time=raw[segmentPosition+3],Value=raw[segmentPosition+4]});points.Add(new CubismMotionPoint{Time=raw[segmentPosition+5],Value=raw[segmentPosition+6]});segmentPosition+=7;break;default:// SDK: CSM_ASSERT(0). Неизвестный тип — останавливаем разбор кривой, чтобы не зациклиться на некорректных данных.
return count;}segments.Add(new MotionSegmentData{BasePointIndex=basePointIndex,SegmentType=segmentType});count++;}return count;}/// <summary> Порт CubismMotion::EvaluateCurve (анонимный namespace CubismMotion.cpp). Сегмент ищется по времени ПЕРВОЙ точки следующего сегмента; если время за пределами всех сегментов — значение последней точки, а при зацикленном V2 (isCorrection) — интерполяция конца кривой к её началу (CorrectEndPoint). </summary>
internal static float EvaluateCurveInternal(MotionCurveData curve,CubismMotionPoint[]points,MotionSegmentData[]segments,float time,bool isCorrection,float endTime,bool areBeziersRestricted){int target= -1;int totalSegmentCount=curve.BaseSegmentIndex+curve.SegmentCount;int pointPosition=0;for(int i=curve.BaseSegmentIndex;i<totalSegmentCount;i++){// Первая точка следующего сегмента = конечная точка текущего.
pointPosition=segments[i].BasePointIndex+(segments[i].SegmentType==CubismMotionSegmentType.Bezier?3:1);if(points[pointPosition].Time>time){target=i;break;}}if(target== -1){if(isCorrection&&time<endTime){// Переход конец -> начало при зацикливании (поведение V2).
return CorrectEndPointInternal(segments[totalSegmentCount-1].SegmentType,points,segments[curve.BaseSegmentIndex].BasePointIndex,pointPosition,time,endTime);}return points[pointPosition].Value;}return EvaluateSegmentInternal(segments[target].SegmentType,points,segments[target].BasePointIndex,time,areBeziersRestricted);}/// <summary> Порт CorrectEndPoint: лерп от конечной точки кривой к начальной, чьё время искусственно продлевается до endTime (границы цикла). </summary>
internal static float CorrectEndPointInternal(CubismMotionSegmentType lastSegmentType,CubismMotionPoint[]points,int beginIndex,int endIndex,float time,float endTime){CubismMotionPoint end=points[endIndex];CubismMotionPoint begin=points[beginIndex];begin.Time=endTime;switch(lastSegmentType){case CubismMotionSegmentType.Stepped:return end.Value;case CubismMotionSegmentType.InverseStepped:return begin.Value;default:// Linear / Bezier / прочее — линейная интерполяция
return LinearEvaluatePoints(end,begin,time);}}/// <summary>Диспетчер оценки одного сегмента — порт таблички SegmentType -> Evaluate.</summary>
internal static float EvaluateSegmentInternal(CubismMotionSegmentType segmentType,CubismMotionPoint[]points,int basePointIndex,float time,bool areBeziersRestricted){switch(segmentType){case CubismMotionSegmentType.Linear:return LinearEvaluateInternal(points,basePointIndex,time);case CubismMotionSegmentType.Bezier:return areBeziersRestricted?BezierEvaluateBinarySearchInternal(points,basePointIndex,time):BezierEvaluateCardanoInternal(points,basePointIndex,time);case CubismMotionSegmentType.Stepped:return points[basePointIndex].Value;case CubismMotionSegmentType.InverseStepped:return points[basePointIndex+1].Value;default:return LinearEvaluateInternal(points,basePointIndex,time);}}/// <summary>Порт LerpPoints: лерп времени и значения пары контрольных точек.</summary>
private static CubismMotionPoint LerpPoints(CubismMotionPoint a,CubismMotionPoint b,float t){return new CubismMotionPoint{Time=a.Time+(b.Time-a.Time)*t,Value=a.Value+(b.Value-a.Value)*t,};}/// <summary>Порт LinearEvaluate: лерп пары точек, t не опускается ниже 0.</summary>
internal static float LinearEvaluatePoints(CubismMotionPoint p0,CubismMotionPoint p1,float time){float t=(time-p0.Time)/(p1.Time-p0.Time);if(t<0f)t=0f;return p0.Value+(p1.Value-p0.Value)*t;}internal static float LinearEvaluateInternal(CubismMotionPoint[]points,int baseIndex,float time)=>LinearEvaluatePoints(points[baseIndex],points[baseIndex+1],time);/// <summary>Порт BezierEvaluate (restricted/старые безье): де Кастельо по параметру t из времени.</summary>
internal static float BezierEvaluateInternal(CubismMotionPoint[]points,int baseIndex,float time){float t=(time-points[baseIndex].Time)/(points[baseIndex+3].Time-points[baseIndex].Time);if(t<0f)t=0f;CubismMotionPoint p01=LerpPoints(points[baseIndex],points[baseIndex+1],t);CubismMotionPoint p12=LerpPoints(points[baseIndex+1],points[baseIndex+2],t);CubismMotionPoint p23=LerpPoints(points[baseIndex+2],points[baseIndex+3],t);CubismMotionPoint p012=LerpPoints(p01,p12,t);CubismMotionPoint p123=LerpPoints(p12,p23,t);return LerpPoints(p012,p123,t).Value;}/// <summary>Порт BezierEvaluateBinarySearch: деление отрезка параметра пополам (x_error = 0.01, 20 итераций).</summary>
internal static float BezierEvaluateBinarySearchInternal(CubismMotionPoint[]points,int baseIndex,float time){const float xError=0.01f;float x=time;float x1=points[baseIndex].Time;float x2=points[baseIndex+3].Time;float cx1=points[baseIndex+1].Time;float cx2=points[baseIndex+2].Time;float ta=0f;float tb=1f;float t=0f;int i=0;for(;i<20;i++){if(x<x1+xError){t=ta;break;}if(x2-xError<x){t=tb;break;}float centerx=(cx1+cx2)*0.5f;cx1=(x1+cx1)*0.5f;cx2=(x2+cx2)*0.5f;float ctrlx12=(cx1+centerx)*0.5f;float ctrlx21=(cx2+centerx)*0.5f;centerx=(ctrlx12+ctrlx21)*0.5f;if(x<centerx){tb=(ta+tb)*0.5f;if(centerx-xError<x){t=tb;break;}x2=centerx;cx2=ctrlx12;}else{ta=(ta+tb)*0.5f;if(x<centerx+xError){t=ta;break;}x1=centerx;cx1=ctrlx21;}}if(i==20)t=(ta+tb)*0.5f;if(t<0f)t=0f;if(t>1f)t=1f;CubismMotionPoint p01=LerpPoints(points[baseIndex],points[baseIndex+1],t);CubismMotionPoint p12=LerpPoints(points[baseIndex+1],points[baseIndex+2],t);CubismMotionPoint p23=LerpPoints(points[baseIndex+2],points[baseIndex+3],t);CubismMotionPoint p012=LerpPoints(p01,p12,t);CubismMotionPoint p123=LerpPoints(p12,p23,t);return LerpPoints(p012,p123,t).Value;}/// <summary> Порт BezierEvaluateCardanoInterpretation: параметр t ищется решением кубического уравнения по времени (алгоритм Кардано), затем де Кастельо. </summary>
internal static float BezierEvaluateCardanoInternal(CubismMotionPoint[]points,int baseIndex,float time){float x1=points[baseIndex].Time;float x2=points[baseIndex+3].Time;float cx1=points[baseIndex+1].Time;float cx2=points[baseIndex+2].Time;float a=x2-3f*cx2+3f*cx1-x1;float b=3f*cx2-6f*cx1+3f*x1;float c=3f*cx1-3f*x1;float d=x1-time;float t=CardanoAlgorithmForBezierInternal(a,b,c,d);CubismMotionPoint p01=LerpPoints(points[baseIndex],points[baseIndex+1],t);CubismMotionPoint p12=LerpPoints(points[baseIndex+1],points[baseIndex+2],t);CubismMotionPoint p23=LerpPoints(points[baseIndex+2],points[baseIndex+3],t);CubismMotionPoint p012=LerpPoints(p01,p12,t);CubismMotionPoint p123=LerpPoints(p12,p23,t);return LerpPoints(p012,p123,t).Value;}/// <summary> Порт CubismMath::CardanoAlgorithmForBezier — решение кубического уравнения по формулам Кардано с выбором корня, ближайшего к 0.5, и зажимом в [0,1]. </summary>
internal static float CardanoAlgorithmForBezierInternal(float a,float b,float c,float d){if(MathF.Abs(a)<MathEpsilon)return RangeFInternal(QuadraticEquationInternal(b,c,d),0f,1f);float ba=b/a;float ca=c/a;float da=d/a;float p=(3f*ca-ba*ba)/3f;float p3=p/3f;float q=(2f*ba*ba*ba-9f*ba*ca+27f*da)/27f;float q2=q/2f;float discriminant=q2*q2+p3*p3*p3;const float center=0.5f;const float threshold=center+0.01f;if(discriminant<0f){float mp3= -p/3f;float mp33=mp3*mp3*mp3;float r=MathF.Sqrt(mp33);float t= -q/(2f*r);float cosphi=RangeFInternal(t,-1f,1f);float phi=MathF.Acos(cosphi);float crtr=MathF.Cbrt(r);float t1=2f*crtr;float root1=t1*MathF.Cos(phi/3f)-ba/3f;if(MathF.Abs(root1-center)<threshold)return RangeFInternal(root1,0f,1f);float root2=t1*MathF.Cos((phi+2f*MathF.PI)/3f)-ba/3f;if(MathF.Abs(root2-center)<threshold)return RangeFInternal(root2,0f,1f);float root3=t1*MathF.Cos((phi+4f*MathF.PI)/3f)-ba/3f;return RangeFInternal(root3,0f,1f);}if(discriminant==0f){float u1=q2<0f?MathF.Cbrt(-q2):-MathF.Cbrt(q2);float root1=2f*u1-ba/3f;if(MathF.Abs(root1-center)<threshold)return RangeFInternal(root1,0f,1f);float root2= -u1-ba/3f;return RangeFInternal(root2,0f,1f);}float sd=MathF.Sqrt(discriminant);float uc=MathF.Cbrt(sd-q2);float vc=MathF.Cbrt(sd+q2);return RangeFInternal(uc-vc-ba/3f,0f,1f);}/// <summary>Порт CubismMath::QuadraticEquation — решение вырожденного (a ≈ 0) случая.</summary>
private static float QuadraticEquationInternal(float a,float b,float c){if(MathF.Abs(a)<MathEpsilon){if(MathF.Abs(b)<MathEpsilon)return-c;return-c/b;}return-(b+MathF.Sqrt(b*b-4f*a*c))/(2f*a);}/// <summary>Порт CubismMath::RangeF — зажим значения в [min, max].</summary>
private static float RangeFInternal(float value,float min,float max){if(value<min)value=min;else if(value>max)value=max;return value;}/// <summary>Порт зажима model->SetParameterValue: значение параметра в границах [min, max].</summary>
private static float ClampParameterValue(float value,float min,float max){if(value<min)return min;if(value>max)return max;return value;}private static CubismMotionCurveTarget ParseTarget(string target)=>target switch{"Model"=>CubismMotionCurveTarget.Model,"Parameter"=>CubismMotionCurveTarget.Parameter,"PartOpacity"=>CubismMotionCurveTarget.PartOpacity,_=>CubismMotionCurveTarget.Parameter};private void ParseEvents(){for(int i=0;i<_motionJson.GetEventCount();i++){_events.Add(new CubismMotionEvent{FireTime=_motionJson.GetEventTime(i),Value=_motionJson.GetEventValue(i)});}}}/// <summary> Internal data for a single parsed motion curve. Хранит тип цели, ID, диапазон сегментов в общем плоском массиве (BaseSegmentIndex/SegmentCount) и пер-кривые времена фейда; значения &lt; 0 означают «не задано» (как в SDK). </summary>
internal class MotionCurveData{public CubismMotionCurveTarget Type;public string Id;public int SegmentCount;public int BaseSegmentIndex;public float FadeInTime;public float FadeOutTime;}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismMotionInternal.cs =====
using System.Runtime.InteropServices;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Specifies which part of the model a motion curve affects. </summary>
public enum CubismMotionCurveTarget{Model,Parameter,PartOpacity}/// <summary> Segment interpolation type within a motion curve. <list type="bullet"> <item><see cref="Linear"/> — straight line between two points</item> <item><see cref="Bezier"/> — cubic bezier (4 control points per segment)</item> <item><see cref="Stepped"/> — hold previous value until the segment ends</item> <item><see cref="InverseStepped"/> — jump to next value at segment start</item> </list> </summary>
public enum CubismMotionSegmentType{Linear=0,Bezier=1,Stepped=2,InverseStepped=3}/// <summary> Данные одного сегмента кривой — порт CubismMotionSegment из CubismMotionInternal.hpp: индекс первой контрольной точки в общем плоском массиве точек и тип интерполяции. Соседние сегменты кривой разделяют общую точку: BasePointIndex следующего сегмента равен индексу последней точки предыдущего, а оценка кривой ищет сегмент по времени первой точки СЛЕДУЮЩЕГО сегмента. </summary>
internal struct MotionSegmentData{/// <summary>Индекс первой контрольной точки сегмента в CubismMotion._points.</summary>
public int BasePointIndex;/// <summary>Тип сегмента (Linear/Bezier/Stepped/InverseStepped).</summary>
public CubismMotionSegmentType SegmentType;}/// <summary> A time-value pair representing a keyframe or control point on a motion curve. </summary>
[StructLayout(LayoutKind.Sequential)]public struct CubismMotionPoint{/// <summary>Time in seconds from the start of the motion.</summary>
public float Time;/// <summary>Value at this point. For bezier control points, this is an intermediate value.</summary>
public float Value;}/// <summary> Describes a single curve within a motion. Contains the target type, parameter/part ID, segment count, offset into the flat segment data, and per-curve fade timing. </summary>
public struct CubismMotionCurve{public CubismMotionCurveTarget Type;public string Id;public int SegmentCount;public int BaseSegmentIndex;public float FadeInTime;public float FadeOutTime;}/// <summary> A named event that fires at a specific time during motion playback. Events are defined in the UserData section of the .motion3.json. Accessed via <see cref="CubismMotion.GetFiredEventValue"/>. </summary>
public struct CubismMotionEvent{/// <summary>Time offset in seconds when this event triggers.</summary>
public float FireTime;/// <summary>String value associated with the event.</summary>
public string Value;}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismMotionJson.cs =====
using System.Collections.Generic;using System.Text.Json;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> JSON parser for the Cubism .motion3.json format. Provides typed access to the Meta section (duration, loop, fps, fade times, curve statistics, beziers-restricted flag) and the Curves/UserData sections. Parsed curve segments are returned as flat float arrays (interleaved segment-type markers and point data), decoded by CubismMotion.ParseSegmentsInternal. </summary>
public class CubismMotionJson{private readonly JsonDocument _json;private readonly JsonElement _meta;private readonly JsonElement _curves;private readonly JsonElement _userData;private readonly bool _hasUserData;private readonly bool _isLoop;private readonly float _duration;private readonly float _fps;private readonly int _curveCount;private readonly int _totalSegmentCount;private readonly int _totalPointCount;private readonly bool _areBeziersRestricted;/// <summary> Parses the given .motion3.json bytes and pre-extracts all Meta fields into readonly fields for fast repeated access. </summary>
public CubismMotionJson(byte[]jsonBytes){_json=JsonDocument.Parse(jsonBytes);var root=_json.RootElement;_meta=root.GetProperty("Meta");_curves=root.GetProperty("Curves");_hasUserData=root.TryGetProperty("UserData",out _userData);_duration=_meta.GetProperty("Duration").GetSingle();_isLoop=_meta.TryGetProperty("Loop",out var loop)&&loop.GetBoolean();_fps=_meta.TryGetProperty("Fps",out var fpsEl)&&fpsEl.ValueKind==JsonValueKind.Number?fpsEl.GetSingle():30f;_curveCount=_meta.TryGetProperty("CurveCount",out var ccEl)?ccEl.GetInt32():0;_totalSegmentCount=_meta.TryGetProperty("TotalSegmentCount",out var tscEl)?tscEl.GetInt32():0;_totalPointCount=_meta.TryGetProperty("TotalPointCount",out var tpcEl)?tpcEl.GetInt32():0;_areBeziersRestricted=_meta.TryGetProperty("AreBeziersRestricted",out var abrEl)&&abrEl.GetBoolean();}/// <summary>Total motion duration in seconds from the Meta section.</summary>
public float GetDuration()=>_duration;/// <summary>Whether the motion loops.</summary>
public bool IsLoop()=>_isLoop;/// <summary>Frames per second, default 30.</summary>
public float GetFps()=>_fps;/// <summary>Number of curves in this motion.</summary>
public int GetCurveCount()=>_curveCount;/// <summary>Total segment count across all curves.</summary>
public int GetTotalSegmentCount()=>_totalSegmentCount;/// <summary>Total point count across all curves.</summary>
public int GetTotalPointCount()=>_totalPointCount;/// <summary> If true, bezier curves use binary search instead of the Cardano formula (non-standard bezier parameterization). </summary>
public bool AreBeziersRestricted()=>_areBeziersRestricted;/// <summary> Motion-level fade-in time in seconds, or -1 if not specified. </summary>
public float GetFadeInTime()=>_meta.TryGetProperty("FadeInTime",out var v)&&v.ValueKind==JsonValueKind.Number?v.GetSingle():-1f;/// <summary> Motion-level fade-out time in seconds, or -1 if not specified. </summary>
public float GetFadeOutTime()=>_meta.TryGetProperty("FadeOutTime",out var v)&&v.ValueKind==JsonValueKind.Number?v.GetSingle():-1f;/// <summary> Returns the target string for the curve at the given index ("Model", "Parameter", or "PartOpacity"). </summary>
public string GetCurveTarget(int index)=>_curves[index].GetProperty("Target").GetString();/// <summary> Returns the parameter/part/model ID string for the curve at the given index. </summary>
public string GetCurveId(int index)=>_curves[index].GetProperty("Id").GetString();/// <summary> Per-curve fade-in time in seconds, or -1 if not specified. </summary>
public float GetCurveFadeInTime(int index)=>_curves[index].TryGetProperty("FadeInTime",out var v)&&v.ValueKind==JsonValueKind.Number?v.GetSingle():-1f;/// <summary> Per-curve fade-out time in seconds, or -1 if not specified. </summary>
public float GetCurveFadeOutTime(int index)=>_curves[index].TryGetProperty("FadeOutTime",out var v)&&v.ValueKind==JsonValueKind.Number?v.GetSingle():-1f;/// <summary> Reads the raw float array of segment data for a curve. Формат Segments из motion3.json: [t0, v0, type, t, v, ...] — первые ДВА элемента это стартовая точка кривой (время, значение), далее для каждого сегмента маркер типа и его точки (Linear/Stepped/InverseStepped — 1 точка, Bezier — 3). Разбор: <see cref="CubismMotion.ParseSegmentsInternal"/>. </summary>
public void GetCurveSegments(int index,out float[]segments){var segArr=_curves[index].GetProperty("Segments");segments=new float[segArr.GetArrayLength()];int si=0;foreach(var s in segArr.EnumerateArray())segments[si++]=s.GetSingle();}/// <summary>Number of user-data events in the motion.</summary>
public int GetEventCount()=>_hasUserData?_userData.GetArrayLength():0;/// <summary>Fire time of the event at the given index, in seconds.</summary>
public float GetEventTime(int index)=>_hasUserData&&index<_userData.GetArrayLength()?_userData[index].GetProperty("Time").GetSingle():0f;/// <summary>String value of the event at the given index.</summary>
public string GetEventValue(int index)=>_hasUserData&&index<_userData.GetArrayLength()?_userData[index].GetProperty("Value").GetString():null;}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismMotionQueueEntry.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Represents one enqueued motion in the playback queue. Holds timing data (start time, end time, fade-in start time), lifecycle flags (available, started, finished, auto-delete), and a reference to the motion itself. Created by <see cref="CubismMotionQueueManager.StartMotion"/> and consumed frame-by-frame in <see cref="CubismMotionQueueManager.DoUpdateMotion"/>. </summary>
public class CubismMotionQueueEntry{/// <summary>Absolute user time when motion playback begins. -1 until the first update.</summary>
public float StartTime= -1f;/// <summary> Absolute user time when playback ends. -1 for infinite (looping) motions. </summary>
public float EndTime= -1f;/// <summary>Absolute user time when fade-in begins (may differ from StartTime for offset motions).</summary>
public float FadeInStartTime;/// <summary>Whether this entry should be processed during updates.</summary>
public bool IsAvailable=true;/// <summary>Flag set when playback completes. Checked by the queue manager during cleanup.</summary>
public bool IsFinished;/// <summary>Flag set on the first update — порт CubismMotionQueueEntry::_started.</summary>
public bool IsStarted;/// <summary>Время последнего кадра (состояние), порт CubismMotionQueueEntry::SetState.</summary>
public float StateTime;/// <summary>Вес последнего кадра (состояние), порт CubismMotionQueueEntry::SetState.</summary>
public float StateWeight;/// <summary>Абсолютное время последней проверки событий UserData (сдвигается каждый кадр).</summary>
public float LastCheckEventTime;/// <summary>Длительность фейда-аута, назначенного при вытеснении новым моушеном.</summary>
public float FadeOutSeconds;/// <summary>Флаг вытеснения: фейд-аут начат, EndTime будет назначен при следующем обновлении.</summary>
public bool IsTriggeredFadeOut;/// <summary>The motion instance driving parameter updates.</summary>
public ACubismMotion Motion;/// <summary>If true, the entry is automatically removed from the queue when finished.</summary>
public bool AutoDelete;/// <summary>Arbitrary user data attached to this queue entry.</summary>
public object CustomData;/// <summary> Creates a new queue entry for the given motion. AutoDelete controls whether the entry is removed on finish. </summary>
public CubismMotionQueueEntry(ACubismMotion motion,bool autoDelete){Motion=motion;AutoDelete=autoDelete;}/// <summary> Порт CubismMotionQueueEntry::SetFadeout: помечает запись для фейда-аута (вызывается очередью, когда стартует новый моушен). </summary>
public void SetFadeout(float fadeOutSeconds){FadeOutSeconds=fadeOutSeconds;IsTriggeredFadeOut=true;}/// <summary> Порт CubismMotionQueueEntry::StartFadeout: назначает EndTime, сокращая его, если фейд-аут уже был запущен раньше и раньше должен закончиться. </summary>
public void StartFadeout(float fadeOutSeconds,float userTimeSeconds){float newEndTimeSeconds=userTimeSeconds+fadeOutSeconds;IsTriggeredFadeOut=true;if(EndTime<0f||newEndTimeSeconds<EndTime){EndTime=newEndTimeSeconds;}}/// <summary>Порт CubismMotionQueueEntry::SetState — сохраняет время и вес последнего кадра.</summary>
public void SetState(float timeSeconds,float weight){StateTime=timeSeconds;StateWeight=weight;}}/// <summary> Manages a queue of <see cref="ACubismMotion"/> instances for a single model. Поддержка вытеснения по приоритету (ReserveMotion), параллельного проигрывания и автосборки завершённых записей. Порт CubismMotionQueueManager из SDK 5-r.5: - StartMotion назначает всем прежним записям фейд-аут; - каждый кадр DoUpdateMotion вызывает <see cref="ACubismMotion.UpdateParameters"/>, затем проверяет события UserData (<see cref="ACubismMotion.GetFiredEvent"/>) и удаляет finished-записи; - колбэком завершения владеет сам моушен: очередь его НЕ дублирует. </summary>
public class CubismMotionQueueManager{private readonly List<CubismMotionQueueEntry>_entries=new();private readonly List<string>_firedEventValues=new();private int _reservedPriority= -1;/// <summary> Событие, вызываемое для каждого сработавшего UserData-события за кадр (порт _eventCallback из CubismMotionQueueManager). </summary>
public event Action<ACubismMotion,string>EventFired;/// <summary> События, собранные за последний кадр DoUpdateMotion по всем записям очереди. Список очищается в начале каждого обновления — читайте его сразу после вызова. </summary>
public IReadOnlyList<string>FiredEventValues=>_firedEventValues;/// <summary> Sets the reserved priority threshold. Motions with priority below this are rejected by <see cref="ReserveMotion"/>. </summary>
public void SetReservePriority(int priority)=>_reservedPriority=priority;/// <summary> Attempts to reserve the given priority level. Returns true if the priority is accepted (higher than current reserved), false if a higher-priority motion is already reserved. </summary>
public bool ReserveMotion(int priority){if(_reservedPriority>=0&&_reservedPriority>priority)return false;_reservedPriority=priority;return true;}/// <summary> Enqueues a motion for playback. Прежним записям назначается фейд-аут (SetFadeout), после чего они плавно завершаются; сама запись инициализируется при первом кадре <see cref="ACubismMotion.UpdateParameters"/>. Параметр userTimeSeconds оставлен для совместимости вызовов. </summary> <param name="motion">The motion to play.</param> <param name="autoDelete">Whether to auto-remove on finish.</param> <param name="userTimeSeconds">Current user time (не используется, порт сигнатуры).</param>
public CubismMotionQueueEntry StartMotion(ACubismMotion motion,bool autoDelete,float userTimeSeconds){if(motion==null)return null;// Порт CubismMotionQueueManager::StartMotion: прежние моушены вытесняются фейд-аутом.
foreach(var existing in _entries){if(existing==null)continue;existing.SetFadeout(existing.Motion?.FadeOutSeconds??0f);}var entry=new CubismMotionQueueEntry(motion,autoDelete);_entries.Add(entry);return entry;}/// <summary> Stops all motions and clears the queue. Resets priority reservation. </summary>
public void StopAllMotions(){_entries.Clear();_reservedPriority= -1;}/// <summary> Returns true if all entries in the queue are finished. </summary>
public bool IsFinished(){foreach(var entry in _entries)if(entry!=null&& !entry.IsFinished)return false;return true;}/// <summary> Per-frame update — порт CubismMotionQueueManager::DoUpdateMotion. Для каждой записи: UpdateParameters (фейд + применение значений + финализация IsFinished самим моушеном), сборUserData-событий за кадр, затем удаление finished-записей и старт отложенного фейда-аута вытесненных. Обход прямой, как в SDK: моушены применяются от старых к новым. </summary>
public void DoUpdateMotion(CubismNativeModel model,float userTimeSeconds){_firedEventValues.Clear();int i=0;while(i<_entries.Count){CubismMotionQueueEntry entry=_entries[i];if(entry==null){_entries.RemoveAt(i);continue;}ACubismMotion motion=entry.Motion;if(motion==null){_entries.RemoveAt(i);continue;}// ------ Применение значений (финализация — внутри моушена/обёртки) ------
motion.UpdateParameters(model,entry,userTimeSeconds);// ------ Проверка UserData-событий за кадр ------
var fired=motion.GetFiredEvent(entry.LastCheckEventTime-entry.StartTime,userTimeSeconds-entry.StartTime);for(int e=0;e<fired.Count;e++)EventFired?.Invoke(motion,fired[e]);_firedEventValues.AddRange(fired);entry.LastCheckEventTime=userTimeSeconds;// ------ Удаление завершённых записей ------
if(entry.IsFinished){_entries.RemoveAt(i);continue;}// Фейд-аут, запланированный при вытеснении, стартует после обновления.
if(entry.IsTriggeredFadeOut){entry.StartFadeout(entry.FadeOutSeconds,userTimeSeconds);}i++;}}}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismNativeModel.cs =====
using System;using System.Collections.Generic;using System.Runtime.InteropServices;using System.Text.Json;using Cthangover.Core.Utils;using Godot;using Cthangover.Live2D.Cubism.Core;using Cthangover.Core.Mods;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Wraps a Live2D CubismCore native model instance. Handles .moc3 loading with the required 64-byte alignment, exposes model data through direct pointers into CubismCore arrays, and manages texture loading from external file providers. Memory layout: .moc3 data and model instance memory are pinned via GCHandle to satisfy CubismCore's alignment requirements (64-byte and 16-byte respectively). The native model handle (_model) is an opaque pointer passed to all CubismCore P/Invoke calls. Implements <see cref="IDisposable"/> to free pinned memory when the model is no longer needed. </summary>
public class CubismNativeModel:IDisposable{private IntPtr _model;private GCHandle _mocAlignedHandle;private GCHandle _modelAlignedHandle;// The Core stores the raw function pointer, so the delegate must live for the whole process — a collected delegate would crash native code.
private static readonly CubismCoreBindings.CsmLogDelegate NativeLogDelegate=OnNativeLog;private static bool _nativeLogWired;static CubismNativeModel(){WireNativeLog();}/// <summary> Routes native Core log output (the only channel for native-side errors: invalid moc3, unsupported version, alignment failures) into GameLogger. </summary>
private static void WireNativeLog(){if(_nativeLogWired)return;_nativeLogWired=true;CubismCoreBindings.csmSetLogFunction(NativeLogDelegate);}private static void OnNativeLog(IntPtr messagePtr){if(messagePtr==IntPtr.Zero)return;string message=Marshal.PtrToStringAnsi(messagePtr)??string.Empty;GameLogger.Log("LIVE2D-CORE",message);}private int _drawableCount;private int _parameterCount;/// <summary> Array of Godot <see cref="ImageTexture"/> instances loaded from the model's texture references. Indexed by drawable texture index. </summary>
public ImageTexture[]Textures{get;private set;}/// <summary>Number of drawables (renderable mesh parts) in the model.</summary>
public int DrawableCount=>_drawableCount;/// <summary>Number of parameters in the model.</summary>
public int ParameterCount=>_parameterCount;/// <summary>Canvas pixel width from the moc3. Available after <see cref="LoadMoc"/>.</summary>
public float CanvasWidth{get;private set;}/// <summary>Canvas pixel height from the moc3. Available after <see cref="LoadMoc"/>.</summary>
public float CanvasHeight{get;private set;}/// <summary>Pixels-per-unit scale for converting Cubism coordinates to pixel space.</summary>
public float PixelsPerUnit{get;private set;}/// <summary>Whether the native model has been successfully initialized.</summary>
public bool IsLoaded=>_model!=IntPtr.Zero;/// <summary>Raw native model handle passed to CubismCore functions.</summary>
public IntPtr ModelPtr=>_model;/// <summary> Loads raw .moc3 bytes into CubismCore. Allocates a 64-byte aligned buffer, copies moc data, revives the moc, allocates a 16-byte aligned model buffer, and initializes the model. Throws <see cref="InvalidOperationException"/> on failure. </summary>
public unsafe void LoadMoc(byte[]mocBytes){if(IsLoaded){// Guard against a silent native-memory leak: handles are pinned and the previous moc/model are never freed, so a second LoadMoc on the same instance is always a bug in the caller.
throw new InvalidOperationException("LoadMoc: a moc is already loaded in this CubismNativeModel instance. "+"Create a new instance to load another model.");}int alignedMocSize=(mocBytes.Length+CubismCoreBindings.AlignofMoc-1)/CubismCoreBindings.AlignofMoc*CubismCoreBindings.AlignofMoc;byte[]mocBuffer=new byte[alignedMocSize+CubismCoreBindings.AlignofMoc];_mocAlignedHandle=GCHandle.Alloc(mocBuffer,GCHandleType.Pinned);try{IntPtr mocPtr=_mocAlignedHandle.AddrOfPinnedObject();long alignedAddr=(mocPtr.ToInt64()+CubismCoreBindings.AlignofMoc-1)/CubismCoreBindings.AlignofMoc*CubismCoreBindings.AlignofMoc;var alignedMocPtr=new IntPtr(alignedAddr);Marshal.Copy(mocBytes,0,alignedMocPtr,mocBytes.Length);// csmReviveMocInPlace (verified by disassembly of the shipped DLL) hard-requires size % 64 == 0 and returns failure with '"size" is invalid.' otherwise. Cubism Editor always exports moc3 files whose length is a multiple of 64, but we do not rely on that: the buffer is already zero-initialized and 64-byte padded, so we pass the padded size — the Core reads only the real content, zero padding is safe.
uint mocBufferSize=(uint)alignedMocSize;// Validate the moc3 buffer against the loaded Core before reviving it. Both checks use Core functions that were previously declared but never called — this gives diagnosable errors instead of a bare "csmReviveMocInPlace failed" for wrong-version or corrupted files.
uint mocVersion=CubismCoreBindings.csmGetMocVersion((void*)alignedMocPtr,mocBufferSize);uint coreVersion=CubismCoreBindings.csmGetLatestMocVersion();if(mocVersion==0){GameLogger.Log("LIVE2D","LoadMoc: buffer is not a recognized .moc3 file (csmGetMocVersion returned 0)",LogLevel.Error);throw new InvalidOperationException("The .moc3 buffer is not a recognized moc3 file");}if(mocVersion>coreVersion){GameLogger.Log("LIVE2D",$"LoadMoc: .moc3 version {mocVersion} is newer than Cubism Core {coreVersion} — ship a newer Live2DCubismCore.dll",LogLevel.Error);throw new InvalidOperationException($".moc3 version {mocVersion} is not supported by Cubism Core {coreVersion}");}if(CubismCoreBindings.csmHasMocConsistency((void*)alignedMocPtr,mocBufferSize)!=1){GameLogger.Log("LIVE2D","LoadMoc: .moc3 failed the consistency check (csmHasMocConsistency != 1) — data is corrupted",LogLevel.Error);throw new InvalidOperationException(".moc3 data is inconsistent (csmHasMocConsistency failed)");}IntPtr moc=CubismCoreBindings.csmReviveMocInPlace((void*)alignedMocPtr,mocBufferSize);if(moc==IntPtr.Zero){GameLogger.Log("LIVE2D","LoadMoc: csmReviveMocInPlace failed — .moc3 data is invalid or corrupted",LogLevel.Error);throw new InvalidOperationException("csmReviveMocInPlace failed");}uint modelSize=CubismCoreBindings.csmGetSizeofModel(moc);byte[]modelBuffer=new byte[modelSize+CubismCoreBindings.AlignofModel];_modelAlignedHandle=GCHandle.Alloc(modelBuffer,GCHandleType.Pinned);IntPtr modelPtr=_modelAlignedHandle.AddrOfPinnedObject();long modelAlignedAddr=(modelPtr.ToInt64()+CubismCoreBindings.AlignofModel-1)/CubismCoreBindings.AlignofModel*CubismCoreBindings.AlignofModel;var alignedModelPtr=new IntPtr(modelAlignedAddr);_model=CubismCoreBindings.csmInitializeModelInPlace(moc,(void*)alignedModelPtr,modelSize);if(_model==IntPtr.Zero){GameLogger.Log("LIVE2D","LoadMoc: csmInitializeModelInPlace failed — model memory allocation failed",LogLevel.Error);throw new InvalidOperationException("csmInitializeModelInPlace failed");}}catch{// Any failure after the Alloc calls must release the pinned buffers — otherwise the GCHandles (and the native memory they pin) leak for the rest of the process, because LoadMoc throws before Dispose is reachable.
if(_mocAlignedHandle.IsAllocated)_mocAlignedHandle.Free();if(_modelAlignedHandle.IsAllocated)_modelAlignedHandle.Free();_mocAlignedHandle=default;_modelAlignedHandle=default;throw;}var sizeInPixels=default(CubismCoreBindings.csmVector2);var originInPixels=default(CubismCoreBindings.csmVector2);float ppu=0;CubismCoreBindings.csmReadCanvasInfo(_model,&sizeInPixels,&originInPixels,&ppu);CanvasWidth=sizeInPixels.X;CanvasHeight=sizeInPixels.Y;PixelsPerUnit=ppu;_drawableCount=CubismCoreBindings.csmGetDrawableCount(_model);_parameterCount=CubismCoreBindings.csmGetParameterCount(_model);}/// <summary> Loads a model from .model3.json bytes. Parses the FileReferences section to find the .moc3 file, calls <see cref="LoadMoc"/>, then loads all referenced textures through the given file provider. </summary>
public void LoadFromProvider(byte[]model3JsonBytes,string modelDir,IModFileProvider provider){var json=JsonDocument.Parse(model3JsonBytes);var fileRefs=json.RootElement.GetProperty("FileReferences");string mocRelPath=fileRefs.GetProperty("Moc").GetString();var mocBytes=ReadFile(modelDir,mocRelPath,provider);if(mocBytes==null){GameLogger.Log("LIVE2D",$"LoadFromProvider: .moc3 not found '{mocRelPath}'",LogLevel.Error);return;}LoadMoc(mocBytes);var texturePaths=new List<string>();if(fileRefs.TryGetProperty("Textures",out var texArr)){foreach(var t in texArr.EnumerateArray())texturePaths.Add(t.GetString());}LoadTextures(texturePaths,modelDir,provider);}private static byte[]ReadFile(string baseDir,string relativePath,IModFileProvider provider){var normalized=relativePath.Replace('\\','/');var combined=string.IsNullOrEmpty(baseDir)?normalized:$"{baseDir}/{normalized}";var result=provider.ReadFileBinary(combined);if(result==null)GameLogger.Log("LIVE2D",$"ReadFile: provider returned null for '{combined}'",LogLevel.Warning);return result;}private void LoadTextures(List<string>paths,string modelDir,IModFileProvider provider){Textures=new ImageTexture[paths.Count];for(int i=0;i<paths.Count;i++){var texBytes=ReadFile(modelDir,paths[i],provider);if(texBytes==null||texBytes.Length==0){GameLogger.Log("LIVE2D",$"LoadTextures: failed to read texture '{paths[i]}'",LogLevel.Warning);continue;}var img=new Image();Error err;if(texBytes[0]==0x89&&texBytes[1]==0x50)err=img.LoadPngFromBuffer(texBytes);else err=img.LoadJpgFromBuffer(texBytes);if(err!=Error.Ok){GameLogger.Log("LIVE2D",$"LoadTextures: failed to decode '{paths[i]}' — error={err}",LogLevel.Warning);continue;}if(img.GetFormat()!=Image.Format.Rgba8)img.Convert(Image.Format.Rgba8);img.GenerateMipmaps();var tex=ImageTexture.CreateFromImage(img);Textures[i]=tex;var w=img.GetWidth();var h=img.GetHeight();img.Dispose();GameLogger.Log("LIVE2D",$"LoadTextures: loaded '{paths[i]}' ({texBytes.Length}b, {w}x{h})");}}/// <summary> Calls <c>csmUpdateModel</c> on the native model. Must be called after modifying parameters/parts/opacities and before reading vertex positions for rendering. </summary>
public unsafe void UpdateModel(){if(_model==IntPtr.Zero)return;CubismCoreBindings.csmUpdateModel(_model);}/// <summary> Resets the per-frame dynamic flags for all drawables. Called at the start of each render update cycle. </summary>
public unsafe void ResetDynamicFlags(){if(_model==IntPtr.Zero)return;CubismCoreBindings.csmResetDrawableDynamicFlags(_model);}/// <summary>Returns the drawable count from the native model.</summary>
public unsafe int GetDrawableCount()=>_drawableCount;/// <summary>Returns a pointer to the dynamic flags byte array (one byte per drawable).</summary>
public unsafe byte*GetDynamicFlags()=>CubismCoreBindings.csmGetDrawableDynamicFlags(_model);/// <summary>Returns a pointer to the constant flags byte array (blend mode, double-sided, etc.).</summary>
public unsafe byte*GetConstantFlags()=>CubismCoreBindings.csmGetDrawableConstantFlags(_model);/// <summary>Returns a pointer to the texture index array (one int per drawable).</summary>
public unsafe int*GetTextureIndices()=>CubismCoreBindings.csmGetDrawableTextureIndices(_model);/// <summary>Returns a pointer to the vertex count array (one int per drawable).</summary>
public unsafe int*GetVertexCounts()=>CubismCoreBindings.csmGetDrawableVertexCounts(_model);/// <summary>Returns a pointer array of csmVector2 arrays — vertex positions per drawable.</summary>
public unsafe CubismCoreBindings.csmVector2**GetVertexPositions()=>CubismCoreBindings.csmGetDrawableVertexPositions(_model);/// <summary>Returns a pointer array of csmVector2 arrays — UV coordinates per drawable.</summary>
public unsafe CubismCoreBindings.csmVector2**GetVertexUvs()=>CubismCoreBindings.csmGetDrawableVertexUvs(_model);/// <summary>Returns a pointer to the index count array (one int per drawable).</summary>
public unsafe int*GetIndexCounts()=>CubismCoreBindings.csmGetDrawableIndexCounts(_model);/// <summary>Returns a pointer array of ushort arrays — triangle indices per drawable.</summary>
public unsafe ushort**GetIndices()=>CubismCoreBindings.csmGetDrawableIndices(_model);/// <summary>Returns a pointer to the per-drawable opacity array (one float per drawable).</summary>
public unsafe float*GetOpacities()=>CubismCoreBindings.csmGetDrawableOpacities(_model);/// <summary>Returns a pointer to the draw order indices array.</summary>
public unsafe int*GetDrawOrders()=>CubismCoreBindings.csmGetDrawableDrawOrders(_model);/// <summary>Returns a pointer to the render order indices array.</summary>
public unsafe int*GetRenderOrders()=>CubismCoreBindings.csmGetRenderOrders(_model);/// <summary>Returns a pointer to the mask count array (one int per drawable).</summary>
public unsafe int*GetMaskCounts()=>CubismCoreBindings.csmGetDrawableMaskCounts(_model);/// <summary>Returns a pointer array of int arrays — mask drawable indices per drawable.</summary>
public unsafe int**GetMasks()=>CubismCoreBindings.csmGetDrawableMasks(_model);/// <summary>Returns a pointer to the per-drawable multiply color array.</summary>
public unsafe CubismCoreBindings.csmVector4*GetMultiplyColors()=>CubismCoreBindings.csmGetDrawableMultiplyColors(_model);/// <summary>Returns a pointer to the per-drawable screen color array.</summary>
public unsafe CubismCoreBindings.csmVector4*GetScreenColors()=>CubismCoreBindings.csmGetDrawableScreenColors(_model);/// <summary>Returns a pointer to the current parameter values array (one float per parameter).</summary>
public unsafe float*GetParameterValues()=>CubismCoreBindings.csmGetParameterValues(_model);/// <summary>Returns a pointer to the parameter minimum bounds array.</summary>
public unsafe float*GetParameterMinimumValues()=>CubismCoreBindings.csmGetParameterMinimumValues(_model);/// <summary>Returns a pointer to the parameter maximum bounds array.</summary>
public unsafe float*GetParameterMaximumValues()=>CubismCoreBindings.csmGetParameterMaximumValues(_model);/// <summary>Returns a pointer to the parameter default values array.</summary>
public unsafe float*GetParameterDefaultValues()=>CubismCoreBindings.csmGetParameterDefaultValues(_model);/// <summary> Returns a pointer array of null-terminated ANSI strings — parameter IDs. Use <see cref="ReadStringFromPtrArray"/> to read individual strings. </summary>
public unsafe IntPtr GetParameterIds()=>CubismCoreBindings.csmGetParameterIds(_model);/// <summary> Reads a null-terminated ANSI string from a pointer array at the given index. Each element is a byte pointer; reads until null terminator. </summary>
public static unsafe string ReadStringFromPtrArray(IntPtr ptrArray,int index){var strPtr= *(byte**)IntPtr.Add(ptrArray,index*IntPtr.Size);return Marshal.PtrToStringAnsi((IntPtr)strPtr);}/// <summary>Returns the number of parts from the native model.</summary>
public unsafe int GetPartCount()=>CubismCoreBindings.csmGetPartCount(_model);/// <summary>Returns a pointer to the per-part opacity array (one float per part).</summary>
public unsafe float*GetPartOpacities()=>CubismCoreBindings.csmGetPartOpacities(_model);/// <summary>Returns a pointer array of part ID strings.</summary>
public unsafe IntPtr GetPartIds()=>CubismCoreBindings.csmGetPartIds(_model);// Snapshot of the parameter values for the Load/Save bracket (port of CubismModel.cpp:878-910).
private float[]_savedParameterValues;/// <summary> Порт CubismModel::SaveParameters: сохраняет текущие значения параметров в снимок. Вызывается сразу после фазы моушена, чтобы аддитивные слои (выражения, дыхание, look-инъекция) каждый кадр стартовали от одного и того же базового состояния и не накапливались кадр-за-кадром. </summary>
public unsafe void SaveParameters(){if(_model==IntPtr.Zero)return;var values=GetParameterValues();if(_savedParameterValues==null||_savedParameterValues.Length!=_parameterCount)_savedParameterValues=new float[_parameterCount];for(int i=0;i<_parameterCount;i++)_savedParameterValues[i]=values[i];}/// <summary> Порт CubismModel::LoadParameters: восстанавливает значения параметров из снимка. До первого SaveParameters ничего не делает (как в SDK). </summary>
public unsafe void LoadParameters(){if(_model==IntPtr.Zero||_savedParameterValues==null)return;var values=GetParameterValues();var count=System.Math.Min(_parameterCount,_savedParameterValues.Length);for(int i=0;i<count;i++)values[i]=_savedParameterValues[i];}/// <summary> Frees pinned memory handles. The native model handle is set to zero; the underlying CubismCore memory was allocated inline and lives in the pinned buffers — freeing those handles releases all model memory. </summary>
public void Dispose(){if(_mocAlignedHandle.IsAllocated)_mocAlignedHandle.Free();if(_modelAlignedHandle.IsAllocated)_modelAlignedHandle.Free();_model=IntPtr.Zero;}}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismParameter.cs =====
namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Cubism model parameter. Each parameter has a string ID, a numeric index into the native parameter arrays, current value, and bounds (min, max, default). Parameters are created during <see cref="CubismModelNode.SetupParameters"/> and exposed via <see cref="CubismModelNode.Parameters"/>. External code can read/write <see cref="Value"/> and call <see cref="ApplyToModel"/> to push changes back to the CubismCore native arrays. </summary>
public class CubismParameter{public CubismNativeModel Model{get;}public CubismParameter(CubismNativeModel model){Model=model;}/// <summary>Cubism parameter ID string (e.g. "ParamAngleX", "ParamMouthOpenY").</summary>
public string Id{get;set;}/// <summary>Zero-based index into the native parameter arrays.</summary>
public int Index{get;set;}/// <summary>Current parameter value. Writes to native model when <see cref="ApplyToModel"/> is called.</summary>
public float Value{get;set;}/// <summary>Minimum allowed value for this parameter.</summary>
public float MinimumValue{get;set;}/// <summary>Maximum allowed value for this parameter.</summary>
public float MaximumValue{get;set;}/// <summary>Default (rest) value for this parameter from the moc3.</summary>
public float DefaultValue{get;set;}/// <summary> Writes <see cref="Value"/> directly to the native model's parameter array. No clamping is performed — the caller should enforce bounds if needed. </summary>
public unsafe void ApplyToModel(){var values=Model.GetParameterValues();values[Index]=Value;}}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismPartOpacity.cs =====
namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Cubism model part opacity. Each part has a string ID, a numeric index into the native part arrays, and a current opacity value (0 = hidden, 1 = fully visible). Parts are created during <see cref="CubismModelNode.SetupParts"/> and exposed via <see cref="CubismModelNode.PartOpacities"/>. External code can set <see cref="Value"/> and call <see cref="ApplyToModel"/> to push the change to CubismCore. </summary>
public class CubismPartOpacity{public CubismNativeModel Model{get;}public CubismPartOpacity(CubismNativeModel model){Model=model;}/// <summary>Cubism part ID string (e.g. "PartArmL", "PartHairBack").</summary>
public string Id{get;set;}/// <summary>Zero-based index into the native part arrays.</summary>
public int Index{get;set;}/// <summary> Current opacity value for this part. 0 = hidden, 1 = fully visible. Written to native model when <see cref="ApplyToModel"/> is called. </summary>
public float Value{get;set;}/// <summary> Writes <see cref="Value"/> directly to the native model's part opacities array. </summary>
public unsafe void ApplyToModel(){var opacities=Model.GetPartOpacities();opacities[Index]=Value;}}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismPhysics.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Cubism physics simulation engine. Drives secondary motion (hair, cloth, accessories) using a chain of particles influenced by parameter inputs (X, Y, angle from model parameters), gravity, and wind. Bit-exact port of Cubism SDK for Native 5-r.5 Physics/CubismPhysics.cpp. Architecture (mirroring the native rig structures): <list type="bullet"> <item><see cref="CubismPhysicsRig"/> holds the complete rig structure: sub-rigs, inputs, outputs, and particles</item> <item>Each sub-rig is an independent chain of particles anchored to one or more model parameters via inputs</item> <item>Inputs map model parameters (normalized and weighted) to particle chain root position/angle</item> <item>Outputs map particle chain state back to model parameters after physics integration</item> </list> The simulation runs at the rig's configured FPS (from .physics3.json, Meta.Fps; 0 means "step once per display frame"). Pendulum results are kept as current/previous rig outputs and the value written to the model parameters is interpolated between them by the remaining sub-frame time. The pure math core (normalization, particle update, output value update) lives in internal static methods operating on plain data for testability. </summary>
public class CubismPhysics{// ------------------------------------------------------------------ Physics constants (SDK CubismPhysics.cpp anonymous namespace). ------------------------------------------------------------------
private const float AirResistance=5f;private const float MaximumWeight=100f;private const float MovementThreshold=0.001f;private const float MaxDeltaTime=5f;/// <summary>SDK CubismMath::Pi (identical to MathF.PI as a csmFloat32).</summary>
private const float Pi=MathF.PI;private readonly CubismPhysicsRig _rig=new();// Runtime options (SDK _options). Native defaults: gravity (0, -1), wind (0, 0).
private float _optionsGravityX;private float _optionsGravityY= -1f;private float _optionsWindX;private float _optionsWindY;// Parameter value caches used for sub-frame input interpolation (SDK _parameterCaches / _parameterInputCaches).
private float[]_parameterCaches=Array.Empty<float>();private float[]_parameterInputCaches=Array.Empty<float>();// Pendulum output history (SDK _currentRigOutputs / _previousRigOutputs, flattened to one slot per absolute output index).
private float[]_currentRigOutputs=Array.Empty<float>();private float[]_previousRigOutputs=Array.Empty<float>();private float _currentRemainTime;/// <summary> Parses a .physics3.json byte buffer and builds the full physics rig with sub-rigs, inputs, outputs, and particles (SDK Parse + Initialize). </summary>
public CubismPhysics(byte[]physics3JsonBytes){var json=new CubismPhysicsJson(physics3JsonBytes);json.GetGravity(out _rig.GravityX,out _rig.GravityY);json.GetWind(out _rig.WindX,out _rig.WindY);_rig.Fps=json.GetFps();int subRigCount=json.GetSubRigCount();var particles=new List<CubismPhysicsParticle>(json.GetVertexCount());_rig.Inputs.Capacity=json.GetTotalInputCount();_rig.Outputs.Capacity=json.GetTotalOutputCount();int inputIndex=0,outputIndex=0,particleIndex=0;for(int si=0;si<subRigCount;si++){var subRig=new CubismPhysicsSubRig{BaseInputIndex=inputIndex,BaseOutputIndex=outputIndex,BaseParticleIndex=particleIndex,};json.GetNormalizationPosition(si,out subRig.NormalizationPosition);json.GetNormalizationAngle(si,out subRig.NormalizationAngle);// Input
subRig.InputCount=json.GetInputCount(si);for(int j=0;j<subRig.InputCount;j++){_rig.Inputs.Add(new CubismPhysicsInput{SourceParameterIndex= -1,Weight=json.GetInputWeight(si,j),Reflect=json.GetInputReflect(si,j),Type=ParseSource(json.GetInputType(si,j)),ParameterId=json.GetInputSourceId(si,j),});}inputIndex+=subRig.InputCount;// Output
subRig.OutputCount=json.GetOutputCount(si);for(int j=0;j<subRig.OutputCount;j++){_rig.Outputs.Add(new CubismPhysicsOutput{DestinationParameterIndex= -1,VertexIndex=json.GetOutputVertexIndex(si,j),AngleScale=json.GetOutputAngleScale(si,j),Weight=json.GetOutputWeight(si,j),ParameterId=json.GetOutputsDestinationId(si,j),Type=ParseSource(json.GetOutputType(si,j)),Reflect=json.GetOutputReflect(si,j),// TranslationScale stays (0, 0): native never loads a translation scale from physics3.json, so X/Y outputs scale by zero exactly like the native implementation.
});}outputIndex+=subRig.OutputCount;// Particle
subRig.ParticleCount=json.GetParticleCount(si);for(int j=0;j<subRig.ParticleCount;j++){json.GetParticlePosition(si,j,out var px,out var py);particles.Add(new CubismPhysicsParticle{InitialX=px,InitialY=py,Mobility=json.GetParticleMobility(si,j),Delay=json.GetParticleDelay(si,j),Acceleration=json.GetParticleAcceleration(si,j),Radius=json.GetParticleRadius(si,j),PositionX=px,PositionY=py,});}particleIndex+=subRig.ParticleCount;_rig.SubRigs.Add(subRig);}_rig.Particles=particles.ToArray();_currentRigOutputs=new float[_rig.Outputs.Count];_previousRigOutputs=new float[_rig.Outputs.Count];Initialize();// SDK CubismPhysics::Create zeroes the rig gravity Y after parsing.
_rig.GravityY=0f;}/// <summary> Lays out the particle chains at their rest positions (SDK CubismPhysics::Initialize). </summary>
private void Initialize(){foreach(var currentSetting in _rig.SubRigs){int baseParticleIndex=currentSetting.BaseParticleIndex;// Initialize the top of the strand.
_rig.Particles[baseParticleIndex].InitialX=0f;_rig.Particles[baseParticleIndex].InitialY=0f;_rig.Particles[baseParticleIndex].LastPositionX=0f;_rig.Particles[baseParticleIndex].LastPositionY=0f;_rig.Particles[baseParticleIndex].LastGravityX=0f;_rig.Particles[baseParticleIndex].LastGravityY=1f;// (0, -1) with Y negated
_rig.Particles[baseParticleIndex].VelocityX=0f;_rig.Particles[baseParticleIndex].VelocityY=0f;_rig.Particles[baseParticleIndex].ForceX=0f;_rig.Particles[baseParticleIndex].ForceY=0f;// Initialize the remaining particles along +Y by radius.
for(int i=1;i<currentSetting.ParticleCount;++i){int idx=baseParticleIndex+i;_rig.Particles[idx].InitialX=_rig.Particles[idx-1].InitialX;_rig.Particles[idx].InitialY=_rig.Particles[idx-1].InitialY+_rig.Particles[idx].Radius;_rig.Particles[idx].PositionX=_rig.Particles[idx].InitialX;_rig.Particles[idx].PositionY=_rig.Particles[idx].InitialY;_rig.Particles[idx].LastPositionX=_rig.Particles[idx].InitialX;_rig.Particles[idx].LastPositionY=_rig.Particles[idx].InitialY;_rig.Particles[idx].LastGravityX=0f;_rig.Particles[idx].LastGravityY=1f;// (0, -1) with Y negated
_rig.Particles[idx].VelocityX=0f;_rig.Particles[idx].VelocityY=0f;_rig.Particles[idx].ForceX=0f;_rig.Particles[idx].ForceY=0f;}}}/// <summary> Overrides gravity and wind at runtime (SDK SetOptions; these feed the wind force during simulation and the parent gravity of angle outputs). </summary>
public void SetOptions(float gravityX,float gravityY,float windX,float windY){_optionsGravityX=gravityX;_optionsGravityY=gravityY;_optionsWindX=windX;_optionsWindY=windY;}/// <summary> Forces the physics to stabilize immediately (SDK CubismPhysics::Stabilization). Particles snap to their rest positions along the current gravity direction and the resolved outputs are written into the model. </summary>
public unsafe void Stabilization(CubismNativeModel model){int parameterCount=model.ParameterCount;float*parameterValues=model.GetParameterValues();float*parameterMaximumValues=model.GetParameterMaximumValues();float*parameterMinimumValues=model.GetParameterMinimumValues();float*parameterDefaultValues=model.GetParameterDefaultValues();if(_parameterCaches.Length<parameterCount){_parameterCaches=new float[parameterCount];}if(_parameterInputCaches.Length<parameterCount){_parameterInputCaches=new float[parameterCount];}for(int j=0;j<parameterCount;++j){_parameterCaches[j]=parameterValues[j];_parameterInputCaches[j]=parameterValues[j];}foreach(var currentSetting in _rig.SubRigs){float totalAngle=0f;float totalTranslationX=0f,totalTranslationY=0f;// Load input parameters.
for(int i=0;i<currentSetting.InputCount;++i){int inputIndex=currentSetting.BaseInputIndex+i;var input=_rig.Inputs[inputIndex];float weight=input.Weight/MaximumWeight;if(input.SourceParameterIndex== -1){input.SourceParameterIndex=ResolveParameterIndex(model,input.ParameterId);}if(input.SourceParameterIndex== -1){continue;}_rig.Inputs[inputIndex]=input;int source=input.SourceParameterIndex;switch(input.Type){case CubismPhysicsSource.X:GetInputTranslationXFromNormalizedParameterValue(ref totalTranslationX,ref totalTranslationY,ref totalAngle,parameterValues[source],parameterMinimumValues[source],parameterMaximumValues[source],parameterDefaultValues[source],currentSetting.NormalizationPosition,currentSetting.NormalizationAngle,input.Reflect,weight);break;case CubismPhysicsSource.Y:GetInputTranslationYFromNormalizedParameterValue(ref totalTranslationX,ref totalTranslationY,ref totalAngle,parameterValues[source],parameterMinimumValues[source],parameterMaximumValues[source],parameterDefaultValues[source],currentSetting.NormalizationPosition,currentSetting.NormalizationAngle,input.Reflect,weight);break;case CubismPhysicsSource.Angle:GetInputAngleFromNormalizedParameterValue(ref totalTranslationX,ref totalTranslationY,ref totalAngle,parameterValues[source],parameterMinimumValues[source],parameterMaximumValues[source],parameterDefaultValues[source],currentSetting.NormalizationPosition,currentSetting.NormalizationAngle,input.Reflect,weight);break;}_parameterCaches[source]=parameterValues[source];}float radAngle=DegreesToRadian(-totalAngle);// NOTE: native computes Y from the already-updated X (bug-compatible with CubismPhysics.cpp Stabilization).
totalTranslationX=totalTranslationX*CosF(radAngle)-totalTranslationY*SinF(radAngle);totalTranslationY=totalTranslationX*SinF(radAngle)+totalTranslationY*CosF(radAngle);// Calculate particles position.
UpdateParticlesForStabilization(_rig.Particles,currentSetting.BaseParticleIndex,currentSetting.ParticleCount,totalTranslationX,totalTranslationY,totalAngle,_optionsWindX,_optionsWindY,MovementThreshold*currentSetting.NormalizationPosition.Maximum);// Update output parameters.
for(int i=0;i<currentSetting.OutputCount;++i){int outputIndex=currentSetting.BaseOutputIndex+i;var output=_rig.Outputs[outputIndex];int particleIndex=output.VertexIndex;if(output.DestinationParameterIndex== -1){output.DestinationParameterIndex=ResolveParameterIndex(model,output.ParameterId);}if(particleIndex<1||particleIndex>=currentSetting.ParticleCount){_rig.Outputs[outputIndex]=output;continue;}int vertex=currentSetting.BaseParticleIndex+particleIndex;float translationX=_rig.Particles[vertex].PositionX-_rig.Particles[vertex-1].PositionX;float translationY=_rig.Particles[vertex].PositionY-_rig.Particles[vertex-1].PositionY;float outputValue=GetOutputValue(output.Type,translationX,translationY,_rig.Particles,currentSetting.BaseParticleIndex,particleIndex,output.Reflect,_optionsGravityX,_optionsGravityY);_currentRigOutputs[outputIndex]=outputValue;_previousRigOutputs[outputIndex]=outputValue;if(output.DestinationParameterIndex!= -1){int destination=output.DestinationParameterIndex;UpdateOutputParameterValue(ref parameterValues[destination],parameterMinimumValues[destination],parameterMaximumValues[destination],outputValue,ref output);_rig.Outputs[outputIndex]=output;_parameterCaches[destination]=parameterValues[destination];}}}}/// <summary> Evaluates physics for one display frame (SDK CubismPhysics::Evaluate). Accumulates time and runs physics steps at the rig's native FPS. Inputs are linearly interpolated between the cached and live parameter values at each step, and the output written to the model parameters is interpolated between the previous and current pendulum results by the remaining sub-frame time. Called from <see cref="CubismModelNode.OnUpdate"/> every frame. </summary>
public unsafe void Evaluate(CubismNativeModel model,float deltaTimeSeconds){if(0f>=deltaTimeSeconds){return;}int parameterCount=model.ParameterCount;float*parameterValues=model.GetParameterValues();float*parameterMaximumValues=model.GetParameterMaximumValues();float*parameterMinimumValues=model.GetParameterMinimumValues();float*parameterDefaultValues=model.GetParameterDefaultValues();_currentRemainTime+=deltaTimeSeconds;if(_currentRemainTime>MaxDeltaTime){_currentRemainTime=0f;}if(_parameterCaches.Length<parameterCount){_parameterCaches=new float[parameterCount];}if(_parameterInputCaches.Length<parameterCount){_parameterInputCaches=new float[parameterCount];for(int j=0;j<parameterCount;++j){_parameterInputCaches[j]=parameterValues[j];}}float physicsDeltaTime=_rig.Fps>0f?1f/_rig.Fps:deltaTimeSeconds;if(!(physicsDeltaTime>0f)){physicsDeltaTime=deltaTimeSeconds;}// After the MaxDeltaTime clamp the loop can consume at most ceil(MaxDeltaTime / physicsDeltaTime) steps; cap it defensively so pathological Fps values cannot spin (SDK relies on well-formed data).
int maxSteps=(int)(MaxDeltaTime/physicsDeltaTime)+2;int steps=0;while(_currentRemainTime>=physicsDeltaTime){if(++steps>maxSteps){_currentRemainTime=0f;break;}// copyRigOutputs: _currentRigOutputs -> _previousRigOutputs.
Array.Copy(_currentRigOutputs,_previousRigOutputs,_currentRigOutputs.Length);// Linear interpolation between the cached input values and the live parameter values at each UpdateParticles timing. _parameterCaches propagates values between groups, which is why it is kept separate from _parameterInputCaches.
float inputWeight=physicsDeltaTime/_currentRemainTime;for(int j=0;j<parameterCount;++j){_parameterCaches[j]=_parameterInputCaches[j]*(1f-inputWeight)+parameterValues[j]*inputWeight;_parameterInputCaches[j]=_parameterCaches[j];}foreach(var currentSetting in _rig.SubRigs){float totalAngle=0f;float totalTranslationX=0f,totalTranslationY=0f;// Load input parameters.
for(int i=0;i<currentSetting.InputCount;++i){int inputIndex=currentSetting.BaseInputIndex+i;var input=_rig.Inputs[inputIndex];float weight=input.Weight/MaximumWeight;if(input.SourceParameterIndex== -1){input.SourceParameterIndex=ResolveParameterIndex(model,input.ParameterId);}if(input.SourceParameterIndex== -1){continue;}_rig.Inputs[inputIndex]=input;int source=input.SourceParameterIndex;switch(input.Type){case CubismPhysicsSource.X:GetInputTranslationXFromNormalizedParameterValue(ref totalTranslationX,ref totalTranslationY,ref totalAngle,_parameterCaches[source],parameterMinimumValues[source],parameterMaximumValues[source],parameterDefaultValues[source],currentSetting.NormalizationPosition,currentSetting.NormalizationAngle,input.Reflect,weight);break;case CubismPhysicsSource.Y:GetInputTranslationYFromNormalizedParameterValue(ref totalTranslationX,ref totalTranslationY,ref totalAngle,_parameterCaches[source],parameterMinimumValues[source],parameterMaximumValues[source],parameterDefaultValues[source],currentSetting.NormalizationPosition,currentSetting.NormalizationAngle,input.Reflect,weight);break;case CubismPhysicsSource.Angle:GetInputAngleFromNormalizedParameterValue(ref totalTranslationX,ref totalTranslationY,ref totalAngle,_parameterCaches[source],parameterMinimumValues[source],parameterMaximumValues[source],parameterDefaultValues[source],currentSetting.NormalizationPosition,currentSetting.NormalizationAngle,input.Reflect,weight);break;}}float radAngle=DegreesToRadian(-totalAngle);// NOTE: native computes Y from the already-updated X (bug-compatible with CubismPhysics.cpp Evaluate).
totalTranslationX=totalTranslationX*CosF(radAngle)-totalTranslationY*SinF(radAngle);totalTranslationY=totalTranslationX*SinF(radAngle)+totalTranslationY*CosF(radAngle);// Calculate particles position.
UpdateParticles(_rig.Particles,currentSetting.BaseParticleIndex,currentSetting.ParticleCount,totalTranslationX,totalTranslationY,totalAngle,_optionsWindX,_optionsWindY,MovementThreshold*currentSetting.NormalizationPosition.Maximum,physicsDeltaTime,AirResistance);// Update output parameters.
for(int i=0;i<currentSetting.OutputCount;++i){int outputIndex=currentSetting.BaseOutputIndex+i;var output=_rig.Outputs[outputIndex];int particleIndex=output.VertexIndex;if(output.DestinationParameterIndex== -1){output.DestinationParameterIndex=ResolveParameterIndex(model,output.ParameterId);}if(particleIndex<1||particleIndex>=currentSetting.ParticleCount){_rig.Outputs[outputIndex]=output;continue;}int vertex=currentSetting.BaseParticleIndex+particleIndex;float translationX=_rig.Particles[vertex].PositionX-_rig.Particles[vertex-1].PositionX;float translationY=_rig.Particles[vertex].PositionY-_rig.Particles[vertex-1].PositionY;float outputValue=GetOutputValue(output.Type,translationX,translationY,_rig.Particles,currentSetting.BaseParticleIndex,particleIndex,output.Reflect,_optionsGravityX,_optionsGravityY);_currentRigOutputs[outputIndex]=outputValue;if(output.DestinationParameterIndex!= -1){int destination=output.DestinationParameterIndex;UpdateOutputParameterValue(ref _parameterCaches[destination],parameterMinimumValues[destination],parameterMaximumValues[destination],outputValue,ref output);}_rig.Outputs[outputIndex]=output;}}_currentRemainTime-=physicsDeltaTime;}float alpha=_currentRemainTime/physicsDeltaTime;Interpolate(parameterValues,parameterMinimumValues,parameterMaximumValues,alpha);}/// <summary> Writes the interpolated pendulum results into the model parameters (SDK CubismPhysics::Interpolate). Outputs whose destination was never resolved are skipped. </summary>
private unsafe void Interpolate(float*parameterValues,float*parameterMinimumValues,float*parameterMaximumValues,float weight){foreach(var currentSetting in _rig.SubRigs){for(int i=0;i<currentSetting.OutputCount;++i){int outputIndex=currentSetting.BaseOutputIndex+i;var output=_rig.Outputs[outputIndex];if(output.DestinationParameterIndex== -1){continue;}int destination=output.DestinationParameterIndex;float interpolated=_previousRigOutputs[outputIndex]*(1-weight)+_currentRigOutputs[outputIndex]*weight;UpdateOutputParameterValue(ref parameterValues[destination],parameterMinimumValues[destination],parameterMaximumValues[destination],interpolated,ref output);_rig.Outputs[outputIndex]=output;}}}// ================================================================== Pure math core (internal static, no CubismNativeModel dependency — unit-testable against the native SDK formulas). ================================================================== <summary>Dispatches to the SDK value getter for an output type.</summary>
internal static float GetOutputValue(CubismPhysicsSource type,float translationX,float translationY,CubismPhysicsParticle[]particles,int baseParticleIndex,int particleIndex,bool isInverted,float parentGravityX,float parentGravityY){switch(type){case CubismPhysicsSource.X:return GetOutputTranslationX(translationX,isInverted);case CubismPhysicsSource.Y:return GetOutputTranslationY(translationY,isInverted);case CubismPhysicsSource.Angle:return GetOutputAngle(translationX,translationY,particles,baseParticleIndex,particleIndex,isInverted,parentGravityX,parentGravityY);default:return GetOutputTranslationX(translationX,isInverted);}}internal static float GetOutputTranslationX(float translationX,bool isInverted){float outputValue=translationX;if(isInverted){outputValue*= -1f;}return outputValue;}internal static float GetOutputTranslationY(float translationY,bool isInverted){float outputValue=translationY;if(isInverted){outputValue*= -1f;}return outputValue;}/// <summary> SDK GetOutputAngle: the parent gravity is the direction between the two previous particles once the pendulum has at least two segments, otherwise the negated rig gravity. </summary>
internal static float GetOutputAngle(float translationX,float translationY,CubismPhysicsParticle[]particles,int baseParticleIndex,int particleIndex,bool isInverted,float parentGravityX,float parentGravityY){float gravityX,gravityY;if(particleIndex>=2){gravityX=particles[baseParticleIndex+particleIndex-1].PositionX-particles[baseParticleIndex+particleIndex-2].PositionX;gravityY=particles[baseParticleIndex+particleIndex-1].PositionY-particles[baseParticleIndex+particleIndex-2].PositionY;}else{gravityX= -parentGravityX;gravityY= -parentGravityY;}float outputValue=DirectionToRadian(gravityX,gravityY,translationX,translationY);if(isInverted){outputValue*= -1f;}return outputValue;}/// <summary>SDK scale getter dispatch: X/Y use the (never loaded, i.e. zero) translation scale; Angle uses AngleScale.</summary>
internal static float GetOutputScale(CubismPhysicsSource type,float translationScaleX,float translationScaleY,float angleScale){switch(type){case CubismPhysicsSource.X:return translationScaleX;case CubismPhysicsSource.Y:return translationScaleY;default:return angleScale;}}/// <summary> SDK UpdateOutputParameterValue: scale the translation, clamp to the parameter range (recording below-minimum/exceeded-maximum extremes), then blend into the parameter by Output.Weight / 100. </summary>
internal static void UpdateOutputParameterValue(ref float parameterValue,float parameterValueMinimum,float parameterValueMaximum,float translation,ref CubismPhysicsOutput output){float outputScale=GetOutputScale(output.Type,output.TranslationScaleX,output.TranslationScaleY,output.AngleScale);float value=translation*outputScale;if(value<parameterValueMinimum){if(value<output.ValueBelowMinimum){output.ValueBelowMinimum=value;}value=parameterValueMinimum;}else if(value>parameterValueMaximum){if(value>output.ValueExceededMaximum){output.ValueExceededMaximum=value;}value=parameterValueMaximum;}float weight=output.Weight/MaximumWeight;if(weight>=1f){parameterValue=value;}else{value=parameterValue*(1f-weight)+value*weight;parameterValue=value;}}/// <summary>SDK GetRangeValue.</summary>
internal static float GetRangeValue(float min,float max){float maxValue=MaxF(min,max);float minValue=MinF(min,max);return AbsF(maxValue-minValue);}/// <summary>SDK Sign.</summary>
internal static int Sign(float value){if(value>0f){return 1;}if(value<0f){return-1;}return 0;}/// <summary>SDK GetDefaultValue: the middle of the parameter range.</summary>
internal static float GetDefaultValue(float min,float max){float minValue=MinF(min,max);return minValue+GetRangeValue(min,max)/2f;}/// <summary> SDK NormalizeParameterValue: clamp the value to its parameter range, map it piecewise across the normalization range relative to the default middle, and invert when the input is not reflected (native applies the sign flip to non-inverted values). </summary>
internal static float NormalizeParameterValue(float value,float parameterMinimum,float parameterMaximum,float parameterDefault,float normalizedMinimum,float normalizedMaximum,float normalizedDefault,bool isInverted){float result=0f;float maxValue=MaxF(parameterMaximum,parameterMinimum);if(maxValue<value){value=maxValue;}float minValue=MinF(parameterMaximum,parameterMinimum);if(minValue>value){value=minValue;}float minNormValue=MinF(normalizedMinimum,normalizedMaximum);float maxNormValue=MaxF(normalizedMinimum,normalizedMaximum);float middleNormValue=normalizedDefault;float middleValue=GetDefaultValue(minValue,maxValue);float paramValue=value-middleValue;switch(Sign(paramValue)){case 1:{float nLength=maxNormValue-middleNormValue;float pLength=maxValue-middleValue;if(pLength!=0f){result=paramValue*(nLength/pLength);result+=middleNormValue;}break;}case-1:{float nLength=minNormValue-middleNormValue;float pLength=minValue-middleValue;if(pLength!=0f){result=paramValue*(nLength/pLength);result+=middleNormValue;}break;}default:{result=middleNormValue;break;}}return isInverted?result:result* -1f;}/// <summary>SDK GetInputTranslationXFromNormalizedParameterValue.</summary>
internal static void GetInputTranslationXFromNormalizedParameterValue(ref float targetTranslationX,ref float targetTranslationY,ref float targetAngle,float value,float parameterMinimumValue,float parameterMaximumValue,float parameterDefaultValue,CubismPhysicsNormalization normalizationPosition,CubismPhysicsNormalization normalizationAngle,bool isInverted,float weight){targetTranslationX+=NormalizeParameterValue(value,parameterMinimumValue,parameterMaximumValue,parameterDefaultValue,normalizationPosition.Minimum,normalizationPosition.Maximum,normalizationPosition.Default,isInverted)*weight;}/// <summary>SDK GetInputTranslationYFromNormalizedParameterValue.</summary>
internal static void GetInputTranslationYFromNormalizedParameterValue(ref float targetTranslationX,ref float targetTranslationY,ref float targetAngle,float value,float parameterMinimumValue,float parameterMaximumValue,float parameterDefaultValue,CubismPhysicsNormalization normalizationPosition,CubismPhysicsNormalization normalizationAngle,bool isInverted,float weight){targetTranslationY+=NormalizeParameterValue(value,parameterMinimumValue,parameterMaximumValue,parameterDefaultValue,normalizationPosition.Minimum,normalizationPosition.Maximum,normalizationPosition.Default,isInverted)*weight;}/// <summary>SDK GetInputAngleFromNormalizedParameterValue.</summary>
internal static void GetInputAngleFromNormalizedParameterValue(ref float targetTranslationX,ref float targetTranslationY,ref float targetAngle,float value,float parameterMinimumValue,float parameterMaximumValue,float parameterDefaultValue,CubismPhysicsNormalization normalizationPosition,CubismPhysicsNormalization normalizationAngle,bool isInverted,float weight){targetAngle+=NormalizeParameterValue(value,parameterMinimumValue,parameterMaximumValue,parameterDefaultValue,normalizationAngle.Minimum,normalizationAngle.Maximum,normalizationAngle.Default,isInverted)*weight;}/// <summary> SDK UpdateParticles: one integration step of a particle strand. The root is pinned to the total translation, each particle keeps its radius distance from its predecessor, is rotated with the gravity change (divided by air resistance), integrates velocity and force, and stores velocity for the next step (only when delay != 0). </summary>
internal static void UpdateParticles(CubismPhysicsParticle[]strand,int baseParticleIndex,int strandCount,float totalTranslationX,float totalTranslationY,float totalAngle,float windX,float windY,float thresholdValue,float deltaTimeSeconds,float airResistance){strand[baseParticleIndex].PositionX=totalTranslationX;strand[baseParticleIndex].PositionY=totalTranslationY;float totalRadian=DegreesToRadian(totalAngle);float currentGravityX=SinF(totalRadian);float currentGravityY=CosF(totalRadian);Normalize(ref currentGravityX,ref currentGravityY);for(int i=1;i<strandCount;++i){int idx=baseParticleIndex+i;strand[idx].ForceX=currentGravityX*strand[idx].Acceleration+windX;strand[idx].ForceY=currentGravityY*strand[idx].Acceleration+windY;strand[idx].LastPositionX=strand[idx].PositionX;strand[idx].LastPositionY=strand[idx].PositionY;float delay=strand[idx].Delay*deltaTimeSeconds*30f;float directionX=strand[idx].PositionX-strand[idx-1].PositionX;float directionY=strand[idx].PositionY-strand[idx-1].PositionY;float radian=DirectionToRadian(strand[idx].LastGravityX,strand[idx].LastGravityY,currentGravityX,currentGravityY)/airResistance;// NOTE: native computes Y from the already-updated X (bug-compatible with CubismPhysics.cpp UpdateParticles).
float newDirectionX=CosF(radian)*directionX-directionY*SinF(radian);directionY=SinF(radian)*newDirectionX+directionY*CosF(radian);directionX=newDirectionX;strand[idx].PositionX=strand[idx-1].PositionX+directionX;strand[idx].PositionY=strand[idx-1].PositionY+directionY;float velocityX=strand[idx].VelocityX*delay;float velocityY=strand[idx].VelocityY*delay;float forceX=strand[idx].ForceX*delay*delay;float forceY=strand[idx].ForceY*delay*delay;strand[idx].PositionX=strand[idx].PositionX+velocityX+forceX;strand[idx].PositionY=strand[idx].PositionY+velocityY+forceY;float newDirectionX2=strand[idx].PositionX-strand[idx-1].PositionX;float newDirectionY2=strand[idx].PositionY-strand[idx-1].PositionY;Normalize(ref newDirectionX2,ref newDirectionY2);strand[idx].PositionX=strand[idx-1].PositionX+newDirectionX2*strand[idx].Radius;strand[idx].PositionY=strand[idx-1].PositionY+newDirectionY2*strand[idx].Radius;if(AbsF(strand[idx].PositionX)<thresholdValue){strand[idx].PositionX=0f;}if(delay!=0f){strand[idx].VelocityX=strand[idx].PositionX-strand[idx].LastPositionX;strand[idx].VelocityY=strand[idx].PositionY-strand[idx].LastPositionY;strand[idx].VelocityX/=delay;strand[idx].VelocityY/=delay;strand[idx].VelocityX*=strand[idx].Mobility;strand[idx].VelocityY*=strand[idx].Mobility;}strand[idx].ForceX=0f;strand[idx].ForceY=0f;strand[idx].LastGravityX=currentGravityX;strand[idx].LastGravityY=currentGravityY;}}/// <summary> SDK UpdateParticlesForStabilization: velocity is zeroed and each particle snaps to one normalized-force radius from its predecessor. </summary>
internal static void UpdateParticlesForStabilization(CubismPhysicsParticle[]strand,int baseParticleIndex,int strandCount,float totalTranslationX,float totalTranslationY,float totalAngle,float windX,float windY,float thresholdValue){strand[baseParticleIndex].PositionX=totalTranslationX;strand[baseParticleIndex].PositionY=totalTranslationY;float totalRadian=DegreesToRadian(totalAngle);float currentGravityX=SinF(totalRadian);float currentGravityY=CosF(totalRadian);Normalize(ref currentGravityX,ref currentGravityY);for(int i=1;i<strandCount;++i){int idx=baseParticleIndex+i;strand[idx].ForceX=currentGravityX*strand[idx].Acceleration+windX;strand[idx].ForceY=currentGravityY*strand[idx].Acceleration+windY;strand[idx].LastPositionX=strand[idx].PositionX;strand[idx].LastPositionY=strand[idx].PositionY;strand[idx].VelocityX=0f;strand[idx].VelocityY=0f;float forceX=strand[idx].ForceX;float forceY=strand[idx].ForceY;Normalize(ref forceX,ref forceY);forceX*=strand[idx].Radius;forceY*=strand[idx].Radius;strand[idx].PositionX=strand[idx-1].PositionX+forceX;strand[idx].PositionY=strand[idx-1].PositionY+forceY;if(AbsF(strand[idx].PositionX)<thresholdValue){strand[idx].PositionX=0f;}strand[idx].ForceX=0f;strand[idx].ForceY=0f;strand[idx].LastGravityX=currentGravityX;strand[idx].LastGravityY=currentGravityY;}}// ================================================================== SDK math equivalents (bit-exact formulas; do not "simplify"). ================================================================== <summary>SDK CubismMath::DegreesToRadian — note the divide-first order.</summary>
internal static float DegreesToRadian(float degrees){return(degrees/180f)*Pi;}/// <summary>SDK CubismMath::DirectionToRadian — atan2 difference wrapped into [-Pi, Pi].</summary>
internal static float DirectionToRadian(float fromX,float fromY,float toX,float toY){float q1=MathF.Atan2(toY,toX);float q2=MathF.Atan2(fromY,fromX);float ret=q1-q2;while(ret< -Pi){ret+=Pi*2f;}while(ret>Pi){ret-=Pi*2f;}return ret;}/// <summary>SDK CubismMath::RadianToDirection — (sin, cos).</summary>
internal static void RadianToDirection(float totalAngle,out float x,out float y){x=SinF(totalAngle);y=CosF(totalAngle);}/// <summary> SDK CubismVector2::Normalize — note that native uses powf(squareLength, 0.5f), not sqrtf, and does not guard zero length. </summary>
internal static void Normalize(ref float x,ref float y){float length=MathF.Pow(x*x+y*y,0.5f);x=x/length;y=y/length;}private static float SinF(float x)=>MathF.Sin(x);private static float CosF(float x)=>MathF.Cos(x);private static float AbsF(float x)=>MathF.Abs(x);private static float MinF(float l,float r)=>(l>r)?r:l;private static float MaxF(float l,float r)=>(l>r)?l:r;/// <summary>Resolves a parameter id to its index; -1 when not present (native asserts instead).</summary>
private static unsafe int ResolveParameterIndex(CubismNativeModel model,string parameterId){var idsPtr=model.GetParameterIds();for(int i=0;i<model.ParameterCount;i++){if(CubismNativeModel.ReadStringFromPtrArray(idsPtr,i)==parameterId){return i;}}return-1;}/// <summary>Maps a physics3.json type tag to its source enum; unknown tags behave as X (enum value 0, like native).</summary>
private static CubismPhysicsSource ParseSource(string value){switch(value){case"Angle":return CubismPhysicsSource.Angle;case"Y":return CubismPhysicsSource.Y;default:return CubismPhysicsSource.X;}}}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismPhysicsJson.cs =====
using System.Collections.Generic;using System.Text.Json;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Physics input/output dimensionality. </summary>
public enum CubismPhysicsSource{X,Y,Angle}/// <summary> Normalization range for mapping raw parameter values to normalized physics inputs. </summary>
public struct CubismPhysicsNormalization{public float Minimum;public float Maximum;public float Default;}/// <summary> A single particle in a physics chain. Holds position, velocity, force accumulators, and per-particle properties (mobility, delay, acceleration, radius) from the .physics3.json file. Mirrors SDK CubismPhysicsParticle. </summary>
public struct CubismPhysicsParticle{public float InitialX,InitialY;public float Mobility;public float Delay;public float Acceleration;public float Radius;public float PositionX,PositionY;public float LastPositionX,LastPositionY;public float LastGravityX,LastGravityY;public float ForceX,ForceY;public float VelocityX,VelocityY;}/// <summary> A sub-rig groups a contiguous range of inputs, outputs, and particles that form one independent physics chain. Each sub-rig has its own normalization settings for position and angle inputs. </summary>
public struct CubismPhysicsSubRig{public int InputCount,OutputCount,ParticleCount;public int BaseInputIndex,BaseOutputIndex,BaseParticleIndex;public CubismPhysicsNormalization NormalizationPosition;public CubismPhysicsNormalization NormalizationAngle;}/// <summary> Maps a model parameter to a physics chain input (X, Y, or angle) with a configurable weight and optional reflection. </summary>
public struct CubismPhysicsInput{public string ParameterId;public int SourceParameterIndex;public float Weight;public CubismPhysicsSource Type;public bool Reflect;}/// <summary> Maps a physics chain output back to a model parameter. <see cref="VertexIndex"/> is the pendulum vertex index relative to the owning sub-rig's base particle (SDK semantics). The translation scale is deliberately never loaded: native CubismPhysicsJson reads only the single "Scale" value into <see cref="AngleScale"/>, so X/Y-typed outputs scale by zero exactly like the native implementation. </summary>
public struct CubismPhysicsOutput{public string ParameterId;public int DestinationParameterIndex;public int VertexIndex;public float TranslationScaleX,TranslationScaleY;public float AngleScale;public float Weight;public CubismPhysicsSource Type;public bool Reflect;public float ValueBelowMinimum;public float ValueExceededMaximum;}/// <summary> Complete physics rig data structure. Holds all sub-rigs, flattened input/output/particle arrays, and global gravity/wind/fps settings. Used by <see cref="CubismPhysics"/> as its internal state. </summary>
public class CubismPhysicsRig{public List<CubismPhysicsSubRig>SubRigs=new();public List<CubismPhysicsInput>Inputs=new();public List<CubismPhysicsOutput>Outputs=new();public CubismPhysicsParticle[]Particles=System.Array.Empty<CubismPhysicsParticle>();public float GravityX,GravityY;public float WindX,WindY;public float Fps;}/// <summary> JSON parser for the Cubism .physics3.json format. Field-by-field port of Cubism SDK for Native 5-r.5 Physics/CubismPhysicsJson.cpp: every getter reads the same key at the same JSON path. Missing numeric values yield 0 / false like the native error-value accessors; in particular a missing Meta.Fps yields 0 (the simulation then steps per display frame). Note: native does not read InitialPosition/InitialVelocity for vertices — those keys are ignored here as well; particles are laid out by CubismPhysics.Initialize(). </summary>
public class CubismPhysicsJson{private readonly JsonDocument _json;/// <summary>Parses the given .physics3.json bytes.</summary>
public CubismPhysicsJson(byte[]jsonBytes){_json=JsonDocument.Parse(jsonBytes);}// ------------------------------------------------------------------ Meta ------------------------------------------------------------------ <summary>Gravity vector from Meta.EffectiveForces.Gravity (missing values read as 0).</summary>
public void GetGravity(out float x,out float y){x=0f;y=0f;if(_json.RootElement.TryGetProperty("Meta",out var meta)&&meta.TryGetProperty("EffectiveForces",out var effectiveForces)&&effectiveForces.TryGetProperty("Gravity",out var gravity)){x=GetFloat(gravity,"X");y=GetFloat(gravity,"Y");}}/// <summary>Wind vector from Meta.EffectiveForces.Wind (missing values read as 0).</summary>
public void GetWind(out float x,out float y){x=0f;y=0f;if(_json.RootElement.TryGetProperty("Meta",out var meta)&&meta.TryGetProperty("EffectiveForces",out var effectiveForces)&&effectiveForces.TryGetProperty("Wind",out var wind)){x=GetFloat(wind,"X");y=GetFloat(wind,"Y");}}/// <summary>Simulation FPS from Meta.Fps; 0 when absent (native behavior).</summary>
public float GetFps(){return _json.RootElement.TryGetProperty("Meta",out var meta)?GetFloat(meta,"Fps"):0f;}/// <summary>Sub-rig count from Meta.PhysicsSettingCount.</summary>
public int GetSubRigCount(){if(_json.RootElement.TryGetProperty("Meta",out var meta)&&meta.TryGetProperty("PhysicsSettingCount",out var count)){return count.GetInt32();}// Defensive fallback: use the actual settings array length (native would return 0 when the key is absent).
return _json.RootElement.TryGetProperty("PhysicsSettings",out var settings)?settings.GetArrayLength():0;}/// <summary>Total input count from Meta.TotalInputCount.</summary>
public int GetTotalInputCount()=>GetMetaInt("TotalInputCount");/// <summary>Total output count from Meta.TotalOutputCount.</summary>
public int GetTotalOutputCount()=>GetMetaInt("TotalOutputCount");/// <summary>Total vertex/particle count from Meta.VertexCount.</summary>
public int GetVertexCount()=>GetMetaInt("VertexCount");// ------------------------------------------------------------------ Normalization ------------------------------------------------------------------ <summary>Position normalization (Minimum/Maximum/Default) for a sub-rig; missing values read as 0.</summary>
public void GetNormalizationPosition(int subRigIndex,out CubismPhysicsNormalization norm){norm=default;if(GetSubRig(subRigIndex).TryGetProperty("Normalization",out var n)&&n.TryGetProperty("Position",out var pos)){norm.Minimum=GetFloat(pos,"Minimum");norm.Maximum=GetFloat(pos,"Maximum");norm.Default=GetFloat(pos,"Default");}}/// <summary>Angle normalization (Minimum/Maximum/Default) for a sub-rig; missing values read as 0.</summary>
public void GetNormalizationAngle(int subRigIndex,out CubismPhysicsNormalization norm){norm=default;if(GetSubRig(subRigIndex).TryGetProperty("Normalization",out var n)&&n.TryGetProperty("Angle",out var angle)){norm.Minimum=GetFloat(angle,"Minimum");norm.Maximum=GetFloat(angle,"Maximum");norm.Default=GetFloat(angle,"Default");}}// ------------------------------------------------------------------ Input ------------------------------------------------------------------ <summary>Number of input entries in the given sub-rig.</summary>
public int GetInputCount(int subRigIndex)=>GetEntriesLength(subRigIndex,"Input");/// <summary>Input weight (Input[i].Weight).</summary>
public float GetInputWeight(int subRigIndex,int inputIndex)=>GetFloat(GetInputEntry(subRigIndex,inputIndex),"Weight");/// <summary>Input reflect flag (Input[i].Reflect).</summary>
public bool GetInputReflect(int subRigIndex,int inputIndex)=>GetBool(GetInputEntry(subRigIndex,inputIndex),"Reflect");/// <summary>Input type tag (Input[i].Type): "X", "Y" or "Angle".</summary>
public string GetInputType(int subRigIndex,int inputIndex)=>GetString(GetInputEntry(subRigIndex,inputIndex),"Type");/// <summary>Source parameter id (Input[i].Source.Id).</summary>
public string GetInputSourceId(int subRigIndex,int inputIndex){var entry=GetInputEntry(subRigIndex,inputIndex);return entry.TryGetProperty("Source",out var source)?GetString(source,"Id"):null;}// ------------------------------------------------------------------ Output ------------------------------------------------------------------ <summary>Number of output entries in the given sub-rig.</summary>
public int GetOutputCount(int subRigIndex)=>GetEntriesLength(subRigIndex,"Output");/// <summary>Pendulum vertex index (Output[i].VertexIndex), relative to the sub-rig base particle.</summary>
public int GetOutputVertexIndex(int subRigIndex,int outputIndex)=>GetInt(GetOutputEntry(subRigIndex,outputIndex),"VertexIndex");/// <summary>Angle scale (Output[i].Scale). Native maps the single "Scale" value to the angle scale only.</summary>
public float GetOutputAngleScale(int subRigIndex,int outputIndex)=>GetFloat(GetOutputEntry(subRigIndex,outputIndex),"Scale");/// <summary>Output weight (Output[i].Weight).</summary>
public float GetOutputWeight(int subRigIndex,int outputIndex)=>GetFloat(GetOutputEntry(subRigIndex,outputIndex),"Weight");/// <summary>Destination parameter id (Output[i].Destination.Id).</summary>
public string GetOutputsDestinationId(int subRigIndex,int outputIndex){var entry=GetOutputEntry(subRigIndex,outputIndex);return entry.TryGetProperty("Destination",out var destination)?GetString(destination,"Id"):null;}/// <summary>Output type tag (Output[i].Type): "X", "Y" or "Angle".</summary>
public string GetOutputType(int subRigIndex,int outputIndex)=>GetString(GetOutputEntry(subRigIndex,outputIndex),"Type");/// <summary>Output reflect flag (Output[i].Reflect).</summary>
public bool GetOutputReflect(int subRigIndex,int outputIndex)=>GetBool(GetOutputEntry(subRigIndex,outputIndex),"Reflect");// ------------------------------------------------------------------ Particle (Vertices) ------------------------------------------------------------------ <summary>Number of vertex/particle entries in the given sub-rig.</summary>
public int GetParticleCount(int subRigIndex)=>GetEntriesLength(subRigIndex,"Vertices");/// <summary>Particle mobility (Vertices[i].Mobility).</summary>
public float GetParticleMobility(int subRigIndex,int vertexIndex)=>GetFloat(GetVertexEntry(subRigIndex,vertexIndex),"Mobility");/// <summary>Particle delay (Vertices[i].Delay).</summary>
public float GetParticleDelay(int subRigIndex,int vertexIndex)=>GetFloat(GetVertexEntry(subRigIndex,vertexIndex),"Delay");/// <summary>Particle acceleration (Vertices[i].Acceleration).</summary>
public float GetParticleAcceleration(int subRigIndex,int vertexIndex)=>GetFloat(GetVertexEntry(subRigIndex,vertexIndex),"Acceleration");/// <summary>Particle radius (Vertices[i].Radius).</summary>
public float GetParticleRadius(int subRigIndex,int vertexIndex)=>GetFloat(GetVertexEntry(subRigIndex,vertexIndex),"Radius");/// <summary>Particle position (Vertices[i].Position.X/Y).</summary>
public void GetParticlePosition(int subRigIndex,int vertexIndex,out float x,out float y){var entry=GetVertexEntry(subRigIndex,vertexIndex);x=0f;y=0f;if(entry.TryGetProperty("Position",out var position)){x=GetFloat(position,"X");y=GetFloat(position,"Y");}}// ------------------------------------------------------------------ Helpers ------------------------------------------------------------------
private JsonElement GetSubRig(int index)=>_json.RootElement.GetProperty("PhysicsSettings")[index];private JsonElement GetInputEntry(int subRigIndex,int inputIndex)=>GetSubRig(subRigIndex).GetProperty("Input")[inputIndex];private JsonElement GetOutputEntry(int subRigIndex,int outputIndex)=>GetSubRig(subRigIndex).GetProperty("Output")[outputIndex];private JsonElement GetVertexEntry(int subRigIndex,int vertexIndex)=>GetSubRig(subRigIndex).GetProperty("Vertices")[vertexIndex];private int GetEntriesLength(int subRigIndex,string name){var subRig=GetSubRig(subRigIndex);return subRig.TryGetProperty(name,out var entries)?entries.GetArrayLength():0;}private int GetMetaInt(string name)=>_json.RootElement.TryGetProperty("Meta",out var meta)?GetInt(meta,name):0;private static float GetFloat(JsonElement element,string name)=>element.TryGetProperty(name,out var value)?value.GetSingle():0f;private static int GetInt(JsonElement element,string name)=>element.TryGetProperty(name,out var value)?value.GetInt32():0;private static bool GetBool(JsonElement element,string name)=>element.TryGetProperty(name,out var value)&&value.ValueKind==JsonValueKind.True;private static string GetString(JsonElement element,string name)=>element.TryGetProperty(name,out var value)&&value.ValueKind==JsonValueKind.String?value.GetString():string.Empty;}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismPose.cs =====
using System;using System.Collections.Generic;using System.Text.Json;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Manages pose-based part visibility from a Cubism .pose3.json file. Pose groups organize model parts into mutual-exclusion groups — only one part in each group is visible at a time. Commonly used for eye states (open/closed/surprised) and other multi-state part switches. This is a faithful port of CubismPose.cpp (Cubism SDK for Native 5-r.5): <list type="number"> <item>Part groups are stored as a flattened list with per-group counts (matching the SDK layout, so index arithmetic in Reset/DoFade is identical).</item> <item>Each group part references both a part index (for opacity) and a parameter index (for visibility). The visible part of a group is decided by the PARAMETER VALUE (&gt; Epsilon), not by the part opacity. If a pose parameter/part does not exist in the model, the SDK's "not exist" maps are emulated, and the group falls back to its first part with opacity 1.</item> <item>Reset (on model change): the first part of each group gets parameter value 1 / part opacity 1, all others get 0; linked parts resolve their indices too.</item> <item>DoFade: the visible part fades toward 1 at rate deltaTime / fadeTimeSeconds while all other parts are pushed toward 0 with the SDK's piecewise-linear crossing formula (Phi = 0.5) and back-opacity threshold (0.15). If no part is visible, the group falls back to its first part with opacity 1. If fadeTimeSeconds is 0, the visible part becomes fully opaque immediately.</item> <item>CopyPartOpacities: parts with a "Link" list copy their current part opacity to every linked part.</item> </list> </summary>
public class CubismPose{private const float Epsilon=0.001f;private const float DefaultFadeInSeconds=0.5f;private const float Phi=0.5f;private const float BackOpacityThreshold=0.15f;private sealed class PartData{public string PartId;public int PartIndex= -1;public int ParameterIndex= -1;public readonly List<PartData>Link=new();/// <summary> SDK CubismPose::PartData::Initialize — resolves both indices for this part id and initializes its pose parameter to 1. </summary>
public void Initialize(CubismNativeModel model,CubismPose owner){ParameterIndex=owner.FindParameterIndex(model,PartId);PartIndex=owner.FindPartIndex(model,PartId);owner.SetParameterValue(model,ParameterIndex,PartId,1f);}}private readonly List<PartData>_partGroups=new();private readonly List<int>_partGroupCounts=new();private float _fadeTimeSeconds=DefaultFadeInSeconds;private IntPtr _lastModel=IntPtr.Zero;// Emulation of the SDK's _notExistParameterValues / _notExistPartOpacities maps: pose3.json may reference parameters/parts that do not exist in the model. They are keyed by id here (the SDK keys parameter entries by synthesized indices; per-id keys are equivalent for this emulation).
private readonly Dictionary<string,float>_notExistParameterValues=new();private readonly Dictionary<string,float>_notExistPartOpacities=new();/// <summary> Parses a .pose3.json byte buffer. Extracts the fade time and all part groups (flattened, with counts per group) including their linked-part chains. </summary>
public CubismPose(byte[]pose3JsonBytes){if(pose3JsonBytes==null||pose3JsonBytes.Length==0)return;var json=JsonDocument.Parse(pose3JsonBytes);var root=json.RootElement;if(root.TryGetProperty("FadeInTime",out var ft)&&ft.ValueKind==JsonValueKind.Number){_fadeTimeSeconds=ft.GetSingle();if(_fadeTimeSeconds<0f)_fadeTimeSeconds=DefaultFadeInSeconds;}if(!root.TryGetProperty("Groups",out var groups)||groups.ValueKind!=JsonValueKind.Array)return;foreach(var group in groups.EnumerateArray()){var groupCount=0;foreach(var part in group.EnumerateArray()){var pd=new PartData{PartId=part.GetProperty("Id").GetString()};if(part.TryGetProperty("Link",out var links)&&links.ValueKind==JsonValueKind.Array){foreach(var link in links.EnumerateArray())pd.Link.Add(new PartData{PartId=link.GetString()});}_partGroups.Add(pd);++groupCount;}_partGroupCounts.Add(groupCount);}}/// <summary> Per-frame pose update, mirroring CubismPose::UpdateParameters. Detects model changes to trigger a full reset, then fades each group and propagates opacities to linked parts. Called from <see cref="CubismModelNode.OnUpdate"/> after expressions. </summary>
public void UpdateParameters(CubismNativeModel model,float deltaTimeSeconds){if(model==null||model.ModelPtr==IntPtr.Zero)return;// 前回のモデルと同じではないときは初期化が必要
if(_lastModel!=model.ModelPtr)Reset(model);_lastModel=model.ModelPtr;// 設定から時間を変更すると、経過時間がマイナスになることがあるので、経過時間0として対応。
if(deltaTimeSeconds<0f)deltaTimeSeconds=0f;var beginIndex=0;for(int i=0;i<_partGroupCounts.Count;i++){var partGroupCount=_partGroupCounts[i];DoFade(model,deltaTimeSeconds,beginIndex,partGroupCount);beginIndex+=partGroupCount;}CopyPartOpacities(model);}/// <summary>SDK CubismPose::Reset — re-resolves indices and restores the default state: the first part of each group is visible (parameter 1, opacity 1), all others hidden, links re-initialized.</summary>
private unsafe void Reset(CubismNativeModel model){var beginIndex=0;for(int gi=0;gi<_partGroupCounts.Count;gi++){var groupCount=_partGroupCounts[gi];for(int j=beginIndex;j<beginIndex+groupCount;j++){var pd=_partGroups[j];pd.Initialize(model,this);if(pd.PartIndex<0)continue;SetPartOpacity(model,pd.PartIndex,pd.PartId,j==beginIndex?1f:0f);SetParameterValue(model,pd.ParameterIndex,pd.PartId,j==beginIndex?1f:0f);for(int k=0;k<pd.Link.Count;k++)pd.Link[k].Initialize(model,this);}beginIndex+=groupCount;}}/// <summary>SDK CubismPose::DoFade — visibility is driven by the group part PARAMETER values; the visible part fades in while the rest are faded out with the crossing-threshold formula.</summary>
private unsafe void DoFade(CubismNativeModel model,float deltaTimeSeconds,int beginIndex,int partGroupCount){var visiblePartIndex= -1;var newOpacity=1f;// 現在、表示状態になっているパーツを取得
for(int i=beginIndex;i<beginIndex+partGroupCount;i++){var pd=_partGroups[i];if(GetParameterValue(model,pd.ParameterIndex,pd.PartId)>Epsilon){if(visiblePartIndex>=0)break;visiblePartIndex=i;if(_fadeTimeSeconds==0f){newOpacity=1f;continue;}newOpacity=ComputeVisibleOpacity(GetPartOpacity(model,pd.PartIndex,pd.PartId),deltaTimeSeconds,_fadeTimeSeconds);}}if(visiblePartIndex<0){visiblePartIndex=0;newOpacity=1f;}// 表示パーツ、非表示パーツの不透明度を設定する
for(int i=beginIndex;i<beginIndex+partGroupCount;i++){var pd=_partGroups[i];if(visiblePartIndex==i){SetPartOpacity(model,pd.PartIndex,pd.PartId,newOpacity);// 先に設定
}else{var opacity=GetPartOpacity(model,pd.PartIndex,pd.PartId);var a1=ComputeHiddenOpacity(newOpacity);if(opacity>a1)opacity=a1;SetPartOpacity(model,pd.PartIndex,pd.PartId,opacity);}}}/// <summary>SDK CubismPose::CopyPartOpacities — every group part with a Link list copies its current part opacity to all linked parts.</summary>
private unsafe void CopyPartOpacities(CubismNativeModel model){foreach(var partData in _partGroups){if(partData.Link.Count==0)continue;// 連動するパラメータはない
var opacity=GetPartOpacity(model,partData.PartIndex,partData.PartId);for(int linkIndex=0;linkIndex<partData.Link.Count;linkIndex++){var linkPart=partData.Link[linkIndex];if(linkPart.PartIndex<0)continue;SetPartOpacity(model,linkPart.PartIndex,linkPart.PartId,opacity);}}}/// <summary>Resolves a part id to a part index, or -1 when absent.</summary>
private unsafe int FindPartIndex(CubismNativeModel model,string partId){var idsPtr=model.GetPartIds();var partCount=model.GetPartCount();for(int i=0;i<partCount;i++){if(CubismNativeModel.ReadStringFromPtrArray(idsPtr,i)==partId)return i;}return-1;}/// <summary>Resolves a parameter id to a parameter index, or -1 when absent.</summary>
private unsafe int FindParameterIndex(CubismNativeModel model,string parameterId){var idsPtr=model.GetParameterIds();var parameterCount=model.ParameterCount;for(int i=0;i<parameterCount;i++){if(CubismNativeModel.ReadStringFromPtrArray(idsPtr,i)==parameterId)return i;}return-1;}private unsafe float GetParameterValue(CubismNativeModel model,int parameterIndex,string parameterId){if(parameterIndex<0)return _notExistParameterValues.TryGetValue(parameterId,out var v)?v:0f;return model.GetParameterValues()[parameterIndex];}private unsafe void SetParameterValue(CubismNativeModel model,int parameterIndex,string parameterId,float value){if(parameterIndex<0){_notExistParameterValues[parameterId]=value;return;}model.GetParameterValues()[parameterIndex]=value;}private unsafe float GetPartOpacity(CubismNativeModel model,int partIndex,string partId){if(partIndex<0)return _notExistPartOpacities.TryGetValue(partId,out var v)?v:0f;return model.GetPartOpacities()[partIndex];}private unsafe void SetPartOpacity(CubismNativeModel model,int partIndex,string partId,float opacity){if(partIndex<0){_notExistPartOpacities[partId]=opacity;return;}model.GetPartOpacities()[partIndex]=opacity;}/// <summary> Pure helper (SDK DoFade, visible branch): the visible part's opacity grows by deltaTimeSeconds / fadeTimeSeconds, clamped to 1. Internal for unit tests. </summary>
internal static float ComputeVisibleOpacity(float currentOpacity,float deltaTimeSeconds,float fadeTimeSeconds){var newOpacity=currentOpacity+(deltaTimeSeconds/fadeTimeSeconds);return newOpacity>1f?1f:newOpacity;}/// <summary> Pure helper (SDK DoFade, hidden branch): piecewise-linear alpha with the Phi = 0.5 crossing and the 0.15 back-opacity threshold. Internal for unit tests. </summary>
internal static float ComputeHiddenOpacity(float newOpacity){float a1;if(newOpacity<Phi)a1=newOpacity*(Phi-1f)/Phi+1f;// (0,1),(phi,phi)を通る直線式
else a1=(1f-newOpacity)*Phi/(1f-Phi);// (1,0),(phi,phi)を通る直線式
var backOpacity=(1f-a1)*(1f-newOpacity);if(backOpacity>BackOpacityThreshold)a1=1f-BackOpacityThreshold/(1f-newOpacity);return a1;}}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismRenderer2D.cs =====
using System;using System.Buffers.Binary;using System.Collections.Generic;using Cthangover.Core.Utils;using Godot;using Cthangover.Core.Mods.Resolvers;using Cthangover.Live2D.Cubism.Core;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Builds and updates 2D mesh instances for rendering a Cubism model in Godot. Handles the full rendering pipeline: <list type="number"> <item><b>BuildModel</b> — Creates <see cref="ArrayMesh"/> instances from native vertex/index data, applies textures, resolves shaders based on drawable blend flags and mask configuration</item> <item><b>UpdateModel</b> — Updates visibility, opacity, and vertex positions each frame by reading CubismCore dynamic flags</item> <item><b>Mask support</b> — Creates SubViewport-based mask textures for drawables that have mask counts. Masked drawables use specialized shaders (2d_cubism_mask_*) that composite the mask texture with the main texture</item> </list> Shader resolution delegates to <see cref="Cthangover.Core.Mods.ModManager.ResolveShader"/>. Shader names follow the pattern: 2d_cubism_[norm|mask]_[mix|add|mul][_inv]? where "norm" = no mask, "mask" = masked, "mix/add/mul" = blend mode, "_inv" = inverted mask. </summary>
public class CubismRenderer2D{private MeshInstance2D[]_meshInstances;private ShaderMaterial[]_materials;private readonly Node2D _parentNode;private CubismNativeModel _model;private struct MaskGroupData{public SubViewport Viewport;public ViewportTexture Texture;public int[]MaskDrawableIndices;public MeshInstance2D[]MaskMeshes;public ArrayMesh[]MaskArrayMeshes;public int ViewportSize;public float OffsetX;public float OffsetY;}private readonly List<MaskGroupData>_maskGroups=new();private int[]_drawableMaskGroupIndex;private Color[]_drawableMaskChannel;/// <summary> Creates a renderer that attaches mesh instances as children of the given parent node. </summary>
public CubismRenderer2D(Node2D parentNode){_parentNode=parentNode;}/// <summary> Builds all mesh instances for the given model. Clears any previously built meshes. Creates mask SubViewports first, then iterates drawables in render order, building ArrayMesh instances with the appropriate shader and texture. </summary>
public unsafe void BuildModel(CubismNativeModel model){_model=model;ClearMeshes();var dc=model.DrawableCount;_meshInstances=new MeshInstance2D[dc];_materials=new ShaderMaterial[dc];GameLogger.Log("LIVE2D",$"BuildModel ENTER: drawables={dc} textures={model.Textures?.Length ?? 0} ppu={model.PixelsPerUnit} canvas={model.CanvasWidth}x{model.CanvasHeight}");var renderOrders=model.GetRenderOrders();GameLogger.Log("LIVE2D",$"BuildModel S1: renderOrders ptr ok");var constantFlags=model.GetConstantFlags();var texIndices=model.GetTextureIndices();var vertexCounts=model.GetVertexCounts();var vertexPositions=model.GetVertexPositions();var vertexUvs=model.GetVertexUvs();var indexCounts=model.GetIndexCounts();var indices=model.GetIndices();var opacities=model.GetOpacities();var maskCounts=model.GetMaskCounts();GameLogger.Log("LIVE2D",$"BuildModel S2: all native ptrs fetched");// Log first 10 renderOrders to understand mapping
var roSample=new System.Text.StringBuilder("renderOrders: ");for(int i=0;i<Math.Min(dc,10);i++)roSample.Append($"[{i}]={renderOrders[i]} ");GameLogger.Log("LIVE2D",roSample.ToString());// Sanity: check renderOrders contain valid drawable indices
for(int i=0;i<dc;i++){var ro=renderOrders[i];if(ro<0||ro>=dc){GameLogger.Log("LIVE2D",$"BuildModel BAD_RO: renderOrders[{i}]={ro} — OUT OF RANGE [0,{dc - 1}]");}}GameLogger.Log("LIVE2D",$"BuildModel S3: renderOrders validated");float minX=float.MaxValue,minY=float.MaxValue,maxX=float.MinValue,maxY=float.MinValue;for(int di=0;di<dc;di++){var vc=vertexCounts[di];if(vc<=0||vc>100000){GameLogger.Log("LIVE2D",$"BuildModel BOUNDS_DIAG: di={di} vc={vc} SKIP");continue;}var vp=vertexPositions[di];if(di==0)GameLogger.Log("LIVE2D",$"BuildModel: drawable[0] vc={vc} vp0=({vp[0].X},{vp[0].Y})");for(int v=0;v<vc;v++){if(vp[v].X<minX)minX=vp[v].X;if(vp[v].Y<minY)minY=vp[v].Y;if(vp[v].X>maxX)maxX=vp[v].X;if(vp[v].Y>maxY)maxY=vp[v].Y;}}GameLogger.Log("LIVE2D",$"BuildModel: vertexBounds=({minX:F3},{minY:F3})-({maxX:F3},{maxY:F3}) ppu={model.PixelsPerUnit}");for(int di=0;di<dc;di++){var vc=vertexCounts[di];if(vc<=0||vc>100000)continue;var uv=vertexUvs[di];float uMin=2,uMax= -1,vMin=2,vMax= -1;for(int i=0;i<vc;i++){if(uv[i].X<uMin)uMin=uv[i].X;if(uv[i].Y<vMin)vMin=uv[i].Y;if(uv[i].X>uMax)uMax=uv[i].X;if(uv[i].Y>vMax)vMax=uv[i].Y;}GameLogger.Log("LIVE2D",$"BuildModel: drawable[{di}] uvBounds=({uMin:F3},{vMin:F3})-({uMax:F3},{vMax:F3})");}// Build mask data
BuildMaskGroups(model,maskCounts);SetupMaskViewports(model);// Second pass: create drawable meshes — iterate by drawable index (matching C++ original)
GameLogger.Log("LIVE2D",$"BuildModel S4: entering mesh loop, dc={dc}");for(int di=0;di<dc;di++){int vCount=vertexCounts[di];int iCount=indexCounts[di];if(vCount<=0||vCount>100000||iCount<=0||iCount>500000)continue;var mesh=new ArrayMesh();var surfaceArray=new Godot.Collections.Array();surfaceArray.Resize((int)Mesh.ArrayType.Max);var verts=new Vector2[vCount];var uvs=new Vector2[vCount];var idxs=new int[iCount];var srcVerts=vertexPositions[di];var srcUvs=vertexUvs[di];var srcIndices=indices[di];for(int v=0;v<vCount;v++){verts[v]=new Vector2(srcVerts[v].X*_model.PixelsPerUnit,-srcVerts[v].Y*_model.PixelsPerUnit);uvs[v]=new Vector2(srcUvs[v].X,1f-srcUvs[v].Y);}for(int t=0;t<iCount;t++)idxs[t]=srcIndices[t];surfaceArray[(int)Mesh.ArrayType.Vertex]=verts;surfaceArray[(int)Mesh.ArrayType.TexUV]=uvs;surfaceArray[(int)Mesh.ArrayType.Index]=idxs;mesh.AddSurfaceFromArrays(Mesh.PrimitiveType.Triangles,surfaceArray);var mi=new MeshInstance2D{Mesh=mesh,ZIndex=renderOrders[di],ZAsRelative=false};var texIndex=texIndices[di];var texture=texIndex>=0&&texIndex<model.Textures.Length?model.Textures[texIndex]:null;var opacity=opacities[di];GameLogger.Log("LIVE2D",$"BuildModel: drawable[{di}] opacity={opacity:F3}");var shader=ResolveShaderForDrawable(di,constantFlags,maskCounts);if(shader==null){// Shader failed to resolve — skip this drawable instead of building a material-less mesh (guards downstream NREs and keeps the rest of the model rendering).
GameLogger.Log("LIVE2D",$"BuildModel: drawable[{di}] SKIPPED — shader not resolved",LogLevel.Error);_meshInstances[di]=null;_materials[di]=null;continue;}ShaderMaterial mat;{mat=new ShaderMaterial{Shader=shader};mat.SetShaderParameter("color_base",new Color(1f,1f,1f,1f));mat.SetShaderParameter("color_multiply",new Color(1f,1f,1f,1f));mat.SetShaderParameter("color_screen",new Color(0f,0f,0f,0f));mat.SetShaderParameter("channel",new Color(0f,0f,0f,0f));mat.SetShaderParameter("mask_scale",1f);mat.SetShaderParameter("mesh_offset",Vector2.Zero);if(texture!=null)mat.SetShaderParameter("tex_main",texture);if(maskCounts[di]>0){var groupIdx=_drawableMaskGroupIndex[di];if(groupIdx>=0&&groupIdx<_maskGroups.Count){var group=_maskGroups[groupIdx];if(group.Texture!=null){mat.SetShaderParameter("tex_mask",group.Texture);mat.SetShaderParameter("channel",_drawableMaskChannel[di]);mat.SetShaderParameter("mask_scale",1f);mat.SetShaderParameter("mesh_offset",new Vector2(group.OffsetX,group.OffsetY));}}}mi.Material=mat;}if(texture==null)GameLogger.Log("LIVE2D",$"BuildModel: drawable[{di}] texture MISSING (texIndex={texIndex})",LogLevel.Warning);mi.Visible=true;_parentNode.AddChild(mi);GameLogger.Log("LIVE2D",$"BuildModel: drawable[{di}] materialSet={mi.Material != null} visible={mi.Visible} parent={mi.GetParent()?.Name}");_meshInstances[di]=mi;_materials[di]=mat;}GameLogger.Log("LIVE2D",$"BuildModel DONE: meshes created");}/// <summary> Returns the union AABB of all drawable meshes in local pixel space. Valid after <see cref="BuildModel"/> has completed. </summary>
public Aabb GetContentBounds(){if(_meshInstances==null)return new Aabb();Aabb aabb=default;bool first=true;foreach(var mi in _meshInstances){if(mi?.Mesh==null)continue;var meshAabb=((ArrayMesh)mi.Mesh).GetAabb();if(first){aabb=meshAabb;first=false;}else aabb=aabb.Merge(meshAabb);}return aabb;}private unsafe void BuildMaskGroups(CubismNativeModel model,int*maskCounts){_maskGroups.Clear();_drawableMaskGroupIndex=new int[model.DrawableCount];_drawableMaskChannel=new Color[model.DrawableCount];for(int i=0;i<model.DrawableCount;i++)_drawableMaskGroupIndex[i]= -1;var masks=model.GetMasks();var dc=model.DrawableCount;var maskSetToGroup=new Dictionary<string,int>();for(int di=0;di<dc;di++){var count=maskCounts[di];if(count==0)continue;var maskIndices=masks[di];var sorted=new int[count];for(int mi=0;mi<count;mi++)sorted[mi]=maskIndices[mi];System.Array.Sort(sorted);var keyBuilder=new System.Text.StringBuilder();for(int mi=0;mi<count;mi++){if(mi>0)keyBuilder.Append(',');keyBuilder.Append(sorted[mi]);}var key=keyBuilder.ToString();if(!maskSetToGroup.TryGetValue(key,out var groupIdx)){groupIdx=_maskGroups.Count;maskSetToGroup[key]=groupIdx;var uniqueMaskDrawables=new System.Collections.Generic.List<int>();for(int mi=0;mi<count;mi++)if(!uniqueMaskDrawables.Contains(sorted[mi]))uniqueMaskDrawables.Add(sorted[mi]);_maskGroups.Add(new MaskGroupData{MaskDrawableIndices=uniqueMaskDrawables.ToArray()});}_drawableMaskGroupIndex[di]=groupIdx;var maskSet=_maskGroups[groupIdx].MaskDrawableIndices;float cr=0f,cg=0f,cb=0f,ca=0f;for(int mi=0;mi<count;mi++){int slot=System.Array.IndexOf(maskSet,sorted[mi]);if(slot>=0){switch(slot){case 0:cr=1f;break;case 1:cg=1f;break;case 2:cb=1f;break;case 3:ca=1f;break;}}}_drawableMaskChannel[di]=new Color(cr,cg,cb,ca);}}private unsafe void SetupMaskViewports(CubismNativeModel model){for(int gi=0;gi<_maskGroups.Count;gi++){var group=_maskGroups[gi];var maskIndices=group.MaskDrawableIndices;var channelCount=maskIndices.Length;var ppu=model.PixelsPerUnit;float minX=float.MaxValue,minY=float.MaxValue,maxX=float.MinValue,maxY=float.MinValue;for(int mi=0;mi<channelCount;mi++){var di=maskIndices[mi];var vCount=model.GetVertexCounts()[di];var vp=model.GetVertexPositions()[di];for(int v=0;v<vCount;v++){float wx=vp[v].X*ppu;float wy= -vp[v].Y*ppu;if(wx<minX)minX=wx;if(wy<minY)minY=wy;if(wx>maxX)maxX=wx;if(wy>maxY)maxY=wy;}}float pad=4f;float vpSize=Math.Max(maxX-minX+pad*2f,maxY-minY+pad*2f);vpSize=Math.Max(vpSize,64f);int vpSizeInt=(int)Mathf.Ceil(vpSize);vpSizeInt=NextPowerOfTwo(vpSizeInt);var viewport=new SubViewport{Size=new Vector2I(vpSizeInt,vpSizeInt),TransparentBg=true,RenderTargetUpdateMode=SubViewport.UpdateMode.Always};_parentNode.AddChild(viewport);group.Viewport=viewport;group.Texture=viewport.GetTexture();group.ViewportSize=vpSizeInt;group.OffsetX=minX-pad;group.OffsetY=minY-pad;group.MaskMeshes=new MeshInstance2D[channelCount];group.MaskArrayMeshes=new ArrayMesh[channelCount];for(int mi=0;mi<channelCount;mi++){var di=maskIndices[mi];var mi2d=new MeshInstance2D();viewport.AddChild(mi2d);group.MaskMeshes[mi]=mi2d;group.MaskArrayMeshes[mi]=BuildMaskArrayMesh(di,model,mi2d,mi,group.OffsetX,group.OffsetY);}_maskGroups[gi]=group;}}private unsafe ArrayMesh BuildMaskArrayMesh(int drawableIndex,CubismNativeModel model,MeshInstance2D mi2d,int channelSlot,float offsetX,float offsetY){var vCount=model.GetVertexCounts()[drawableIndex];var iCount=model.GetIndexCounts()[drawableIndex];if(vCount==0||iCount==0)return null;var mesh=new ArrayMesh();var surfaceArray=new Godot.Collections.Array();surfaceArray.Resize((int)Mesh.ArrayType.Max);var verts=new Vector2[vCount];var uvs=new Vector2[vCount];var idxs=new int[iCount];var srcVerts=model.GetVertexPositions()[drawableIndex];var srcUvs=model.GetVertexUvs()[drawableIndex];var srcIndices=model.GetIndices()[drawableIndex];var ppu=model.PixelsPerUnit;for(int v=0;v<vCount;v++){verts[v]=new Vector2(srcVerts[v].X*ppu-offsetX,-srcVerts[v].Y*ppu-offsetY);uvs[v]=new Vector2(srcUvs[v].X,1f-srcUvs[v].Y);}for(int t=0;t<iCount;t++)idxs[t]=srcIndices[t];surfaceArray[(int)Mesh.ArrayType.Vertex]=verts;surfaceArray[(int)Mesh.ArrayType.TexUV]=uvs;surfaceArray[(int)Mesh.ArrayType.Index]=idxs;mesh.AddSurfaceFromArrays(Mesh.PrimitiveType.Triangles,surfaceArray);var maskTexIndex=model.GetTextureIndices()[drawableIndex];var texture=maskTexIndex>=0&&maskTexIndex<model.Textures.Length?model.Textures[maskTexIndex]:null;mi2d.Mesh=mesh;var shader=ResolveShader("2d_cubism_mask");if(shader==null)return mesh;var mat=new ShaderMaterial{Shader=shader};mat.SetShaderParameter("channel",ChannelFromSlot(channelSlot));if(texture!=null)mat.SetShaderParameter("tex_main",texture);mi2d.Material=mat;return mesh;}private static Color ChannelFromSlot(int slot)=>slot switch{0=>new Color(1f,0f,0f,0f),1=>new Color(0f,1f,0f,0f),2=>new Color(0f,0f,1f,0f),_=>new Color(0f,0f,0f,1f)};/// <summary> Per-frame render update. Resets dynamic flags, then iterates all mesh instances in render order, applying visibility, opacity, and vertex position changes based on CubismCore dynamic flags. </summary>
public unsafe void UpdateModel(){if(_model==null||_meshInstances==null)return;var dynamicFlags=_model.GetDynamicFlags();var opacities=_model.GetOpacities();var renderOrders=_model.GetRenderOrders();var multiplyColors=_model.GetMultiplyColors();var screenColors=_model.GetScreenColors();var dc=_model.DrawableCount;for(int di=0;di<dc;di++){var mi=_meshInstances[di];if(mi==null)continue;var mat=_materials[di];var flags=dynamicFlags[di];if((flags&CubismCoreBindings.VisibilityDidChange)!=0)mi.Visible=(flags&CubismCoreBindings.IsVisible)!=0;mi.ZIndex=renderOrders[di];if(mat!=null){var mc=multiplyColors[di];mat.SetShaderParameter("color_multiply",new Color(mc.X,mc.Y,mc.Z,mc.W));var sc=screenColors[di];mat.SetShaderParameter("color_screen",new Color(sc.X,sc.Y,sc.Z,sc.W));if((flags&CubismCoreBindings.OpacityDidChange)!=0)mat.SetShaderParameter("color_base",new Color(1f,1f,1f,opacities[di]));}if((flags&CubismCoreBindings.VertexPositionsDidChange)!=0){UpdateMeshVertices(di,mi.Mesh as ArrayMesh);}}UpdateMaskGroups(dynamicFlags,dc);_model.ResetDynamicFlags();}/// <summary> Per-frame mask refresh, mirroring CubismRenderer::DrawClip: the clip bounding box is recomputed from the CURRENT vertex positions of the mask drawables each frame. When the clip rect moves, the mask meshes are re-offset into the SubViewport space and the masked drawables' mesh_offset shader parameter is refreshed; when the rect outgrows the render target, the SubViewport is resized (power of two, so this stays rare). Without flag changes and offset movement the per-drawable work is skipped. </summary>
private unsafe void UpdateMaskGroups(byte*dynamicFlags,int dc){if(_maskGroups.Count==0)return;var vertexCounts=_model.GetVertexCounts();var vertexPositions=_model.GetVertexPositions();var ppu=_model.PixelsPerUnit;for(int gi=0;gi<_maskGroups.Count;gi++){var group=_maskGroups[gi];var maskIndices=group.MaskDrawableIndices;if(maskIndices==null||maskIndices.Length==0)continue;// Current bounds over the mask drawables (pixel space).
float minX=float.MaxValue,minY=float.MaxValue,maxX=float.MinValue,maxY=float.MinValue;var any=false;for(int mi=0;mi<maskIndices.Length;mi++){var di=maskIndices[mi];if(di<0||di>=dc)continue;var vCount=vertexCounts[di];var vp=vertexPositions[di];for(int v=0;v<vCount;v++){float x=vp[v].X*ppu;float y= -vp[v].Y*ppu;if(x<minX)minX=x;if(y<minY)minY=y;if(x>maxX)maxX=x;if(y>maxY)maxY=y;any=true;}}if(!any)continue;const float pad=4f;float newOffsetX=minX-pad;float newOffsetY=minY-pad;float needed=Math.Max(maxX-minX+pad*2f,maxY-minY+pad*2f);needed=Math.Max(needed,64f);int neededSize=NextPowerOfTwo((int)Mathf.Ceil(needed));bool offsetChanged=newOffsetX!=group.OffsetX||newOffsetY!=group.OffsetY;bool sizeChanged=neededSize!=group.ViewportSize;if(sizeChanged){group.ViewportSize=neededSize;group.Viewport.Size=new Vector2I(neededSize,neededSize);}if(offsetChanged){group.OffsetX=newOffsetX;group.OffsetY=newOffsetY;}_maskGroups[gi]=group;// Rewrite mask mesh vertices when the mask drawable moved or the clip rect moved (offsets are baked into the vertex positions).
for(int mi=0;mi<maskIndices.Length;mi++){var di=maskIndices[mi];if(di<0||di>=dc)continue;bool flagged=(dynamicFlags[di]&CubismCoreBindings.VertexPositionsDidChange)!=0;if(!flagged&& !offsetChanged)continue;UpdateMaskMeshVertices(di,group.MaskArrayMeshes[mi],gi);}// Masked drawables sample the mask texture through mesh_offset; refresh it whenever the clip rect moved or was resized.
if(offsetChanged||sizeChanged){for(int di=0;di<dc;di++){if(_drawableMaskGroupIndex==null||di>=_drawableMaskGroupIndex.Length)break;if(_drawableMaskGroupIndex[di]!=gi)continue;var mat=_materials[di];if(mat!=null)mat.SetShaderParameter("mesh_offset",new Vector2(group.OffsetX,group.OffsetY));}}}}private unsafe void UpdateMeshVertices(int drawableIndex,ArrayMesh mesh){if(mesh==null)return;var vCount=_model.GetVertexCounts()[drawableIndex];var srcVerts=_model.GetVertexPositions()[drawableIndex];var ppu=_model.PixelsPerUnit;int stride=8;byte[]data=new byte[vCount*stride];float minX=float.MaxValue,minY=float.MaxValue,maxX=float.MinValue,maxY=float.MinValue;for(int v=0;v<vCount;v++){float x=srcVerts[v].X*ppu;float y= -srcVerts[v].Y*ppu;BinaryPrimitives.WriteSingleLittleEndian(data.AsSpan(v*stride),x);BinaryPrimitives.WriteSingleLittleEndian(data.AsSpan(v*stride+4),y);if(x<minX)minX=x;if(y<minY)minY=y;if(x>maxX)maxX=x;if(y>maxY)maxY=y;}mesh.SurfaceUpdateVertexRegion(0,0,data);mesh.CustomAabb=new Aabb(new Vector3(minX,minY,0),new Vector3(maxX-minX,maxY-minY,0));}private unsafe void UpdateMaskMeshVertices(int drawableIndex,ArrayMesh mesh,int groupIndex){if(mesh==null||groupIndex<0||groupIndex>=_maskGroups.Count)return;var group=_maskGroups[groupIndex];float offsetX=group.OffsetX;float offsetY=group.OffsetY;var ppu=_model.PixelsPerUnit;var vCount=_model.GetVertexCounts()[drawableIndex];var srcVerts=_model.GetVertexPositions()[drawableIndex];int stride=8;byte[]data=new byte[vCount*stride];float vMinX=float.MaxValue,vMinY=float.MaxValue,vMaxX=float.MinValue,vMaxY=float.MinValue;for(int v=0;v<vCount;v++){float x=srcVerts[v].X*ppu-offsetX;float y= -srcVerts[v].Y*ppu-offsetY;BinaryPrimitives.WriteSingleLittleEndian(data.AsSpan(v*stride),x);BinaryPrimitives.WriteSingleLittleEndian(data.AsSpan(v*stride+4),y);if(x<vMinX)vMinX=x;if(y<vMinY)vMinY=y;if(x>vMaxX)vMaxX=x;if(y>vMaxY)vMaxY=y;}mesh.SurfaceUpdateVertexRegion(0,0,data);mesh.CustomAabb=new Aabb(new Vector3(vMinX,vMinY,0),new Vector3(vMaxX-vMinX,vMaxY-vMinY,0));}private void ClearMeshes(){if(_meshInstances!=null){for(int i=0;i<_meshInstances.Length;i++){var mi=_meshInstances[i];if(mi!=null)mi.QueueFree();}}_meshInstances=null;_materials=null;_drawableMaskGroupIndex=null;_drawableMaskChannel=null;foreach(var group in _maskGroups)group.Viewport?.QueueFree();_maskGroups.Clear();}private unsafe Shader ResolveShaderForDrawable(int drawableIndex,byte*constantFlags,int*maskCounts){var flags=constantFlags[drawableIndex];var hasMask=maskCounts[drawableIndex]>0;var isInverted=(flags&CubismCoreBindings.IsInvertedMask)!=0;var isAdditive=(flags&CubismCoreBindings.BlendAdditive)!=0;var isMultiplicative=(flags&CubismCoreBindings.BlendMultiplicative)!=0;string name;if(!hasMask){name=isAdditive?"2d_cubism_norm_add":isMultiplicative?"2d_cubism_norm_mul":"2d_cubism_norm_mix";}else if(!isInverted){name=isAdditive?"2d_cubism_mask_add":isMultiplicative?"2d_cubism_mask_mul":"2d_cubism_mask_mix";}else{name=isAdditive?"2d_cubism_mask_add_inv":isMultiplicative?"2d_cubism_mask_mul_inv":"2d_cubism_mask_mix_inv";}return ResolveShader(name);}private static Shader ResolveShader(string name){try{// Canonical resource path: shader resolution goes through the mod resolver system (ShaderResolver via ModManager.GetResolver).
var shader=Cthangover.Core.Mods.ModManager.Instance?.GetResolver<ShaderResolver>()?.Resolve(name);if(shader!=null){GameLogger.Log("LIVE2D",$"ResolveShader: '{name}' OK");return shader;}}catch(Exception ex){GameLogger.Log("LIVE2D",$"ResolveShader: '{name}' threw {ex.Message}",LogLevel.Error);}GameLogger.Log("LIVE2D",$"ResolveShader: '{name}' FAILED",LogLevel.Error);return null;}private static int NextPowerOfTwo(int v){if(v<=0)return 1;v--;v|=v>>1;v|=v>>2;v|=v>>4;v|=v>>8;v|=v>>16;return v+1;}}}

===== FILE mods/live2d_base/source_code/Cubism/Framework/CubismTargetPoint.cs =====
using System;namespace Cthangover.Live2D.Cubism.Framework{/// <summary> Smooth target-point tracking for face/eye movement parameters. Drives the ParamAngleX/Y and ParamEyeBallX/Y model parameters in <see cref="CubismModelNode"/>. Bit-exact port of Cubism SDK for Native 5-r.5 Math/CubismTargetPoint.cpp: a velocity-based follower that accelerates toward the target with a capped acceleration (reaching top speed in 0.15 s), then smoothly decelerates so the configured acceleration can still brake before the target. All time is expressed in internal 30 fps frame units. </summary>
public class CubismTargetPoint{/// <summary>Internal reference frame rate (SDK FrameRate).</summary>
private const int FrameRate=30;/// <summary>Threshold below which no movement is applied (SDK Epsilon).</summary>
private const float Epsilon=0.01f;private float _faceTargetX,_faceTargetY;private float _faceX,_faceY;private float _faceVX,_faceVY;private float _lastTimeSeconds;private float _userTimeSeconds;/// <summary>Current smoothed X position.</summary>
public float X=>_faceX;/// <summary>Current smoothed Y position.</summary>
public float Y=>_faceY;/// <summary> Sets the target point immediately. The actual position will smoothly approach this target over subsequent calls to <see cref="Update"/>. </summary>
public void Set(float x,float y){_faceTargetX=x;_faceTargetY=y;}/// <summary> Advances the smoothing simulation by one frame (SDK CubismTargetPoint::Update). The very first call only anchors the internal clock and performs no integration, matching the native implementation. </summary>
public void Update(float deltaTimeSeconds){// Add the delta time.
_userTimeSeconds+=deltaTimeSeconds;// The average speed of swinging the head from center to side is about 1 second. Considering acceleration/deceleration, twice that is the maximum speed. The face direction is 0.0 at center and +-1.0 at the sides.
const float FaceParamMaxV=40f/10f;// Moves 40 in 7.5 seconds (5.3/sc)
float MaxV=FaceParamMaxV*1f/FrameRate;// Upper speed limit per frame First call only anchors the clock; no integration (SDK behavior).
if(_lastTimeSeconds==0f){_lastTimeSeconds=_userTimeSeconds;return;}float deltaTimeWeight=(_userTimeSeconds-_lastTimeSeconds)*FrameRate;_lastTimeSeconds=_userTimeSeconds;// Time until maximum speed, expressed in internal frames.
const float TimeToMaxSpeed=0.15f;const float FrameToMaxSpeed=TimeToMaxSpeed*FrameRate;// sec * frame/sec
float MaxA=deltaTimeWeight*MaxV/FrameToMaxSpeed;// Acceleration per frame The target direction is the vector (dx, dy).
float dx=_faceTargetX-_faceX;float dy=_faceTargetY-_faceY;if(MathF.Abs(dx)<=Epsilon&&MathF.Abs(dy)<=Epsilon){return;// No change.
}// Slow down when the distance requires more than the maximum speed.
float d=MathF.Sqrt((dx*dx)+(dy*dy));// Maximum speed vector toward the target.
float vx=MaxV*dx/d;float vy=MaxV*dy/d;// Acceleration needed to change from the current velocity to the new one.
float ax=vx-_faceVX;float ay=vy-_faceVY;float a=MathF.Sqrt((ax*ax)+(ay*ay));// While accelerating.
if(a< -MaxA||a>MaxA){ax*=MaxA/a;ay*=MaxA/a;}// Add the acceleration to the original velocity to get the new velocity.
_faceVX+=ax;_faceVY+=ay;// When approaching the target direction, decelerate smoothly: from the relation between acceleration, speed and distance, compute the maximum speed that can still be stopped with the configured acceleration, and clamp the current speed to it.
{// Relation between acceleration, velocity and distance. 2 6 2 3 sqrt(a t + 16 a h t - 8 a h) - a t v = -------------------------------------- 2 4 t - 2 (t=1)
float maxV=0.5f*(MathF.Sqrt((MaxA*MaxA)+16f*MaxA*d-8f*MaxA*d)-MaxA);float curV=MathF.Sqrt((_faceVX*_faceVX)+(_faceVY*_faceVY));if(curV>maxV){// Current speed > maximum speed: decelerate to the maximum.
_faceVX*=maxV/curV;_faceVY*=maxV/curV;}}_faceX+=_faceVX;_faceY+=_faceVY;}}}

===== FILE mods/live2d_base/source_code/Cubism/Handlers/Live2DService.cs =====
using Godot;using Cthangover.Live2D.Cubism.Framework;namespace Cthangover.Live2D.Cubism.Services{/// <summary> Configuration for <see cref="Live2DService.CreateModel(Node, Live2DModelConfig)"/>. When <see cref="Position"/> is set, <see cref="ScaleFit"/> defaults to <see cref="FitMode.None"/> to disable automatic layout, giving manual coordinates full control. Otherwise <see cref="FitMode.FitAuto"/> is used. </summary>
public class Live2DModelConfig{public string ModelPath;public string ModId;public string NodeName;public Vector2?Position;public Vector2?Scale;public float AnchorX=0.5f;public float AnchorY=0.5f;public FitMode ScaleFit=FitMode.FitAuto;/// <summary> Enables the automatic eye-blink effect. Disable for pixel-deterministic rendering (screenshot/visual tests): the blink fires on a random timer and would otherwise change pixels between captures. </summary>
public bool EnableEyeBlink=true;}/// <summary> High-level factory for creating Live2D model nodes and attaching them to the scene tree. Encapsulates the boilerplate of instantiating <see cref="CubismModelNode"/>, adding to a parent, and calling <see cref="CubismModelNode.LoadModelFromMod"/>. </summary>
public static class Live2DService{/// <summary> Creates a <see cref="CubismModelNode"/>, adds it to <paramref name="parent"/>, and loads the model from the specified mod. </summary> <param name="parent">Parent node to attach the model to.</param> <param name="modelPath">Path to the .model3.json file, relative to the mod root.</param> <param name="modId">Registered mod ID containing the model assets.</param> <param name="nodeName">Godot node name for the model instance.</param> <returns>The created and loaded model node.</returns>
public static CubismModelNode CreateModel(Node parent,string modelPath,string nodeName,string modId=null){return CreateModel(parent,new Live2DModelConfig{ModelPath=modelPath,ModId=modId,NodeName=nodeName});}/// <summary> Creates a <see cref="CubismModelNode"/> using full configuration. When <see cref="Live2DModelConfig.Position"/> is provided, <see cref="Live2DModelConfig.ScaleFit"/> is forced to <see cref="FitMode.None"/> — manual coordinates take priority over the automatic <see cref="CubismModelNode.DeferredApplyLayout"/>. </summary>
public static CubismModelNode CreateModel(Node parent,Live2DModelConfig config){var fitMode=config.Position.HasValue?FitMode.None:config.ScaleFit;var model=new CubismModelNode{Name=config.NodeName,AnchorX=config.AnchorX,AnchorY=config.AnchorY,ScaleFit=fitMode,EnableEyeBlink=config.EnableEyeBlink};parent.AddChild(model);if(config.Position.HasValue)model.Position=config.Position.Value;if(config.Scale.HasValue)model.Scale=config.Scale.Value;model.LoadModelFromMod(config.ModelPath,config.ModId);return model;}}}

===== FILE mods/live2d_base/source_code/Properties/AssemblyInfo.cs =====
using System.Runtime.CompilerServices;// Exposes internal pure-math/parsing helpers of the Live2D mod to the game's unit test project (tests/Cthangover.Tests). Internal members are the test seam; public runtime API stays unchanged.
[assembly:InternalsVisibleTo("Cthangover.Tests")]

===== FILE mods/live2d_base/src/AddLive2DModelAction.cs =====
using Cthangover.Core.Actions;using Cthangover.Core.Scenes;using Cthangover.Core.Utils;using Godot;using Cthangover.Live2D.Cubism.Services;using Cthangover.Mods.Interface.View;namespace Mods.Live2D{public class AddLive2DModelAction:IScenarioAction{public string Name=>"add_live2d_model";public void Run(IActionContext ctx){var modelPath=ctx.GetParam("model_path");var modId=ctx.GetParam("mod_id");var nodeName=ctx.GetParam("node_name")??"Live2DModel";if(string.IsNullOrEmpty(modelPath)){ctx.LogWarning("LIVE2D","AddLive2DModelAction: missing 'model_path'");return;}var viewBox=SceneContextNode.FindNode<Control>("ViewBox");if(viewBox==null){ctx.LogWarning("LIVE2D","AddLive2DModelAction: ViewBox not found");return;}var existing=viewBox.GetNodeOrNull<Node>(nodeName);if(existing!=null){ctx.Log("LIVE2D",$"AddLive2DModelAction: '{nodeName}' already exists, skipping");return;}var config=new Live2DModelConfig{ModelPath=modelPath,ModId=modId,NodeName=nodeName};var xStr=ctx.GetParam("x");var yStr=ctx.GetParam("y");if(!string.IsNullOrEmpty(xStr)&& !string.IsNullOrEmpty(yStr)&&float.TryParse(xStr,out var x)&&float.TryParse(yStr,out var y)){config.Position=new Vector2(x,y);}var scaleStr=ctx.GetParam("scale");if(!string.IsNullOrEmpty(scaleStr)&&float.TryParse(scaleStr,out var s))config.Scale=new Vector2(s,s);Live2DService.CreateModel(viewBox,config);ctx.Log("LIVE2D",$"AddLive2DModelAction: loaded '{modelPath}' from mod '{modId}' as '{nodeName}'");}}}

===== FILE mods/live2d_base/test_suites/avatar/src/Live2DAvatarTestDriver.cs =====
using System.Collections.Generic;using System.IO;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Cthangover.Live2D.Cubism.Framework;using Cthangover.Live2D.Cubism.Services;using Godot;namespace Mods.Live2DTest{/// <summary> Интеграционный тест Live2D-аватара: создаёт модель через мод-пайплайн (тот же путь, что и <see cref="Live2DOverlay"/>), держит руки опущенными секунду, затем поднимает правую руку через <c>ParamArmR01</c> и проверяет смену позы компьютерным зрением по скриншотам вьюпорта: <list type="bullet"> <item>детерминизм — два кадра статичной позы идентичны попиксельно;</item> <item>видимость — команда смены позы перекрашивает значимую часть кадра;</item> <item>силуэт — в верхнем боковом секторе (зона над плечом) площадь аватара растёт после подъёма руки;</item> <item>удержание — параметр держит поднятое значение между кадрами (регрессия на Load/SaveParameters-брекет).</item> </list> Скриншоты сохраняются в tests/artifacts/live2d_avatar. Инстанцируется игровой интеграционной сценой через TscnScenes (мод — единственное место с компайл-тайм доступом к Live2D-обёртке); сам печатает [TEST_RESULT] и завершает дерево с кодом выхода. </summary>
public partial class Live2DAvatarTestDriver:Node2D{private const string ModId="live2d_base__avatar";private const string ModelPath="models/runtime/ren.model3.json";private const string LeftArmParam="ParamArmL01";private const string RightArmParam="ParamArmR01";/// <summary>Сколько держим опущенные руки до скриншота (по ТЗ — секунду).</summary>
private const float LoweredHoldSeconds=1.0f;/// <summary>Пауза между кадрами проверки детерминизма.</summary>
private const float StabilityGapSeconds=0.4f;/// <summary>Ожидание растеризации поднятой руки.</summary>
private const float RaisedSettleSeconds=0.8f;/// <summary>Порог «далёкий от фона» пиксель силуэта, 0..1.</summary>
private const float SilhouetteTolerance=0.2f;/// <summary>Порог значимого изменения канала для diff-подсчёта.</summary>
private const float DiffTolerance=0.1f;private int passed;private int failed;private string artifactDir;public override void _Ready()=>_=RunAsync();private async System.Threading.Tasks.Task RunAsync(){try{GameLogger.Log("TEST","Live2DAvatarTestDriver: starting Live2D avatar integration test");artifactDir=ProjectSettings.GlobalizePath("res://tests/artifacts/live2d_avatar");Directory.CreateDirectory(artifactDir);await RunTest();}catch(System.Exception ex){Fail($"unexpected crash: {ex.GetType().Name}: {ex.Message}");GameLogger.Log("TEST",ex.StackTrace??"(no stack)");}GameLogger.Log("TEST",$"Live2DAvatarTestDriver: {passed} passed, {failed} failed");GameLogger.Log("TEST",$"[TEST_RESULT] name=live2d_avatar passed={passed} failed={failed}");GetTree().Quit(failed==0?0:1);}private async System.Threading.Tasks.Task RunTest(){Pass($"driver started for mod '{ModId}'");// Тот же путь создания, что в продовом оверлее; моргание выключено, чтобы кадры были пиксельно детерминированы.
var model=Live2DService.CreateModel(this,new Live2DModelConfig{ModelPath=ModelPath,ModId=ModId,NodeName="Live2DTestAvatar",EnableEyeBlink=false});if(model==null||model.Parameters.Length==0){Fail("avatar creation failed (no parameters)");}Pass($"avatar created: {model.Parameters.Length} parameters, canvas {model.CanvasWidth:F0}x{model.CanvasHeight:F0}");var leftArm=model.FindParameter(LeftArmParam);var rightArm=model.FindParameter(RightArmParam);if(leftArm==null||rightArm==null){Fail($"arm parameters not found ('{LeftArmParam}' / '{RightArmParam}')");return;}Pass($"{LeftArmParam} [{leftArm.MinimumValue}..{leftArm.MaximumValue}], {RightArmParam} [{rightArm.MinimumValue}..{rightArm.MaximumValue}]");// Фаза 1 — руки опущены, держим ~1 секунду.
model.SetLiveParameter(LeftArmParam,leftArm.MinimumValue);model.SetLiveParameter(RightArmParam,rightArm.MinimumValue);await Delay(LoweredHoldSeconds);var lowered=await Capture();await Delay(StabilityGapSeconds);var stability=await Capture();// Фаза 2 — поднимаем руку.
model.SetLiveParameter(RightArmParam,rightArm.MaximumValue);await Delay(RaisedSettleSeconds);var raised=await Capture();SavePng("01_lowered.png",lowered);SavePng("02_lowered_stability.png",stability);SavePng("03_raised.png",raised);Analyze(model,lowered,stability,raised);}/// <summary> Компьютерное зрение по трём кадрам: детерминизм статичной позы, видимая площадь смены позы, рост силуэта над плечом и удержание параметром поднятого значения. </summary>
private void Analyze(CubismModelNode model,Image lowered,Image stability,Image raised){var bg=lowered.GetPixel(2,2);Pass($"background reference color: {bg}");var sil=SilhouetteBounds(lowered,bg);if(sil.Size.X<=0||sil.Size.Y<=0){Fail("avatar silhouette not found on the lowered frame");return;}Pass($"silhouette bbox: pos={sil.Position} size={sil.Size}");int framePixels=lowered.GetWidth()*lowered.GetHeight();// 1) Детерминизм: статичная поза должна рендериться идентично.
int stabilityDiff=CountChangedPixels(lowered,stability);double stabilityRatio=stabilityDiff/(double)framePixels;if(stabilityRatio<=0.001)Pass($"static pose deterministic: {stabilityDiff} changed px ({stabilityRatio:P3})");else Fail($"static pose not deterministic: {stabilityDiff} changed px ({stabilityRatio:P3}) — ambient animation is on");// 2) Команда смены позы должна перекрасить значимую часть кадра.
int poseDiff=CountChangedPixels(lowered,raised);double poseRatio=poseDiff/(double)framePixels;if(poseRatio>=0.003)Pass($"pose change visible: {poseDiff} changed px ({poseRatio:P3})");else Fail($"pose change too small: {poseDiff} changed px ({poseRatio:P3})");// 3) CV: поднятая рука добавляет силуэт ЗА пределами опущенного bbox'а на высоте плеча (лево или право — модель может быть зеркальна зрителю). Полосы фиксированы по bbox опущенной позы, поэтому не съезжают вместе с bbox поднятой руки.
int stripH=System.Math.Max(1,(int)(sil.Size.Y*0.30f));int stripY=(int)(sil.Position.Y+sil.Size.Y*0.25f);int stripW=System.Math.Max(1,(int)(sil.Size.X*2.0f));int margin=4;int leftX=System.Math.Max(0,(int)sil.Position.X-margin-stripW);int rightX=System.Math.Min(lowered.GetWidth()-stripW,(int)sil.End.X+margin);var leftStrip=new Rect2I(leftX,stripY,stripW,stripH);var rightStrip=new Rect2I(rightX,stripY,stripW,stripH);int loweredLeft=CountModelPixels(lowered,leftStrip,bg);int raisedLeft=CountModelPixels(raised,leftStrip,bg);int loweredRight=CountModelPixels(lowered,rightStrip,bg);int raisedRight=CountModelPixels(raised,rightStrip,bg);int gain=System.Math.Max(raisedLeft-loweredLeft,raisedRight-loweredRight);int threshold=System.Math.Max(300,(int)(stripW*stripH*0.02f));GameLogger.Log("TEST",$"  side strips @shoulder: left {loweredLeft}->{raisedLeft}, right {loweredRight}->{raisedRight} px (strip {stripW}x{stripH}, need {threshold})");if(gain>=threshold)Pass($"raised arm silhouette appears beside the body: +{gain} px");else Fail($"no raised-arm silhouette beside the body: +{gain} px (threshold {threshold})");// 4) Параметр держит поднятое значение между кадрами (регрессия на Load/SaveParameters-брекет и SetLiveParameter).
var armNow=model.FindParameter(RightArmParam);if(armNow!=null&&System.Math.Abs(armNow.Value-armNow.MaximumValue)<0.001f)Pass($"{RightArmParam} holds MaximumValue across frames ({armNow.Value})");else Fail($"{RightArmParam} did not hold MaximumValue (value={armNow?.Value})");}private async System.Threading.Tasks.Task Delay(float seconds)=>await ToSignal(GetTree().CreateTimer(seconds),SceneTreeTimer.SignalName.Timeout);/// <summary>Ждёт обработки кадра и снимает вьюпорт в RGBA8.</summary>
private async System.Threading.Tasks.Task<Image>Capture(){await ToSignal(GetTree(),SceneTree.SignalName.ProcessFrame);await ToSignal(GetTree(),SceneTree.SignalName.ProcessFrame);var image=GetViewport().GetTexture().GetImage();image.Convert(Image.Format.Rgba8);return image;}private void SavePng(string fileName,Image image){var path=Path.Combine(artifactDir,fileName);if(image.SavePng(path)==Error.Ok)Pass($"screenshot saved: {path}");else Fail($"SavePng failed for '{path}'");}private static Rect2 SilhouetteBounds(Image image,Color bg){byte[]data=image.GetData();int w=image.GetWidth(),h=image.GetHeight();int minX=int.MaxValue,minY=int.MaxValue,maxX= -1,maxY= -1;for(int y=0;y<h;y++){for(int x=0;x<w;x++){int i=(y*w+x)*4;if(IsFarFromBackground(data,i,bg)){if(x<minX)minX=x;if(x>maxX)maxX=x;if(y<minY)minY=y;if(y>maxY)maxY=y;}}}return maxX<0?new Rect2(0,0,0,0):new Rect2(minX,minY,maxX-minX+1,maxY-minY+1);}private static int CountModelPixels(Image image,Rect2I region,Color bg){byte[]data=image.GetData();int w=image.GetWidth();int count=0;for(int y=region.Position.Y;y<region.End.Y;y++){for(int x=region.Position.X;x<region.End.X;x++){if(IsFarFromBackground(data,(y*w+x)*4,bg))count++;}}return count;}private static int CountChangedPixels(Image a,Image b){byte[]da=a.GetData(),db=b.GetData();int count=0;int limit=System.Math.Min(da.Length,db.Length);for(int i=0;i<limit;i+=4){if(System.Math.Abs(da[i]-db[i])>DiffTolerance*255f||System.Math.Abs(da[i+1]-db[i+1])>DiffTolerance*255f||System.Math.Abs(da[i+2]-db[i+2])>DiffTolerance*255f)count++;}return count;}private static bool IsFarFromBackground(byte[]data,int i,Color bg)=>System.Math.Abs(data[i]/255f-bg.R)>SilhouetteTolerance||System.Math.Abs(data[i+1]/255f-bg.G)>SilhouetteTolerance||System.Math.Abs(data[i+2]/255f-bg.B)>SilhouetteTolerance;private void Pass(string msg){passed++;GameLogger.Log("TEST",$"  [PASS] {msg}");}private void Fail(string msg){failed++;GameLogger.Log("TEST",$"  [FAIL] {msg}",LogLevel.Error);}}}

===== FILE mods/live2d_base/test_suites/avatar/src/Live2DOverlay.cs =====
using Cthangover.Core.UI;using Cthangover.Core.Utils;using Cthangover.Live2D.Cubism.Framework;using Cthangover.Live2D.Cubism.Services;using Godot;using Cthangover.Mods.Interface.Base.Lists;namespace Mods.Live2DTest{/// <summary> Scene-driven replacement for the old live2d_test subscription snippet. The overlay root is added to a shell slot by <see cref="UiHost"/>; it spawns the Cubism model into the <c>Lastground</c> anchor and keeps a transparent drag handle on top so the player can steer the model's head parameters with the mouse. Created per <c>home_outside</c> visit (once=false) and destroyed together with the overlay. </summary>
public partial class Live2DOverlay:ModWidget{private const string ModelPath="models/runtime/ren.model3.json";private const string ModelNodeName="Live2DTestModel";private const string ContainerNodeName="Lastground";private CubismModelNode model;private ColorRect dragHandle;private bool dragging;private float mouseStartX;private float mouseStartY;private float baseValueX;private float baseValueY;protected override void Construct(){MouseFilter=MouseFilterEnum.Ignore;var container=UiHost.Instance?.FindNode(ContainerNodeName);if(container==null){GameLogger.Log("LIVE2D_TEST",$"Live2DOverlay: container '{ContainerNodeName}' not found",LogLevel.Error);return;}model=Live2DService.CreateModel(container,ModelPath,ModelNodeName,"live2d_base__avatar");if(model==null){GameLogger.Log("LIVE2D_TEST","Live2DOverlay: model creation failed",LogLevel.Error);return;}dragHandle=new ColorRect{Name="Live2DDragPanel",MouseFilter=MouseFilterEnum.Stop,Color=new Color(0,1f,0,0f),Size=new Vector2(400,400),AnchorLeft=0.5f,AnchorRight=0.5f,AnchorTop=0.5f,AnchorBottom=0.5f,OffsetLeft= -200f,OffsetRight=200f,OffsetTop= -200f,OffsetBottom= -200f};AddChild(dragHandle);dragHandle.GuiInput+=OnDragInput;}protected override void Destruct(){if(model!=null&&GodotObject.IsInstanceValid(model))model.QueueFree();model=null;}private void OnDragInput(InputEvent@event){if(@event is InputEventMouseButton mb&&mb.ButtonIndex==MouseButton.Left){if(mb.Pressed){dragging=true;mouseStartX=mb.GlobalPosition.X;mouseStartY=mb.GlobalPosition.Y;baseValueX=model.FindParameter("ParamAngleX")?.Value??0f;baseValueY=model.FindParameter("ParamAngleY")?.Value??0f;}else{dragging=false;}return;}if(!dragging||@event is not InputEventMouseMotion mm||model==null)return;var targetX=model.FindParameter("ParamAngleX");var targetY=model.FindParameter("ParamAngleY");var deltaX=(mm.GlobalPosition.X-mouseStartX)/30f;var deltaY=(mm.GlobalPosition.Y-mouseStartY)/30f;if(targetX!=null)model.SetLiveParameter("ParamAngleX",Mathf.Clamp(baseValueX+deltaX,targetX.MinimumValue,targetX.MaximumValue));if(targetY!=null)model.SetLiveParameter("ParamAngleY",Mathf.Clamp(baseValueY+deltaY,targetY.MinimumValue,targetY.MaximumValue));}}}

===== FILE mods/locale_atlas/src/LocaleAtlasBuilder.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text.Json;using System.Text.RegularExpressions;using Cthangover.Core.Mods;namespace Cthangover.LocaleAtlas{/// <summary> Builds the localization key map. Pure .NET over <see cref="IModFileProvider"/> (safe on a worker thread, testable with in-memory providers). Declarations are parsed exactly like <c>LocaleLoader</c> parses them (<c>locale/&lt;lang&gt;.properties</c> or <c>locale/*_&lt;lang&gt;.properties</c>, only the top level of <c>locale/</c>, <c>#</c> comments, first <c>=</c> splits, both sides trimmed). References come from: <list type="bullet"> <item><c>.scenario</c> — <c>key=</c> (the quoted literal is the fallback text);</item> <item>C# — <c>Translate("…")</c>/<c>Tr("…")</c> (any receiver) and <c>Locale = "…"</c>;</item> <item>JSON data — key-shaped values of text fields (<c>Name</c>, <c>Description</c>, <c>locale</c>, <c>title</c>, …) and any <c>"@key"</c> value;</item> <item><c>.tscn</c> — key-shaped <c>text</c>/<c>tooltip_text</c>/<c>placeholder_text</c>/<c>title</c>, which Godot auto-translates;</item> <item>manifest — <c>mod_&lt;id&gt;_name</c>/<c>_desc</c> used by the mod manager (optional: the manifest text is the fallback).</item> </list> Collection packages come as sources named <see cref="CollectionSourcePrefix"/> + id: their <c>locale/</c> files are declarations like a mod's, and their <c>collection.json</c> is checked for inline <c>locale</c> entries — a literal <c>name</c>/<c>description</c> is the <c>en</c> text, every other project language needs a <c>locale</c> entry. </summary>
public static class LocaleAtlasBuilder{/// <summary>Source id prefix of a collection package (<c>collection:&lt;id&gt;</c>), as opposed to a mod.</summary>
public const string CollectionSourcePrefix="collection:";/// <summary>Language of literal collection texts (fallback in <c>ModCollection.GetLocalizedName</c>).</summary>
public const string CollectionBaseLanguage="en";private const string KeyPattern=@"[a-z_][A-Za-z0-9_]*(?:/[A-Za-z0-9_.\-]+)+";private static readonly Regex ScenarioKey=new(@"\bkey=(\S+)",RegexOptions.Compiled);private static readonly Regex ScenarioLiteral=new(@"^\s*[a-z_]+\s+""((?:[^""\\]|\\.)*)""",RegexOptions.Compiled);private static readonly Regex CodeTranslate=new(@"\b(?:Translate|Tr)\(\s*""("+KeyPattern+@")""",RegexOptions.Compiled);private static readonly Regex CodeLocale=new(@"\bLocale\s*=\s*""("+KeyPattern+@")""",RegexOptions.Compiled);private static readonly Regex SceneText=new(@"^(?:text|tooltip_text|placeholder_text|title) = ""("+KeyPattern+@")""\s*$",RegexOptions.Compiled|RegexOptions.Multiline);private static readonly Regex JsonTextField=new(@"""(?:Name|Description|Title|Text|Label|locale|title|description|text|label|Key)""\s*:\s*""(" + KeyPattern + @")""",RegexOptions.Compiled);// The '@' prefix itself marks a key (CollectionText), so a key without '/' counts too.
private static readonly Regex JsonAtKey=new(@"""@([A-Za-z0-9_][A-Za-z0-9_./\-]*)""",RegexOptions.Compiled);private static readonly Regex Placeholder=new(@"\{[A-Za-z0-9_]*\}",RegexOptions.Compiled);private static readonly Regex Cyrillic=new(@"\p{IsCyrillic}",RegexOptions.Compiled);private static readonly Regex FileExtension=new(@"\.(png|jpe?g|webp|svg|ogg|wav|mp3|tscn|tres|json|scenario|gdshader|cs|gd)$",RegexOptions.Compiled|RegexOptions.IgnoreCase);/// <summary>Languages written in Latin script: a Cyrillic value there is almost always an untranslated copy.</summary>
private static readonly HashSet<string>LatinLanguages=new(StringComparer.OrdinalIgnoreCase){"en","de","fr","es","it","pt","pl"};public static LocaleAtlasResult Build(IReadOnlyList<(string ModId,IModFileProvider Provider)>mods){var result=new LocaleAtlasResult();var collections=new List<(string ModId,string File,JsonElement Root)>();var documents=new List<JsonDocument>();try{foreach(var(modId,provider)in mods){if(provider==null)continue;result.Mods.Add(modId);ScanLocaleFiles(modId,provider,result);ScanReferences(modId,provider,result,collections,documents);if(modId.StartsWith(CollectionSourcePrefix,StringComparison.Ordinal))continue;result.References.Add(new LocaleReference{Key=$"mod_{modId}_name",ModId=modId,File="manifest.json",Via="manifest",Optional=true});result.References.Add(new LocaleReference{Key=$"mod_{modId}_desc",ModId=modId,File="manifest.json",Via="manifest",Optional=true});}result.Languages.AddRange(result.Files.Select(f=>f.Language).Distinct(StringComparer.Ordinal).OrderBy(l=>l,StringComparer.Ordinal));foreach(var(modId,file,root)in collections)CheckCollection(modId,file,root,result);}finally{foreach(var document in documents)document.Dispose();}BuildRows(result);Analyze(result);return result;}/// <summary> Language of a locale file name, matching <c>LocaleLoader</c>: <c>ru.properties</c> → ru, <c>scenarios_ru.properties</c> → ru. Null for non-<c>.properties</c> files. </summary>
public static string LanguageOf(string fileName){if(!fileName.EndsWith(".properties",StringComparison.OrdinalIgnoreCase))return null;var stem=fileName.Substring(0,fileName.Length-".properties".Length);var underscore=stem.LastIndexOf('_');return underscore>=0?stem.Substring(underscore+1):stem;}/// <summary>Sibling file of <paramref name="file"/> for another language: <c>locale/ui_en.properties</c> → <c>locale/ui_ru.properties</c>.</summary>
public static string SiblingFile(string file,string fromLanguage,string toLanguage){var suffix=fromLanguage+".properties";return file.EndsWith(suffix,StringComparison.Ordinal)?file.Substring(0,file.Length-suffix.Length)+toLanguage+".properties":$"locale/{toLanguage}.properties";}private static void ScanLocaleFiles(string modId,IModFileProvider provider,LocaleAtlasResult result){foreach(var file in ListFiles(provider,"locale").OrderBy(f=>f,StringComparer.Ordinal)){var name=Path.GetFileName(file);var language=LanguageOf(name);if(language==null)continue;var info=new LocaleFileInfo{ModId=modId,File=file,Language=language};result.Files.Add(info);var bytes=provider.ReadFileBinary(file)??Array.Empty<byte>();info.Bytes=bytes.Length;var check=LocaleEncodingChecker.Check(bytes);info.Encoding=check.Encoding;info.LineEndings=check.LineEndings;foreach(var issue in check.Issues){result.Findings.Add(new LocaleFinding{Severity=issue.Severity,Code=issue.Code,ModId=modId,Language=language,File=file,Line=issue.Line,Message=issue.Message,});}info.Issues=check.Issues.Count;if(file.Count(c=>c=='/')>1){result.Findings.Add(new LocaleFinding{Severity=LocaleSeverity.Error,Code="file_not_loaded",ModId=modId,Language=language,File=file,Message="LocaleLoader reads only the top level of locale/; keys in sub-folders are never loaded",Fix=$"move to locale/{name}",});}var lines=check.Text.Replace("\r\n","\n").Split('\n');for(var i=0;i<lines.Length;i++){var line=lines[i].Trim();if(line.Length==0||line[0]=='#')continue;var separator=line.IndexOf('=');if(separator<0){result.Findings.Add(new LocaleFinding{Severity=LocaleSeverity.Warning,Code="line_without_separator",ModId=modId,Language=language,File=file,Line=i+1,Message=$"line has no '=' and is ignored by the loader: '{Shorten(line)}'",});continue;}var key=line.Substring(0,separator).Trim();if(key.Length==0){result.Findings.Add(new LocaleFinding{Severity=LocaleSeverity.Warning,Code="empty_key",ModId=modId,Language=language,File=file,Line=i+1,Message="line has an empty key and is ignored by the loader",});continue;}if(key.Any(char.IsWhiteSpace)){result.Findings.Add(new LocaleFinding{Severity=LocaleSeverity.Warning,Code="key_whitespace",ModId=modId,Key=key,Language=language,File=file,Line=i+1,Message="key contains whitespace; no lookup can match it",});}info.Keys++;result.Entries.Add(new LocaleEntry{Key=key,Language=language,Value=line.Substring(separator+1).Trim(),ModId=modId,File=file,Line=i+1,});}}}private static void ScanReferences(string modId,IModFileProvider provider,LocaleAtlasResult result,List<(string,string,JsonElement)>collections,List<JsonDocument>documents){foreach(var file in ListFiles(provider,string.Empty).OrderBy(f=>f,StringComparer.Ordinal)){if(IsToolingPath(file)||file.StartsWith("locale/",StringComparison.Ordinal))continue;var extension=Path.GetExtension(file).ToLowerInvariant();if(extension is not(".scenario"or".cs"or".json"or".tscn"))continue;var text=provider.ReadFileText(file);if(string.IsNullOrEmpty(text))continue;text=text.Replace("\r\n","\n");switch(extension){case".scenario":ScanScenario(text,file,modId,result);break;case".cs":text=StripCSharpComments(text);AddMatches(CodeTranslate,text,file,modId,"code",result);AddMatches(CodeLocale,text,file,modId,"code",result);break;case".tscn":AddMatches(SceneText,text,file,modId,"scene",result);break;case".json":var isCollection=file=="collection.json"&&modId.StartsWith(CollectionSourcePrefix,StringComparison.Ordinal);if(!isCollection)AddMatches(JsonTextField,text,file,modId,"json",result);AddMatches(JsonAtKey,text,file,modId,isCollection?"collection":"json",result);if(isCollection){try{var document=JsonDocument.Parse(text,new JsonDocumentOptions{CommentHandling=JsonCommentHandling.Skip,AllowTrailingCommas=true});documents.Add(document);collections.Add((modId,file,document.RootElement));}catch(JsonException){// Broken collection JSON is the collection loader's error, not a locale one.
}}break;}}}private static void ScanScenario(string text,string file,string modId,LocaleAtlasResult result){var lines=text.Split('\n');for(var i=0;i<lines.Length;i++){var line=lines[i];if(line.TrimStart().StartsWith("#",StringComparison.Ordinal))continue;foreach(Match match in ScenarioKey.Matches(line)){var literal=ScenarioLiteral.Match(line);result.References.Add(new LocaleReference{Key=match.Groups[1].Value,ModId=modId,File=file,Line=i+1,Via="scenario",Fallback=literal.Success?literal.Groups[1].Value.Replace("\\\"","\"").Replace("\\\\","\\"):null,});}}}private static void AddMatches(Regex pattern,string text,string file,string modId,string via,LocaleAtlasResult result){foreach(Match match in pattern.Matches(text)){var key=match.Groups[1].Value;if(FileExtension.IsMatch(key))continue;// a path, not a key
result.References.Add(new LocaleReference{Key=key,ModId=modId,File=file,Line=LineOf(text,match.Index),Via=via});}}/// <summary> Every object with a literal <c>name</c> (collection, mechanic, include) must carry a <c>locale</c> entry for each project language except the base one; <c>"@key"</c> texts are covered by the key map instead. </summary>
private static void CheckCollection(string modId,string file,JsonElement root,LocaleAtlasResult result){void Walk(JsonElement element,string path){if(element.ValueKind==JsonValueKind.Array){var index=0;foreach(var item in element.EnumerateArray())Walk(item,$"{path}[{index++}]");return;}if(element.ValueKind!=JsonValueKind.Object)return;if(TryGetString(element,"name",out var name)&& !name.StartsWith("@",StringComparison.Ordinal)){var present=new HashSet<string>(StringComparer.OrdinalIgnoreCase){CollectionBaseLanguage};if(element.TryGetProperty("locale",out var locale)&&locale.ValueKind==JsonValueKind.Array){foreach(var entry in locale.EnumerateArray()){if(entry.ValueKind==JsonValueKind.Object&&TryGetString(entry,"lang",out var lang)&&(TryGetString(entry,"name",out var n)&&n.Length>0||TryGetString(entry,"description",out var d)&&d.Length>0))present.Add(lang);}}var id=TryGetString(element,"id",out var objectId)?objectId:name;foreach(var language in result.Languages.Where(l=> !present.Contains(l))){result.Findings.Add(new LocaleFinding{Severity=LocaleSeverity.Error,Code="collection_text_missing",ModId=modId,Key=$"{id}",Language=language,File=file,Message=$"'{name}' ({path.TrimStart('/').OrEmpty("root")}) has no locale entry for '{language}'",Fix=$"add {{ \"lang\": \"{language}\", \"name\": \"…\", \"description\": \"…\" }} to \"locale\"",});}}foreach(var property in element.EnumerateObject()){if(property.NameEquals("locale"))continue;Walk(property.Value,$"{path}/{property.Name}");}}Walk(root,string.Empty);}private static void BuildRows(LocaleAtlasResult result){LocaleKeyRow Row(string key){if(!result.Keys.TryGetValue(key,out var row))result.Keys[key]=row=new LocaleKeyRow{Key=key};return row;}foreach(var entry in result.Entries){var row=Row(entry.Key);if(!row.Declarations.TryGetValue(entry.Language,out var list))row.Declarations[entry.Language]=list=new List<LocaleEntry>();list.Add(entry);}var localized=new HashSet<string>(result.Files.Select(f=>f.ModId),StringComparer.Ordinal);foreach(var reference in result.References){// Optional manifest keys of mods without any locale file are not part of the map.
if(reference.Optional&& !result.Keys.ContainsKey(reference.Key)&& !localized.Contains(reference.ModId))continue;Row(reference.Key).References.Add(reference);}foreach(var row in result.Keys.Values){if(!row.Declared)continue;foreach(var language in result.Languages){if(!row.Declarations.TryGetValue(language,out var entries))row.MissingLanguages.Add(language);else if(entries.Any(e=>e.Value.Length==0))row.EmptyLanguages.Add(language);}foreach(var mod in row.DeclaringMods){var lacks=result.Languages.Where(l=> !row.Declarations.TryGetValue(l,out var entries)||entries.All(e=>e.ModId!=mod)).ToList();if(lacks.Count>0)row.PartialMods[mod]=lacks;}}}private static void Analyze(LocaleAtlasResult result){foreach(var row in result.Keys.Values){if(!row.Declared){AnalyzeUndeclared(row,result);continue;}foreach(var(mod,lacks)in row.PartialMods){var own=row.Declarations.Values.SelectMany(e=>e).First(e=>e.ModId==mod);foreach(var language in lacks){var global=row.MissingLanguages.Contains(language);var other=row.Declarations.TryGetValue(language,out var others)?others.Select(e=>e.ModId).Distinct().ToList():null;result.Findings.Add(new LocaleFinding{Severity=global?LocaleSeverity.Error:LocaleSeverity.Warning,Code=global?"key_missing_language":"key_partial_override",ModId=mod,Key=row.Key,Language=language,File=own.File,Line=own.Line,Message=global?$"declared in {string.Join(", ", row.Declarations.Keys)} but not in '{language}'":$"declared by this mod only in {string.Join(", ", row.Declarations.Values.SelectMany(e => e).Where(e => e.ModId == mod).Select(e => e.Language).Distinct())}; '{language}' comes from {string.Join(", ", other)}",Fix=$"{SiblingFile(own.File, own.Language, language)}: {row.Key} = {SuggestValue(row, language, mod)}",});}}foreach(var entries in row.Declarations.Values){foreach(var entry in entries.Where(e=>e.Value.Length==0)){result.Findings.Add(new LocaleFinding{Severity=LocaleSeverity.Error,Code="key_empty",ModId=entry.ModId,Key=row.Key,Language=entry.Language,File=entry.File,Line=entry.Line,Message="value is empty",Fix="write the text, or drop the key (and the key= reference) when the text is meant to be empty",});}foreach(var group in entries.GroupBy(e=>e.ModId).Where(g=>g.Count()>1)){var values=group.Select(e=>e.Value).Distinct(StringComparer.Ordinal).Count();var first=group.First();result.Findings.Add(new LocaleFinding{Severity=values>1?LocaleSeverity.Error:LocaleSeverity.Warning,Code=values>1?"key_conflict":"key_duplicate",ModId=group.Key,Key=row.Key,Language=first.Language,File=first.File,Line=first.Line,Message=(values>1?"declared with different values in ":"declared more than once in ")+string.Join(", ",group.Select(e=>$"{e.File}:{e.Line}")),Fix="keep one declaration (scenario keys belong in scenarios_<lang>.properties)",});}foreach(var entry in entries.Where(e=>LatinLanguages.Contains(e.Language)&&Cyrillic.IsMatch(e.Value))){result.Findings.Add(new LocaleFinding{Severity=LocaleSeverity.Warning,Code="untranslated",ModId=entry.ModId,Key=row.Key,Language=entry.Language,File=entry.File,Line=entry.Line,Message=$"Cyrillic text in '{entry.Language}': '{Shorten(entry.Value)}'",});}}AnalyzePlaceholders(row,result);}}private static void AnalyzeUndeclared(LocaleKeyRow row,LocaleAtlasResult result){foreach(var group in row.References.GroupBy(r=>r.ModId)){var first=group.First();var optional=group.All(r=>r.Optional);foreach(var language in result.Languages){result.Findings.Add(new LocaleFinding{Severity=optional?LocaleSeverity.Warning:LocaleSeverity.Error,Code=optional?"key_undeclared_optional":"key_undeclared",ModId=group.Key,Key=row.Key,Language=language,File=first.File,Line=first.Line,Message=optional?"not declared; the manifest text is shown instead":$"referenced ({string.Join(", ", group.Select(r => r.Via).Distinct())}, {group.Count()}×) but declared in no locale file",Fix=$"{TargetFile(group.Key, first.Via, language, result)}: {row.Key} = {SuggestFromFallback(group, language)}",});}}}/// <summary>Placeholders (<c>{0}</c>, <c>{name}</c>) must be the same in every language of one mod.</summary>
private static void AnalyzePlaceholders(LocaleKeyRow row,LocaleAtlasResult result){foreach(var mod in row.DeclaringMods){var sets=row.Declarations.Values.SelectMany(e=>e).Where(e=>e.ModId==mod&&e.Value.Length>0).GroupBy(e=>e.Language).Select(g=>(Entry:g.First(),Set:string.Join(" ",Placeholder.Matches(g.First().Value).Select(m=>m.Value).Distinct().OrderBy(v=>v,StringComparer.Ordinal)))).ToList();if(sets.Select(s=>s.Set).Distinct().Count()<=1)continue;var entry=sets[0].Entry;result.Findings.Add(new LocaleFinding{Severity=LocaleSeverity.Warning,Code="placeholder_mismatch",ModId=mod,Key=row.Key,Language=entry.Language,File=entry.File,Line=entry.Line,Message="placeholders differ between languages: "+string.Join("; ",sets.Select(s=>$"{s.Entry.Language}: {s.Set.OrEmpty("none")}")),});}}private static string TargetFile(string modId,string via,string language,LocaleAtlasResult result){var files=result.Files.Where(f=>f.ModId==modId&&f.Language==language).Select(f=>f.File).ToList();if(via=="scenario")return$"locale/scenarios_{language}.properties";return files.FirstOrDefault(f=> !Path.GetFileName(f).StartsWith("scenarios_",StringComparison.Ordinal))??$"locale/{language}.properties";}private static string SuggestValue(LocaleKeyRow row,string language,string mod){var fallback=row.References.Where(r=>r.ModId==mod&&r.Fallback!=null).Select(r=>r.Fallback).FirstOrDefault();if(fallback!=null&&MatchesScript(fallback,language))return fallback;var hint=row.Declarations.Where(d=>d.Key!=language).Select(d=>$"{d.Key}: {d.Value.First().Value}").FirstOrDefault();return$"… ({Shorten(hint ?? "no text in other languages")})";}private static string SuggestFromFallback(IEnumerable<LocaleReference>references,string language){var fallback=references.Select(r=>r.Fallback).FirstOrDefault(f=> !string.IsNullOrEmpty(f));return fallback!=null&&MatchesScript(fallback,language)?fallback:"…";}/// <summary>A scenario literal can be reused as the value when its script fits the language.</summary>
private static bool MatchesScript(string text,string language)=>LatinLanguages.Contains(language)? !Cyrillic.IsMatch(text):Cyrillic.IsMatch(text)|| !text.Any(char.IsLetter);private static bool TryGetString(JsonElement element,string name,out string value){value=null;if(!element.TryGetProperty(name,out var property)||property.ValueKind!=JsonValueKind.String)return false;value=property.GetString()??string.Empty;return true;}private static List<string>ListFiles(IModFileProvider provider,string directory){var result=new List<string>();var pending=new Stack<string>();pending.Push(directory);while(pending.Count>0){foreach(var entry in provider.ListFiles(pending.Pop())??Enumerable.Empty<string>()){if(entry.EndsWith("/",StringComparison.Ordinal))pending.Push(entry.TrimEnd('/'));else result.Add(entry);}}return result;}/// <summary>Tests and build output are not shipped content; their literals are not references.</summary>
private static bool IsToolingPath(string file)=>file.StartsWith("tests/",StringComparison.Ordinal)||file.StartsWith("test_suites/",StringComparison.Ordinal)// mounted as their own mod (<host>__<suite>)
||file.StartsWith("source_code/",StringComparison.Ordinal)||file.Contains("/bin/",StringComparison.Ordinal)||file.Contains("/obj/",StringComparison.Ordinal);private static string StripCSharpComments(string text){var chars=text.ToCharArray();var i=0;while(i<chars.Length){var c=chars[i];var next=i+1<chars.Length?chars[i+1]:'\0';if(c=='/'&&next=='/'){while(i<chars.Length&&chars[i]!='\n')chars[i++]=' ';}else if(c=='/'&&next=='*'){while(i<chars.Length&& !(chars[i]=='*'&&i+1<chars.Length&&chars[i+1]=='/')){if(chars[i]!='\n')chars[i]=' ';i++;}if(i<chars.Length){chars[i++]=' ';chars[i++]=' ';}}else if(c=='"'||c=='\''){var verbatim=c=='"'&&i>0&&(chars[i-1]=='@'||(chars[i-1]=='$'&&i>1&&chars[i-2]=='@'));i++;while(i<chars.Length&&chars[i]!=c&&chars[i]!='\n'){if(!verbatim&&chars[i]=='\\')i++;else if(verbatim&&chars[i]=='"'&&i+1<chars.Length&&chars[i+1]=='"')i++;i++;}i++;}else{i++;}}return new string(chars);}private static int LineOf(string text,int index){var line=1;for(var i=0;i<index&&i<text.Length;i++){if(text[i]=='\n')line++;}return line;}internal static string Shorten(string text)=>text.Length<=60?text:text.Substring(0,57)+"...";internal static string OrEmpty(this string text,string fallback)=>string.IsNullOrEmpty(text)?fallback:text;}}

===== FILE mods/locale_atlas/src/LocaleAtlasDriverNode.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Threading.Tasks;using Cthangover.Core.Bootstrap;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Cthangover.LocaleAtlas{/// <summary> Runs one pass after the boot: scans every installed mod (loaded ones in load order, then the rest — localization belongs to the mod, not to the game that happens to load it) on a worker thread, writes the report, logs every error as <c>[FAIL]</c> and prints <c>[TEST_RESULT] name=locale_atlas passed=&lt;clean mods&gt; failed=&lt;mods with errors&gt;</c> before quitting with 0/1. A watchdog ends a hung pass with a failure instead of blocking the test run. </summary>
public partial class LocaleAtlasDriverNode:Node{private const string ResultName="locale_atlas";private readonly string directory;private readonly float timeoutSeconds;private double elapsed;private bool started;private bool finished;private Task<LocaleAtlasResult>pass;public LocaleAtlasDriverNode(){}public LocaleAtlasDriverNode(string directory,float timeoutSeconds){this.directory=directory;this.timeoutSeconds=timeoutSeconds;Name="LocaleAtlasDriver";}public override void _Process(double delta){if(finished)return;elapsed+=delta;if(elapsed>timeoutSeconds){Finish(null,$"timeout after {timeoutSeconds:F0}s");return;}if(!started){if(!Boot.IsReady)return;started=true;var mods=SnapshotMods();GameLogger.Log("LOCALE_ATLAS",$"building locale map for {mods.Count} mod(s)");pass=Task.Run(()=>LocaleAtlasBuilder.Build(mods));return;}if(pass is{IsCompleted:true}){if(pass.IsFaulted)Finish(null,$"locale atlas pass failed: {pass.Exception?.GetBaseException()}");else Finish(pass.Result,null);}}private static List<(string,IModFileProvider)>SnapshotMods(){var registry=ModRegistry.Instance;var result=new List<(string,IModFileProvider)>();var seen=new HashSet<string>(StringComparer.Ordinal);foreach(var id in registry.GetOrderedModIds()){if(registry.Mods.TryGetValue(id,out var info)&&info?.FileProvider!=null&&seen.Add(id))result.Add((id,info.FileProvider));}foreach(var info in registry.AllDiscoveredMods.Values.OrderBy(m=>m.Id,StringComparer.Ordinal)){if(info?.FileProvider!=null&&seen.Add(info.Id))result.Add((info.Id,info.FileProvider));}foreach(var collection in ModCollectionStore.LoadAll()){if(collection.Package!=null)result.Add((LocaleAtlasBuilder.CollectionSourcePrefix+collection.Id,collection.Package));}return result;}private void Finish(LocaleAtlasResult result,string failure){if(finished)return;finished=true;var passed=0;var failed=0;if(result!=null){try{LocaleAtlasReportWriter.Write(result,directory);}catch(Exception ex){failure=$"report write failed: {ex.Message}";}foreach(var error in result.Errors){GameLogger.Log("LOCALE_ATLAS",$"[FAIL] {error.ModId}: {error.Code} '{error.Key}' [{error.Language}] {error.Message} ({error.File}{(error.Line > 0 ? ":" + error.Line : string.Empty)})");}var failing=result.FailingMods();failed=failing.Count;passed=result.Mods.Count-failed;GameLogger.Log("LOCALE_ATLAS",$"locale atlas: {result.Keys.Count} key(s), {result.InterestingKeys.Count()} need attention, "+$"{result.Errors.Count()} error(s), {result.Warnings.Count()} warning(s); report: {Path.Combine(directory, "report.md")}");}if(failure!=null){failed++;GameLogger.Log("LOCALE_ATLAS",$"[FAIL] {failure}",LogLevel.Error);}GameLogger.Log("LOCALE_ATLAS",$"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");GetTree().Quit(failed==0?0:1);}}}

===== FILE mods/locale_atlas/src/LocaleAtlasInitializer.cs =====
using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Cthangover.LocaleAtlas{/// <summary> Activates the key map only on demand: the <c>--locale-atlas</c> user arg (after <c>--</c>) queues <see cref="LocaleAtlasDriverNode"/>, which waits for the boot, writes the report and quits. Optional <c>--locale-out=&lt;dir&gt;</c> (res://, user:// or absolute) sets the report directory, <c>--timeout=&lt;s&gt;</c> the watchdog. </summary>
public class LocaleAtlasInitializer:IModInitializer{public const string Flag="--locale-atlas";private const string OutPrefix="--locale-out=";private const string DefaultOut="user://locale_atlas";private static bool queued;public void OnModLoaded(string modId){}public void OnModResourcesReady(){if(queued)return;var args=OS.GetCmdlineUserArgs();if(!args.Contains(Flag))return;var output=args.FirstOrDefault(a=>a.StartsWith(OutPrefix,System.StringComparison.Ordinal))?.Substring(OutPrefix.Length);if(string.IsNullOrWhiteSpace(output))output=DefaultOut;queued=true;var directory=ProjectSettings.GlobalizePath(output);GameLogger.Log("LOCALE_ATLAS",$"locale atlas requested, report directory '{directory}'");(Godot.Engine.GetMainLoop()as SceneTree)?.Root?.CallDeferred("add_child",new LocaleAtlasDriverNode(directory,ParseTimeout(args)));}private static float ParseTimeout(string[]args){var raw=args.FirstOrDefault(a=>a.StartsWith("--timeout=",System.StringComparison.Ordinal))?.Substring("--timeout=".Length);return float.TryParse(raw,System.Globalization.NumberStyles.Float,System.Globalization.CultureInfo.InvariantCulture,out var seconds)&&seconds>0?seconds:120f;}}}

===== FILE mods/locale_atlas/src/LocaleAtlasModel.cs =====
using System;using System.Collections.Generic;using System.Linq;namespace Cthangover.LocaleAtlas{public enum LocaleSeverity{Info,Warning,Error,}/// <summary>One <c>key = value</c> line of a <c>locale/*.properties</c> file.</summary>
public sealed class LocaleEntry{public string Key{get;set;}public string Language{get;set;}public string Value{get;set;}public string ModId{get;set;}public string File{get;set;}public int Line{get;set;}}/// <summary> A place that asks for a key: <c>scenario</c> (<c>key=</c>), <c>code</c> (<c>Translate</c>/<c>Tr</c>/<c>Locale =</c> literals), <c>json</c> (text fields of data), <c>scene</c> (auto-translated <c>.tscn</c> texts), <c>collection</c> (<c>"@key"</c>) or <c>manifest</c> (<c>mod_&lt;id&gt;_name/_desc</c>). </summary>
public sealed class LocaleReference{public string Key{get;set;}public string ModId{get;set;}public string File{get;set;}public int Line{get;set;}public string Via{get;set;}/// <summary>Text shown when the key is absent (scenario literal); null when there is none.</summary>
public string Fallback{get;set;}/// <summary>The runtime has a non-key fallback (manifest name/description), so a gap is a warning.</summary>
public bool Optional{get;set;}}/// <summary>A finding of the pass; <see cref="Code"/> is stable for tools.</summary>
public sealed class LocaleFinding{public LocaleSeverity Severity{get;set;}public string Code{get;set;}public string ModId{get;set;}public string Key{get;set;}public string Language{get;set;}public string File{get;set;}public int Line{get;set;}public string Message{get;set;}/// <summary>Where and what to write to fix it (e.g. <c>locale/ru.properties: key = …</c>); null when not applicable.</summary>
public string Fix{get;set;}}/// <summary> Everything known about one key: declarations per language across all mods and every reference. A key is "complete" when each project language has a non-empty declaration in every mod that declares it. </summary>
public sealed class LocaleKeyRow{public string Key{get;set;}public Dictionary<string,List<LocaleEntry>>Declarations{get;}=new(StringComparer.Ordinal);public List<LocaleReference>References{get;}=new();public List<string>MissingLanguages{get;}=new();public List<string>EmptyLanguages{get;}=new();/// <summary>Mod → languages it lacks while declaring the key in others (only for mods that declare the key).</summary>
public Dictionary<string,List<string>>PartialMods{get;}=new(StringComparer.Ordinal);public bool Declared=>Declarations.Count>0;public bool Complete=>Declared&&MissingLanguages.Count==0&&EmptyLanguages.Count==0&&PartialMods.Count==0;public IEnumerable<string>DeclaringMods=>Declarations.Values.SelectMany(e=>e).Select(e=>e.ModId).Distinct();}/// <summary>Encoding facts of one locale file.</summary>
public sealed class LocaleFileInfo{public string ModId{get;set;}public string File{get;set;}public string Language{get;set;}public int Bytes{get;set;}public int Keys{get;set;}/// <summary><c>UTF-8</c>, <c>UTF-8 BOM</c>, <c>UTF-16 LE/BE</c>, <c>UTF-32</c> or <c>invalid</c>.</summary>
public string Encoding{get;set;}/// <summary><c>LF</c>, <c>CRLF</c>, <c>CR</c>, <c>mixed</c> or <c>none</c> (single line).</summary>
public string LineEndings{get;set;}public int Issues{get;set;}}/// <summary>Result of one pass; report writers and the driver read only this.</summary>
public sealed class LocaleAtlasResult{/// <summary>Project languages: every language that has at least one locale file in a scanned mod.</summary>
public List<string>Languages{get;}=new();public List<string>Mods{get;}=new();public List<LocaleFileInfo>Files{get;}=new();public List<LocaleEntry>Entries{get;}=new();public List<LocaleReference>References{get;}=new();public SortedDictionary<string,LocaleKeyRow>Keys{get;}=new(StringComparer.Ordinal);public List<LocaleFinding>Findings{get;}=new();public IEnumerable<LocaleFinding>Errors=>Findings.Where(f=>f.Severity==LocaleSeverity.Error);public IEnumerable<LocaleFinding>Warnings=>Findings.Where(f=>f.Severity==LocaleSeverity.Warning);/// <summary>Keys worth looking at: everything that is not complete in every language.</summary>
public IEnumerable<LocaleKeyRow>InterestingKeys=>Keys.Values.Where(k=> !k.Complete);/// <summary>Mods with at least one error, in scan order.</summary>
public List<string>FailingMods(){var failing=new HashSet<string>(Errors.Select(f=>f.ModId),StringComparer.Ordinal);return Mods.Where(failing.Contains).ToList();}}}

===== FILE mods/locale_atlas/src/LocaleAtlasReportWriter.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text;using System.Text.Encodings.Web;using System.Text.Json;namespace Cthangover.LocaleAtlas{/// <summary> Writes <c>report.md</c> (read first: errors with the fix, the map of keys that are not complete in every language, file encodings) and <c>report.json</c> (the same for tools). Keys declared non-empty in every language are counted but not listed — they are not interesting. </summary>
public static class LocaleAtlasReportWriter{public static void Write(LocaleAtlasResult result,string directory){Directory.CreateDirectory(directory);File.WriteAllText(Path.Combine(directory,"report.md"),BuildMarkdown(result),new UTF8Encoding(false));File.WriteAllText(Path.Combine(directory,"report.json"),BuildJson(result),new UTF8Encoding(false));}public static string BuildMarkdown(LocaleAtlasResult result){var md=new StringBuilder();var errors=result.Errors.ToList();var warnings=result.Warnings.ToList();var interesting=result.InterestingKeys.ToList();md.AppendLine("# Карта ключей локализации");md.AppendLine();md.AppendLine($"Создано: {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC · модов: {result.Mods.Count} · файлов локализации: {result.Files.Count} · "+$"языки: {string.Join(", ", result.Languages.Select(l => $"`{l}`"))}");md.AppendLine();md.AppendLine($"Ключей: {result.Keys.Count} · во всех языках: {result.Keys.Count - interesting.Count} · "+$"**требуют внимания: {interesting.Count}** · ссылок на ключи: {result.References.Count}");md.AppendLine();md.AppendLine($"**Ошибок: {errors.Count}** (моды: {string.Join(", ", result.FailingMods()).OrEmpty("нет")}) · предупреждений: {warnings.Count}");md.AppendLine();md.AppendLine("Как читать: ключ попадает в отчёт, только если он не объявлен непустым во всех языках "+"каждого мода, который его объявляет, или на него ссылаются, но он нигде не объявлен. "+"`✓` — объявлен, `∅` — пустое значение, `—` — нет в этом языке. Колонка «Исправление» — файл и строка, которые нужно добавить.");md.AppendLine();AppendEncoding(md,result);AppendKeyMap(md,result,interesting);AppendFindings(md,"Необъявленные ключи (есть ссылка, нет ни в одной локализации)",result.Findings.Where(f=>f.Code is"key_undeclared"or"key_undeclared_optional"));AppendFindings(md,"Ключ есть не во всех языках",result.Findings.Where(f=>f.Code is"key_missing_language"or"key_partial_override"));AppendFindings(md,"Пустые значения",result.Findings.Where(f=>f.Code=="key_empty"));AppendFindings(md,"Повторные объявления в одном моде",result.Findings.Where(f=>f.Code is"key_duplicate"or"key_conflict"));AppendFindings(md,"Тексты коллекций без перевода",result.Findings.Where(f=>f.Code=="collection_text_missing"));AppendFindings(md,"Прочее (плейсхолдеры, непереведённый текст, формат строк)",result.Findings.Where(f=>f.Code is"placeholder_mismatch"or"untranslated"or"line_without_separator"or"empty_key"or"key_whitespace"or"file_not_loaded"));AppendModTable(md,result);return md.ToString();}private static void AppendEncoding(StringBuilder md,LocaleAtlasResult result){md.AppendLine("## Кодировки файлов локализации");md.AppendLine();var encodingCodes=new HashSet<string>(StringComparer.Ordinal){"encoding_utf32","encoding_utf16","encoding_utf16_nobom","utf8_bom","invalid_utf8","mixed_line_endings","cr_line_endings","replacement_char","control_char","inner_bom","mojibake_cp1251","mojibake_latin1","question_marks",};var issues=result.Findings.Where(f=>encodingCodes.Contains(f.Code)).ToList();var byEncoding=result.Files.GroupBy(f=>f.Encoding).Select(g=>$"{g.Key}: {g.Count()}");var byEndings=result.Files.GroupBy(f=>f.LineEndings).Select(g=>$"{g.Key}: {g.Count()}");md.AppendLine($"Кодировка — {string.Join(", ", byEncoding)}; переводы строк — {string.Join(", ", byEndings)}.");md.AppendLine();if(issues.Count==0){md.AppendLine("Проблем нет: все файлы — корректный UTF-8, без U+FFFD, управляющих символов и признаков кракозябр (cp1251/latin-1).");md.AppendLine();}else{md.AppendLine("| Серьёзность | Мод | Файл | Строка | Проблема |");md.AppendLine("|---|---|---|---|---|");foreach(var f in issues.OrderByDescending(f=>f.Severity).ThenBy(f=>f.ModId,StringComparer.Ordinal).ThenBy(f=>f.File,StringComparer.Ordinal).ThenBy(f=>f.Line))md.AppendLine($"| {Severity(f.Severity)} | `{f.ModId}` | `{f.File}` | {(f.Line > 0 ? f.Line.ToString() : "—")} | {Cell(f.Message)} |");md.AppendLine();}md.AppendLine("<details><summary>Все файлы</summary>");md.AppendLine();md.AppendLine("| Мод | Файл | Язык | Кодировка | Переводы строк | Ключей | Байт |");md.AppendLine("|---|---|---|---|---|---|---|");foreach(var f in result.Files)md.AppendLine($"| `{f.ModId}` | `{f.File}` | {f.Language} | {f.Encoding} | {f.LineEndings} | {f.Keys} | {f.Bytes} |");md.AppendLine();md.AppendLine("</details>");md.AppendLine();}private static void AppendKeyMap(StringBuilder md,LocaleAtlasResult result,List<LocaleKeyRow>interesting){md.AppendLine("## Карта ключей, требующих внимания");md.AppendLine();if(interesting.Count==0){md.AppendLine("Нет: каждый ключ объявлен непустым во всех языках, необъявленных ссылок нет.");md.AppendLine();return;}foreach(var group in interesting.GroupBy(Owner).OrderBy(g=>result.Mods.IndexOf(g.Key))){md.AppendLine($"### `{group.Key}`");md.AppendLine();md.AppendLine("| Ключ | "+string.Join(" | ",result.Languages)+" | Объявлен в | Ссылки | Проблема |");md.AppendLine("|---|"+string.Concat(result.Languages.Select(_=>"---|"))+"---|---|---|");foreach(var row in group){var cells=result.Languages.Select(l=> !row.Declarations.TryGetValue(l,out var e)?"—":e.Any(x=>x.Value.Length==0)?"∅":"✓");var declared=row.Declared?string.Join(", ",row.Declarations.Values.SelectMany(e=>e).Select(e=>$"{e.ModId}:{Path.GetFileName(e.File)}").Distinct()):"нигде";var refs=row.References.Count==0?"—":string.Join(", ",row.References.GroupBy(r=>(r.ModId,r.Via)).Select(g=>$"{g.Key.ModId} ({g.Key.Via}{(g.Count() > 1 ? $" ×{g.Count()}" : string.Empty)})"));md.AppendLine($"| `{row.Key}` | {string.Join(" | ", cells)} | {Cell(declared)} | {Cell(refs)} | {Problem(row)} |");}md.AppendLine();}}private static void AppendFindings(StringBuilder md,string title,IEnumerable<LocaleFinding>findings){var list=findings.ToList();md.AppendLine($"## {title}");md.AppendLine();if(list.Count==0){md.AppendLine("Нет.");md.AppendLine();return;}md.AppendLine("| Серьёзность | Мод | Ключ | Язык | Где | Описание | Исправление |");md.AppendLine("|---|---|---|---|---|---|---|");foreach(var f in list.OrderByDescending(f=>f.Severity).ThenBy(f=>f.ModId,StringComparer.Ordinal).ThenBy(f=>f.Key,StringComparer.Ordinal).ThenBy(f=>f.Language,StringComparer.Ordinal)){md.AppendLine($"| {Severity(f.Severity)} | `{f.ModId}` | `{f.Key}` | {f.Language} | `{f.File}{(f.Line > 0 ? ":" + f.Line : string.Empty)}` | "+$"{Cell(f.Message)} | {(f.Fix == null ? "—" : $"`{Cell(f.Fix)}`")} |");}md.AppendLine();}private static void AppendModTable(StringBuilder md,LocaleAtlasResult result){md.AppendLine("## Сводка по модам");md.AppendLine();md.AppendLine("| Мод | Файлов | Ключей | Требуют внимания | Ошибок | Предупреждений |");md.AppendLine("|---|---|---|---|---|---|");foreach(var mod in result.Mods){var files=result.Files.Count(f=>f.ModId==mod);var keys=result.Entries.Where(e=>e.ModId==mod).Select(e=>e.Key).Distinct().Count();var attention=result.InterestingKeys.Count(r=>Owner(r)==mod);var errors=result.Errors.Count(f=>f.ModId==mod);var warnings=result.Warnings.Count(f=>f.ModId==mod);if(files==0&&attention==0&&errors==0&&warnings==0)continue;md.AppendLine($"| `{mod}` | {files} | {keys} | {attention} | {errors} | {warnings} |");}md.AppendLine();}/// <summary>The mod that should fix the key: the first declaring mod, else the first referencing one.</summary>
private static string Owner(LocaleKeyRow row)=>row.PartialMods.Keys.FirstOrDefault()??row.Declarations.Values.SelectMany(e=>e).Select(e=>e.ModId).FirstOrDefault()??row.References.Select(r=>r.ModId).FirstOrDefault()??"?";private static string Problem(LocaleKeyRow row){var parts=new List<string>();if(!row.Declared)parts.Add(row.References.All(r=>r.Optional)?"не объявлен (есть запасной текст манифеста)":"не объявлен");if(row.MissingLanguages.Count>0)parts.Add("нет в "+string.Join(", ",row.MissingLanguages));if(row.EmptyLanguages.Count>0)parts.Add("пусто в "+string.Join(", ",row.EmptyLanguages));foreach(var(mod,lacks)in row.PartialMods){// Languages missing everywhere are already listed; here only overrides that cover part of the languages.
var own=lacks.Except(row.MissingLanguages).ToList();if(own.Count>0)parts.Add($"{mod} переопределяет без {string.Join(", ", own)}");}return string.Join("; ",parts);}public static string BuildJson(LocaleAtlasResult result){var payload=new{generated=DateTime.UtcNow.ToString("o"),languages=result.Languages,mods=result.Mods,summary=new{files=result.Files.Count,keys=result.Keys.Count,complete=result.Keys.Values.Count(k=>k.Complete),attention=result.InterestingKeys.Count(),references=result.References.Count,errors=result.Errors.Count(),warnings=result.Warnings.Count(),failingMods=result.FailingMods(),},files=result.Files.Select(f=>new{mod=f.ModId,file=f.File,language=f.Language,encoding=f.Encoding,lineEndings=f.LineEndings,keys=f.Keys,bytes=f.Bytes}),keys=result.InterestingKeys.Select(r=>new{key=r.Key,declared=r.Declared,missing=r.MissingLanguages,empty=r.EmptyLanguages,partial=r.PartialMods,declarations=r.Declarations.ToDictionary(d=>d.Key,d=>d.Value.Select(e=>new{mod=e.ModId,file=e.File,line=e.Line,value=e.Value})),references=r.References.Select(x=>new{mod=x.ModId,file=x.File,line=x.Line,via=x.Via,fallback=x.Fallback,optional=x.Optional}),}),findings=result.Findings.Select(f=>new{severity=f.Severity.ToString().ToLowerInvariant(),code=f.Code,mod=f.ModId,key=f.Key,language=f.Language,file=f.File,line=f.Line,message=f.Message,fix=f.Fix,}),};return JsonSerializer.Serialize(payload,new JsonSerializerOptions{WriteIndented=true,Encoder=JavaScriptEncoder.UnsafeRelaxedJsonEscaping,});}private static string Severity(LocaleSeverity severity)=>severity switch{LocaleSeverity.Error=>"ошибка",LocaleSeverity.Warning=>"предупреждение",_=>"инфо",};private static string Cell(string text)=>(text??string.Empty).Replace("|","\\|").Replace("\n"," ");}}

===== FILE mods/locale_atlas/src/LocaleEncodingChecker.cs =====
using System;using System.Collections.Generic;using System.Text;using System.Text.RegularExpressions;namespace Cthangover.LocaleAtlas{/// <summary> Byte-level check of one locale file. <c>LocaleLoader</c> reads files with <c>File.ReadAllText</c>/<c>StreamReader</c>, which silently turn invalid UTF-8 into U+FFFD, so broken text is only visible in the raw bytes. Detects: non-UTF-8 encodings (UTF-16/32 with or without BOM, cp1251/latin-1 bytes), invalid sequences, the replacement character, control characters, and mojibake — UTF-8 Cyrillic that was decoded as cp1251 (<c>привет</c>) or latin-1/cp1252 (<c>При</c>) and saved again. Also reports the BOM and line endings. </summary>
public static class LocaleEncodingChecker{public sealed class Issue{public LocaleSeverity Severity{get;set;}public string Code{get;set;}public int Line{get;set;}public string Message{get;set;}}public sealed class Result{/// <summary>Decoded text (BOM stripped); best effort when the file is not valid UTF-8.</summary>
public string Text{get;set;}=string.Empty;public string Encoding{get;set;}="UTF-8";public string LineEndings{get;set;}="none";public List<Issue>Issues{get;}=new();}private static readonly UTF8Encoding StrictUtf8=new(false,true);// Second byte of a UTF-8 Cyrillic pair (0x80–0xBF) as cp1251 shows it, after Р (0xD0) or С (0xD1). Characters that also follow Р/С in real Russian text (ё, Ё, «, », dashes, quotes, nbsp) are left out.
private static readonly Regex Cp1251Mojibake=new("[РС][ЂЃ‚ѓ„†‡€‰Љ‹ЊЌЋЏђ™љ›њќћџЎўЈ¤Ґ¦§©Є¬\u00AD®Ї°±Ііґµ¶·№єјЅѕї]",RegexOptions.Compiled);// The same pair as latin-1/cp1252 shows it (Ð = 0xD0, Ñ = 0xD1), and Â/à + continuation for double-encoded Latin-1 text.
private static readonly Regex Latin1Mojibake=new("[ÐÑÂÃ][\u0080-¿ŒœŠšŸŽžƒˆ˜–—‘’‚“”„†‡•…‰‹›€™]",RegexOptions.Compiled);private static readonly Regex LostCharacters=new(@"\?{3,}",RegexOptions.Compiled);public static Result Check(byte[]bytes){var result=new Result();if(bytes==null||bytes.Length==0){result.Encoding="empty";return result;}var offset=0;if(StartsWith(bytes,0xFF,0xFE,0x00,0x00)||StartsWith(bytes,0x00,0x00,0xFE,0xFF)){result.Encoding="UTF-32";Add(result,LocaleSeverity.Error,"encoding_utf32",0,"file is UTF-32; locale files must be UTF-8");result.Text=DecodeOrEmpty(new UTF32Encoding(bytes[0]==0x00,true),bytes,4);result.LineEndings=LineEndings(result.Text);return result;}if(StartsWith(bytes,0xFF,0xFE)||StartsWith(bytes,0xFE,0xFF)){var bigEndian=bytes[0]==0xFE;result.Encoding=bigEndian?"UTF-16 BE":"UTF-16 LE";Add(result,LocaleSeverity.Error,"encoding_utf16",0,$"file is {result.Encoding}; the loader reads it, but locale files must be UTF-8 (tools, diffs and zip packaging expect it)");result.Text=DecodeOrEmpty(new UnicodeEncoding(bigEndian,true),bytes,2);result.LineEndings=LineEndings(result.Text);CheckText(result);return result;}if(StartsWith(bytes,0xEF,0xBB,0xBF)){offset=3;result.Encoding="UTF-8 BOM";Add(result,LocaleSeverity.Warning,"utf8_bom",1,"UTF-8 with BOM; the loader strips it, but the project convention is UTF-8 without BOM");}if(LooksLikeUtf16WithoutBom(bytes,offset)){result.Encoding="UTF-16 (no BOM)";Add(result,LocaleSeverity.Error,"encoding_utf16_nobom",0,"file looks like UTF-16 without BOM (NUL every other byte); the loader reads it as garbage");return result;}try{result.Text=StrictUtf8.GetString(bytes,offset,bytes.Length-offset);}catch(DecoderFallbackException){result.Encoding="invalid";ReportInvalidLines(bytes,offset,result);result.Text=Encoding.UTF8.GetString(bytes,offset,bytes.Length-offset);}result.LineEndings=LineEndings(result.Text);if(result.LineEndings=="mixed")Add(result,LocaleSeverity.Warning,"mixed_line_endings",0,"mixed LF/CRLF line endings");else if(result.LineEndings=="CR")Add(result,LocaleSeverity.Error,"cr_line_endings",0,"old Mac CR-only line endings; the loader sees the whole file as one line");CheckText(result);return result;}/// <summary>Character-level checks on decoded text, line by line.</summary>
private static void CheckText(Result result){var lines=result.Text.Replace("\r\n","\n").Split('\n');for(var i=0;i<lines.Length;i++){var line=lines[i];var number=i+1;if(line.IndexOf('�')>=0)Add(result,LocaleSeverity.Error,"replacement_char",number,"contains U+FFFD (�): text was already broken by a wrong decode before it was saved");foreach(var c in line){if((c<0x20&&c!='\t'&&c!='\r')||c==0x7F||(c>=0x80&&c<0xA0)){Add(result,LocaleSeverity.Error,"control_char",number,$"control character U+{(int)c:X4}");break;}}if(line.Length>0&&line[0]==''&& !(i==0&&result.Encoding=="UTF-8 BOM"))Add(result,LocaleSeverity.Warning,"inner_bom",number,"U+FEFF inside the file (a BOM glued in by concatenation); it becomes part of the key");var cp1251=Cp1251Mojibake.Match(line);if(cp1251.Success)Add(result,LocaleSeverity.Error,"mojibake_cp1251",number,$"mojibake: UTF-8 text decoded as cp1251 near '{Excerpt(line, cp1251.Index)}'");var latin1=Latin1Mojibake.Match(line);if(latin1.Success)Add(result,LocaleSeverity.Error,"mojibake_latin1",number,$"mojibake: UTF-8 text decoded as latin-1/cp1252 near '{Excerpt(line, latin1.Index)}'");if(LostCharacters.IsMatch(line)&& !line.TrimStart().StartsWith("#",StringComparison.Ordinal))Add(result,LocaleSeverity.Warning,"question_marks",number,"'???' in a value: characters may have been lost by saving in a non-Unicode code page");}}private static void ReportInvalidLines(byte[]bytes,int offset,Result result){var line=1;var start=offset;var reported=0;for(var i=offset;i<=bytes.Length;i++){if(i<bytes.Length&&bytes[i]!=(byte)'\n')continue;try{StrictUtf8.GetString(bytes,start,i-start);}catch(DecoderFallbackException){if(reported++ <20)Add(result,LocaleSeverity.Error,"invalid_utf8",line,$"invalid UTF-8 bytes{GuessLegacy(bytes, start, i - start)}");}line++;start=i+1;}if(reported>20)Add(result,LocaleSeverity.Error,"invalid_utf8",0,$"… and {reported - 20} more line(s) with invalid UTF-8");}/// <summary>Hints the legacy code page: Cyrillic letters of cp1251 are bytes 0xC0–0xFF.</summary>
private static string GuessLegacy(byte[]bytes,int start,int length){var high=0;var cyrillic=0;for(var i=start;i<start+length;i++){if(bytes[i]<0x80)continue;high++;if(bytes[i]>=0xC0)cyrillic++;}return high>0&&cyrillic*10>=high*7?" (looks like cp1251: re-save the file as UTF-8)":string.Empty;}private static bool LooksLikeUtf16WithoutBom(byte[]bytes,int offset){var length=Math.Min(bytes.Length-offset,512);if(length<4)return false;var nul=0;for(var i=offset;i<offset+length;i++){if(bytes[i]==0)nul++;}return nul*4>=length;}private static string LineEndings(string text){var crlf=0;var lf=0;var cr=0;for(var i=0;i<text.Length;i++){if(text[i]=='\r'){if(i+1<text.Length&&text[i+1]=='\n'){crlf++;i++;}else cr++;}else if(text[i]=='\n')lf++;}var kinds=(crlf>0?1:0)+(lf>0?1:0)+(cr>0?1:0);if(kinds==0)return"none";if(kinds>1)return"mixed";return crlf>0?"CRLF":lf>0?"LF":"CR";}private static string DecodeOrEmpty(Encoding encoding,byte[]bytes,int offset){try{return encoding.GetString(bytes,offset,bytes.Length-offset);}catch(DecoderFallbackException){return string.Empty;}}private static string Excerpt(string line,int index){var start=Math.Max(0,index-4);return line.Substring(start,Math.Min(16,line.Length-start));}private static bool StartsWith(byte[]bytes,params byte[]prefix){if(bytes.Length<prefix.Length)return false;for(var i=0;i<prefix.Length;i++){if(bytes[i]!=prefix[i])return false;}return true;}private static void Add(Result result,LocaleSeverity severity,string code,int line,string message)=>result.Issues.Add(new Issue{Severity=severity,Code=code,Line=line,Message=message});}}

===== FILE mods/locale_atlas/tests/LocaleAtlasTests.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Testing;namespace Cthangover.LocaleAtlas.Tests{/// <summary> Pure tests of the encoding checker and the key map on in-memory mods: no scene tree, no installed mods, so they pin the rules themselves. </summary>
public class LocaleAtlasTests{private static readonly Encoding Utf8=new UTF8Encoding(false);[ModTest("encoding/valid_utf8_is_clean")]static void ValidUtf8IsClean(ModTestContext ctx){// Real Russian that starts with Р/С followed by ё, «, — must not look like mojibake.
var check=LocaleEncodingChecker.Check(Utf8.GetBytes("a = Рёв зверя\nb = Сёстры — «Р»\nc = Hello {0}\n"));ModAssert.Equal("UTF-8",check.Encoding,"encoding");ModAssert.Equal("LF",check.LineEndings,"line endings");ModAssert.Equal(0,check.Issues.Count,"issues: "+string.Join("; ",check.Issues.Select(i=>i.Code)));}[ModTest("encoding/bom_and_line_endings")]static void BomAndLineEndings(ModTestContext ctx){var bytes=new byte[]{0xEF,0xBB,0xBF}.Concat(Utf8.GetBytes("a = 1\r\nb = 2\n")).ToArray();var check=LocaleEncodingChecker.Check(bytes);ModAssert.Equal("UTF-8 BOM",check.Encoding,"encoding");ModAssert.Equal("mixed",check.LineEndings,"line endings");ModAssert.True(check.Issues.Any(i=>i.Code=="utf8_bom"&&i.Severity==LocaleSeverity.Warning),"BOM warning");ModAssert.True(check.Issues.Any(i=>i.Code=="mixed_line_endings"),"mixed endings warning");ModAssert.False(check.Text.StartsWith("",StringComparison.Ordinal),"BOM stripped from text");}[ModTest("encoding/cp1251_bytes_are_invalid_utf8")]static void Cp1251BytesAreInvalid(ModTestContext ctx){// "a = Привет" saved in cp1251.
var bytes=Encoding.ASCII.GetBytes("ok = 1\na = ").Concat(new byte[]{0xCF,0xF0,0xE8,0xE2,0xE5,0xF2}).ToArray();var check=LocaleEncodingChecker.Check(bytes);ModAssert.Equal("invalid",check.Encoding,"encoding");var issue=check.Issues.FirstOrDefault(i=>i.Code=="invalid_utf8");ModAssert.NotNull(issue,"invalid_utf8 reported");ModAssert.Equal(2,issue.Line,"line of the broken bytes");ModAssert.True(issue.Message.Contains("cp1251"),"cp1251 hint: "+issue.Message);}[ModTest("encoding/mojibake_is_detected")]static void MojibakeIsDetected(ModTestContext ctx){// UTF-8 "Привет" shown as cp1251 and as cp1252, then saved as UTF-8 again.
var a=LocaleEncodingChecker.Check(Utf8.GetBytes("ok = Привет\na = Привет\n"));ModAssert.True(a.Issues.Any(i=>i.Code=="mojibake_cp1251"&&i.Line==2),"cp1251 mojibake on line 2");ModAssert.False(a.Issues.Any(i=>i.Line==1),"clean line 1");var b=LocaleEncodingChecker.Check(Utf8.GetBytes("b = Привет\n"));ModAssert.True(b.Issues.Any(i=>i.Code=="mojibake_latin1"),"cp1252 mojibake");var raw=Encoding.Latin1.GetString(Utf8.GetBytes("Привет"));var c1=LocaleEncodingChecker.Check(Utf8.GetBytes($"b = {raw}\n"));ModAssert.True(c1.Issues.Any(i=>i.Code is"mojibake_latin1"or"control_char"),"latin-1 mojibake (C1 controls)");var c=LocaleEncodingChecker.Check(Utf8.GetBytes("c = Пр�вет\n"));ModAssert.True(c.Issues.Any(i=>i.Code=="replacement_char"),"U+FFFD");}[ModTest("encoding/utf16_is_reported")]static void Utf16IsReported(ModTestContext ctx){var withBom=LocaleEncodingChecker.Check(Encoding.Unicode.GetPreamble().Concat(Encoding.Unicode.GetBytes("a = 1\n")).ToArray());ModAssert.Equal("UTF-16 LE",withBom.Encoding,"UTF-16 with BOM");ModAssert.True(withBom.Issues.Any(i=>i.Code=="encoding_utf16"),"UTF-16 reported");var noBom=LocaleEncodingChecker.Check(Encoding.Unicode.GetBytes("a = 1\nb = 2\n"));ModAssert.True(noBom.Issues.Any(i=>i.Code=="encoding_utf16_nobom"),"UTF-16 without BOM reported");}[ModTest("map/complete_keys_are_not_interesting")]static void CompleteKeysAreNotInteresting(ModTestContext ctx){var mod=new MemoryProvider("m").Add("locale/en.properties","ok = OK\nmod_m_name = M\nmod_m_desc = M\n").Add("locale/ru.properties","ok = Ок\nmod_m_name = М\nmod_m_desc = М\n");var result=LocaleAtlasBuilder.Build(new[]{("m",(IModFileProvider)mod)});ModAssert.Equal("en,ru",string.Join(",",result.Languages),"languages from file names");ModAssert.True(result.Keys["ok"].Complete,"key is complete");ModAssert.Equal(0,result.InterestingKeys.Count(),"nothing to report");ModAssert.Equal(0,result.Errors.Count(),"no errors");}[ModTest("map/missing_language_points_to_sibling_file")]static void MissingLanguagePointsToSibling(ModTestContext ctx){var mod=new MemoryProvider("m").Add("locale/ui_en.properties","ui/only_en = Only English\nui/both = Both\n").Add("locale/ui_ru.properties","ui/both = Оба\nui/only_ru = Только русский\n");var result=LocaleAtlasBuilder.Build(new[]{("m",(IModFileProvider)mod)});var onlyEn=result.Findings.Single(f=>f.Key=="ui/only_en");ModAssert.Equal("key_missing_language",onlyEn.Code,"only_en code");ModAssert.Equal("ru",onlyEn.Language,"only_en lacks ru");ModAssert.True(onlyEn.Fix.StartsWith("locale/ui_ru.properties: ui/only_en = ",StringComparison.Ordinal),"fix goes to the sibling file: "+onlyEn.Fix);var onlyRu=result.Findings.Single(f=>f.Key=="ui/only_ru");ModAssert.Equal("en",onlyRu.Language,"only_ru lacks en");ModAssert.Equal(4,result.InterestingKeys.Count(),"two keys + mod_m_name/_desc");var modName=result.Findings.Where(f=>f.Key=="mod_m_name").ToList();ModAssert.Equal(2,modName.Count,"manifest name missing in both languages");ModAssert.True(modName.All(f=>f.Code=="key_undeclared_optional"&&f.Severity==LocaleSeverity.Warning),"optional: manifest text is the fallback");}[ModTest("map/mods_without_locale_skip_manifest_keys")]static void ModsWithoutLocaleSkipManifestKeys(ModTestContext ctx){var localized=new MemoryProvider("a").Add("locale/en.properties","mod_a_name = A\nmod_a_desc = A\n").Add("locale/ru.properties","mod_a_name = А\nmod_a_desc = А\n");var bare=new MemoryProvider("b").Add("manifest.json","{ \"id\": \"b\" }");var result=LocaleAtlasBuilder.Build(new[]{("a",(IModFileProvider)localized),("b",bare)});ModAssert.False(result.Keys.ContainsKey("mod_b_name"),"a mod without locale files is not asked for keys");ModAssert.Equal(0,result.Findings.Count,"clean");}[ModTest("map/undeclared_empty_and_duplicate")]static void UndeclaredEmptyAndDuplicate(ModTestContext ctx){var mod=new MemoryProvider("m").Add("locale/en.properties","a/empty = \na/dup = One\n").Add("locale/scenarios_en.properties","a/dup = Two\n").Add("locale/ru.properties","a/empty = Пусто\na/dup = Один\n").Add("scenarios/s.scenario","text \"Привет\" key=a/missing\noption \"Hi \\\"there\\\"\" key=a/dup -> :x\n").Add("src/Code.cs","class C { string s = Tr(\"ui/from_code\"); // Tr(\"ui/commented\")\n}\n").Add("items/i.json","{ \"Name\": \"items/x/name\", \"Image\": \"items/x.png\" }");var result=LocaleAtlasBuilder.Build(new[]{("m",(IModFileProvider)mod)});var missing=result.Findings.Where(f=>f.Key=="a/missing").ToList();ModAssert.Equal(2,missing.Count,"undeclared in both languages");ModAssert.True(missing.All(f=>f.Code=="key_undeclared"),"undeclared code");ModAssert.Equal("locale/scenarios_ru.properties: a/missing = Привет",missing.Single(f=>f.Language=="ru").Fix,"ru fix reuses the Russian literal");ModAssert.Equal("locale/scenarios_en.properties: a/missing = …",missing.Single(f=>f.Language=="en").Fix,"en fix needs a translation");ModAssert.True(result.Findings.Any(f=>f.Key=="a/empty"&&f.Code=="key_empty"&&f.Language=="en"),"empty value");ModAssert.True(result.Findings.Any(f=>f.Key=="a/dup"&&f.Code=="key_conflict"),"conflicting duplicate");ModAssert.True(result.Keys.ContainsKey("ui/from_code"),"code reference");ModAssert.False(result.Keys.ContainsKey("ui/commented"),"commented code is ignored");ModAssert.True(result.Keys.ContainsKey("items/x/name"),"json text field");ModAssert.False(result.Keys.ContainsKey("items/x.png"),"paths are not keys");ModAssert.Equal("Hi \"there\"",result.Keys["a/dup"].References.Single().Fallback,"escaped literal");}[ModTest("map/partial_override_is_a_warning")]static void PartialOverrideIsWarning(ModTestContext ctx){var baseMod=new MemoryProvider("base").Add("locale/en.properties","k = Base\n").Add("locale/ru.properties","k = База\n");var overrideMod=new MemoryProvider("over").Add("locale/en.properties","k = Override\n");var result=LocaleAtlasBuilder.Build(new[]{("base",(IModFileProvider)baseMod),("over",overrideMod)});var finding=result.Findings.Single(f=>f.Key=="k");ModAssert.Equal("key_partial_override",finding.Code,"code");ModAssert.True(finding.Severity==LocaleSeverity.Warning,"warning, ru still comes from base");ModAssert.Equal("over",finding.ModId,"owner");}[ModTest("map/collection_texts_need_every_language")]static void CollectionTextsNeedEveryLanguage(ModTestContext ctx){var package=new MemoryProvider("c").Add("locale/en.properties","x = X\n").Add("locale/ru.properties","x = Икс\n").Add("collection.json","{ \"id\": \"c\", \"name\": \"Game\", \"locale\": [ { \"lang\": \"ru\", \"name\": \"Игра\" } ],\n"+"  \"mechanics\": [ { \"id\": \"m1\", \"name\": \"Story\" }, { \"id\": \"m2\", \"name\": \"@x\" } ] }");var result=LocaleAtlasBuilder.Build(new[]{(LocaleAtlasBuilder.CollectionSourcePrefix+"c",(IModFileProvider)package)});var missing=result.Findings.Where(f=>f.Code=="collection_text_missing").ToList();ModAssert.Equal(1,missing.Count,"only the literal mechanic without ru: "+string.Join("; ",missing.Select(f=>f.Message)));ModAssert.Equal("m1",missing[0].Key,"mechanic id");ModAssert.True(result.Keys["x"].References.Any(r=>r.Via=="collection"),"@key is a reference");}/// <summary>In-memory mod: paths are mod-relative with '/' separators.</summary>
private sealed class MemoryProvider:IModFileProvider{private readonly Dictionary<string,byte[]>files=new(StringComparer.Ordinal);public MemoryProvider(string mod)=>Mod=mod;public string Mod{get;}public string RootPath=>null;public MemoryProvider Add(string path,string text){files[path]=Utf8.GetBytes(text);return this;}public bool FileExists(string path)=>files.ContainsKey(path);public IEnumerable<string>ListFiles(string directory=""){var prefix=string.IsNullOrEmpty(directory)?string.Empty:directory.TrimEnd('/')+"/";var seen=new HashSet<string>(StringComparer.Ordinal);foreach(var path in files.Keys.Where(p=>p.StartsWith(prefix,StringComparison.Ordinal))){var rest=path.Substring(prefix.Length);var slash=rest.IndexOf('/');var entry=slash<0?path:prefix+rest.Substring(0,slash+1);if(seen.Add(entry))yield return entry;}}public string ReadFileText(string path)=>files.TryGetValue(path,out var bytes)?Utf8.GetString(bytes):null;public byte[]ReadFileBinary(string path)=>files.TryGetValue(path,out var bytes)?bytes:null;public Stream OpenStream(string path)=>files.TryGetValue(path,out var bytes)?new MemoryStream(bytes):null;public string GetFileSystemPath(string path)=>null;public string ResolveIncludePath(string includePath,string currentPath)=>includePath;public void Dispose(){}}}}

===== FILE mods/map_base/src/Actions/MapCloseZoneAction.cs =====
using Cthangover.Core.Actions;namespace Cthangover.Mods.Map{/// <summary> Closes a map zone for travel by ID. Used for zones that were open by default but must become unavailable at some story point, or to take back access granted earlier. The override is stored by the map service and persisted in the save, so the closure survives scene changes and play sessions. </summary>
public class MapCloseZoneAction:IScenarioAction{/// <summary> Registered as "map.close_zone" — closes the zone named by the required "zone_id" dialog variable. Unknown IDs are ignored with a warning. </summary>
public string Name=>"map.close_zone";/// <summary> Reads "zone_id" and delegates to the map service. Missing "zone_id" is logged and the action does nothing instead of throwing inside dialog execution. </summary>
public void Run(IActionContext ctx){var zoneId=ctx.GetParam("zone_id");if(string.IsNullOrEmpty(zoneId)){ctx.Log("EVENT","MapCloseZoneAction: missing 'zone_id' variable");return;}ctx.Map.Close(zoneId);ctx.Log("MAP",$"MapCloseZoneAction: close '{zoneId}'");}}}

===== FILE mods/map_base/src/Actions/MapHideZoneAction.cs =====
using Cthangover.Core.Actions;namespace Cthangover.Mods.Map{/// <summary> Hides a map zone by ID, removing it from the map until it is shown again. Useful when a location is destroyed or temporarily inaccessible in the story and the marker should disappear rather than appear closed. The override is stored by the map service and persisted in the save. </summary>
public class MapHideZoneAction:IScenarioAction{/// <summary> Registered as "map.hide_zone" — hides the zone named by the required "zone_id" dialog variable. Unknown IDs are ignored with a warning. </summary>
public string Name=>"map.hide_zone";/// <summary> Reads "zone_id" and delegates to the map service. Missing "zone_id" is logged and the action does nothing instead of throwing inside dialog execution. </summary>
public void Run(IActionContext ctx){var zoneId=ctx.GetParam("zone_id");if(string.IsNullOrEmpty(zoneId)){ctx.Log("EVENT","MapHideZoneAction: missing 'zone_id' variable");return;}ctx.Map.Hide(zoneId);ctx.Log("MAP",$"MapHideZoneAction: hide '{zoneId}'");}}}

===== FILE mods/map_base/src/Actions/MapOpenZoneAction.cs =====
using Cthangover.Core.Actions;namespace Cthangover.Mods.Map{/// <summary> Opens a map zone for travel by ID. The counterpart of <see cref="MapCloseZoneAction"/>: authors close a zone in the map JSON (<c>"locked": true</c>) and then open it from a scenario when the story allows visiting it, e.g. after a quest tag is granted. The override is stored by the map service and persisted in the save, so the zone stays open across scene changes and play sessions. </summary>
public class MapOpenZoneAction:IScenarioAction{/// <summary> Registered as "map.open_zone" — opens the zone named by the required "zone_id" dialog variable. Unknown IDs are ignored with a warning, so a scenario can safely reference a zone added by another mod that may not be installed. </summary>
public string Name=>"map.open_zone";/// <summary> Reads "zone_id" and delegates to the map service. Missing "zone_id" is a script authoring error: it is logged and the action does nothing instead of throwing inside dialog execution. </summary>
public void Run(IActionContext ctx){var zoneId=ctx.GetParam("zone_id");if(string.IsNullOrEmpty(zoneId)){ctx.Log("EVENT","MapOpenZoneAction: missing 'zone_id' variable");return;}ctx.Map.Open(zoneId);ctx.Log("MAP",$"MapOpenZoneAction: open '{zoneId}'");}}}

===== FILE mods/map_base/src/Actions/MapShowZoneAction.cs =====
using Cthangover.Core.Actions;namespace Cthangover.Mods.Map{/// <summary> Reveals a hidden map zone by ID. Hidden zones are completely absent from the map — no polygon, no marker, no label, no zone counter entry — which lets authors keep late-game locations out of the early map without shipping separate map files. The override is stored by the map service and persisted in the save. </summary>
public class MapShowZoneAction:IScenarioAction{/// <summary> Registered as "map.show_zone" — reveals the zone named by the required "zone_id" dialog variable. Unknown IDs are ignored with a warning. </summary>
public string Name=>"map.show_zone";/// <summary> Reads "zone_id" and delegates to the map service. Missing "zone_id" is logged and the action does nothing instead of throwing inside dialog execution. </summary>
public void Run(IActionContext ctx){var zoneId=ctx.GetParam("zone_id");if(string.IsNullOrEmpty(zoneId)){ctx.Log("EVENT","MapShowZoneAction: missing 'zone_id' variable");return;}ctx.Map.Show(zoneId);ctx.Log("MAP",$"MapShowZoneAction: show '{zoneId}'");}}}

===== FILE mods/map_base/src/Actions/MapZoneServiceImpl.cs =====
using Cthangover.Core.Actions;namespace Cthangover.Mods.Map{/// <summary> Thin adapter from the scenario action layer to the process-wide <see cref="MapZoneService"/> singleton, following the same shape as <see cref="QuestServiceImpl"/>: the interface exists so <see cref="IActionContext"/> can be mocked in tests, while the actual state always lives in the singleton and therefore survives for the whole session and lands in the save through <c>RuntimeSerializer</c>. </summary>
public class MapZoneServiceImpl:IMapZoneService{/// <summary>True when the zone is currently open for travel.</summary>
public bool IsOpen(string id)=>MapZoneService.Instance.IsOpen(id);/// <summary>True when the zone is currently visible on the map.</summary>
public bool IsVisible(string id)=>MapZoneService.Instance.IsVisible(id);/// <summary>Opens the zone for travel (overrides the map JSON's "locked" flag; persisted in the save).</summary>
public void Open(string id)=>MapZoneService.Instance.Open(id);/// <summary>Closes the zone — the marker stays but travel is blocked (persisted in the save).</summary>
public void Close(string id)=>MapZoneService.Instance.Close(id);/// <summary>Reveals a hidden zone on the map (persisted in the save).</summary>
public void Show(string id)=>MapZoneService.Instance.Show(id);/// <summary>Hides a zone from the map entirely (persisted in the save).</summary>
public void Hide(string id)=>MapZoneService.Instance.Hide(id);}}

===== FILE mods/map_base/src/MapMarker.cs =====
using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Theming;namespace Cthangover.Mods.Map{/// <summary> One location marker on the world map: a round emblem with the zone icon, the localised name pill and an optional state note ("вы здесь" / "закрыто"). The node structure lives in <c>map_marker.tscn</c>; this class only binds zone data and repaints state-dependent styles from <see cref="UiTokens"/>. <para/> Visual state priority is current (green — the player's active scene) over selected (gold) over locked (dimmed, with a drawn padlock) over hover. The marker never navigates by itself: it reports presses and hover changes to <see cref="MapWidget"/>, which owns the selected zone, the location card and the actual scene switch. That keeps a marker reusable and the map behaviour in one place. </summary>
public partial class MapMarker:VBoxContainer{[Signal]public delegate void ZonePressedEventHandler(string zoneId);[Signal]public delegate void ZoneHoverChangedEventHandler(string zoneId,bool hovered);private const int EmblemRadius=23;private const float LockBadgeRadius=9f;private Control _emblemWrap;private Panel _emblem;private TextureRect _glyph;private Label _nameLabel;private Label _noteLabel;private string _zoneId;private bool _selected;private bool _current;private bool _locked;private bool _hover;/// <summary>Resolves the prefab nodes and subscribes pointer events; the marker never navigates on its own, it only reports to <see cref="MapWidget"/>.</summary>
public override void _Ready(){MouseFilter=MouseFilterEnum.Stop;ResolveNodes();GuiInput+=OnGuiInput;MouseEntered+=OnMouseEntered;MouseExited+=OnMouseExited;}/// <summary>Binds a zone to the marker; <paramref name="icon"/> is an already resolved marker texture.</summary>
public void Setup(MapZone zone,Texture2D icon,bool current,bool locked){_zoneId=zone.ID;_current=current;_locked=locked;_selected=false;_hover=false;if(_glyph!=null)_glyph.Texture=icon;if(_nameLabel!=null)_nameLabel.Text=TranslationServer.Translate(zone.Title);RefreshStyle();}/// <summary>Re-applies the dynamic zone state after the player moved to another scene.</summary>
public void ApplyState(bool current,bool locked){_current=current;_locked=locked;RefreshStyle();}/// <summary>Panel-owned selection: the selected marker gets the golden ring and surface.</summary>
public void SetSelected(bool selected){if(_selected==selected)return;_selected=selected;RefreshStyle();}/// <summary> Hover can come from this marker's own pointer events or from the paired polygon (<see cref="MapZonesLayer.SetHovered"/> mirror); the method only repaints and never re-emits, so the two layers cannot loop signals through each other. </summary>
public void SetHovered(bool hovered){if(_hover==hovered)return;_hover=hovered;RefreshStyle();}/// <summary> Draws the padlock badge of a closed zone over the emblem corner. A drawn badge is used instead of a texture so marker states need no extra icons from mods and stay sharp at any UI scale. </summary>
public override void _Draw(){if(!_locked||_emblemWrap==null)return;var wrap=_emblemWrap.GetRect();var center=new Vector2(wrap.End.X-3f,wrap.Position.Y+3f);var outline=UiTokens.BadgeLine(UiTokens.TypeEnemy);DrawCircle(center,LockBadgeRadius,UiTokens.Surface0);DrawArc(center,LockBadgeRadius,0f,Mathf.Tau,28,outline,1f,true);DrawRect(new Rect2(center.X-4.5f,center.Y-0.5f,9f,6.5f),UiTokens.TypeEnemy,false,1.2f);DrawArc(center+new Vector2(0f,-0.5f),3.2f,Mathf.Pi,Mathf.Tau,14,UiTokens.TypeEnemy,1.4f,true);}private void ResolveNodes(){_emblemWrap=GetNodeOrNull<Control>("EmblemWrap");_emblem=GetNodeOrNull<Panel>("EmblemWrap/Emblem");_glyph=GetNodeOrNull<TextureRect>("EmblemWrap/Emblem/Glyph");_nameLabel=GetNodeOrNull<Label>("NameLabel");_noteLabel=GetNodeOrNull<Label>("NoteLabel");}private void RefreshStyle(){var background=UiTokens.Surface0;var border=UiTokens.AccentLine;var glyphColor=UiTokens.AccentBright;var nameColor=UiTokens.Ink;var nameBorder=UiTokens.Line;var shadowColor=new Color(0f,0f,0f,0f);var shadowSize=0;if(_locked){border=UiTokens.LineDisabled;glyphColor=UiTokens.Faint;nameColor=UiTokens.Faint;nameBorder=UiTokens.LineDisabled;}if(_selected){background=UiTokens.SurfaceSelected;border=UiTokens.Accent;glyphColor=UiTokens.AccentHover;nameColor=UiTokens.AccentBright;nameBorder=UiTokens.AccentLine;shadowColor=UiTokens.Alpha(UiTokens.Accent,0.35f);shadowSize=6;}if(_current){background=UiTokens.Surface0;border=UiTokens.TypePassive;glyphColor=UiTokens.SuccessText;nameColor=UiTokens.SuccessText;nameBorder=UiTokens.BadgeLine(UiTokens.TypePassive);shadowColor=UiTokens.SuccessSoft;shadowSize=3;}else if(_hover&& !_selected&& !_locked){background=UiTokens.Surface1;border=UiTokens.AccentLineStrong;}if(_emblem!=null){var style=UiKit.Frame(EmblemRadius,background,border);if(shadowSize>0){style.ShadowColor=shadowColor;style.ShadowSize=shadowSize;}_emblem.AddThemeStyleboxOverride("panel",style);}if(_glyph!=null)_glyph.Modulate=glyphColor;if(_nameLabel!=null){var style=UiKit.Frame(UiTokens.RadiusXs,UiTokens.SlotTagBg,nameBorder);style.ContentMarginLeft=8;style.ContentMarginRight=8;style.ContentMarginTop=2;style.ContentMarginBottom=2;_nameLabel.AddThemeStyleboxOverride("normal",style);_nameLabel.AddThemeColorOverride("font_color",nameColor);_nameLabel.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);}ApplyNote();QueueRedraw();}private void ApplyNote(){if(_noteLabel==null)return;_noteLabel.Visible=_current||_locked;if(!_noteLabel.Visible)return;var color=_current?UiTokens.SuccessText:UiTokens.DangerText;var fill=_current?UiTokens.SuccessSoft:UiTokens.DangerSoft;var line=UiTokens.BadgeLine(_current?UiTokens.TypePassive:UiTokens.TypeEnemy);var style=UiKit.Frame(UiTokens.RadiusXs,fill,line);style.ContentMarginLeft=6;style.ContentMarginRight=6;style.ContentMarginTop=1;style.ContentMarginBottom=1;_noteLabel.AddThemeStyleboxOverride("normal",style);_noteLabel.AddThemeColorOverride("font_color",color);_noteLabel.AddThemeFontSizeOverride("font_size",UiTokens.FontMicro);_noteLabel.Text=TranslationServer.Translate(_current?"ui/map/you_are_here":"ui/map/closed_note").ToString();_noteLabel.Uppercase=true;}private void OnGuiInput(InputEvent@event){if(@event is not InputEventMouseButton button|| !button.Pressed||button.ButtonIndex!=MouseButton.Left)return;EmitSignal(SignalName.ZonePressed,_zoneId);AcceptEvent();}private void OnMouseEntered(){if(_hover)return;_hover=true;RefreshStyle();EmitSignal(SignalName.ZoneHoverChanged,_zoneId,true);}private void OnMouseExited(){if(!_hover)return;_hover=false;RefreshStyle();EmitSignal(SignalName.ZoneHoverChanged,_zoneId,false);}}}

===== FILE mods/map_base/src/MapModInitializer.cs =====
using Cthangover.Core.Actions;using Cthangover.Core.Mods;namespace Cthangover.Mods.Map{/// <summary> Wires the map subsystem into the kernel. On <c>OnModResourcesReady</c> it installs the zone-state hooks into <see cref="MapStateBridge"/> — export into the save's <c>__core__</c> slot, restore from a loaded save, and the new-game override reset — so the core save pipeline keeps working against the mod-owned <see cref="MapZoneService"/> singleton. </summary>
public class MapModInitializer:IModInitializer{/// <summary>No per-mod work: the bridge binding happens once the whole mod set is ready.</summary>
public void OnModLoaded(string modId){}/// <summary>Installs the zone-state hooks (first registration wins).</summary>
public void OnModResourcesReady(){var service=MapZoneService.Instance;MapStateBridge.Register(service.ExportState,service.ImportState,service.ResetOverrides);}}}

===== FILE mods/map_base/src/MapWidget.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Events;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Scenes;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.Mods.Map{/// <summary> Full-screen world map overlay toggled by the Map button in the TopView toolbar. The window structure lives in <c>map_widget.tscn</c>; this behaviour resolves the nodes, paints them with <see cref="UiTokens"/>, renders the zones held by <see cref="MapZoneService"/> and drives the interaction. <para/> The widget no longer reads map JSON itself: the zone list and the live open/hidden state belong to <see cref="MapZoneService"/>, so scenarios (<c>map.open_zone</c> and friends), save loading and the map editor all affect the same state, and the map simply redraws from it. All coordinates are normalised [0..1] against the displayed background rectangle, which keeps the layout resolution-independent. <para/> Two layers compose a zone: the polygon drawn by <see cref="MapZonesLayer"/> is the generous click target, and the icon produced by the <c>map_marker.tscn</c> prefab is the readable pinned location. A zone with a <c>label_pos</c> but no icon is a pure inscription — it is drawn as text, has no click target and is excluded from the zone counter. Zones whose runtime state is hidden are absent from both layers entirely; that is how scenarios keep late-game locations off the map. A zone whose scene is empty or unknown stays closed — it remains selectable so the card can explain the block, but its marker is dimmed and the transition is disabled. The zone matching the active scene is marked "вы здесь" and cannot be entered again. <para/> The map closes on Escape, on the header close button, and automatically after a successful transition. Zone state is recomputed on every <see cref="ShowConstruct"/> and the widget also subscribes to <see cref="MapZoneChangedEvent"/>, so a zone opened by a scenario while the map is open updates live. <see cref="Reload"/> re-reads the raw zone data without losing runtime overrides and is used by the map editor. <see cref="SetPreviewZones"/> turns the widget into a game-like preview of unsaved editor data: zones and their authoring flags come from the caller instead of the service, while hover, selection, the location card and markers behave exactly as in the live map. </summary>
public partial class MapWidget:Widget{private const float MapMarginPercent=0.08f;private const string MarkerScenePath="scenes/ui/map_marker.tscn";private static readonly Color Transparent=new(0f,0f,0f,0f);private readonly Dictionary<string,MapMarker>_markers=new();private readonly List<Control>_labels=new();private ModScenePrefab _markerPrefab;private Texture2D _mapTexture;private MapZone _selectedZone;private IReadOnlyList<MapZone>_previewZones;private ColorRect _backdrop;private Panel _sheet;private TextureRect _mapImage;private Panel _frame;private MapZonesLayer _zonesLayer;private Control _markersLayer;private VBoxContainer _hud;private Label _hudTitle;private Label _hudSub;private Button _closeButton;private PanelContainer _legend;private Panel _legendCurrentDot;private Panel _legendAvailableDot;private Panel _legendLockedDot;private Label _legendCurrentText;private Label _legendAvailableText;private Label _legendLockedText;private PanelContainer _hint;private Label _hintLabel;private PanelContainer _card;private Panel _cardEmblem;private TextureRect _cardGlyph;private Label _cardName;private Label _cardSub;private HBoxContainer _cardBadges;private Label _cardDesc;private Panel _cardRule;private Label _cardCaption;private Label _cardHint;private Label _cardMessage;private Button _goButton;private CenterContainer _emptyState;private Label _emptyTitle;private Label _emptyHint;/// <summary>Resolves nodes, applies styles and texts, and subscribes to zone changes, close/go buttons and layer signals.</summary>
public override void _Ready(){base._Ready();MouseFilter=MouseFilterEnum.Stop;_markerPrefab=new ModScenePrefab(MarkerScenePath);ResolveNodes();ApplyStaticStyles();ApplyStaticTexts();if(_emptyState!=null)MoveChild(_emptyState,GetChildCount()-1);ModEventBus.Subscribe<MapZoneChangedEvent>(OnZoneStateChanged);if(_closeButton!=null)_closeButton.Pressed+=OnClosePressed;if(_goButton!=null)_goButton.Pressed+=OnGoPressed;if(_zonesLayer!=null){_zonesLayer.ZoneClicked+=OnZoneClicked;_zonesLayer.ZoneHovered+=OnZoneHovered;}}/// <summary>Unsubscribes from <see cref="MapZoneChangedEvent"/> so a closed map does not rebuild pointlessly.</summary>
public override void _ExitTree(){ModEventBus.Unsubscribe<MapZoneChangedEvent>(OnZoneStateChanged);base._ExitTree();}protected override void OnceConstruct(){if(_previewZones==null)MapZoneService.Instance.EnsureLoaded();LoadMapBackground();if(_mapTexture==null){UpdateHud();return;}RebuildAll();}protected override void ShowConstruct(){ClearSelection();LayoutMap();RebuildAll();}protected override void HideDestruct(){ClearSelection();}/// <summary>Closes the map on Escape while visible, consuming the input so it does not reach the scene underneath.</summary>
public override void _Input(InputEvent@event){if(!Visible)return;if(@event is InputEventKey key&&key.Pressed&&key.Keycode==Key.Escape){Hide();GetViewport().SetInputAsHandled();}}/// <summary> Re-reads the raw map data through <see cref="MapZoneService"/> while keeping runtime overrides, then rebuilds every layer. Used by the map editor after saving so the running game reflects the edit without a restart; safe to call while the map is hidden. </summary>
public void Reload(){MapZoneService.Instance.Reload();if(_mapTexture==null)LoadMapBackground();RebuildAll();}/// <summary> Switches the widget into authoring preview mode used by the map editor: the supplied zone list replaces <see cref="MapZoneService"/> data, and the initial <see cref="MapZone.Locked"/> / <see cref="MapZone.Hidden"/> flags are applied directly, without runtime overrides. Nothing is written back to the service or the save, so the preview can show unsaved editor state next to the live game without disturbing either. </summary>
public void SetPreviewZones(IReadOnlyList<MapZone>zones){_previewZones=zones;if(!Visible||_mapTexture==null||_zonesLayer==null)return;RebuildAll();}private void OnZoneStateChanged(MapZoneChangedEvent@event){if(!Visible||_mapTexture==null)return;RebuildAll();}private void RebuildAll(){if(_zonesLayer==null||_markersLayer==null)return;ClearOverlay();var zones=ZoneSource;foreach(var zone in zones){if(!IsVisible(zone))continue;_zonesLayer.AddZone(zone.ID,zone.Points,IsLocked(zone));}foreach(var zone in zones){if(!IsVisible(zone))continue;if(!string.IsNullOrEmpty(zone.Image))BuildMarker(zone);else if(zone.LabelPos.HasValue&& !string.IsNullOrEmpty(zone.Title))BuildLabel(zone);}RestoreSelection();UpdateHud();}private void ClearOverlay(){foreach(var marker in _markers.Values){marker.ZonePressed-=OnMarkerPressed;marker.ZoneHoverChanged-=OnMarkerHoverChanged;marker.QueueFree();}_markers.Clear();foreach(var label in _labels)label.QueueFree();_labels.Clear();_zonesLayer.ClearZones();}private void BuildMarker(MapZone zone){var icon=Textures.Resolve(zone.Image);if(icon==null){GameLogger.Log("MAP",$"marker icon '{zone.Image}' not found for zone '{zone.ID}'",LogLevel.Warning);return;}var marker=_markerPrefab.Instantiate<MapMarker>();if(marker==null){GameLogger.Log("MAP",$"failed to instantiate marker for zone '{zone.ID}'",LogLevel.Error);return;}_markersLayer.AddChild(marker);marker.Setup(zone,icon,IsCurrent(zone),IsLocked(zone));marker.ZonePressed+=OnMarkerPressed;marker.ZoneHoverChanged+=OnMarkerHoverChanged;marker.Size=marker.GetCombinedMinimumSize();marker.Position=zone.ImagePos*_sheet.Size-marker.Size/2f;_markers[zone.ID]=marker;}/// <summary> Creates the free-standing text inscription of a zone that has a <c>label_pos</c> but no marker icon. Labels use the same pill styling as marker names but are deliberately non-interactive: they name regions, they are not locations. </summary>
private void BuildLabel(MapZone zone){var label=new Label{Text=Translate(zone.Title),MouseFilter=MouseFilterEnum.Ignore,HorizontalAlignment=HorizontalAlignment.Center};var style=UiKit.Frame(UiTokens.RadiusXs,UiTokens.SlotTagBg,UiTokens.Line);style.ContentMarginLeft=10;style.ContentMarginRight=10;style.ContentMarginTop=3;style.ContentMarginBottom=3;label.AddThemeStyleboxOverride("normal",style);label.AddThemeColorOverride("font_color",UiTokens.Ink);label.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);_markersLayer.AddChild(label);label.Size=label.GetCombinedMinimumSize();label.Position=zone.LabelPos.Value*_sheet.Size-label.Size/2f;_labels.Add(label);}/// <summary> Re-applies the selected-zone highlight after the layers were rebuilt from scratch, and clears the card when the selected zone became hidden (or disappeared after a reload). </summary>
private void RestoreSelection(){if(_selectedZone==null)return;if(!IsVisible(_selectedZone)){ClearSelection();return;}_zonesLayer.SetSelected(_selectedZone.ID);foreach(var pair in _markers)pair.Value.SetSelected(pair.Key==_selectedZone.ID);}private void ClearSelection(){_selectedZone=null;_zonesLayer?.SetSelected(null);foreach(var pair in _markers)pair.Value.SetSelected(false);if(_card!=null)_card.Visible=false;}private void ResolveNodes(){_backdrop=GetNodeOrNull<ColorRect>("Bg");_sheet=GetNodeOrNull<Panel>("MapRoot/Sheet");_mapImage=GetNodeOrNull<TextureRect>("MapRoot/Sheet/MapImage");_frame=GetNodeOrNull<Panel>("MapRoot/Sheet/Frame");_zonesLayer=GetNodeOrNull<MapZonesLayer>("MapRoot/Sheet/ZonesLayer");_markersLayer=GetNodeOrNull<Control>("MapRoot/Sheet/MarkersLayer");_hud=GetNodeOrNull<VBoxContainer>("Hud");_hudTitle=GetNodeOrNull<Label>("Hud/Title");_hudSub=GetNodeOrNull<Label>("Hud/Sub");_closeButton=GetNodeOrNull<Button>("MapRoot/Sheet/CloseButton");_legend=GetNodeOrNull<PanelContainer>("Legend");_legendCurrentDot=GetNodeOrNull<Panel>("Legend/Items/Current/Dot");_legendCurrentText=GetNodeOrNull<Label>("Legend/Items/Current/Text");_legendAvailableDot=GetNodeOrNull<Panel>("Legend/Items/Available/Dot");_legendAvailableText=GetNodeOrNull<Label>("Legend/Items/Available/Text");_legendLockedDot=GetNodeOrNull<Panel>("Legend/Items/Locked/Dot");_legendLockedText=GetNodeOrNull<Label>("Legend/Items/Locked/Text");_hint=GetNodeOrNull<PanelContainer>("Hint");_hintLabel=GetNodeOrNull<Label>("Hint/Label");_card=GetNodeOrNull<PanelContainer>("ZoneCard");_cardEmblem=GetNodeOrNull<Panel>("ZoneCard/Column/Head/Emblem");_cardGlyph=GetNodeOrNull<TextureRect>("ZoneCard/Column/Head/Emblem/Glyph");_cardName=GetNodeOrNull<Label>("ZoneCard/Column/Head/Info/Name");_cardSub=GetNodeOrNull<Label>("ZoneCard/Column/Head/Info/Sub");_cardBadges=GetNodeOrNull<HBoxContainer>("ZoneCard/Column/Badges");_cardDesc=GetNodeOrNull<Label>("ZoneCard/Column/Desc");_cardRule=GetNodeOrNull<Panel>("ZoneCard/Column/Rule");_cardCaption=GetNodeOrNull<Label>("ZoneCard/Column/Caption");_cardHint=GetNodeOrNull<Label>("ZoneCard/Column/CardHint");_cardMessage=GetNodeOrNull<Label>("ZoneCard/Column/Message");_goButton=GetNodeOrNull<Button>("ZoneCard/Column/GoButton");_emptyState=GetNodeOrNull<CenterContainer>("EmptyState");_emptyTitle=GetNodeOrNull<Label>("EmptyState/EmptyBox/Title");_emptyHint=GetNodeOrNull<Label>("EmptyState/EmptyBox/Hint");}private void ApplyStaticStyles(){if(_backdrop!=null)UiKit.ApplyPanelBackdrop(_backdrop);if(_sheet!=null)_sheet.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusMd,UiTokens.Paper,UiTokens.PaperLine));if(_frame!=null)_frame.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusXs,Transparent,UiTokens.PaperInset));if(_hudTitle!=null){_hudTitle.AddThemeFontSizeOverride("font_size",UiTokens.FontTitle);_hudTitle.AddThemeColorOverride("font_color",UiTokens.InkBright);}if(_hudSub!=null){_hudSub.AddThemeFontSizeOverride("font_size",UiTokens.FontBody);_hudSub.AddThemeColorOverride("font_color",UiTokens.Muted);}if(_closeButton!=null){_closeButton.Text="✕";CthangoverTheme.ApplyIcon(_closeButton);}if(_goButton!=null)CthangoverTheme.ApplyPrimary(_goButton);StyleLegend();StyleHint();StyleCard();StyleEmptyState();}private void StyleLegend(){if(_legend!=null){var style=UiKit.Frame(UiTokens.RadiusMd,UiTokens.Surface2,UiTokens.Line);style.ContentMarginLeft=14;style.ContentMarginRight=14;style.ContentMarginTop=10;style.ContentMarginBottom=10;_legend.AddThemeStyleboxOverride("panel",style);}StyleLegendItem(_legendCurrentDot,_legendCurrentText,UiTokens.TypePassive,UiTokens.SuccessSoft);StyleLegendItem(_legendAvailableDot,_legendAvailableText,UiTokens.Accent,UiTokens.AccentSoft);StyleLegendItem(_legendLockedDot,_legendLockedText,Transparent,Transparent);if(_legendLockedDot!=null)_legendLockedDot.AddThemeStyleboxOverride("panel",UiKit.Frame(5,Transparent,UiTokens.LineFaint));}private static void StyleLegendItem(Panel dot,Label text,Color dotColor,Color ringColor){if(dot!=null){dot.CustomMinimumSize=new Vector2(10f,10f);var style=UiKit.Frame(5,dotColor);style.ShadowColor=ringColor;style.ShadowSize=2;dot.AddThemeStyleboxOverride("panel",style);}if(text!=null){text.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);text.AddThemeColorOverride("font_color",UiTokens.Muted);}}private void StyleHint(){if(_hint!=null){var style=UiKit.Frame(UiTokens.RadiusSm,UiTokens.SlotTagBg,UiTokens.Line);style.ContentMarginLeft=14;style.ContentMarginRight=14;style.ContentMarginTop=8;style.ContentMarginBottom=8;_hint.AddThemeStyleboxOverride("panel",style);}if(_hintLabel!=null){_hintLabel.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);_hintLabel.AddThemeColorOverride("font_color",UiTokens.Muted);}}private void StyleCard(){if(_card!=null){var style=UiKit.Frame(UiTokens.RadiusMd,UiTokens.SurfaceRaised,UiTokens.Line);style.ShadowColor=new Color(0f,0f,0f,0.5f);style.ShadowSize=12;style.ShadowOffset=new Vector2(0f,5f);style.ContentMarginLeft=14;style.ContentMarginRight=14;style.ContentMarginTop=14;style.ContentMarginBottom=14;_card.AddThemeStyleboxOverride("panel",style);}if(_cardName!=null){_cardName.AddThemeFontSizeOverride("font_size",UiTokens.FontH2);_cardName.AddThemeColorOverride("font_color",UiTokens.InkBright);}if(_cardSub!=null){_cardSub.AddThemeFontSizeOverride("font_size",UiTokens.FontMicro);_cardSub.AddThemeColorOverride("font_color",UiTokens.Faint);}if(_cardDesc!=null){_cardDesc.AddThemeFontSizeOverride("font_size",UiTokens.FontBody);_cardDesc.AddThemeColorOverride("font_color",UiTokens.Body);}if(_cardRule!=null)_cardRule.AddThemeStyleboxOverride("panel",UiKit.Frame(0,UiTokens.Line));if(_cardCaption!=null){_cardCaption.AddThemeFontSizeOverride("font_size",UiTokens.FontTiny);_cardCaption.AddThemeColorOverride("font_color",UiTokens.AccentDim);}if(_cardHint!=null){_cardHint.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);_cardHint.AddThemeColorOverride("font_color",UiTokens.Faint);}if(_cardMessage!=null){var style=UiKit.Frame(UiTokens.RadiusSm,UiTokens.DangerSoft,UiTokens.Danger);style.ContentMarginLeft=12;style.ContentMarginRight=12;style.ContentMarginTop=8;style.ContentMarginBottom=8;_cardMessage.AddThemeStyleboxOverride("normal",style);_cardMessage.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);_cardMessage.AddThemeColorOverride("font_color",UiTokens.DangerText);}}private void StyleEmptyState(){if(_emptyTitle!=null){_emptyTitle.AddThemeFontSizeOverride("font_size",UiTokens.FontBodyLarge);_emptyTitle.AddThemeColorOverride("font_color",UiTokens.Faint);}if(_emptyHint!=null){_emptyHint.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);_emptyHint.AddThemeColorOverride("font_color",UiTokens.Faint);}}private void ApplyStaticTexts(){if(_hudTitle!=null)_hudTitle.Text=Translate("ui/map/title").ToUpperInvariant();if(_hudSub!=null)_hudSub.Text=Translate("ui/map/subtitle");if(_closeButton!=null)_closeButton.TooltipText=Translate("ui/map/close");if(_legendCurrentText!=null)_legendCurrentText.Text=Translate("ui/map/legend_current");if(_legendAvailableText!=null)_legendAvailableText.Text=Translate("ui/map/legend_available");if(_legendLockedText!=null)_legendLockedText.Text=Translate("ui/map/legend_locked");if(_hintLabel!=null)_hintLabel.Text=Translate("ui/map/hint");if(_cardCaption!=null)_cardCaption.Text=Translate("ui/map/transition").ToUpperInvariant();if(_cardHint!=null)_cardHint.Text=Translate("ui/map/card_hint");if(_cardMessage!=null)_cardMessage.Text=Translate("ui/map/locked_message");if(_emptyTitle!=null)_emptyTitle.Text=Translate("ui/map/empty");if(_emptyHint!=null)_emptyHint.Text=Translate("ui/map/empty_hint");}private void LoadMapBackground(){_mapTexture=BackgroundFactory.Instance.Get("map");if(_mapTexture==null){GameLogger.Log("MAP","map texture not found, showing empty state");ShowEmptyState(true);LayoutMap();return;}if(_mapImage!=null)_mapImage.Texture=_mapTexture;ShowEmptyState(false);LayoutMap();}private void ShowEmptyState(bool empty){if(_mapImage!=null)_mapImage.Visible= !empty;if(_zonesLayer!=null)_zonesLayer.Visible= !empty;if(_markersLayer!=null)_markersLayer.Visible= !empty;if(_emptyState!=null)_emptyState.Visible=empty;if(_legend!=null)_legend.Visible= !empty;if(_hint!=null)_hint.Visible= !empty;if(_card!=null)_card.Visible=false;}private void LayoutMap(){if(_sheet==null)return;var viewportSize=GetViewportRect().Size;var available=viewportSize*(1f-2f*MapMarginPercent);Vector2 displaySize;if(_mapTexture==null){displaySize=available;}else{var textureSize=_mapTexture.GetSize();if(textureSize.X<=0f||textureSize.Y<=0f){GameLogger.Log("MAP",$"invalid map texture size: {textureSize}",LogLevel.Error);_sheet.Position=viewportSize*MapMarginPercent;_sheet.Size=available;return;}var mapRatio=textureSize.X/textureSize.Y;var availableRatio=available.X/available.Y;if(mapRatio>availableRatio){displaySize.X=available.X;displaySize.Y=available.X/mapRatio;}else{displaySize.Y=available.Y;displaySize.X=available.Y*mapRatio;}}_sheet.Position=(viewportSize-displaySize)/2f;_sheet.Size=displaySize;}private void UpdateHud(){if(_hudSub==null)return;var count=0;foreach(var zone in ZoneSource){if(!IsVisible(zone))continue;if(IsLocation(zone))count++;}_hudSub.Text=$"{Translate("ui/map/subtitle")} · {string.Format(Translate("ui/map/zone_count"), count)}";}/// <summary> A "location" is anything the player can see as a place to travel to: a polygon region or a marker icon. Pure text inscriptions are not locations and are not counted in the HUD. </summary>
private static bool IsLocation(MapZone zone){return zone.Points!=null&&zone.Points.Count>=3|| !string.IsNullOrEmpty(zone.Image);}/// <summary> The zone list the widget currently renders: the preview list in authoring preview mode, otherwise the live <see cref="MapZoneService"/> data. </summary>
private IReadOnlyList<MapZone>ZoneSource=>_previewZones??MapZoneService.Instance.Zones;/// <summary> Effective visibility of a single zone: the authoring flag in preview mode (no runtime overrides), otherwise the service state. </summary>
private bool IsVisible(MapZone zone){return _previewZones==null?MapZoneService.Instance.IsVisible(zone.ID):!zone.Hidden;}/// <summary> Effective open state of a single zone: the authoring flag in preview mode, otherwise the service state. Whether the zone is actually enterable still depends on its scene resolving — see <see cref="IsLocked"/>. </summary>
private bool IsOpen(MapZone zone){return _previewZones==null?MapZoneService.Instance.IsOpen(zone.ID):!zone.Locked;}/// <summary>Turns a zone ID coming from a marker or polygon signal back into the rendered zone.</summary>
private MapZone FindZone(string id){if(_previewZones==null)return MapZoneService.Instance.Get(id);foreach(var zone in _previewZones){if(string.Equals(zone.ID,id,StringComparison.OrdinalIgnoreCase))return zone;}return null;}private static bool IsCurrent(MapZone zone){var current=SceneManager.GetCurrentSceneName();if(string.IsNullOrEmpty(current))return false;return string.Equals(zone.Scene,current,StringComparison.OrdinalIgnoreCase)||string.Equals(zone.ID,current,StringComparison.OrdinalIgnoreCase);}private bool IsLocked(MapZone zone){return MapZoneRules.IsLocked(zone,IsOpen(zone));}private void OnZoneClicked(string zoneId){SelectZone(FindZone(zoneId));}private void OnMarkerPressed(string zoneId){SelectZone(FindZone(zoneId));}private void OnZoneHovered(string zoneId,bool hovered){if(_markers.TryGetValue(zoneId,out var marker))marker.SetHovered(hovered);}private void OnMarkerHoverChanged(string zoneId,bool hovered){_zonesLayer?.SetHovered(zoneId,hovered);}private void SelectZone(MapZone zone){if(zone==null)return;_selectedZone=zone;_zonesLayer?.SetSelected(zone.ID);foreach(var pair in _markers)pair.Value.SetSelected(pair.Key==zone.ID);RefreshCard(zone);}private void RefreshCard(MapZone zone){if(_card==null)return;var current=IsCurrent(zone);var locked=IsLocked(zone);if(_cardName!=null)_cardName.Text=Translate(zone.Title);if(_cardSub!=null)_cardSub.Text=string.IsNullOrEmpty(zone.Scene)?zone.ID:$"scene: {zone.Scene}";if(_cardDesc!=null){_cardDesc.Visible= !string.IsNullOrEmpty(zone.Description);_cardDesc.Text=_cardDesc.Visible?Translate(zone.Description):string.Empty;}if(_cardMessage!=null)_cardMessage.Visible=locked&& !current;RebuildCardBadges(current,locked);ApplyCardEmblem(zone,current,locked);if(_goButton!=null){_goButton.Disabled=current||locked;_goButton.Text=current?Translate("ui/map/here"):Translate("ui/map/go");}_card.Visible=true;}private void RebuildCardBadges(bool current,bool locked){if(_cardBadges==null)return;ClearChildren(_cardBadges);if(current)_cardBadges.AddChild(UiKit.Badge(Translate("ui/map/current"),UiTokens.TypePassive));else if(locked)_cardBadges.AddChild(UiKit.Badge(Translate("ui/map/locked"),UiTokens.TypeEnemy));else _cardBadges.AddChild(UiKit.Badge(Translate("ui/map/available"),UiTokens.Accent));}private void ApplyCardEmblem(MapZone zone,bool current,bool locked){if(_cardEmblem==null)return;var accent=current?UiTokens.TypePassive:locked?UiTokens.TypeEnemy:UiTokens.Accent;_cardEmblem.AddThemeStyleboxOverride("panel",UiKit.Frame(29,UiTokens.Surface2,UiTokens.BadgeLine(accent)));if(_cardGlyph==null)return;_cardGlyph.Texture=string.IsNullOrEmpty(zone.Image)?null:Textures.Resolve(zone.Image);_cardGlyph.Modulate=current?UiTokens.SuccessText:locked?UiTokens.Faint:UiTokens.AccentBright;}private void OnGoPressed(){var zone=_selectedZone;if(zone==null||string.IsNullOrEmpty(zone.Scene)||IsCurrent(zone)||IsLocked(zone))return;var sceneManager=SceneContextNode.FindNode<SceneManager>("SceneManager");if(sceneManager==null){GameLogger.Log("MAP","SceneManager not found, cannot switch scene",LogLevel.Error);return;}GameLogger.Log("MAP",$"navigating to scene '{zone.Scene}' from zone '{zone.ID}'");sceneManager.SwitchScene(zone.Scene);Hide();}private void OnClosePressed(){Hide();}private static void ClearChildren(Node parent){if(parent==null)return;foreach(var child in parent.GetChildren())child.QueueFree();}private static string Translate(string key){return string.IsNullOrEmpty(key)?string.Empty:TranslationServer.Translate(key).ToString();}}}

===== FILE mods/map_base/src/MapZone.cs =====
using System.Collections.Generic;using System.Text.Json.Serialization;using Cthangover.Core.Factories;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Mods.Map{/// <summary> Describes one interactive zone on the world map. Defines its bounding polygon (for a semi-transparent highlight), a clickable marker icon, a localised title label, and the target scene that opens when clicked. All coordinate properties (<see cref="Points"/> and <see cref="ImagePos"/>) use normalised values in the [0.0 .. 1.0] range, relative to the map background image dimensions. This makes zone layouts resolution-independent and safe to share across mods. Maps use the standard <c>{"Items": [...]}</c> JSON envelope in <c>mods/&lt;name&gt;/map/</c>. Zones are read per mod in load order, so later mods can override zones by <see cref="ID"/>. <para/> This type is raw authoring data only. The live open/hidden state of a zone is owned by <see cref="MapZoneService"/>, which seeds itself from <see cref="Locked"/>/<see cref="Hidden"/> and lets scenarios and code change it at runtime; only those runtime overrides are saved. </summary>
public class MapZone:IIdentifiable{/// <summary> Unique zone identifier used as the dictionary key in factory collections. Typically equals the target scene name for clarity. </summary>
[JsonPropertyName("ID")]public string ID{get;set;}/// <summary> Polygon vertices in normalised map coordinates [0..1]. Drawn as a semi-transparent colour wash over the map background to visually outline the zone. </summary>
[JsonPropertyName("points")][JsonConverter(typeof(Vector2ListJsonConverter))]public List<Vector2>Points{get;set;}=new();/// <summary> Localisation key for the zone name displayed next to the marker. Resolved via <c>TranslationServer.Translate</c>. </summary>
[JsonPropertyName("title")]public string Title{get;set;}/// <summary> Target scene identifier passed directly to <c>SceneManager.SwitchScene</c> when the marker is clicked. Causes the current visual-novel scene to transition to this zone. An empty or unresolved scene marks the zone as closed: the map shows its marker and polygon dimmed and blocks the transition button, which lets authors sketch future regions without breaking navigation. </summary>
[JsonPropertyName("scene")]public string Scene{get;set;}/// <summary> Optional localisation key for the flavour text shown in the location card. Zones without a description show only the state badge and the transition button, so map data can stay minimal. </summary>
[JsonPropertyName("description")]public string Description{get;set;}/// <summary> Asset ID of the marker icon texture, resolved through the mod texture system (e.g. <c>ModManager.ResolveTexture</c>). </summary>
[JsonPropertyName("image")]public string Image{get;set;}/// <summary> Normalised [0..1] position of the marker icon on the map image. Stored as <c>[x, y]</c> in JSON, deserialized into a <see cref="Vector2"/>. </summary>
[JsonPropertyName("image_pos")][JsonConverter(typeof(Vector2JsonConverter))]public Vector2 ImagePos{get;set;}/// <summary> Normalised [0..1] position of a free-standing text label drawn directly on the map. <c>null</c> (absent in JSON) means the zone has no label. A zone with a label but no <see cref="Image"/> is a pure inscription — it is not a clickable location, does not open the location card and is excluded from the zone counter. Used for region names that have no icon of their own. </summary>
[JsonPropertyName("label_pos")][JsonConverter(typeof(Vector2NullableJsonConverter))]public Vector2?LabelPos{get;set;}/// <summary> Authoring default for the zone being closed to travel. <c>false</c> (the default) means the zone is enterable as soon as its scene resolves. Runtime state is owned by <see cref="MapZoneService"/>: scenarios open and close zones through the <c>map.open_zone</c> / <c>map.close_zone</c> actions, and those overrides are persisted in the save, while this field only seeds the state of an untouched zone. </summary>
[JsonPropertyName("locked")]public bool Locked{get;set;}/// <summary> Authoring default for the zone being completely hidden from the map. <c>false</c> (the default) draws the zone. Hidden zones are absent from the map until a scenario reveals them with the <c>map.show_zone</c> action; like <see cref="Locked"/>, this is only the initial value and the runtime override lives in <see cref="MapZoneService"/>. </summary>
[JsonPropertyName("hidden")]public bool Hidden{get;set;}}}

===== FILE mods/map_base/src/MapZoneRules.cs =====
using Cthangover.Core.Scenes;using Cthangover.Core.UI;namespace Cthangover.Mods.Map{/// <summary> Shared rule that decides whether a map zone is actually enterable. Lives outside <see cref="MapWidget"/> because three views must agree on what "closed" means: the live map, the map editor's game-like preview and the map editor canvas (plus its availability hint). <para/> The authoring flag alone is not the whole story: a zone with an empty or unregistered target scene is closed in the running game no matter what the flag says, because authors routinely sketch regions before the target scene exists and travelling must stay blocked. Without this shared helper the editor showed such zones as open while the player blocked them — the editor must surface the same result the player gets. </summary>
public static class MapZoneRules{/// <summary> Effective locked state of a zone. <paramref name="open"/> is the open state owned by the caller: the runtime override in the live map or the authoring <see cref="MapZone.Locked"/> flag in the editor. A zone is locked when it is missing, has no target scene, is closed by its state, or when its scene is not registered in the running game. </summary>
public static bool IsLocked(MapZone zone,bool open){if(zone==null||string.IsNullOrEmpty(zone.Scene))return true;if(!open)return true;var sceneManager=SceneContextNode.FindNode<SceneManager>("SceneManager");return sceneManager!=null&& !sceneManager.HasScene(zone.Scene);}}}

===== FILE mods/map_base/src/MapZoneService.cs =====
using System;using System.Collections.Generic;using System.Text.Json;using Cthangover.Core.Events;using Cthangover.Core.UI.Tool;using Cthangover.Core.Mods;using Cthangover.Core.Utils;namespace Cthangover.Mods.Map{/// <summary> Single owner of the world map zone list and its live state. Replaces the raw "read JSON per mod" logic that used to live inside <see cref="MapWidget"/> so that the map, scenarios and mods all see the same zones and the same open/hidden flags. <para/> Two layers of state exist: <list type="bullet"> <item>authoring data — <see cref="MapZone"/> loaded from <c>mods/&lt;name&gt;/map/*.json</c>, with initial <c>locked</c>/<c>hidden</c> flags;</item> <item>runtime overrides — sparse dictionaries managed here through <see cref="Open"/>, <see cref="Close"/>, <see cref="Show"/>, <see cref="Hide"/> and <see cref="ResetZone"/>.</item> </list> The effective state is the override when present, otherwise the authoring default. Overrides are what <see cref="ExportState"/> writes into the save and <see cref="ImportState"/> restores, so a mod update that changes the defaults is immediately reflected for every zone the player never touched. <para/> Every effective state change publishes <see cref="MapZoneChangedEvent"/> — <see cref="MapWidget"/> rebuilds its layers from it and <see cref="Scenes.ConditionStateMonitor"/> invalidates cached scenario conditions, because zone state can gate scenario headers. <para/> The single instance is process-wide and deliberately not owned by <see cref="AppServices"/>: zone overrides are identity-sensitive across the whole process (map widget, saves, scenario conditions). New-game reset goes through the AppServices resettable adapter ("map_zones", see <see cref="ResetOverrides"/>); unit tests inject zones via <see cref="LoadZones"/> while <c>GameEnvironment.IsRuntime</c> is false. </summary>
public sealed class MapZoneService{/// <summary>Owns the process-wide instance; lazily created and alive for the whole session.</summary>
private static readonly Lazy<MapZoneService>instance=new(()=>new MapZoneService());/// <summary>Process-wide singleton. Deliberately NOT owned by AppServices: zone state is identity-sensitive across the process (map widget, saves, scenario conditions); reset goes through the AppServices resettable adapter instead.</summary>
public static MapZoneService Instance=>instance.Value;private readonly List<MapZone>zones=new();private readonly Dictionary<string,MapZone>byId=new(StringComparer.OrdinalIgnoreCase);private readonly Dictionary<string,bool>openOverrides=new(StringComparer.OrdinalIgnoreCase);private readonly Dictionary<string,bool>visibleOverrides=new(StringComparer.OrdinalIgnoreCase);private bool loaded;private MapZoneService(){}/// <summary> All loaded zones in mod load order, hidden ones included. The map filters by <see cref="IsVisible"/>; other consumers (e.g. the map editor) may want the full list. </summary>
public IReadOnlyList<MapZone>Zones=>zones;/// <summary> Loads the zone list from the given data, replacing the previous list but preserving runtime overrides. This is the testable entry point; runtime code uses <see cref="ReloadFromMods"/>. </summary>
public void LoadZones(IEnumerable<MapZone>newZones){zones.Clear();byId.Clear();if(newZones!=null){foreach(var zone in newZones){if(zone==null||string.IsNullOrEmpty(zone.ID))continue;if(byId.ContainsKey(zone.ID)){GameLogger.Log("MAP",$"duplicate zone id '{zone.ID}' ignored",LogLevel.Warning);continue;}zones.Add(zone);byId[zone.ID]=zone;}}loaded=true;GameLogger.Log("MAP",$"MapZoneService loaded {zones.Count} zone(s)");}/// <summary> Reads every loaded mod's <c>map/*.json</c> through its <see cref="IModFileProvider"/> and rebuilds the zone list. Safe to call outside the Godot runtime: the call is a no-op in unit tests, where zones are injected with <see cref="LoadZones"/> instead. </summary>
public void ReloadFromMods(){if(!GameEnvironment.IsRuntime){loaded=true;return;}var result=new List<MapZone>();var options=JsonOptionsProvider.ReadOptions;foreach(var kvp in ModManager.Instance.Mods){var modId=kvp.Key;var provider=kvp.Value.FileProvider;foreach(var entry in provider.ListFiles("map")){if(entry.EndsWith("/")|| !entry.EndsWith(".json",StringComparison.OrdinalIgnoreCase))continue;var jsonText=provider.ReadFileText(entry);if(jsonText==null){GameLogger.Log("MAP",$"failed to read map json '{modId}/{entry}'",LogLevel.Error);continue;}try{var fileData=JsonSerializer.Deserialize<FileData<MapZone>>(jsonText,options);if(fileData?.Items==null)continue;foreach(var zone in fileData.Items){result.Add(zone);GameLogger.Log("MAP",$"loaded zone '{zone.ID}' from '{modId}'");}}catch(JsonException ex){GameLogger.Log("MAP",$"JSON parse failed for '{modId}/{entry}': {ex.Message}",LogLevel.Error);}}}LoadZones(result);}/// <summary> Loads zones on first use and re-reads nothing afterwards; call <see cref="Reload"/> when the mod set or the map files changed. </summary>
public void EnsureLoaded(){if(!loaded)ReloadFromMods();}/// <summary> Re-reads the raw zone data while keeping every runtime override, so a running game picks up map edits without losing progression. Publishes a zone change so the map rebuilds. </summary>
public void Reload(){ReloadFromMods();ModEventBus.Publish(new MapZoneChangedEvent{ZoneId=string.Empty,Change=MapZoneChangeKind.Reload});}/// <summary>Returns the zone with the given ID, or <c>null</c> when unknown.</summary>
public MapZone Get(string id){if(string.IsNullOrEmpty(id))return null;return byId.TryGetValue(id,out var zone)?zone:null;}/// <summary>True when a zone with the given ID is loaded from any mod.</summary>
public bool Contains(string id)=>Get(id)!=null;/// <summary> Effective open state: runtime override when present, otherwise the authoring default (<see cref="MapZone.Locked"/> negated). Unknown IDs are closed. Note that a zone is only actually enterable when its scene also resolves — that check stays in <see cref="MapWidget"/>. </summary>
public bool IsOpen(string id){if(string.IsNullOrEmpty(id))return false;if(openOverrides.TryGetValue(id,out var value))return value;return Get(id)is{Locked:false};}/// <summary> Effective visibility: runtime override when present, otherwise the authoring default (<see cref="MapZone.Hidden"/> negated). Unknown IDs are invisible. </summary>
public bool IsVisible(string id){if(string.IsNullOrEmpty(id))return false;if(visibleOverrides.TryGetValue(id,out var value))return value;return Get(id)is{Hidden:false};}/// <summary>Opens the zone for travel, overriding the authoring default.</summary>
public void Open(string id)=>SetOpen(id,true);/// <summary>Closes the zone for travel, overriding the authoring default.</summary>
public void Close(string id)=>SetOpen(id,false);/// <summary>Reveals a hidden zone on the map.</summary>
public void Show(string id)=>SetVisible(id,true);/// <summary>Removes the zone from the map entirely until shown again.</summary>
public void Hide(string id)=>SetVisible(id,false);/// <summary> Drops both overrides, returning the zone to its authoring defaults. Unlike the other mutators this one works on unknown IDs too, so stale overrides of removed zones can be cleaned up. </summary>
public void ResetZone(string id){if(string.IsNullOrEmpty(id))return;var changed=openOverrides.Remove(id);changed|=visibleOverrides.Remove(id);if(changed)PublishChange(id,MapZoneChangeKind.Reset);}/// <summary> Forgets every override, returning all zones to their authoring defaults. Called when a new game starts so progression from the previous playthrough does not leak into it. </summary>
public void ResetOverrides(){if(openOverrides.Count==0&&visibleOverrides.Count==0)return;openOverrides.Clear();visibleOverrides.Clear();PublishChange(string.Empty,MapZoneChangeKind.Reset);}/// <summary>Copies the sparse overrides for the save system.</summary>
public MapZoneStateData ExportState(){return new MapZoneStateData{Open=new Dictionary<string,bool>(openOverrides,StringComparer.OrdinalIgnoreCase),Visible=new Dictionary<string,bool>(visibleOverrides,StringComparer.OrdinalIgnoreCase),};}/// <summary> Replaces the overrides with the values from a save. A save is the authoritative override set of its own playthrough, so the previous overrides are dropped entirely; zone IDs that no longer exist are kept (they cost nothing and survive a temporarily disabled mod). </summary>
public void ImportState(MapZoneStateData data){openOverrides.Clear();visibleOverrides.Clear();if(data?.Open!=null){foreach(var pair in data.Open)openOverrides[pair.Key]=pair.Value;}if(data?.Visible!=null){foreach(var pair in data.Visible)visibleOverrides[pair.Key]=pair.Value;}PublishChange(string.Empty,MapZoneChangeKind.Restore);}private void SetOpen(string id,bool value){if(!ChangeOverride(openOverrides,id,value,"open/close"))return;PublishChange(id,value?MapZoneChangeKind.Open:MapZoneChangeKind.Close);}private void SetVisible(string id,bool value){if(!ChangeOverride(visibleOverrides,id,value,"show/hide"))return;PublishChange(id,value?MapZoneChangeKind.Show:MapZoneChangeKind.Hide);}private bool ChangeOverride(Dictionary<string,bool>overrides,string id,bool value,string operation){if(string.IsNullOrEmpty(id))return false;if(!Contains(id)){GameLogger.Log("MAP",$"cannot {operation} unknown zone '{id}'",LogLevel.Warning);return false;}if(overrides.TryGetValue(id,out var current)&&current==value)return false;overrides[id]=value;return true;}private static void PublishChange(string zoneId,string change){ModEventBus.Publish(new MapZoneChangedEvent{ZoneId=zoneId,Change=change});}}}

===== FILE mods/map_base/src/MapZoneStyle.cs =====
using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Mods.Map{/// <summary> Single source of truth for world map zone visuals, shared by the live map (<see cref="MapZonesLayer"/>, which also backs the map editor's game-like preview) and the map editor's editing canvas. The editor and the game read the same colours, pattern and outline parameters here, so the two views cannot drift apart when the style is tuned. <para/> Zone visuals are built from a two-tone accent pair — gold with orange as its partner — instead of flat washes of a single hue, so a zone reads as an area while staying in the gold UI family: <list type="bullet"> <item>the fill is a diagonal hatch whose stripes alternate gold and orange, stamped through polygon UVs at a low state-dependent alpha so the map texture stays readable through the gaps;</item> <item>hover and selection use a two-tone dashed keyline: a solid orange polyline with gold dashes stamped on top, the classic marching-ants alternation repeated in the same accent pair.</item> </list> State only changes the hatch opacity and the outline weight, so the visual language is identical across normal, hover, selected and locked zones; the editor additionally uses the muted hidden state for zones the game would omit. The hatch is a tiny repeating texture stamped through polygon UVs rather than CPU line clipping, because the editor redraws on every drag frame and geometry clipping per frame would be wasteful. </summary>
public static class MapZoneStyle{/// <summary>Side of the repeating hatch tile in pixels.</summary>
public const int HatchTile=12;/// <summary>Thickness of one hatch stripe in pixels; the orange stripe follows the gold one immediately.</summary>
public const int HatchStripe=2;/// <summary>Gold half of the zone accent pair, used for hatch stripes and keyline dashes.</summary>
public static readonly Color Gold=UiTokens.AccentBright;/// <summary> Orange half of the zone accent pair, the deeper tone of the hatch and the keyline underlay. Deliberately close to <see cref="Gold"/> (the <c>TypeUsed</c> token) so the hatch reads as one warm texture instead of two fighting colours. </summary>
public static readonly Color Orange=UiTokens.TypeUsed;private static readonly Color NormalFill=UiTokens.Alpha(UiTokens.White,0.4f);private static readonly Color HoverFill=UiTokens.Alpha(UiTokens.White,0.6f);private static readonly Color SelectedFill=UiTokens.Alpha(UiTokens.White,0.8f);private static readonly Color LockedFill=UiTokens.Alpha(UiTokens.White,0.18f);private static readonly Color HiddenFill=UiTokens.Alpha(UiTokens.Faint,0.8f);private static readonly Color OutlineSolid=UiTokens.Alpha(Orange,0.95f);private static readonly Color OutlineDash=UiTokens.Alpha(Gold,0.95f);private static readonly Color OutlineSolidSoft=UiTokens.Alpha(Orange,0.6f);private static readonly Color OutlineDashSoft=UiTokens.Alpha(Gold,0.7f);private const float HoverOutlineWidth=1.6f;private const float SelectedOutlineWidth=2.6f;private const float OutlineDashLength=7f;/// <summary>Handle colour of a polygon vertex on the editing canvas.</summary>
public static readonly Color VertexHandle=UiTokens.AccentBright;/// <summary>Handle colour of a marker position on the editing canvas.</summary>
public static readonly Color MarkerHandle=UiTokens.TypePassive;/// <summary>Handle colour of a label position on the editing canvas.</summary>
public static readonly Color LabelHandle=UiTokens.AccentDim;private static Texture2D _hatchTexture;/// <summary> Enables texture repeat and filtering needed by the hatch fills. Call once from the drawing control (the map zones layer and the editing canvas both do it when they enter the tree). </summary>
public static void Prepare(CanvasItem canvas){canvas.TextureRepeat=CanvasItem.TextureRepeatEnum.Enabled;canvas.TextureFilter=CanvasItem.TextureFilterEnum.Linear;}/// <summary> Draws one zone polygon in pixel coordinates with the shared style. Polygons that cannot be triangulated (a self-intersecting outline produced while authoring) skip the hatch fill, so Godot does not report a native triangulation error on every frame; the keyline is still drawn for the selected and hovered states, which is enough for the author to find and repair the shape. <para/> <paramref name="hidden"/> is only passed by the editing canvas: the game and the preview omit hidden zones entirely, but the author still has to see and edit them, so they are painted with the muted hidden hatch and a soft keyline instead of disappearing. </summary>
public static void Draw(CanvasItem canvas,Vector2[]polygon,bool selected,bool hovered,bool locked,bool hidden=false){if(canvas==null||polygon==null||polygon.Length<2)return;var fill=hidden?HiddenFill:locked?LockedFill:selected?SelectedFill:hovered?HoverFill:NormalFill;if(polygon.Length>=3&&Geometry2D.TriangulatePolygon(polygon).Length>0)DrawHatch(canvas,polygon,fill);if(selected)DrawDashedKeyline(canvas,polygon,OutlineSolid,OutlineDash,SelectedOutlineWidth);else if(hidden||(hovered&& !locked))DrawDashedKeyline(canvas,polygon,OutlineSolidSoft,OutlineDashSoft,HoverOutlineWidth);}/// <summary> Colour of the repeating hatch pattern at a pixel offset: a 45° diagonal where the gold stripe is immediately followed by the orange one. Kept pure (no Godot resources) so the two guarantees the whole style rests on — the pattern tiles and every tile carries both accent colours — are unit tested without a rendering context. </summary>
public static Color HatchPixel(int x,int y){var band=(x+y)%HatchTile;if(band<0)band+=HatchTile;if(band<HatchStripe)return Gold;if(band<HatchStripe*2)return Orange;return new Color(0f,0f,0f,0f);}private static void DrawHatch(CanvasItem canvas,Vector2[]polygon,Color modulate){var uvs=new Vector2[polygon.Length];for(var i=0;i<polygon.Length;i++)uvs[i]=polygon[i]/HatchTile;canvas.DrawColoredPolygon(polygon,modulate,uvs,HatchTexture);}/// <summary> Two-tone dashed keyline: the orange line is drawn solid first and the gold dashes — whose gaps equal their length — are stamped on top, so the perimeter alternates the two accent colours like marching ants. </summary>
private static void DrawDashedKeyline(CanvasItem canvas,Vector2[]polygon,Color solid,Color dash,float width){var loop=new Vector2[polygon.Length+1];polygon.CopyTo(loop,0);loop[polygon.Length]=polygon[0];canvas.DrawPolyline(loop,solid,width,true);for(var i=0;i<polygon.Length;i++)canvas.DrawDashedLine(polygon[i],loop[i+1],dash,width,OutlineDashLength,true,true);}private static Texture2D HatchTexture=>_hatchTexture??=BuildHatchTexture();private static Texture2D BuildHatchTexture(){var image=Image.CreateEmpty(HatchTile,HatchTile,false,Image.Format.Rgba8);for(var y=0;y<HatchTile;y++){for(var x=0;x<HatchTile;x++)image.SetPixel(x,y,HatchPixel(x,y));}return ImageTexture.CreateFromImage(image);}}}

===== FILE mods/map_base/src/MapZonesLayer.cs =====
using System.Collections.Generic;using Cthangover.Core.UI;using Godot;namespace Cthangover.Mods.Map{/// <summary> Invisible interactive layer of the world map that draws every zone polygon and reports pointer activity through signals. Polygons arrive as normalised [0..1] point lists — the same data <see cref="MapZone"/> reads from <c>mods/&lt;mod&gt;/map/*.json</c> — and are scaled by the layer's own size, which keeps the map resolution-independent and makes the polygon a much larger click target than the tiny marker icon. <para/> The layer never selects anything by itself: <see cref="MapWidget"/> owns the selected zone and controls the outline through <see cref="SetSelected"/>. Hover is deliberately split in two: <see cref="SetHovered"/> is the cross-highlight entry point used when the pointer is over the paired marker, while pointer motion inside the layer emits <see cref="ZoneHoveredEventHandler"/>. Because the setter does not emit, marker and polygon can highlight each other without signal loops. <para/> All painting is delegated to <see cref="MapZoneStyle"/>, the single style definition shared with the map editor canvas, so the game and the editor render zones identically. Closed zones stay selectable (the location card explains the block): they use the dimmed locked hatch, and unlike before the selection keyline is still drawn for them, so a picked locked zone is visible too. </summary>
public partial class MapZonesLayer:Control{[Signal]public delegate void ZoneClickedEventHandler(string zoneId);[Signal]public delegate void ZoneHoveredEventHandler(string zoneId,bool hovered);private sealed class ZoneShape{/// <summary>Zone id this shape highlights on hover and reports on click.</summary>
public string ZoneId;/// <summary>Polygon vertices scaled to layer pixels at draw time.</summary>
public Vector2[]Points;/// <summary>True when the zone's effective open state is closed; switches the hatch to the dimmed locked style.</summary>
public bool Locked;}private readonly List<ZoneShape>_shapes=new();private string _selectedId;private string _hoverId;/// <summary>Prepares the canvas for hatch painting and subscribes pointer and resize handlers.</summary>
public override void _Ready(){MouseFilter=MouseFilterEnum.Stop;MapZoneStyle.Prepare(this);GuiInput+=OnGuiInput;MouseExited+=OnMouseExited;Resized+=OnResized;}/// <summary>Drops every registered polygon; call before rebuilding the zone set.</summary>
public void ClearZones(){_shapes.Clear();_selectedId=null;_hoverId=null;QueueRedraw();}/// <summary> Registers one normalised polygon. Shapes with fewer than three points have no interior and are skipped — such a zone can still be reached through its marker. </summary>
public void AddZone(string zoneId,IReadOnlyList<Vector2>normalizedPoints,bool locked){if(string.IsNullOrEmpty(zoneId)||normalizedPoints==null||normalizedPoints.Count<3)return;var points=new Vector2[normalizedPoints.Count];for(var i=0;i<points.Length;i++)points[i]=normalizedPoints[i];_shapes.Add(new ZoneShape{ZoneId=zoneId,Points=points,Locked=locked});QueueRedraw();}/// <summary>Updates the closed state of a registered zone, e.g. after the active scene changed.</summary>
public void SetLocked(string zoneId,bool locked){foreach(var shape in _shapes){if(shape.ZoneId!=zoneId)continue;shape.Locked=locked;QueueRedraw();return;}}/// <summary>Marks the zone as selected; its outline is painted above every other polygon.</summary>
public void SetSelected(string zoneId){_selectedId=zoneId;QueueRedraw();}/// <summary> Applies a hover state coming from the paired marker. Does not emit <see cref="ZoneHoveredEventHandler"/> on purpose, so the marker's hover and the polygon's hover never bounce off each other. </summary>
public void SetHovered(string zoneId,bool hovered){if(hovered){if(_hoverId==zoneId)return;_hoverId=zoneId;}else{if(_hoverId!=zoneId)return;_hoverId=null;}QueueRedraw();}/// <summary>Paints every zone polygon, redrawing the selected one last so its keyline sits on top.</summary>
public override void _Draw(){if(_shapes.Count==0)return;ZoneShape selected=null;foreach(var shape in _shapes){if(shape.ZoneId==_selectedId){selected=shape;continue;}DrawShape(shape,Size);}if(selected!=null)DrawShape(selected,Size);}/// <summary> Paints one zone polygon through <see cref="MapZoneStyle"/>. Polygons that cannot be triangulated — a self-intersecting outline or a degenerate sliver produced while authoring — skip the fill entirely, because Godot's textured polygon draw reports a native triangulation error for them on every frame; the outline is still drawn so the author can see and repair the shape. </summary>
private void DrawShape(ZoneShape shape,Vector2 size){MapZoneStyle.Draw(this,ScaledPolygon(shape,size),shape.ZoneId==_selectedId,shape.ZoneId==_hoverId,shape.Locked);}private void OnGuiInput(InputEvent@event){switch(@event){case InputEventMouseMotion:ApplyPointer(HitTest(GetLocalMousePosition()));break;case InputEventMouseButton button when button.Pressed&&button.ButtonIndex==MouseButton.Left:var shape=HitTest(button.Position);if(shape==null)return;EmitSignal(SignalName.ZoneClicked,shape.ZoneId);AcceptEvent();break;}}private void OnMouseExited(){ApplyPointer(null);}private void OnResized(){QueueRedraw();}/// <summary>Resolves the topmost zone under a layer-local point; polygons later in the list win overlaps.</summary>
private ZoneShape HitTest(Vector2 point){for(var i=_shapes.Count-1;i>=0;i--){var shape=_shapes[i];if(Geometry2D.IsPointInPolygon(point,ScaledPolygon(shape,Size)))return shape;}return null;}private void ApplyPointer(ZoneShape shape){var nextId=shape?.ZoneId;var nextCursor=shape==null?CursorShape.Arrow:CursorShape.PointingHand;if(MouseDefaultCursorShape!=nextCursor)MouseDefaultCursorShape=nextCursor;if(_hoverId==nextId)return;var previous=_hoverId;_hoverId=nextId;QueueRedraw();if(!string.IsNullOrEmpty(previous))EmitSignal(SignalName.ZoneHovered,previous,false);if(!string.IsNullOrEmpty(nextId))EmitSignal(SignalName.ZoneHovered,nextId,true);}private static Vector2[]ScaledPolygon(ZoneShape shape,Vector2 size){var result=new Vector2[shape.Points.Length];for(var i=0;i<result.Length;i++)result[i]=shape.Points[i]*size;return result;}}}

===== FILE mods/map_base/src/SubsystemRegistration.cs =====
namespace Cthangover.Mods.Map{/// <summary> Marks the world map engine mod for subsystem coverage discovery: the coverage test scans loaded assemblies for this marker and requires a declared integration test for the subsystem. </summary>
public static class SubsystemRegistration{public static string SubsystemId=>"map";}}

===== FILE mods/menu_base/src/BaseMenuInitializer.cs =====
using System;using Cthangover.Core.Games;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Core.UI.Menu;using Godot;namespace Cthangover.BaseMenu{/// <summary> Registers the built-in main-menu buttons and the settings/save-load panels in <see cref="MenuRegistry"/>. Runs from <c>ModInitializerRegistry.NotifyResourcesReady</c>, which fires after the registry itself is built, so these entries are appended to live registry state rather than collected from a manifest. </summary>
public class BaseMenuInitializer:IModInitializer{private const string ModId="menu_base";public void OnModLoaded(string modId){}public void OnModResourcesReady(){MenuRegistry.RegisterMenuPanel(new MenuPanelEntry{Id="settings",ModId=ModId,ScenePath="scenes/menu/settings_menu.tscn",Locale="menu/settings",});MenuRegistry.RegisterMenuPanel(new MenuPanelEntry{Id="save_load",ModId=ModId,ScenePath="scenes/menu/save_load_menu.tscn",});MenuRegistry.RegisterMenuButton(new MenuButtonEntry{Id="continue",ModId=ModId,Section="game",Locale="menu/continue",Variation="primary",Index=5,Visible=GameFlow.HasContinue,Handler=BaseMenuActions.Continue,});MenuRegistry.RegisterMenuButton(new MenuButtonEntry{Id="play",ModId=ModId,Section="game",Locale="menu/play",Variation="primary",Index=10,Handler=BaseMenuActions.Play,});// A new game of the running game; from the front-end games start from the picker instead.
MenuRegistry.RegisterMenuButton(new MenuButtonEntry{Id="new_game",ModId=ModId,Section="game",Locale="menu/newgame",Index=15,Visible=()=> !ActiveGame.IsFrontendOnly,Handler=BaseMenuActions.NewGame,});MenuRegistry.RegisterMenuButton(new MenuButtonEntry{Id="load",ModId=ModId,Section="game",Locale="ui/menu/load",Index=20,Handler=BaseMenuActions.Load,});MenuRegistry.RegisterMenuButton(new MenuButtonEntry{Id="settings",ModId=ModId,Section="game",Locale="menu/settings",Index=30,Handler=BaseMenuActions.Settings,});MenuRegistry.RegisterMenuButton(new MenuButtonEntry{Id="exit",ModId=ModId,Section="exit",Locale="menu/exit",Variation="danger",Index=1000,Handler=BaseMenuActions.Exit,});}}/// <summary> Handlers for the built-in menu buttons. Each receives the menu root control so it can locate the panels instantiated as named children by <see cref="MainMenu"/>. </summary>
internal static class BaseMenuActions{/// <summary> Starts a new game at the active game's entry scene (<c>start_scene</c> when no game collection is active) through <see cref="GameSession"/>. </summary>
public static void NewGame(Control host){GameSession.StartNew(host);}/// <summary>Loads the last save of the last played game (restarting into it when needed).</summary>
public static void Continue(Control host)=>GameFlow.Continue(host);/// <summary>Opens the game picker.</summary>
public static void Play(Control host){host.GetNodeOrNull<GamePickerPanel>("GamePickerPanel")?.Open();}/// <summary>Opens the save/load panel in load mode.</summary>
public static void Load(Control host){host.GetNodeOrNull<SaveLoadMenu>("SaveLoadPanel")?.OpenForLoad();}/// <summary>Shows the settings overlay.</summary>
public static void Settings(Control host){host.GetNodeOrNull<SettingsMenu>("SettingsPanel")?.Open();}/// <summary>Quits the application immediately via <c>SceneTree.Quit</c>.</summary>
public static void Exit(Control host){host.GetTree().Quit();}}}

===== FILE mods/menu_base/src/GameFlow.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Text;using Cthangover.Core.Games;using Cthangover.Core.Mods;using Cthangover.Core.Settings;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.BaseMenu{/// <summary> Menu-side rules for entering games: "Continue", starting a game and loading a save. A game other than the running one (or any game from the front-end) is entered by replacing the mod set in-process (<see cref="GameSession.SwitchToAsync"/>). Before a save is loaded into a changed composition the player sees what changes (added mechanics start fresh, removed ones keep their data dormant) and a backup is taken. </summary>
internal static class GameFlow{/// <summary>Set after installing mods in this process: entering any game, even the running one, reloads the mod set to pick them up.</summary>
public static bool ReloadRequired{get;set;}public static bool HasContinue()=>LastSessionStore.Load()!=null;public static void Continue(Node host){var last=LastSessionStore.Load();if(last!=null)RequestLoad(host,last.GameId,last.FileName);}public static void StartGame(Node host,string gameId){if(IsRunning(gameId)&& !ReloadRequired)GameSession.StartNew(host);else Switch(host,gameId,null);}/// <summary> Loads <paramref name="fileName"/> of <paramref name="gameId"/> (null = a save made without a game), comparing compositions first. </summary>
public static void RequestLoad(Node host,string gameId,string fileName){if(string.IsNullOrEmpty(gameId)){// Development launch with every mod: the save belongs to that mod set, load it as before.
if(!ActiveGame.IsActive&& !ActiveGame.IsFrontendOnly){GameSession.LoadAndEnter(host,fileName);return;}ChooseGameForSave(host,fileName);return;}var package=SaveService.ReadPackage(gameId,fileName);var diff=SaveCompositionDiff.Compute(package?.Game?.Mods,GameCatalog.Composition(gameId));if(diff.IsEmpty){Proceed(host,gameId,fileName,backup:false);return;}Confirm(host,TranslationServer.Translate("menu/load/diff_title"),DescribeDiff(diff),TranslationServer.Translate("menu/load/diff_ok"),()=>Proceed(host,gameId,fileName,backup:true));}private static void Proceed(Node host,string gameId,string fileName,bool backup){if(backup)SaveService.BackupSave(gameId,fileName);if(IsRunning(gameId)&& !ReloadRequired)GameSession.LoadAndEnter(host,fileName);else Switch(host,gameId,fileName);}private static async void Switch(Node host,string gameId,string fileName){if(await GameSession.SwitchToAsync(host,gameId,fileName))ReloadRequired=false;}private static bool IsRunning(string gameId)=>ActiveGame.IsActive&&string.Equals(ActiveGame.Id,gameId,StringComparison.OrdinalIgnoreCase);/// <summary>Lists what a composition change does to the save, in player terms.</summary>
public static string DescribeDiff(SaveCompositionDiff diff){var text=new StringBuilder(TranslationServer.Translate("menu/load/diff_intro"));void Section(string key,IEnumerable<string>lines){var list=lines.ToList();if(list.Count==0)return;text.Append("\n\n").Append(TranslationServer.Translate(key));foreach(var line in list)text.Append("\n  • ").Append(line);}Section("menu/load/diff_added",diff.Added.Select(ModTitle));Section("menu/load/diff_removed",diff.Removed.Select(ModTitle));Section("menu/load/diff_updated",diff.Updated.Select(u=>$"{ModTitle(u.ModId)}: {u.From} → {u.To}"+(u.Downgrade?" "+TranslationServer.Translate("menu/load/diff_downgrade"):string.Empty)));text.Append("\n\n").Append(TranslationServer.Translate("menu/load/diff_backup"));return text.ToString();}private static string ModTitle(string modId)=>ModRegistry.Instance.AllDiscoveredMods.TryGetValue(modId,out var info)&& !string.IsNullOrEmpty(info.DisplayTitle)?$"{info.DisplayTitle} ({modId})":modId;/// <summary>A save made before games existed: the player picks the game it belongs to; it moves there.</summary>
private static void ChooseGameForSave(Node host,string fileName){var games=GameCatalog.Games().Where(g=>g.IsReady).ToList();if(games.Count==0){Confirm(host,TranslationServer.Translate("menu/load/legacy_title"),TranslationServer.Translate("menu/load/legacy_no_games"),null,null);return;}var select=new OptionButton();foreach(var game in games)select.AddItem(game.Name??game.Collection.Id);var content=new VBoxContainer();content.AddChild(new Label{Text=TranslationServer.Translate("menu/load/legacy_text"),AutowrapMode=TextServer.AutowrapMode.WordSmart,CustomMinimumSize=new Vector2(420,0),});content.AddChild(select);var dialog=new ConfirmationDialog{Title=TranslationServer.Translate("menu/load/legacy_title"),OkButtonText=TranslationServer.Translate("menu/load/legacy_ok"),CancelButtonText=TranslationServer.Translate("ui/save/cancel_button"),Exclusive=true,};dialog.AddChild(content);dialog.Confirmed+=()=>{var gameId=games[Math.Max(0,select.Selected)].Collection.Id;dialog.QueueFree();var slot=SaveService.AdoptLegacySave(fileName,gameId);RequestLoad(host,gameId,slot);};dialog.Canceled+=dialog.QueueFree;host.AddChild(dialog);dialog.PopupCentered();}/// <summary>Message or confirmation dialog; without <paramref name="onConfirm"/> it is a plain notice.</summary>
public static void Confirm(Node host,string title,string text,string okText,Action onConfirm){AcceptDialog dialog=onConfirm==null?new AcceptDialog():new ConfirmationDialog{CancelButtonText=TranslationServer.Translate("ui/save/cancel_button")};dialog.Title=title;dialog.DialogText=text;dialog.Exclusive=true;if(!string.IsNullOrEmpty(okText))dialog.OkButtonText=okText;dialog.Confirmed+=()=>{dialog.QueueFree();onConfirm?.Invoke();};dialog.Canceled+=dialog.QueueFree;host.AddChild(dialog);if(onConfirm!=null)CthangoverTheme.ApplyPrimary(dialog.GetOkButton());dialog.PopupCentered();}}}

===== FILE mods/menu_base/src/GamePickerPanel.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Games;using Cthangover.Core.Mods;using Cthangover.Core.Settings;using Cthangover.Core.UI.Menu;using Cthangover.Core.UI.Theming;using Cthangover.Mods.Interface.Menu;using Cthangover.Mods.Interface.Theming;using Godot;namespace Cthangover.BaseMenu{/// <summary> "Play": every game collection as a card — cover, name, description, readiness (what is missing and what can be installed), saves and the last play date — with New game / Continue / Saves / Install / Configure. Data comes from <see cref="GameCatalog"/> against all installed mods, so it works from the front-end where only the menu is loaded. </summary>
public partial class GamePickerPanel:Control{private VBoxContainer _list;private readonly HashSet<string>_sessionInstalled=new(StringComparer.OrdinalIgnoreCase);/// <summary>Raised when the player opens a game's saves; the menu shows its save browser.</summary>
public event Action<string>SavesRequested;public event Action Closed;public override void _Ready(){SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);MouseFilter=MouseFilterEnum.Stop;var backdrop=new ColorRect{Color=new Color(0,0,0,0.65f)};backdrop.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);AddChild(backdrop);var panel=new PanelContainer();panel.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusLg,UiTokens.Surface1,UiTokens.Line));panel.AnchorLeft=panel.AnchorRight=panel.AnchorTop=panel.AnchorBottom=0.5f;panel.OffsetLeft= -460;panel.OffsetRight=460;panel.OffsetTop= -310;panel.OffsetBottom=310;panel.GrowHorizontal=GrowDirection.Both;panel.GrowVertical=GrowDirection.Both;AddChild(panel);var margin=new MarginContainer();foreach(var side in new[]{"left","right","top","bottom"})margin.AddThemeConstantOverride($"margin_{side}",UiTokens.Space6);panel.AddChild(margin);var column=UiKit.Column(UiTokens.Space4);column.MouseFilter=MouseFilterEnum.Pass;margin.AddChild(column);column.AddChild(UiKit.Label(TranslationServer.Translate("menu/play/title"),UiTokens.FontH2,UiTokens.InkBright));var scroll=new ScrollContainer{SizeFlagsVertical=SizeFlags.ExpandFill,HorizontalScrollMode=ScrollContainer.ScrollMode.Disabled,};column.AddChild(scroll);_list=UiKit.Column(UiTokens.Space3);_list.MouseFilter=MouseFilterEnum.Pass;_list.SizeFlagsHorizontal=SizeFlags.ExpandFill;scroll.AddChild(_list);var back=UiKit.Button(TranslationServer.Translate("settings/back"));back.SizeFlagsHorizontal=SizeFlags.ShrinkEnd;back.Pressed+=Close;column.AddChild(back);Visible=false;}public void Open(){Visible=true;Rebuild();}private void Close(){Visible=false;Closed?.Invoke();}private void Rebuild(){foreach(var child in _list.GetChildren())child.QueueFree();var games=GameCatalog.Games(_sessionInstalled);if(games.Count==0){_list.AddChild(UiKit.Label(TranslationServer.Translate("menu/play/no_games"),UiTokens.FontBody,UiTokens.Muted));return;}foreach(var game in games)_list.AddChild(BuildCard(game));}private Control BuildCard(ModCollectionGameSummary game){var collection=game.Collection;var saves=SaveService.ListSaves(collection.Id);var newest=saves.OrderByDescending(s=>s.SaveTime).FirstOrDefault();var card=new PanelContainer{SizeFlagsHorizontal=SizeFlags.ExpandFill};card.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusMd,UiTokens.Surface2,ActiveGame.Id==collection.Id?UiTokens.Accent:UiTokens.LineCard));var row=UiKit.Row(UiTokens.Space4);row.MouseFilter=MouseFilterEnum.Pass;card.AddChild(row);var cover=GameCatalog.LoadCover(collection);row.AddChild(new TextureRect{Texture=cover,CustomMinimumSize=new Vector2(176,99),ExpandMode=TextureRect.ExpandModeEnum.IgnoreSize,StretchMode=TextureRect.StretchModeEnum.KeepAspectCovered,SizeFlagsVertical=SizeFlags.ShrinkCenter,});var info=UiKit.Column(UiTokens.Space1);info.SizeFlagsHorizontal=SizeFlags.ExpandFill;row.AddChild(info);info.AddChild(UiKit.Label(game.Name??collection.Id,UiTokens.FontH3,UiTokens.InkBright));var description=UiKit.Label(game.Description??string.Empty,UiTokens.FontBodySmall,UiTokens.Body);description.AutowrapMode=TextServer.AutowrapMode.WordSmart;info.AddChild(description);var(statusText,statusColor)=Status(game);info.AddChild(UiKit.Label(statusText,UiTokens.FontSmall,statusColor));info.AddChild(UiKit.Label(saves.Count==0?TranslationServer.Translate("menu/play/no_saves"):string.Format(TranslationServer.Translate("menu/play/saves"),saves.Count,newest.SaveTime.ToLocalTime().ToString("dd.MM.yy HH:mm")),UiTokens.FontTiny,UiTokens.Muted));var buttons=UiKit.Column(UiTokens.Space2);buttons.MouseFilter=MouseFilterEnum.Pass;buttons.CustomMinimumSize=new Vector2(190,0);row.AddChild(buttons);var start=UiKit.Button(TranslationServer.Translate("menu/play/new_game"),ButtonKind.Primary);start.Disabled= !game.IsReady;start.Pressed+=()=>GameFlow.StartGame(this,collection.Id);buttons.AddChild(start);if(newest!=null){var resume=UiKit.Button(TranslationServer.Translate("menu/play/continue"));resume.Disabled= !game.IsReady;resume.Pressed+=()=>GameFlow.RequestLoad(this,collection.Id,newest.FileName);buttons.AddChild(resume);var browse=UiKit.Button(TranslationServer.Translate("menu/play/browse_saves"));browse.Pressed+=()=>SavesRequested?.Invoke(collection.Id);buttons.AddChild(browse);}if(!game.IsReady&&game.MissingIds.Count==0&&game.MismatchIds.Count==0&&game.ProblemsCount==0&&game.PlanInstallCount+game.PlanEnableCount>0){var install=UiKit.Button(TranslationServer.Translate("menu/play/install"));install.Pressed+=()=>Install(collection,install);buttons.AddChild(install);}if(MenuRegistry.GetButtonById(ModsHubId)!=null){var configure=UiKit.Button(TranslationServer.Translate("menu/play/configure"));configure.Pressed+=()=>OpenEditor(collection.Id);buttons.AddChild(configure);}return card;}private static(string Text,Color Color)Status(ModCollectionGameSummary game){if(game.IsReady)return(TranslationServer.Translate("menu/play/status_ready"),UiTokens.SuccessText);if(game.MissingIds.Count>0)return(string.Format(TranslationServer.Translate("menu/play/status_missing"),string.Join(", ",game.MissingIds)),UiTokens.DangerText);if(game.MismatchIds.Count>0)return(string.Format(TranslationServer.Translate("menu/play/status_mismatch"),string.Join(", ",game.MismatchIds)),UiTokens.DangerText);if(game.ProblemsCount>0)return(string.Format(TranslationServer.Translate("menu/play/status_problems"),game.ProblemsCount),UiTokens.Warning);return(string.Format(TranslationServer.Translate("menu/play/status_install"),game.PlanInstallCount,game.PlanEnableCount),UiTokens.Warning);}private const string ModsHubId="mods_hub";/// <summary>Installs/enables the missing mods; entering the game afterwards reloads the mod set to pick them up.</summary>
private async void Install(ModCollection collection,Button button){button.Disabled=true;button.Text=TranslationServer.Translate("menu/play/installing");var collections=GameCatalog.LoadCollections();var catalog=CatalogService.CachedEntries??new List<CatalogExportEntry>();var graph=ModCollectionResolver.Resolve(collection,collections,ModRegistry.Instance.AllDiscoveredMods,DisabledModsStore.Load(),catalog,_sessionInstalled);var ok=await ModCollectionInstaller.ApplyAsync(ModCollectionPlan.Build(graph,catalog),_sessionInstalled);if(!IsInstanceValid(this))return;if(ok)GameFlow.ReloadRequired=true;GameFlow.Confirm(this,TranslationServer.Translate("menu/play/install"),TranslationServer.Translate(ok?"menu/play/install_done":"menu/play/install_failed"),null,null);Rebuild();}/// <summary>Opens the mod manager on the game's scheme (loose coupling through the menu registry).</summary>
private void OpenEditor(string collectionId){var entry=MenuRegistry.GetButtonById(ModsHubId);var host=GetParent()as Control??this;var hub=MenuBuilder.OpenScene(host,entry?.ScenePath,ModsHubId);if(hub!=null&&hub.HasMethod("OpenCollectionEditor"))hub.CallDeferred("OpenCollectionEditor",collectionId);Close();}}}

===== FILE mods/menu_base/src/MainMenu.cs =====
using System.Collections.Generic;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Core.UI.Menu;using Cthangover.Core.UI.Theming;using Cthangover.Core.UI.Tool;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Menu;namespace Cthangover.BaseMenu{/// <summary> Title screen main menu, fully driven by <see cref="MenuRegistry"/>. The scene only defines named section containers (game, mods, exit, tools); every button — built-in or mod-provided — is created from registry entries, so mods can add, replace or re-order entries without touching this scene. Below the sections, every registered tool appends its own launcher button via <see cref="IToolProvider.AddMenuButton"/>, so no tool list is hardcoded here. If GameLogger reports mod compilation errors, a popup lists them. The SettingsMenu and SaveLoadMenu panels are instantiated at ready time but stay hidden; they are registered in <see cref="MenuRegistry"/> as panels "settings" and "save_load", so other mods can open them by id. </summary>
public partial class MainMenu:Control{private SettingsMenu _settingsMenu;private SaveLoadMenu _saveLoadMenu;private GamePickerPanel _gamePicker;private readonly Dictionary<Button,string>_btnKeys=new();public override void _Ready(){try{BuildUi();}catch(System.Exception ex){GameLogger.Log("MAIN_MENU",$"_Ready failed: {ex.GetType().Name}: {ex.Message}{System.Environment.NewLine}{ex.StackTrace}",LogLevel.Error);throw;}}/// <summary> Assembles the runtime UI: theme, hidden settings and save/load panels, telegram link wiring, scene manager initialization and registry/tool driven buttons. </summary>
private void BuildUi(){CthangoverTheme.Apply(this);_settingsMenu=new ModScenePrefab("scenes/menu/settings_menu.tscn").Instantiate<SettingsMenu>();_settingsMenu.Name="SettingsPanel";_settingsMenu.Visible=false;_settingsMenu.LanguageChanged+=RefreshButtons;AddChild(_settingsMenu);_saveLoadMenu=new ModScenePrefab("scenes/menu/save_load_menu.tscn").Instantiate<SaveLoadMenu>();_saveLoadMenu.Name="SaveLoadPanel";_saveLoadMenu.Visible=false;AddChild(_saveLoadMenu);_gamePicker=new GamePickerPanel{Name="GamePickerPanel"};AddChild(_gamePicker);_gamePicker.SavesRequested+=gameId=>{_gamePicker.Visible=false;_saveLoadMenu.OpenForGame(gameId);};var telegramLink=GetNode<RichTextLabel>("TelegramLink");telegramLink.MetaClicked+=OnTelegramLinkClicked;var sceneManager=GetNode<SceneManager>("/root/SceneManager");sceneManager.Initialize();BuildRegistryButtons();BuildToolButtons();CheckModCompilationErrors();RefreshButtons();}/// <summary> Creates one button per <see cref="MenuRegistry"/> entry, routed to the section container matching its <c>Section</c> ("game", "mods", "exit", "tools"). Runs after <see cref="SceneManager.Initialize"/> because the registry is built during boot and filled by mod initializers; the defensive Initialize call keeps direct scene launches working. Each button's translation key is registered so <see cref="RefreshButtons"/> keeps it localized. </summary>
private void BuildRegistryButtons(){var sections=new Dictionary<string,Container>{{"game",GetNode<Container>("MenuContainer/GameButtons")},{MenuButtonEntry.SectionMods,GetNode<Container>("MenuContainer/ModMenuButtons")},{"exit",GetNode<Container>("MenuContainer/ExitButtons")},{"tools",GetNode<Container>("MenuContainer/ToolMenuButtons")},};MenuBuilder.Build(this,sections);foreach(var container in sections.Values)MenuBuilder.CollectLocaleKeys(container,_btnKeys);}/// <summary> Lets every registered tool append its launcher button to the tool block below Exit (<c>MenuContainer/ToolMenuButtons</c>). Tools register themselves in <see cref="ToolFactory"/>, so the block stays empty when no tools are installed and needs no changes when a mod introduces a new tool. Each button carries its translation key in metadata, keeping <see cref="RefreshButtons"/> aware of it. </summary>
private void BuildToolButtons(){var container=GetNodeOrNull<VBoxContainer>("MenuContainer/ToolMenuButtons");if(container==null)return;foreach(var tool in ToolFactory.Instance.GetAll())tool.AddMenuButton(container);MenuBuilder.CollectLocaleKeys(container,_btnKeys);GameLogger.Log("MAIN_MENU",$"Built {container.GetChildCount()} tool button(s)");}private void CheckModCompilationErrors(){if(GameLogger.CompilationErrors.Count==0)return;var errorText=string.Join("\r\n",GameLogger.CompilationErrors);ShowModErrorPopup(errorText);}private void RefreshButtons(){foreach(var kv in _btnKeys)kv.Key.Text=TranslationServer.Translate(kv.Value);}private void ShowModErrorPopup(string errorText){var dialog=new AcceptDialog();dialog.Title="Mod Compilation Error";dialog.DialogText=errorText;dialog.OkButtonText="OK";dialog.Exclusive=true;AddChild(dialog);dialog.PopupCentered();}private void OnTelegramLinkClicked(Variant meta){OS.ShellOpen($"https://{meta.AsString()}");}}}

===== FILE mods/menu_base/src/SaveLoadMenu.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Games;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Core.UI.Menu;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.BaseMenu{/// <summary> Save/load browser with unlimited slots. The grid shows 4×3 slots per screen and scrolls past that. Saves belong to games: in a game the panel shows that game's slots (save mode appends one empty "new" slot); from the front-end the selector at the top switches between games with saves and the saves made before games existed. Loading goes through <see cref="GameFlow.RequestLoad"/> — composition comparison, backup, restart into another game, adoption of game-less saves. </summary>
public partial class SaveLoadMenu:Control,ISaveLoadPanel{private Label _titleLabel;private OptionButton _gameSelect;private SaveSlotList _slotList;private Label _emptyLabel;private Button _backBtn;private bool _isLoadMode;private string _gameId;private readonly List<string>_gameIds=new();private List<SaveSlotInfo>_currentSlots;/// <summary> Raised when the player clicks the back button to dismiss the save/load screen. </summary>
public event Action Closed;public override void _Ready(){_titleLabel=GetNode<Label>("Panel/Margin/VBox/Title");_gameSelect=GetNode<OptionButton>("Panel/Margin/VBox/GameSelect");_slotList=GetNode<SaveSlotList>("Panel/Margin/VBox/Scroll/SlotGrid");_emptyLabel=GetNode<Label>("Panel/Margin/VBox/EmptyLabel");_backBtn=GetNode<Button>("Panel/Margin/VBox/BackBtn");CthangoverTheme.ApplyIcon(_backBtn);_slotList.SlotPressed+=OnSlotPressed;_gameSelect.ItemSelected+=OnGameSelected;_backBtn.Pressed+=OnBackPressed;_backBtn.Text=TranslationServer.Translate("settings/back");Visible=false;}/// <summary>Load mode: the running game's saves, or every game's from the front-end.</summary>
public void OpenForLoad()=>OpenFor(true,ActiveGame.Id);/// <summary>Save mode: always the running game's slots plus a free one.</summary>
public void OpenForSave()=>OpenFor(false,ActiveGame.Id);/// <summary>Load mode preselected on <paramref name="gameId"/> (game picker → "Saves").</summary>
public void OpenForGame(string gameId)=>OpenFor(true,gameId);/// <inheritdoc cref="IMenuPanel" />
public void Open()=>OpenForLoad();private void OpenFor(bool load,string gameId){_isLoadMode=load;_titleLabel.Text=TranslationServer.Translate(load?"ui/load/title":"ui/save/title");BuildGameSelect(gameId);Visible=true;CallDeferred(nameof(RefreshSlots));}/// <summary> Games offered by the selector: in save mode only the running game; in load mode every game with saves (the running/preselected one first) and, last, the saves made without a game. </summary>
private void BuildGameSelect(string preselected){_gameIds.Clear();_gameSelect.Clear();if(!_isLoadMode){AddGame(ActiveGame.Id,ActiveGame.Collection?.DisplayTitle);}else{if(!string.IsNullOrEmpty(preselected))AddGame(preselected,GameName(preselected));foreach(var game in SaveService.ListGamesWithSaves()){if(!_gameIds.Contains(game.GameId))AddGame(game.GameId,game.GameId==null?null:GameName(game.GameId)??game.GameName);}if(_gameIds.Count==0)AddGame(preselected,GameName(preselected));}_gameId=_gameIds.Count>0?_gameIds[0]:null;_gameSelect.Selected=_gameIds.Count>0?0:-1;_gameSelect.Visible=_gameIds.Count>1||(_gameIds.Count==1&&_gameIds[0]==null&&ActiveGame.IsActive);}private void AddGame(string gameId,string name){_gameIds.Add(gameId);_gameSelect.AddItem(gameId==null?TranslationServer.Translate("menu/load/legacy_group"):name??gameId);}private static string GameName(string gameId){if(string.IsNullOrEmpty(gameId))return null;if(ActiveGame.Id==gameId)return ActiveGame.Collection?.GetLocalizedName(TranslationServer.GetLocale());return GameCatalog.LoadCollections().TryGetValue(gameId,out var collection)?CollectionText.Resolve(collection.GetLocalizedName(TranslationServer.GetLocale())):null;}private void OnGameSelected(long index){_gameId=index>=0&&index<_gameIds.Count?_gameIds[(int)index]:null;RefreshSlots();}/// <summary>Lists the selected game's saves; save mode appends the next free slot.</summary>
public void RefreshSlots(){_currentSlots=SaveService.ListSaves(_gameId);if(!_isLoadMode)_currentSlots.Add(SaveService.EmptySlot(_gameId,SaveService.NextSlotName(_gameId)));_emptyLabel.Visible=_currentSlots.Count==0;_emptyLabel.Text=TranslationServer.Translate("menu/load/empty");_slotList.SetSlots(_currentSlots);}private void OnSlotPressed(string fileName){var slot=FindSlot(fileName);if(_isLoadMode){if(slot==null||slot.IsEmpty)return;GameFlow.RequestLoad(this,_gameId,fileName);return;}if(slot==null||slot.IsEmpty){SaveScreenshotService.CaptureAndSave(fileName);SaveService.Save(fileName);RefreshSlots();}else{ShowOverwriteConfirm(fileName);}}private SaveSlotInfo FindSlot(string fileName)=>_currentSlots?.Find(s=>s.FileName==fileName);private void ShowOverwriteConfirm(string fileName){var dialog=new ConfirmationDialog();dialog.Title=TranslationServer.Translate("ui/save/overwrite_title");dialog.DialogText=TranslationServer.Translate("ui/save/overwrite_text");dialog.OkButtonText=TranslationServer.Translate("ui/save/save_button");dialog.CancelButtonText=TranslationServer.Translate("ui/save/cancel_button");dialog.Exclusive=true;AddChild(dialog);CthangoverTheme.ApplyPrimary(dialog.GetOkButton());dialog.Confirmed+=()=>{SaveScreenshotService.CaptureAndSave(fileName);SaveService.Save(fileName);RefreshSlots();};dialog.PopupCentered();}private void OnBackPressed(){Visible=false;Closed?.Invoke();}}}

===== FILE mods/menu_base/src/SaveSettings.cs =====
using System.Collections.Generic;using Cthangover.Core.Mods;using Cthangover.Core.Settings;using Godot;namespace Cthangover.BaseMenu{/// <summary> Save settings shown under the base menu in the mods settings tab. Games keep the data of mechanics the player turned off (dormant slots, see <see cref="SaveDormancy"/>) so it returns with the mechanic; this is where the player drops that data from every save on purpose. </summary>
public class SaveSettings:IModSettings,IModSettingsActions{public const string PurgeDormantKey="purge_dormant";public IReadOnlyList<ModSettingDefinition>GetDefinitions()=>new List<ModSettingDefinition>{new(){Key=PurgeDormantKey,Name="settings/saves/purge_dormant",Type=SettingType.Action,ActionText="settings/saves/purge_dormant_button",ConfirmText="settings/saves/purge_dormant_confirm",},};public void WriteValues(DataBlob blob){}public void ReadValues(DataBlob blob){}public string RunAction(string key){if(key!=PurgeDormantKey)return null;var purged=SaveService.PurgeDormantData();return string.Format(TranslationServer.Translate("settings/saves/purge_dormant_done"),purged);}}}

===== FILE mods/menu_base/src/SaveSlotItem.cs =====
using System;using Cthangover.Core.Settings;using Cthangover.Mods.Interface.Base.Lists;using Godot;namespace Cthangover.BaseMenu{/// <summary> A single save slot card in the save/load grid. Extends ListItem with SaveSlotInfo as the model. Renders differently for empty vs. occupied slots: empty slots show a placeholder label; occupied slots display the screenshot, in-game date (from TimeData), real-world save timestamp, and scene name. The stats label shows party size. Button click emits SlotPressed with the file name for parent handling. Destruct disconnects the button signal before QueueFree to avoid phantom callbacks. </summary>
public partial class SaveSlotItem:ListItem<SaveSlotInfo>{private TextureRect screenshotRect;private Label dateLabel;private Label timeLabel;private Label sceneLabel;private Label emptyLabel;private Button slotButton;private Label statsLabel;/// <summary> Raised when this specific slot is clicked, carrying the save file name so the parent <see cref="SaveSlotList"/> can forward it to <see cref="SaveLoadMenu"/>. A plain C# event because mod assemblies are compiled without the Godot source generators. </summary>
public event Action<string>SlotPressed;public override void _Ready(){screenshotRect=GetNode<TextureRect>("ScreenshotRect");dateLabel=GetNode<Label>("DateLabel");timeLabel=GetNode<Label>("TimeLabel");sceneLabel=GetNode<Label>("SceneLabel");emptyLabel=GetNode<Label>("EmptyLabel");slotButton=GetNode<Button>("SlotButton");statsLabel=GetNodeOrNull<Label>("StatsLabel");}/// <summary> Populates the slot visuals from the <see cref="SaveSlotInfo"/> model. For occupied slots, loads the screenshot texture, formats the game date from <see cref="SaveSlotInfo.GameTime"/>, shows the real-world save timestamp, and displays party size. Empty slots show only a localized "empty" label. Wires the button click signal to emit <see cref="SlotPressed"/>. </summary>
public override void Construct(SaveSlotInfo model){base.Construct(model);if(model.IsEmpty){screenshotRect.Visible=false;dateLabel.Visible=false;timeLabel.Visible=false;sceneLabel.Visible=false;emptyLabel.Visible=true;if(statsLabel!=null)statsLabel.Visible=false;emptyLabel.Text=TranslationServer.Translate("ui/save/empty");}else{emptyLabel.Visible=false;screenshotRect.Visible=true;dateLabel.Visible=true;timeLabel.Visible=true;sceneLabel.Visible=true;if(statsLabel!=null)statsLabel.Visible=true;var tex=SaveScreenshotService.LoadScreenshotFromPath(model.ScreenshotPath);if(tex!=null)screenshotRect.Texture=tex;var gameTimeObj=new TimeData(model.GameTime);dateLabel.Text=TranslationServer.Translate("ui/save/game_day").ToString().Replace("{day}",(gameTimeObj.Days+1).ToString()).Replace("{time}",gameTimeObj.Text);timeLabel.Text=model.SaveTime!=System.DateTime.MinValue?model.SaveTime.ToLocalTime().ToString("dd.MM.yy HH:mm"):"";sceneLabel.Text=model.SceneName;if(statsLabel!=null)statsLabel.Text=TranslationServer.Translate("ui/save/party_size").ToString().Replace("{count}",model.CharacterCount.ToString());}slotButton.Pressed+=OnPressed;}private void OnPressed(){SlotPressed?.Invoke(Model.FileName);}/// <summary> Disconnects the button signal to prevent callbacks on freed nodes, then queues this item for deletion. Called by the <see cref="SaveSlotList"/> when rebuilding the grid, ensuring no dangling signal references. </summary>
public override void Destruct(){if(slotButton!=null&&GodotObject.IsInstanceValid(slotButton))slotButton.Pressed-=OnPressed;QueueFree();}}}

===== FILE mods/menu_base/src/SaveSlotList.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Base.Lists.Impls;namespace Cthangover.BaseMenu{/// <summary> Save slot grid layout with configurable column/row count and padding. Does NOT use the ListWidget/ColumnCellListWidget framework — manually calculates cell dimensions from the control size and instantiates SaveSlotItem prefabs in a flat grid. This is intentional: the save grid has a fixed visual size and doesn't need scrolling or dynamic content sizing. OnResize refreshes if slots were cleared (e.g. initial render when size wasn't known yet). </summary>
public partial class SaveSlotList:Control{private ModScenePrefab _itemScene;private readonly List<SaveSlotItem>_items=new();/// <summary> Raised when any child <see cref="SaveSlotItem"/> is clicked, forwarding the save file name. A plain C# event because mod assemblies are compiled without the Godot source generators. </summary>
public event Action<string>SlotPressed;/// <summary>Number of columns in the save slot grid. Editor-configurable.</summary>
[Export]public int ColumnCount{get;set;}=4;/// <summary>Number of rows in the save slot grid. Editor-configurable.</summary>
[Export]public int RowCount{get;set;}=3;/// <summary>Spacing between adjacent slots in pixels. Editor-configurable.</summary>
[Export]public float SlotPadding{get;set;}=8f;private List<SaveSlotInfo>_slotModels;/// <summary>Width kept free for the vertical scroll bar of the parent <see cref="ScrollContainer"/>.</summary>
private const float ScrollBarReserve=14f;public override void _Ready(){_itemScene=new ModScenePrefab("scenes/menu/save_slot_item.tscn");Resized+=OnResized;if(GetParent()is Control viewport)viewport.Resized+=OnResized;}/// <summary> Area one screen of slots occupies: the parent scroll viewport when the list scrolls (slots are unlimited), otherwise the list itself. </summary>
private Vector2 ViewportSize()=>GetParent()is ScrollContainer scroll?new Vector2(scroll.Size.X-ScrollBarReserve,scroll.Size.Y):Size;/// <summary> Stores the slot data models and triggers a full grid rebuild via <see cref="Refresh"/>. </summary>
public void SetSlots(List<SaveSlotInfo>slots){_slotModels=slots;Refresh();}private void ClearItems(){foreach(var item in _items){if(GodotObject.IsInstanceValid(item))item.QueueFree();}_items.Clear();}/// <summary> Destroys all existing <see cref="SaveSlotItem"/> children and rebuilds the grid from <c>_slotModels</c>. Calculates cell dimensions as (total - gaps) / column|row count, then positions each item using flat coordinates (no <c>GridContainer</c> dependency). Skips rendering if the control has zero size (e.g. called before first layout pass). </summary>
public void Refresh(){ClearItems();if(_slotModels==null||_itemScene==null)return;var contentSize=ViewportSize();if(contentSize.X<=0||contentSize.Y<=0)return;// One screen holds ColumnCount × RowCount slots; more rows extend the list and the parent ScrollContainer scrolls.
var cellWidth=(contentSize.X-(ColumnCount-1)*SlotPadding)/ColumnCount;var cellHeight=(contentSize.Y-(RowCount-1)*SlotPadding)/RowCount;var rows=Math.Max(1,(_slotModels.Count+ColumnCount-1)/ColumnCount);CustomMinimumSize=new Vector2(0,rows*cellHeight+(rows-1)*SlotPadding);for(int i=0;i<_slotModels.Count;i++){var model=_slotModels[i];var item=_itemScene.Instantiate<SaveSlotItem>();AddChild(item);var col=i%ColumnCount;var row=i/ColumnCount;item.Position=new Vector2(col*(cellWidth+SlotPadding),row*(cellHeight+SlotPadding));item.Size=new Vector2(cellWidth,cellHeight);item.Construct(model);item.SlotPressed+=OnItemSlotPressed;_items.Add(item);}}private void OnResized(){var view=ViewportSize();if(_slotModels!=null&&view.X>0&&view.Y>0&&(_items.Count==0||Math.Abs(view.X-_laidOutFor.X)>1||Math.Abs(view.Y-_laidOutFor.Y)>1)){_laidOutFor=view;Refresh();}}private Vector2 _laidOutFor;private void OnItemSlotPressed(string fileName){SlotPressed?.Invoke(fileName);}}}

===== FILE mods/menu_base/src/SettingsMenu.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Localization;using Cthangover.Core.Mods;using Cthangover.Core.Settings;using Cthangover.Core.UI.Menu;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Menu;namespace Cthangover.BaseMenu{/// <summary> Audio, language, and display settings panel with tabbed layout. Uses an _initialized guard to prevent save-triggered callbacks from firing during UI population (LoadSettingsToUI → widget changes → callbacks → premature saves). Language changes call LocaleLoader.LoadCurrentLanguage() for immediate effect, then emit LanguageChanged so parent menus can refresh their translated button text. Sliders become non-editable when their toggle is off, enforcing the "disabled means off" semantics visually. Display changes (window mode, resolution, monitor, V-Sync, scale) are applied live via DisplayServer and ProjectSettings. </summary>
public partial class SettingsMenu:Control,ISettingsPanel{/// <summary> Raised after the player selects a different language and it has been applied. Parent menus (<see cref="MainMenu"/>, <see cref="GameMenu"/>) subscribe to refresh their translated button text. </summary>
public event Action LanguageChanged;private TabContainer _tabs;private CheckButton _soundsToggle;private CheckButton _musicToggle;private CheckButton _ambientToggle;private HSlider _soundsVolume;private HSlider _musicVolume;private HSlider _ambientVolume;private Label _soundsVolumeValue;private Label _musicVolumeValue;private Label _ambientVolumeValue;private OptionButton _langSelect;private Button _backBtn;private VBoxContainer _modListContainer;private Label _selectedModName;private Button _resetModSettingsBtn;private VBoxContainer _settingsContainer;private string _selectedModId;private readonly Dictionary<Control,string>_controlKeys=new();private OptionButton _windowModeSelect;private OptionButton _resolutionSelect;private OptionButton _monitorSelect;private OptionButton _scaleSelect;private CheckButton _vsyncToggle;private readonly Dictionary<Label,string>_labelKeys=new();private bool _initialized;private static readonly Vector2I[]CommonResolutions={new(1280,720),new(1366,768),new(1600,900),new(1920,1080),new(2560,1440),new(3840,2160),};private static readonly int[]ScaleOptions={1,2,3,4};public override void _Ready(){_tabs=GetNode<TabContainer>("Panel/Margin/VBox/Tabs");_langSelect=GetNode<OptionButton>("Panel/Margin/VBox/Tabs/GeneralMargin/General/LangBox/LangSelect");_soundsToggle=GetNode<CheckButton>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/ToggleRow/SoundsToggle");_soundsVolume=GetNode<HSlider>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/VolumeRow/SoundsVolume");_soundsVolumeValue=GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/VolumeRow/SoundsVolumeValue");_musicToggle=GetNode<CheckButton>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/ToggleRow2/MusicToggle");_musicVolume=GetNode<HSlider>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/VolumeRow2/MusicVolume");_musicVolumeValue=GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/VolumeRow2/MusicVolumeValue");_ambientToggle=GetNode<CheckButton>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/ToggleRow3/AmbientToggle");_ambientVolume=GetNode<HSlider>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/VolumeRow3/AmbientVolume");_ambientVolumeValue=GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/VolumeRow3/AmbientVolumeValue");_windowModeSelect=GetNode<OptionButton>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ModeBox/ModeSelect");_resolutionSelect=GetNode<OptionButton>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ResBox/ResSelect");_monitorSelect=GetNode<OptionButton>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/MonitorBox/MonitorSelect");_scaleSelect=GetNode<OptionButton>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ScaleBox/ScaleSelect");_vsyncToggle=GetNode<CheckButton>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/VSyncRow/VSyncToggle");_backBtn=GetNode<Button>("Panel/Margin/VBox/BackBtn");CthangoverTheme.ApplyIcon(_backBtn);_modListContainer=GetNode<VBoxContainer>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/LeftPanel/ModScroll/ModList");_selectedModName=GetNode<Label>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel/TopBar/SelectedModName");_resetModSettingsBtn=GetNode<Button>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel/TopBar/ResetBtn");CthangoverTheme.ApplyDanger(_resetModSettingsBtn);_settingsContainer=GetNode<VBoxContainer>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel/SettingsScroll/SettingsContainer");RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Title"),"menu/settings");RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/GeneralMargin/General/LangBox/LangLabel"),"settings/language");RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/ToggleRow/SoundsLabel"),"settings/sounds");RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/SoundsBox/VolumeRow/SoundsVolumeLabel"),"settings/volume");RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/ToggleRow2/MusicLabel"),"settings/music");RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/MusicBox/VolumeRow2/MusicVolumeLabel"),"settings/volume");RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/ToggleRow3/AmbientLabel"),"settings/ambient");RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/AudioMargin/Audio/AmbientBox/VolumeRow3/AmbientVolumeLabel"),"settings/volume");RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ModeBox/ModeLabel"),"settings/window_mode");RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ResBox/ResLabel"),"settings/resolution");RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/MonitorBox/MonitorLabel"),"settings/monitor");RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/ScaleBox/ScaleLabel"),"settings/scale");RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/DisplayMargin/Display/VSyncRow/VSyncLabel"),"settings/vsync");RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/LeftPanel/ModsTitle"),"settings/mod_list_title");RegisterLabel(GetNode<Label>("Panel/Margin/VBox/Tabs/ModSettingsMargin/ModSettings/RightPanel/SettingsScroll/SettingsContainer/Placeholder"),"settings/mods_no_settings");_soundsToggle.Toggled+=OnSoundsToggled;_musicToggle.Toggled+=OnMusicToggled;_ambientToggle.Toggled+=OnAmbientToggled;_soundsVolume.ValueChanged+=OnSoundsVolumeChanged;_musicVolume.ValueChanged+=OnMusicVolumeChanged;_ambientVolume.ValueChanged+=OnAmbientVolumeChanged;_langSelect.ItemSelected+=OnLanguageSelected;_backBtn.Pressed+=OnBackPressed;_backBtn.Text=TranslationServer.Translate("settings/back");_windowModeSelect.ItemSelected+=OnDisplaySettingChanged;_resolutionSelect.ItemSelected+=OnDisplaySettingChanged;_monitorSelect.ItemSelected+=OnDisplaySettingChanged;_scaleSelect.ItemSelected+=OnDisplaySettingChanged;_vsyncToggle.Toggled+=OnDisplayToggleChanged;_resetModSettingsBtn.Pressed+=OnResetModSettings;LoadSettingsToUI();BuildModList();_initialized=true;}private void RegisterLabel(Label label,string key){if(label!=null)_labelKeys[label]=key;}private void RefreshUIText(){foreach(var kv in _labelKeys){if(IsInstanceValid(kv.Key))kv.Key.Text=TranslationServer.Translate(kv.Value);}_backBtn.Text=TranslationServer.Translate("settings/back");_tabs.SetTabTitle(0,TranslationServer.Translate("settings/tab_general"));_tabs.SetTabTitle(1,TranslationServer.Translate("settings/tab_audio"));_tabs.SetTabTitle(2,TranslationServer.Translate("settings/tab_display"));_tabs.SetTabTitle(3,TranslationServer.Translate("settings/tab_mods"));_resetModSettingsBtn.Text=TranslationServer.Translate("settings/mods_reset");PopulateLangOptions();PopulateDisplayOptions();}private void LoadSettingsToUI(){var s=GameData.Instance?.Settings;if(s==null)return;_soundsToggle.ButtonPressed=s.SoundsEnabled;_musicToggle.ButtonPressed=s.MusicsEnabled;_ambientToggle.ButtonPressed=s.AmbientEnabled;_soundsVolume.Value=s.SoundsVolume;_musicVolume.Value=s.MusicsVolume;_ambientVolume.Value=s.AmbientVolume;_soundsVolumeValue.Text=$"{s.SoundsVolume}%";_musicVolumeValue.Text=$"{s.MusicsVolume}%";_ambientVolumeValue.Text=$"{s.AmbientVolume}%";_vsyncToggle.ButtonPressed=s.VsyncEnabled;_windowModeSelect.Selected=WindowModeToIndex(s.WindowMode);UpdateSlidersEditable();PopulateLangOptions();PopulateDisplayOptions();RefreshUIText();SelectCurrentResolution(s);SelectCurrentMonitor(s);SelectCurrentScale(s);}private void PopulateLangOptions(){_langSelect.Clear();_langSelect.AddItem(TranslationServer.Translate("settings/lang_ru"));_langSelect.AddItem(TranslationServer.Translate("settings/lang_en"));_langSelect.AddItem(TranslationServer.Translate("settings/lang_zh"));var s=GameData.Instance?.Settings;if(s==null)return;_langSelect.Selected=s.Language switch{"en"=>1,"zh"=>2,_=>0};}private void PopulateDisplayOptions(){var previousWindow=_windowModeSelect.Selected;var previousRes=_resolutionSelect.Selected;var previousMon=_monitorSelect.Selected;var previousScale=_scaleSelect.Selected;_windowModeSelect.Clear();_windowModeSelect.AddItem(TranslationServer.Translate("settings/window_mode_windowed"));_windowModeSelect.AddItem(TranslationServer.Translate("settings/window_mode_borderless"));_windowModeSelect.AddItem(TranslationServer.Translate("settings/window_mode_fullscreen"));_resolutionSelect.Clear();foreach(var r in CommonResolutions)_resolutionSelect.AddItem($"{r.X}×{r.Y}");_monitorSelect.Clear();var count=DisplayServer.GetScreenCount();if(count<=0)count=1;for(var i=0;i<count;i++){var size=DisplayServer.ScreenGetSize(i);var label=string.Format(TranslationServer.Translate("settings/monitor_screen"),i+1,size.X,size.Y);_monitorSelect.AddItem(label);}_scaleSelect.Clear();foreach(var sc in ScaleOptions)_scaleSelect.AddItem(TranslationServer.Translate($"settings/scale_{sc}x"));_windowModeSelect.Selected=Mathf.Clamp(previousWindow,0,2);_resolutionSelect.Selected=Mathf.Clamp(previousRes,0,CommonResolutions.Length-1);_monitorSelect.Selected=Mathf.Clamp(previousMon,0,count-1);_scaleSelect.Selected=Mathf.Clamp(previousScale,0,ScaleOptions.Length-1);}private void SelectCurrentResolution(SettingsData s){for(var i=0;i<CommonResolutions.Length;i++){if(CommonResolutions[i].X==s.ResolutionWidth&&CommonResolutions[i].Y==s.ResolutionHeight){_resolutionSelect.Selected=i;return;}}}private void SelectCurrentMonitor(SettingsData s){var count=DisplayServer.GetScreenCount();if(s.CurrentScreen>=0&&s.CurrentScreen<count)_monitorSelect.Selected=s.CurrentScreen;}private void SelectCurrentScale(SettingsData s){for(var i=0;i<ScaleOptions.Length;i++){if(ScaleOptions[i]==s.Scale){_scaleSelect.Selected=i;return;}}}private void ApplyAndSave(){if(!_initialized)return;var s=GameData.Instance?.Settings;if(s==null)return;s.SoundsEnabled=_soundsToggle.ButtonPressed;s.MusicsEnabled=_musicToggle.ButtonPressed;s.AmbientEnabled=_ambientToggle.ButtonPressed;s.SoundsVolume=(int)_soundsVolume.Value;s.MusicsVolume=(int)_musicVolume.Value;s.AmbientVolume=(int)_ambientVolume.Value;s.Save();}private void ApplyDisplayAndSave(){if(!_initialized)return;var s=GameData.Instance?.Settings;if(s==null)return;var windowMode=IndexToWindowMode(_windowModeSelect.Selected);var resIdx=Mathf.Clamp(_resolutionSelect.Selected,0,CommonResolutions.Length-1);var resolution=CommonResolutions[resIdx];var monitorIdx=_monitorSelect.Selected;var scaleIdx=Mathf.Clamp(_scaleSelect.Selected,0,ScaleOptions.Length-1);var scale=ScaleOptions[scaleIdx];var vsync=_vsyncToggle.ButtonPressed;if(monitorIdx>=0&&monitorIdx<DisplayServer.GetScreenCount())DisplayServer.WindowSetCurrentScreen(monitorIdx);DisplayServer.WindowSetMode(windowMode);if(windowMode==DisplayServer.WindowMode.Windowed)DisplayServer.WindowSetSize(resolution);DisplayServer.WindowSetVsyncMode(vsync?DisplayServer.VSyncMode.Enabled:DisplayServer.VSyncMode.Disabled);ProjectSettings.SetSetting("display/window/stretch/scale",(float)scale);s.WindowMode=(int)windowMode;s.ResolutionWidth=resolution.X;s.ResolutionHeight=resolution.Y;s.CurrentScreen=monitorIdx;s.VsyncEnabled=vsync;s.Scale=scale;s.Save();}private void OnSoundsToggled(bool on){UpdateSlidersEditable();ApplyAndSave();}private void OnMusicToggled(bool on){UpdateSlidersEditable();ApplyAndSave();}private void OnAmbientToggled(bool on){UpdateSlidersEditable();ApplyAndSave();}private void OnSoundsVolumeChanged(double v){_soundsVolumeValue.Text=$"{(int)v}%";ApplyAndSave();}private void OnMusicVolumeChanged(double v){_musicVolumeValue.Text=$"{(int)v}%";ApplyAndSave();}private void OnAmbientVolumeChanged(double v){_ambientVolumeValue.Text=$"{(int)v}%";ApplyAndSave();}private void OnDisplaySettingChanged(long idx)=>ApplyDisplayAndSave();private void OnDisplayToggleChanged(bool on)=>ApplyDisplayAndSave();private void OnLanguageSelected(long idx){if(!_initialized)return;var s=GameData.Instance?.Settings;if(s==null)return;var lang=idx switch{1=>"en",2=>"zh",_=>"ru-ru"};if(s.Language==lang)return;s.Language=lang;s.Save();LocaleLoader.LoadCurrentLanguage();RefreshUIText();LanguageChanged?.Invoke();}/// <inheritdoc cref="IMenuPanel" />
public void Open()=>Visible=true;private void OnBackPressed()=>Visible=false;private void UpdateSlidersEditable(){_soundsVolume.Editable=_soundsToggle.ButtonPressed;_musicVolume.Editable=_musicToggle.ButtonPressed;_ambientVolume.Editable=_ambientToggle.ButtonPressed;}private static int WindowModeToIndex(int mode){return mode switch{3=>1,4=>2,_=>0,};}private static DisplayServer.WindowMode IndexToWindowMode(int index){return index switch{1=>DisplayServer.WindowMode.Fullscreen,2=>DisplayServer.WindowMode.ExclusiveFullscreen,_=>DisplayServer.WindowMode.Windowed,};}private void BuildModList(){foreach(var child in _modListContainer.GetChildren())child.QueueFree();var modsWithSettings=ModSettingsRegistry.GetModsWithSettings();if(modsWithSettings.Count==0)return;foreach(var modId in modsWithSettings){var btn=new Button{Text=ModSettingsRegistry.GetDisplayTitle(modId),SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,};btn.Pressed+=()=>OnModSelected(modId);_modListContainer.AddChild(btn);}var firstMod=modsWithSettings[0];OnModSelected(firstMod);}private void OnModSelected(string modId){_selectedModId=modId;_selectedModName.Text=ModSettingsRegistry.GetDisplayTitle(modId);BuildModSettingsPanel(modId);}private void BuildModSettingsPanel(string modId){_controlKeys.Clear();foreach(var child in _settingsContainer.GetChildren())child.QueueFree();_settingsContainer.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;var definitions=ModSettingsRegistry.GetDefinitions(modId);if(definitions.Count==0){var placeholder=new Label{Text=TranslationServer.Translate("settings/mods_no_settings"),HorizontalAlignment=HorizontalAlignment.Center,};_settingsContainer.AddChild(placeholder);return;}var currentBlob=ModSettingsRegistry.WriteCurrentValues(modId);foreach(var def in definitions){var row=new HBoxContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,CustomMinimumSize=new Vector2(0,40),};row.AddThemeConstantOverride("separation",12);var label=new Label{Text=TranslationServer.Translate(def.Name),CustomMinimumSize=new Vector2(180,0),VerticalAlignment=VerticalAlignment.Center,};row.AddChild(label);if(def.Type==SettingType.Action){AddActionControls(row,modId,def);_settingsContainer.AddChild(row);continue;}Control control=CreateControlForType(def);ApplyControlValue(control,def,currentBlob);_controlKeys[control]=def.Key;switch(def.Type){case SettingType.Bool:if(control is CheckButton checkbox)checkbox.Toggled+=_=>OnModSettingChanged();break;case SettingType.Integer:case SettingType.Float:if(control is SpinBox spin)spin.ValueChanged+=_=>OnModSettingChanged();break;case SettingType.String:if(control is LineEdit lineEdit)lineEdit.TextChanged+=_=>OnModSettingChanged();break;case SettingType.Enum:if(control is OptionButton option)option.ItemSelected+=_=>OnModSettingChanged();break;case SettingType.Slider:if(control is HSlider slider){slider.ValueChanged+=_=>{var val=(double)slider.GetMeta("step",1.0).AsDouble();var step=val;var stepped=Mathf.Round(slider.Value/step)*step;slider.Value=stepped;OnModSettingChanged();};}break;}row.AddChild(control);if(def.Type==SettingType.Slider){var valLabel=new Label{Text=FormatSliderValue(currentBlob,def),CustomMinimumSize=new Vector2(48,0),HorizontalAlignment=HorizontalAlignment.Right,};row.AddChild(valLabel);}_settingsContainer.AddChild(row);}var timer=new Timer{OneShot=true,Autostart=true,WaitTime=0.01};timer.Timeout+=()=>{timer.QueueFree();LogModSettingsSizes();};_settingsContainer.AddChild(timer);}private void LogModSettingsSizes(){if(_settingsContainer==null)return;GameLogger.Log("MODS_SETTINGS_UI",$"SettingsContainer size: {_settingsContainer.Size}",LogLevel.Debug);foreach(var child in _settingsContainer.GetChildren()){if(child is HBoxContainer row){GameLogger.Log("MODS_SETTINGS_UI",$"  Row size: {row.Size}",LogLevel.Debug);foreach(var rowChild in row.GetChildren()){if(rowChild is HSlider slider)GameLogger.Log("MODS_SETTINGS_UI",$"    HSlider size: {slider.Size}, min_size: {slider.CustomMinimumSize}",LogLevel.Debug);else if(rowChild is Control c)GameLogger.Log("MODS_SETTINGS_UI",$"    {rowChild.GetType().Name} size: {c.Size}",LogLevel.Debug);}}}}/// <summary> Action setting: a button (with an optional confirmation) that runs <see cref="ModSettingsRegistry.RunAction"/> and shows the result next to it. Actions store no value, so they stay out of <c>_controlKeys</c>. </summary>
private void AddActionControls(HBoxContainer row,string modId,ModSettingDefinition def){var button=new Button{Text=TranslationServer.Translate(def.ActionText??def.Name)};var result=new Label{VerticalAlignment=VerticalAlignment.Center,SizeFlagsHorizontal=Control.SizeFlags.ExpandFill};void Run()=>result.Text=TranslationServer.Translate(ModSettingsRegistry.RunAction(modId,def.Key)??string.Empty);button.Pressed+=()=>{if(string.IsNullOrEmpty(def.ConfirmText)){Run();return;}var dialog=new ConfirmationDialog{DialogText=TranslationServer.Translate(def.ConfirmText),Exclusive=true,};dialog.Confirmed+=()=>{Run();dialog.QueueFree();};dialog.Canceled+=dialog.QueueFree;AddChild(dialog);dialog.PopupCentered();};row.AddChild(button);row.AddChild(result);}private static Control CreateControlForType(ModSettingDefinition def){switch(def.Type){case SettingType.Bool:return new CheckButton();case SettingType.Integer:return new SpinBox{AllowGreater=true,AllowLesser=true,Step=1.0,MinValue=int.MinValue,MaxValue=int.MaxValue,};case SettingType.Float:return new SpinBox{AllowGreater=true,AllowLesser=true,Step=0.1,MinValue=double.MinValue,MaxValue=double.MaxValue,};case SettingType.String:return new LineEdit();case SettingType.Enum:{var opt=new OptionButton();if(def.Options!=null){foreach(var kv in def.Options)opt.AddItem(TranslationServer.Translate(kv.Value));}return opt;}case SettingType.Slider:{var slider=new HSlider{MinValue=def.Min,MaxValue=def.Max,Step=0.01,SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill,};slider.SetMeta("step",Variant.From(def.Step>0?def.Step:1.0));return slider;}default:return new HBoxContainer();}}private static void ApplyControlValue(Control control,ModSettingDefinition def,DataBlob blob){switch(def.Type){case SettingType.Bool:if(control is CheckButton checkbox)checkbox.ButtonPressed=blob.GetString(def.Key,def.DefaultValue??"false").ToLowerInvariant()=="true";break;case SettingType.Integer:if(control is SpinBox spin){var defVal=int.TryParse(blob.GetString(def.Key,def.DefaultValue??"0"),out var parsed)?parsed:0;spin.Value=blob.GetInt(def.Key,defVal);}break;case SettingType.Float:if(control is SpinBox spinFloat){var defVal=float.TryParse(blob.GetString(def.Key,def.DefaultValue??"0"),out var parsed)?parsed:0f;spinFloat.Value=blob.GetFloat(def.Key,defVal);}break;case SettingType.String:if(control is LineEdit lineEdit)lineEdit.Text=blob.GetString(def.Key,def.DefaultValue??"");break;case SettingType.Enum:if(control is OptionButton opt){var current=blob.GetString(def.Key,def.DefaultValue??"");opt.Selected=GetOptionIndex(def,current);}break;case SettingType.Slider:if(control is HSlider slider){var defVal=float.TryParse(blob.GetString(def.Key,def.DefaultValue??"0"),out var parsed)?parsed:0f;slider.Value=blob.GetFloat(def.Key,defVal);}break;}}private static string ReadControlValue(Control control,SettingType type,ModSettingDefinition def){switch(type){case SettingType.Bool:return control is CheckButton checkbox?checkbox.ButtonPressed.ToString().ToLowerInvariant():"false";case SettingType.Integer:return control is SpinBox spin?((int)spin.Value).ToString():"0";case SettingType.Float:return control is SpinBox spinFloat?spinFloat.Value.ToString("F4"):"0";case SettingType.String:return control is LineEdit lineEdit?lineEdit.Text:"";case SettingType.Enum:if(control is OptionButton opt&&def.Options!=null&&opt.Selected>=0&&opt.Selected<def.Options.Count){var idx=0;foreach(var kv in def.Options){if(idx==opt.Selected)return kv.Key;idx++;}}return def.DefaultValue??"";case SettingType.Slider:return control is HSlider slider?slider.Value.ToString("F4"):"0";default:return"";}}private static string FormatSliderValue(DataBlob blob,ModSettingDefinition def){var value=blob.GetFloat(def.Key,float.TryParse(def.DefaultValue,out var d)?d:0f);var step=def.Step>0?def.Step:1f;if(step>=1f)return$"{(int)value}";return$"{value:F2}";}private static int GetOptionIndex(ModSettingDefinition def,string key){if(def.Options==null)return 0;var idx=0;foreach(var kv in def.Options){if(kv.Key==key)return idx;idx++;}return 0;}private void OnModSettingChanged(){if(!_initialized||string.IsNullOrEmpty(_selectedModId))return;var blob=new DataBlob();var definitions=ModSettingsRegistry.GetDefinitions(_selectedModId);foreach(var def in definitions){var control=FindControlForKey(def.Key);if(control==null)continue;var value=ReadControlValue(control,def.Type,def);blob.SetString(def.Key,value);if(def.Type==SettingType.Slider)UpdateSliderValueLabel(control);}ModSettingsRegistry.SaveModSettings(_selectedModId,blob);ModSettingsRegistry.ApplyValues(_selectedModId,blob);}private Control FindControlForKey(string key){foreach(var kv in _controlKeys){if(kv.Value==key)return kv.Key;}return null;}private void UpdateSliderValueLabel(Control slider){if(!(slider is HSlider hslider))return;var parent=slider.GetParent();if(parent==null)return;var lastChild=parent.GetChild(parent.GetChildCount()-1);if(lastChild is Label valLabel){var step=(double)slider.GetMeta("step",1.0).AsDouble();if(step>=1.0)valLabel.Text=$"{(int)hslider.Value}";else valLabel.Text=$"{hslider.Value:F2}";}}private void OnResetModSettings(){if(string.IsNullOrEmpty(_selectedModId))return;ModSettingsRegistry.DeleteModSettings(_selectedModId);BuildModSettingsPanel(_selectedModId);}}}

===== FILE mods/menu_base/test_suites/hot_reload/src/HotReloadTestDriverNode.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using Cthangover.Core.Bootstrap;using Cthangover.Core.Events;using Cthangover.Core.Games;using Cthangover.Core.Mods;using Cthangover.Core.Mods.HotReload;using Cthangover.Core.Utils;using Godot;namespace Mods.HotReloadTest{/// <summary> Hot reload in the running front-end, driven through the real file watcher. Two probe mods are written into <c>user://mods</c>: a library returning a version string and an app mod (depending on it) whose initializer puts a node with that string into <c>/root</c>. <list type="number"> <item>v1 is loaded by adding the probes to the selection and writing the files.</item> <item>Editing the library to v2 reloads both probes (the dependent cascades), the old contexts are collected, the front-end stays.</item> <item>A compile error in the library is rejected: v2 keeps running.</item> <item>Fixing it (v3) reloads again.</item> <item>Dropping the probes from the selection unloads them and their node.</item> </list> Reload outcomes are read from <see cref="ModSetReloadedEvent"/>s, since the watcher may add no-op reloads of its own. Prints <c>[TEST_RESULT] name=hot_reload</c>. </summary>
public partial class HotReloadTestDriverNode:Node{public const string Flag="--itest=hot_reload";private const string LibId="hot_reload_probe_lib";private const string AppId="hot_reload_probe_app";private const string ProbeNodeName="HotReloadProbeNode";private const float TimeoutSeconds=300f;private readonly List<string>failures=new();private readonly List<ModSetReloadedEvent>reloads=new();private int passed;private int step;private double elapsed;private double stepAt;private bool done;private GameLaunchArgs startSelection;private string modsRoot;public override void _Ready(){ProcessMode=ProcessModeEnum.Always;ModEventBus.Subscribe<ModSetReloadedEvent>(OnReloaded);modsRoot=ProjectSettings.GlobalizePath("user://mods");}public override void _ExitTree(){ModEventBus.Unsubscribe<ModSetReloadedEvent>(OnReloaded);}private void OnReloaded(ModSetReloadedEvent e)=>reloads.Add(e);public override void _Process(double delta){if(done)return;elapsed+=delta;if(elapsed>TimeoutSeconds){failures.Add($"timeout at step {step} (probe='{ProbeValue()}', reloads={reloads.Count})");Finish();return;}if(elapsed-stepAt<0.25)return;stepAt=elapsed;try{RunStep();}catch(Exception ex){failures.Add($"step {step}: {ex.GetType().Name}: {ex.Message}");Finish();}}private static bool Busy=>GameSession.IsSwitching||ModManager.Instance.IsReloading||ModHotReloadService.IsReloadRunning;private void RunStep(){switch(step){case 0:if(!Boot.IsReady)return;// First launch shows the launcher screen; go on like a player.
if(GetTree().CurrentScene?.FindChild("LauncherPlay",true,false)is Button launcherPlay){launcherPlay.EmitSignal(BaseButton.SignalName.Pressed);return;}if(GetTree().CurrentScene?.FindChild("play",true,false)==null)return;Check("hot reload watcher is installed",ModHotReloadService.Instance!=null);startSelection=GameLaunchArgs.Current;GameLaunchArgs.SetCurrent(GameLaunchArgs.Parse(startSelection.UserArgs.Concat(new[]{GameLaunchArgs.WithModsPrefix+AppId})));reloads.Clear();WriteProbes("v1");break;case 1:if(Busy||ProbeValue()!="v1")return;Check("probes loaded by the watcher",AnyApplied(LoadedBoth));CheckNoLeaks("v1");reloads.Clear();WriteLib("v2",compiles:true);break;case 2:if(Busy||ProbeValue()!="v2")return;Check("editing the library reloads it and its dependent",AnyApplied(UnloadedAndLoadedBoth));Check("the front-end stays loaded",!AnyApplied(UnloadedBaseMenu));CheckNoLeaks("v2");reloads.Clear();WriteLib("v2",compiles:false);break;case 3:if(Busy|| !reloads.Any(IsRejectedForLib))return;Check("a compile error rejects the reload",true);Check("the previous code keeps running",ProbeValue()=="v2");reloads.Clear();WriteLib("v3",compiles:true);break;case 4:if(Busy||ProbeValue()!="v3")return;Check("fixing the error reloads again",AnyApplied(LoadedBoth));CheckNoLeaks("v3");reloads.Clear();GameLaunchArgs.SetCurrent(startSelection);StartManualReload();break;case 5:if(Busy||ProbeValue()!=null|| !AnyApplied(UnloadedBoth))return;Check("dropping the probes unloads them and their node",true);CheckNoLeaks("removal");DeleteProbes();Finish();return;}step++;}private static async void StartManualReload()=>await ModHotReloadService.ReloadNowAsync();private string ProbeValue(){var node=GetTree().Root.GetNodeOrNull(ProbeNodeName);return node!=null&&IsInstanceValid(node)&&node.HasMeta("value")?node.GetMeta("value").AsString():null;}private bool AnyApplied(Func<ModSetReloadedEvent,bool>predicate){foreach(var reload in reloads){if(reload.Applied&&predicate(reload))return true;}return false;}private static bool LoadedBoth(ModSetReloadedEvent e)=>e.Loaded.Contains(LibId)&&e.Loaded.Contains(AppId);private static bool UnloadedBoth(ModSetReloadedEvent e)=>e.Unloaded.Contains(LibId)&&e.Unloaded.Contains(AppId);private static bool UnloadedAndLoadedBoth(ModSetReloadedEvent e)=>UnloadedBoth(e)&&LoadedBoth(e);private static bool UnloadedBaseMenu(ModSetReloadedEvent e)=>e.Unloaded.Contains("menu_base");private static bool IsRejectedForLib(ModSetReloadedEvent e)=> !e.Applied&&e.Errors.Any(IsLibError);private static bool IsLibError(string error)=>error.Contains(LibId);private void CheckNoLeaks(string stage){var leaked=new List<string>();foreach(var reload in reloads)leaked.AddRange(reload.Leaked);Check($"every released context collected ({stage})",leaked.Count==0);if(leaked.Count>0)GameLogger.Log("TEST",$"HotReloadTest: leaked: {string.Join(", ", leaked)}",LogLevel.Error);}private void WriteProbes(string version){var app=Path.Combine(modsRoot,AppId);Directory.CreateDirectory(Path.Combine(app,"src"));File.WriteAllText(Path.Combine(app,"manifest.json"),"{ \"id\": \""+AppId+"\", \"name\": \"Hot reload probe app\", \"version\": \"1.0.0\", \"author\": \"test\", "+"\"sources\": [\"src/*.cs\"], \"depends\": [\""+LibId+">=1\"] }");File.WriteAllText(Path.Combine(app,"src","ProbeApp.cs"),ProbeAppSource);WriteLib(version,compiles:true);}private void WriteLib(string version,bool compiles){var lib=Path.Combine(modsRoot,LibId);Directory.CreateDirectory(Path.Combine(lib,"src"));File.WriteAllText(Path.Combine(lib,"manifest.json"),"{ \"id\": \""+LibId+"\", \"name\": \"Hot reload probe library\", \"version\": \"1.0.0\", \"author\": \"test\", \"sources\": [\"src/*.cs\"] }");var body=compiles?"\""+version+"\";":"\""+version+"\"";File.WriteAllText(Path.Combine(lib,"src","ProbeLib.cs"),"namespace HotReloadProbe\n{\n    public static class ProbeLib\n    {\n        public static string Value() => "+body+"\n    }\n}\n");}private void DeleteProbes(){foreach(var id in new[]{LibId,AppId}){var dir=Path.Combine(modsRoot,id);if(Directory.Exists(dir))Directory.Delete(dir,recursive:true);}}private const string ProbeAppSource=@"using Godot;

namespace HotReloadProbeApp
{
    public partial class ProbeNode : Node
    {
    }

    public class ProbeAppInitializer : Cthangover.Core.Mods.IModInitializer
    {
        public void OnModLoaded(string modId) { }

        public void OnModResourcesReady()
        {
            var root = (Engine.GetMainLoop() as SceneTree)?.Root;
            if (root == null)
                return;
            root.GetNodeOrNull(""HotReloadProbeNode"")?.Free();
            var node = new ProbeNode { Name = ""HotReloadProbeNode"" };
            node.SetMeta(""value"", HotReloadProbe.ProbeLib.Value());
            root.AddChild(node);
        }
    }
}
";private void Check(string name,bool ok){if(ok){passed++;GameLogger.Log("TEST",$"HotReloadTest: [OK] {name}");}else{failures.Add(name);GameLogger.Log("TEST",$"HotReloadTest: [FAIL] {name}",LogLevel.Error);}}private void Finish(){if(done)return;done=true;foreach(var failure in failures)GameLogger.Log("TEST",$"HotReloadTest: failed: {failure}");GameLogger.Log("TEST",$"[TEST_RESULT] name=hot_reload passed={passed} failed={failures.Count}");GetTree().Quit(failures.Count==0?0:1);}}}

===== FILE mods/menu_base/test_suites/hot_reload/src/HotReloadTestInitializer.cs =====
using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Mods.HotReloadTest{/// <summary>Queues <see cref="HotReloadTestDriverNode"/> only for <c>--itest=hot_reload</c>.</summary>
public class HotReloadTestInitializer:IModInitializer{private static bool queued;public void OnModLoaded(string modId){}public void OnModResourcesReady(){if(queued|| !OS.GetCmdlineUserArgs().Contains(HotReloadTestDriverNode.Flag))return;queued=true;GameLogger.Log("TEST","HotReloadTest: activating");(Godot.Engine.GetMainLoop()as SceneTree)?.Root?.CallDeferred("add_child",new HotReloadTestDriverNode());}}}

===== FILE mods/menu_base/test_suites/menu_flow/src/MenuFlowTestDriverNode.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text.Json;using Cthangover.BaseMenu;using Cthangover.Core.Bootstrap;using Cthangover.Core.Games;using Cthangover.Core.Mods;using Cthangover.Core.Settings;using Cthangover.Core.Utils;using Godot;namespace Mods.MenuFlowTest{/// <summary> Front-end walk-through, one step per tick so deferred UI work settles: only the menu mods are loaded; the menu offers Play (no New game without a game); Play opens a card per game; the save browser lists a game's save; a player's own game can be deleted from the collection editor (a built-in one cannot) while its mods stay; the composition of a game is computed without loading it; Continue sees the last session. Prints <c>[TEST_RESULT] name=menu_flow</c>. </summary>
public partial class MenuFlowTestDriverNode:Node{private const string GameId="novel_template";private const string Slot="slot_942";private const string DeletableId="menu_flow_deletable";private const float TimeoutSeconds=90f;private readonly List<string>failures=new();private int passed;private int step;private double elapsed;private double stepAt;private bool done;private Node hub;public override void _Process(double delta){if(done)return;elapsed+=delta;if(elapsed>TimeoutSeconds){failures.Add($"timeout at step {step}");Finish();return;}if(elapsed-stepAt<0.5)return;stepAt=elapsed;try{RunStep();}catch(Exception ex){failures.Add($"step {step}: {ex.GetType().Name}: {ex.Message}");Finish();}}private Control Menu=>GetTree().CurrentScene as Control;private void RunStep(){switch(step){case 0:if(!Boot.IsReady)return;// First launch shows the launcher screen; go on like a player.
if(GetTree().CurrentScene?.FindChild("LauncherPlay",true,false)is Button launcherPlay){launcherPlay.EmitSignal(BaseButton.SignalName.Pressed);return;}if(GetTree().CurrentScene is not MainMenu)return;Check("front-end only",ActiveGame.IsFrontendOnly&& !ActiveGame.IsActive);Check("menu mods loaded",ModRegistry.Instance.Mods.ContainsKey("menu_base")&&ModRegistry.Instance.Mods.ContainsKey("mod_manager"));Check("game content not loaded",!ModRegistry.Instance.Mods.ContainsKey("cthangover_game"));Check("Play button shown",Menu.FindChild("play",true,false)is Button);Check("New game hidden without a game",Menu.FindChild("new_game",true,false)==null);break;case 1:((Button)Menu.FindChild("play",true,false)).EmitSignal(BaseButton.SignalName.Pressed);break;case 2:{var picker=Menu.GetNodeOrNull<GamePickerPanel>("GamePickerPanel");Check("Play opens the game picker",picker?.Visible==true);var games=GameCatalog.Games();var newGameText=TranslationServer.Translate("menu/play/new_game").ToString();var startButtons=Buttons(picker).Where(b=>b.Text==newGameText).ToList();Check($"one card per game ({games.Count})",games.Count>=2&&startButtons.Count==games.Count);var ready=games.Count(g=>g.IsReady);Check("ready games can start",startButtons.Count(b=> !b.Disabled)==ready&&ready>0);var configureText=TranslationServer.Translate("menu/play/configure").ToString();var configure=Buttons(picker).FirstOrDefault(b=>b.Text==configureText);Check("cards offer Configure",configure!=null);configure?.EmitSignal(BaseButton.SignalName.Pressed);break;}case 3:{hub=Menu.FindChildren("*","",true,false).FirstOrDefault(n=>n.HasMethod("OpenCollectionEditor"));Check("Configure opens the mod manager",hub!=null);var duplicateText=TranslationServer.Translate("mod_manager/collection_duplicate").ToString();Check("editor opened with Duplicate",hub!=null&&Buttons(hub).Any(b=>b.Text==duplicateText&&b.IsVisibleInTree()));Check("a built-in game offers no Delete",DeleteButton()==null);var user=ModCollectionStore.LoadUser();user.Add(new ModCollection{Id=DeletableId,Name="Deletable",Kind=ModCollectionKind.Game});ModCollectionStore.SaveUser(user);hub?.Call("OpenCollectionEditor",DeletableId);break;}case 4:{var delete=DeleteButton();Check("a player's own game offers Delete",delete!=null);delete?.EmitSignal(BaseButton.SignalName.Pressed);break;}case 5:{var dialog=hub?.FindChildren("*","ConfirmationDialog",true,false).OfType<ConfirmationDialog>().FirstOrDefault(d=>d.Visible);var expected=string.Format(TranslationServer.Translate("mod_manager/collection_delete_confirm"),"Deletable",0);Check("Delete warns that the mods stay",dialog?.DialogText==expected);dialog?.EmitSignal(AcceptDialog.SignalName.Confirmed);Check("the collection is deleted",ModCollectionStore.LoadAll().All(c=>c.Id!=DeletableId));Check("its mods stay in the system",ModRegistry.Instance.AllDiscoveredMods.ContainsKey("card_battle"));hub?.QueueFree();break;}case 6:WriteFakeSave();Menu.GetNode<SaveLoadMenu>("SaveLoadPanel").OpenForGame(GameId);break;case 7:{var browser=Menu.GetNode<SaveLoadMenu>("SaveLoadPanel");var slots=browser.FindChildren("*","",true,false).OfType<SaveSlotItem>().ToList();Check("save browser lists the game's save",slots.Any(s=>s.Model?.FileName==Slot));Check("save list carries the game id",SaveService.ListSaves(GameId).Any(s=>s.FileName==Slot&&s.GameId==GameId));browser.Visible=false;var composition=GameCatalog.Composition(GameId);Check("composition computed without loading the game",composition?.Any(m=>m.Id=="my_novel_game")==true);var diff=SaveCompositionDiff.Compute(new[]{new SaveGameMod{Id="cthangover_game",Version="2.0.0"},new SaveGameMod{Id="ghost_mechanic",Version="1.0.0"}},composition);Check("comparison reports removed and added mechanics",diff.Removed.Contains("ghost_mechanic")&&diff.Added.Contains("my_novel_game"));LastSessionStore.Record(GameId,Slot);Check("Continue sees the last session",LastSessionStore.Load()?.GameId==GameId);Cleanup();Finish();break;}}step++;}private Button DeleteButton(){var text=TranslationServer.Translate("mod_manager/collection_delete").ToString();return Buttons(hub).FirstOrDefault(b=>b.Text==text&&b.IsVisibleInTree());}private static IEnumerable<Button>Buttons(Node root)=>root==null?Enumerable.Empty<Button>():root.FindChildren("*","Button",true,false).OfType<Button>();private static void WriteFakeSave(){var package=new SavePackage{SaveId=Slot,Timestamp=DateTime.UtcNow,CoreVersion="0.1.0",Game=new SaveGameInfo{Id=GameId,Name="Novel Template",Version="1.0.0"},};package.ModData[SavePackage.CoreSlotKey]=new ModSlot();var dir=ProjectSettings.GlobalizePath(SaveService.GameDirectory(GameId));Directory.CreateDirectory(dir);File.WriteAllText(Path.Combine(dir,Slot+".json"),JsonSerializer.Serialize(package,JsonOptionsProvider.WriteCompactOptions));}private static void Cleanup(){var save=Path.Combine(ProjectSettings.GlobalizePath(SaveService.GameDirectory(GameId)),Slot+".json");if(File.Exists(save))File.Delete(save);ModCollectionStore.RemoveUser(DeletableId);var last=ProjectSettings.GlobalizePath("user://last_session.json");if(File.Exists(last))File.Delete(last);}private void Check(string name,bool ok){if(ok){passed++;GameLogger.Log("TEST",$"MenuFlowTest: [OK] {name}");}else{failures.Add(name);GameLogger.Log("TEST",$"MenuFlowTest: [FAIL] {name}",LogLevel.Error);}}private void Finish(){if(done)return;done=true;foreach(var failure in failures)GameLogger.Log("TEST",$"MenuFlowTest: failed: {failure}");GameLogger.Log("TEST",$"[TEST_RESULT] name=menu_flow passed={passed} failed={failures.Count}");GetTree().Quit(failures.Count==0?0:1);}}}

===== FILE mods/menu_base/test_suites/menu_flow/src/MenuFlowTestInitializer.cs =====
using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Mods.MenuFlowTest{/// <summary>Queues <see cref="MenuFlowTestDriverNode"/> only for <c>--itest=menu_flow</c>.</summary>
public class MenuFlowTestInitializer:IModInitializer{private static bool queued;public void OnModLoaded(string modId){}public void OnModResourcesReady(){if(queued|| !OS.GetCmdlineUserArgs().Contains("--itest=menu_flow"))return;queued=true;GameLogger.Log("TEST","MenuFlowTest: activating");(Godot.Engine.GetMainLoop()as SceneTree)?.Root?.CallDeferred("add_child",new MenuFlowTestDriverNode());}}}

===== FILE mods/menu_base/test_suites/new_game/src/NewGameTestDriverNode.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Bootstrap;using Cthangover.Core.Events;using Cthangover.Core.Games;using Cthangover.Core.Mods;using Cthangover.Core.Mods.HotReload;using Cthangover.Core.Scenes;using Cthangover.Core.UI.Dialog;using Cthangover.Core.Utils;using Godot;namespace Mods.NewGameTest{/// <summary> Plays the games the way a player does, in ONE process: no restart happens anywhere, every game switch replaces the mod set in place. <list type="number"> <item>Front-end menu → Play → the <c>cthangover</c> card → New game: the process switches to the game, enters <c>prolog_alley</c> and the opening dialog about the hungover Marao is clicked through.</item> <item>Hot reload inside the game (forced reload of <c>cthangover_party</c>): the session is parked, the mod reloads into a new context and the game comes back in <c>prolog_alley</c>.</item> <item>Back to the menu → <c>novel_template</c>: mods outside that game are unloaded and a scene of the game is entered.</item> <item>Back to the menu → <c>cthangover</c> again → <c>prolog_alley</c>.</item> </list> After every switch the released mod contexts must be collected (<c>ModManager.LastReload.Leaked</c> empty). Scene entries are taken from <see cref="SceneEnteredEvent"/>, not by polling the current scene name: a game's entry scene may move on to the next scene within a frame. Prints <c>[TEST_RESULT] name=new_game</c>. </summary>
public partial class NewGameTestDriverNode:Node{public const string Flag="--itest=new_game";private const string MainGame="cthangover";private const string SecondGame="novel_template";private const string PrologScene="prolog_alley";private const string HotReloadedMod="cthangover_party";private const float TimeoutSeconds=420f;private readonly List<string>failures=new();private readonly List<string>enteredScenes=new();private int passed;private int step;private double elapsed;private double stepAt;private bool done;private bool sawMarao;private int clicks;private bool hotReloadDone;public override void _Ready(){// The driver must keep ticking across every mod-set switch of the run.
ProcessMode=ProcessModeEnum.Always;ModEventBus.Subscribe<SceneEnteredEvent>(OnSceneEntered);}public override void _ExitTree(){ModEventBus.Unsubscribe<SceneEnteredEvent>(OnSceneEntered);}public override void _Process(double delta){if(done)return;elapsed+=delta;if(elapsed>TimeoutSeconds){failures.Add($"timeout at step {step} (scene='{SceneManager.GetCurrentSceneName()}', game='{ActiveGame.Id}')");Finish();return;}if(elapsed-stepAt<0.25)return;stepAt=elapsed;try{RunStep();}catch(Exception ex){failures.Add($"step {step}: {ex.GetType().Name}: {ex.Message}");Finish();}}private Control Menu=>GetTree().CurrentScene as Control;private static bool Busy=>GameSession.IsSwitching||ModManager.Instance.IsReloading||ModHotReloadService.IsReloadRunning;private void OnSceneEntered(SceneEnteredEvent e)=>enteredScenes.Add(e.SceneName);private void RunStep(){switch(step){case 0:if(!Boot.IsReady|| !PressMenuPlay())return;Check("front-end menu offers Play",ActiveGame.IsFrontendOnly);break;case 1:if(!StartFromPicker(MainGame))return;break;case 2:if(Busy||ActiveGame.Id!=MainGame|| !enteredScenes.Contains(PrologScene))return;CheckSwitch(MainGame,expectUnload:false);Check($"entered '{PrologScene}'",true);break;case 3:{var box=DialogBox();if(box==null|| !box.IsRunning)return;Check("prologue dialog runs",true);break;}case 4:{// Click through the opening like a player, watching the text for Marao.
var box=DialogBox();sawMarao|=DialogMentionsMarao();if(box!=null&& !box.IsDialogEnded){if(box.IsWaitingAnswer)box.TrySelectVariant(0);else box.NextAction();clicks++;return;}Check("prologue text is about Marao",sawMarao);Check("opening dialog has several lines",clicks>=5);Check($"still in '{PrologScene}' after the opening",SceneManager.GetCurrentSceneName()==PrologScene);enteredScenes.Clear();StartHotReload();break;}case 5:{if(!hotReloadDone||Busy|| !enteredScenes.Contains(PrologScene))return;var reload=ModManager.Instance.LastReload;Check("in-game hot reload applied",reload?.Applied==true);Check($"hot reload reloaded '{HotReloadedMod}'",reload?.Plan?.Load.Contains(HotReloadedMod)==true);Check("hot reload kept the front-end loaded",reload?.Plan?.Keep.Contains("menu_base")==true);Check("hot reload released every old context",reload?.Leaked.Count==0);Check($"hot reload kept the game '{MainGame}'",ActiveGame.Id==MainGame);Check($"session came back in '{PrologScene}'",true);GetTree().Root.GetNode<GodotSceneService>("GodotSceneService").SwitchToMenu();break;}case 6:if(Busy|| !PressMenuPlay())return;enteredScenes.Clear();break;case 7:if(!StartFromPicker(SecondGame))return;break;case 8:if(Busy||ActiveGame.Id!=SecondGame||enteredScenes.Count==0)return;CheckSwitch(SecondGame,expectUnload:true);Check($"entered a scene of '{SecondGame}' ('{enteredScenes[0]}')",true);GetTree().Root.GetNode<GodotSceneService>("GodotSceneService").SwitchToMenu();break;case 9:if(Busy|| !PressMenuPlay())return;enteredScenes.Clear();break;case 10:if(!StartFromPicker(MainGame))return;break;case 11:if(Busy||ActiveGame.Id!=MainGame|| !enteredScenes.Contains(PrologScene))return;CheckSwitch(MainGame,expectUnload:false);Check($"back in '{PrologScene}' after switching games twice",true);Finish();return;}step++;}private async void StartHotReload(){await ModHotReloadService.ReloadNowAsync(new[]{HotReloadedMod});hotReloadDone=true;}/// <summary>The launcher's or the main menu's Play button, pressed; false while neither is on screen.</summary>
private bool PressMenuPlay(){if(GetTree().CurrentScene?.FindChild("LauncherPlay",true,false)is Button launcherPlay){launcherPlay.EmitSignal(BaseButton.SignalName.Pressed);return false;}if(Menu?.FindChild("play",true,false)is not Button play)return false;play.EmitSignal(BaseButton.SignalName.Pressed);return true;}/// <summary>Presses New game on the game's card in the picker; false until the picker is open.</summary>
private bool StartFromPicker(string gameId){var picker=Menu?.FindChild("GamePickerPanel",true,false)as Control;if(picker?.Visible!=true)return false;var game=FindGame(gameId);Check($"game '{gameId}' is listed and ready",game?.IsReady==true);var start=StartButton(picker,game?.Name??gameId);Check($"'{gameId}' card has an enabled New game button",start is{Disabled:false});if(start==null||start.Disabled||failures.Count>0){Finish();return false;}start.EmitSignal(BaseButton.SignalName.Pressed);Check($"New game of '{gameId}' switches in-process, without a restart",!OS.IsRestartOnExitSet());return true;}private void CheckSwitch(string gameId,bool expectUnload){var reload=ModManager.Instance.LastReload;Check($"switch into '{gameId}' applied",reload?.Applied==true);Check($"switch into '{gameId}' kept the front-end loaded",reload?.Plan?.Keep.Contains("menu_base")==true);if(expectUnload)Check($"switch into '{gameId}' unloaded mods outside the game",reload?.Plan?.Unload.Count>0);Check($"switch into '{gameId}' released every old context",reload?.Leaked.Count==0);if(reload?.Leaked.Count>0)GameLogger.Log("TEST",$"NewGameTest: leaked contexts: {string.Join(", ", reload.Leaked)}",LogLevel.Error);}private static ModCollectionGameSummary FindGame(string gameId){foreach(var game in GameCatalog.Games()){if(game.Collection.Id==gameId)return game;}return null;}/// <summary>The New game button on the card titled <paramref name="title"/>.</summary>
private static Button StartButton(Node picker,string title){if(picker==null)return null;var newGameText=TranslationServer.Translate("menu/play/new_game").ToString();// A card is the innermost panel holding exactly one New game button (the picker frame holds them all).
foreach(var card in picker.FindChildren("*","PanelContainer",true,false)){Button start=null;var starts=0;foreach(var child in card.FindChildren("*","Button",true,false)){if(child is Button button&&button.Text==newGameText){start=button;starts++;}}if(starts==1&&HasLabel(card,title))return start;}return null;}private static bool HasLabel(Node card,string title){foreach(var child in card.FindChildren("*","Label",true,false)){if(child is Label label&&label.Text==title)return true;}return false;}private IDialogBox DialogBox()=>GetTree().Root.FindChild("DialogBox",true,false)as IDialogBox;private bool DialogMentionsMarao(){if(DialogBox()is not Node node)return false;foreach(var child in node.FindChildren("*","",true,false)){var text=child switch{RichTextLabel r=>r.Text,Label l=>l.Text,_=>null,};if(text!=null&&(text.Contains("Марао")||text.Contains("Marao")))return true;}return false;}private void Check(string name,bool ok){if(ok){passed++;GameLogger.Log("TEST",$"NewGameTest: [OK] {name}");}else{failures.Add(name);GameLogger.Log("TEST",$"NewGameTest: [FAIL] {name}",LogLevel.Error);}}private void Finish(){if(done)return;done=true;foreach(var failure in failures)GameLogger.Log("TEST",$"NewGameTest: failed: {failure}");GameLogger.Log("TEST",$"[TEST_RESULT] name=new_game passed={passed} failed={failures.Count}");GetTree().Quit(failures.Count==0?0:1);}}}

===== FILE mods/menu_base/test_suites/new_game/src/NewGameTestInitializer.cs =====
using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Mods.NewGameTest{/// <summary>Queues <see cref="NewGameTestDriverNode"/> only for <c>--itest=new_game</c>.</summary>
public class NewGameTestInitializer:IModInitializer{private static bool queued;public void OnModLoaded(string modId){}public void OnModResourcesReady(){if(queued|| !OS.GetCmdlineUserArgs().Contains(NewGameTestDriverNode.Flag))return;queued=true;GameLogger.Log("TEST","NewGameTest: activating");(Godot.Engine.GetMainLoop()as SceneTree)?.Root?.CallDeferred("add_child",new NewGameTestDriverNode());}}}

===== FILE mods/menu_base/test_suites/save_dormancy/src/SaveDormancyProbe.cs =====
using Cthangover.Core.Mods;using Cthangover.Core.Settings;namespace Mods.SaveDormancyTest{/// <summary> Saveable of the test mod: records whether a load restored its slot or told it that it joined an existing game without data. </summary>
public class SaveDormancyProbe:IModSaveable{public static bool Joined{get;set;}public static bool Restored{get;set;}public void OnSave(ModSlot slot){slot.Data["probe"]=new DataBlob();}public bool OnLoad(ModSlot slot,out string errorMessage){Restored=true;errorMessage=null;return true;}public void OnJoinedExistingGame(){Joined=true;}}}

===== FILE mods/menu_base/test_suites/save_dormancy/src/SaveDormancyTestDriverNode.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text.Json;using Cthangover.Core.Bootstrap;using Cthangover.Core.Games;using Cthangover.Core.Mods;using Cthangover.Core.Settings;using Cthangover.Core.Utils;using Godot;namespace Mods.SaveDormancyTest{/// <summary> Runs the composition-change round trip once the game is running and prints <c>[TEST_RESULT] name=save_dormancy</c>: a save of the active game lands in its own folder; data of a mod the game runs without survives a load and the next save as a dormant slot; a mod without data in the save gets <c>OnJoinedExistingGame</c>; the purge drops dormant data. </summary>
public partial class SaveDormancyTestDriverNode:Node{private const string SlotA="itest_dormancy_a";private const string SlotB="itest_dormancy_b";private const string AbsentMod="recruiting";private const float TimeoutSeconds=90f;private readonly List<string>failures=new();private int passed;private double elapsed;private bool done;public override void _Process(double delta){if(done)return;elapsed+=delta;if(elapsed>TimeoutSeconds){failures.Add("timeout waiting for the game");Finish();return;}if(!Boot.IsReady||GameData.Instance?.Runtime==null||GetTree().CurrentScene==null||elapsed<3)return;try{Run();}catch(Exception ex){failures.Add($"unhandled {ex.GetType().Name}: {ex.Message}");}Finish();}private void Run(){Check("a game collection is active",ActiveGame.IsActive);Check($"'{AbsentMod}' is not part of the game",!ActiveGame.ModIds.Contains(AbsentMod));SaveService.Save(SlotA);var pathA=FilePath(SlotA);Check("save lands in the game's folder",pathA.Contains($"saves{Path.DirectorySeparatorChar}{ActiveGame.Id}",StringComparison.Ordinal)||pathA.Contains($"saves/{ActiveGame.Id}",StringComparison.Ordinal));var package=Read(pathA);Check("save records the game id",package?.Game?.Id==ActiveGame.Id);Check("save records the composition",package?.Game?.Mods?.Count>0);// Simulate a save written while the game still had the mechanic, and before this test mod joined the game.
var absentSlot=new ModSlot{Metadata=new ModMetadata{ModId=AbsentMod,ModVersion="1.1.0"}};absentSlot.Data["marker"]=new DataBlob(1,new Dictionary<string,string>{["value"]="42"});package.ModData[AbsentMod]=absentSlot;package.ModData.Remove("menu_base__save_dormancy");File.WriteAllText(pathA,JsonSerializer.Serialize(package,JsonOptionsProvider.WriteCompactOptions));SaveDormancyProbe.Joined=false;SaveDormancyProbe.Restored=false;Check("load succeeds despite a missing mod",SaveService.Load(SlotA));Check("missing mod's data went dormant",SaveService.LastLoadDormancy?.Dormant.ContainsKey(AbsentMod)==true);Check("mod without data joined the game",SaveDormancyProbe.Joined&& !SaveDormancyProbe.Restored);SaveService.Save(SlotB);var resaved=Read(FilePath(SlotB));var carried=resaved?.Dormant!=null&&resaved.Dormant.TryGetValue(AbsentMod,out var slot)?slot:null;Check("dormant slot carried into the next save",carried?.Metadata?.Dormant==true);Check("dormant data unchanged",carried?.Data!=null&&carried.Data.TryGetValue("marker",out var marker)&&marker.ReadJson<Dictionary<string,string>>().GetValueOrDefault("value")=="42");Check("live mods still saved",resaved?.ModData?.ContainsKey(SavePackage.CoreSlotKey)==true);var message=ModSettingsRegistry.RunAction("menu_base","purge_dormant");Check("purge action runs from the save settings",!string.IsNullOrEmpty(message));Check("purge removed dormant data from the save",Read(FilePath(SlotB))?.Dormant==null);SaveService.DeleteSave(SlotA);SaveService.DeleteSave(SlotB);}private static string FilePath(string slot)=>ProjectSettings.GlobalizePath($"{SaveService.SaveDirectory}/{slot}.json");private static SavePackage Read(string path)=>File.Exists(path)?JsonSerializer.Deserialize<SavePackage>(File.ReadAllText(path),JsonOptionsProvider.ReadOptions):null;private void Check(string name,bool ok){if(ok){passed++;GameLogger.Log("TEST",$"SaveDormancyTest: [OK] {name}");}else{failures.Add(name);GameLogger.Log("TEST",$"SaveDormancyTest: [FAIL] {name}",LogLevel.Error);}}private void Finish(){if(done)return;done=true;foreach(var failure in failures)GameLogger.Log("TEST",$"SaveDormancyTest: failed: {failure}");GameLogger.Log("TEST",$"[TEST_RESULT] name=save_dormancy passed={passed} failed={failures.Count}");GetTree().Quit(failures.Count==0?0:1);}}}

===== FILE mods/menu_base/test_suites/save_dormancy/src/SaveDormancyTestInitializer.cs =====
using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Mods.SaveDormancyTest{/// <summary>Queues <see cref="SaveDormancyTestDriverNode"/> only for <c>--itest=save_dormancy</c>.</summary>
public class SaveDormancyTestInitializer:IModInitializer{private static bool queued;public void OnModLoaded(string modId){}public void OnModResourcesReady(){if(queued|| !OS.GetCmdlineUserArgs().Contains("--itest=save_dormancy"))return;queued=true;GameLogger.Log("TEST","SaveDormancyTest: activating");(Godot.Engine.GetMainLoop()as SceneTree)?.Root?.CallDeferred("add_child",new SaveDormancyTestDriverNode());}}}

===== FILE mods/mod_manager/src/ModCollectionsScreen.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Text.Json;using Cthangover.Core.Mods;using Cthangover.Core.Settings;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Mods.ModManager{/// <summary> Fullscreen scheme editor opened from the main menu: the whole game on one canvas exactly like the approved mockup. The header picks the game and creates new games/mechanics, the canvas shows movable and resizable mechanic contours with freely placed mod chips and dependency lines, and the inspector edits the selected group or mod. <para/> All heavy lifting stays in the core: <see cref="ModCollectionStore"/> merges documents, <see cref="ModCollectionResolver"/> expands includes into groups and problems, <see cref="ModCollectionPlan"/> decides what to download and enable, and <see cref="CollectionLayoutStore"/> persists the arrangement. Edits mutate drafts in <c>_overrides</c> — never the loaded documents — and only "Сохранить" writes them back through <see cref="ModCollectionStore.SaveUser"/>, so the player can explore layouts without committing anything. </summary>
public partial class ModCollectionsScreen:ModWidget{private const string RoleExtra="extra";private Button _close;private Button _refresh;private Button _addMechanic;private Button _newGame;private Button _apply;private OptionButton _gameSelect;private CheckBox _edgesToggle;private Button _pillProblems;private Button _pillPlan;private Label _modsCount;private Label _dirty;private Button _json;private Button _reset;private Button _save;private Label _bannerLabel;private Label _detailTitle;private Label _detailMeta;private Label _detailDescription;private VBoxContainer _detailExtra;private PanelContainer _bannerPanel;private CollectionSchemeCanvas _canvas;private Button _tabInspector;private Button _tabProblems;private Button _tabPlan;private HBoxContainer _crumbs;private Button _crumbCollections;private Label _buildName;private Label _buildInstall;private Label _buildEnable;private Label _buildProblems;private Label _buildHint;private Button _buildManager;private Button _buildApply;private List<ModCollection>_collections=new();private readonly Dictionary<string,ModCollection>_overrides=new(StringComparer.OrdinalIgnoreCase);private string _currentId;private ModCollectionGraph _graph;private ModCollectionPlan _plan;private ModCollectionLayout _layout=new();private string _selectedGroupKey;private string _selectedModId;private string _tab="inspector";/// <summary>Mods installed during this session; the registry only sees them after a restart.</summary>
private HashSet<string>_sessionInstalled=new(StringComparer.OrdinalIgnoreCase);private bool _installing;private bool _restartRequired;private string _statusMessage;private Button _duplicate;private Button _revert;private Button _delete;private Label _savesInfo;/// <summary>Drafts created by "Duplicate", mapped to the game they were copied from (offer to copy its saves).</summary>
private readonly Dictionary<string,string>_duplicatedFrom=new(StringComparer.OrdinalIgnoreCase);/// <summary> When true the editor runs as the second level of the collections tab: the close/title/new-game/catalog-refresh header controls are hidden (the hosting <see cref="ModHubScreen"/> owns navigation) and the game selector moves into the breadcrumb row. Editing, saving and applying behaviour are unchanged. </summary>
public bool Embedded{get;set;}/// <summary>Raised when the breadcrumbs ask to go back to the games gallery.</summary>
public event Action GalleryRequested;/// <summary>Raised when a "not found" problem asks to search the mod catalog; the argument is the missing id.</summary>
public event Action<string>OpenManagerRequested;/// <summary>Raised after every rebuild so the hosting hub can refresh its header.</summary>
public event Action ViewChanged;/// <summary>Localized, '@key'-resolved title of the collection being edited; null when nothing is selected.</summary>
public string CurrentCollectionTitle{get{var collection=EffectiveCollection(_currentId);if(collection==null)return null;var raw=collection.GetLocalizedName(TranslationServer.GetLocale());return CollectionText.Resolve(raw);}}/// <summary>Shares the hub-wide set of mods installed this session so gallery and editor stay consistent.</summary>
public void SetSessionInstalled(HashSet<string>sessionInstalled)=>_sessionInstalled=sessionInstalled??new HashSet<string>(StringComparer.OrdinalIgnoreCase);/// <summary> Opens the scheme editor on the given collection id, falling back to the first available game when the id is null or unknown. Selection is cleared — the same fresh view a game-switch produces. </summary>
public void OpenCollection(string collectionId){_currentId=collectionId;_selectedGroupKey=null;_selectedModId=null;// The gallery re-reads the store on every refresh; re-read it here too, so a collection that appeared since this editor was built opens instead of falling back to the first game.
ReloadCollections();}/// <summary>Creates a fresh player game draft and opens it — the gallery's "build your own game" action.</summary>
public void OpenNewGame()=>OnNewGamePressed();protected override void Construct(){MouseFilter=MouseFilterEnum.Stop;CthangoverTheme.Apply(this);BindSceneNodes();SubscribeEvents();CatalogService.LoadCache();ReloadCollections();StartCatalogRefreshIfNeeded();}protected override void Destruct(){UnsubscribeEvents();}private void BindSceneNodes(){_close=GetNode<Button>("Margin/Root/Header/Close");_refresh=GetNode<Button>("Margin/Root/Header/Refresh");_addMechanic=GetNode<Button>("Margin/Root/Header/AddMechanic");_newGame=GetNode<Button>("Margin/Root/Header/NewGame");_gameSelect=GetNode<OptionButton>("Margin/Root/Header/GameSelect");_edgesToggle=GetNode<CheckBox>("Margin/Root/Header/EdgesToggle");_pillProblems=GetNode<Button>("Margin/Root/Header/Problems");_pillPlan=GetNode<Button>("Margin/Root/Header/Plan");_modsCount=GetNode<Label>("Margin/Root/Header/ModsCount");_dirty=GetNode<Label>("Margin/Root/Header/Dirty");_json=GetNode<Button>("Margin/Root/Header/Json");_reset=GetNode<Button>("Margin/Root/Header/Reset");_save=GetNode<Button>("Margin/Root/Header/Save");_bannerPanel=GetNode<PanelContainer>("Margin/Root/Banner");_bannerLabel=GetNode<Label>("Margin/Root/Banner/BannerLabel");_canvas=GetNode<CollectionSchemeCanvas>("Margin/Root/Body/CanvasScroll/SchemeCanvas");var detail="Margin/Root/Body/Details/DetailScroll/Detail/";_detailTitle=GetNode<Label>(detail+"DetailTitle");_detailMeta=GetNode<Label>(detail+"DetailMeta");_detailDescription=GetNode<Label>(detail+"DetailDescription");_detailExtra=GetNode<VBoxContainer>(detail+"DetailExtra");_apply=GetNode<Button>(detail+"Apply");_tabInspector=GetNode<Button>(detail+"Tabs/TabInspector");_tabProblems=GetNode<Button>(detail+"Tabs/TabProblems");_tabPlan=GetNode<Button>(detail+"Tabs/TabPlan");_close.Text=TranslationServer.Translate("mod_manager/back");_refresh.Text=TranslationServer.Translate("mod_manager/refresh");_addMechanic.Text=TranslationServer.Translate("mod_manager/collection_add_mechanic");_newGame.Text=TranslationServer.Translate("mod_manager/collection_new_game");_pillProblems.Text=TranslationServer.Translate("mod_manager/collection_problems");_pillPlan.Text=TranslationServer.Translate("mod_manager/collection_plan");_dirty.Text=TranslationServer.Translate("mod_manager/collection_unsaved");_json.Text=TranslationServer.Translate("mod_manager/collection_json");_reset.Text=TranslationServer.Translate("mod_manager/collection_reset");_save.Text=TranslationServer.Translate("mod_manager/collection_save");_tabInspector.Text=TranslationServer.Translate("mod_manager/collection_tab_inspector");_tabProblems.Text=TranslationServer.Translate("mod_manager/collection_tab_problems");_tabPlan.Text=TranslationServer.Translate("mod_manager/collection_tab_plan");_apply.Text=TranslationServer.Translate("mod_manager/collection_apply");_edgesToggle.Text=TranslationServer.Translate("mod_manager/collection_links");var header=_json.GetParent();_savesInfo=new Label{VerticalAlignment=VerticalAlignment.Center};_savesInfo.AddThemeColorOverride("font_color",UiTokens.Muted);_duplicate=new Button{Text=TranslationServer.Translate("mod_manager/collection_duplicate")};_revert=new Button{Text=TranslationServer.Translate("mod_manager/collection_revert")};_delete=new Button{Text=TranslationServer.Translate("mod_manager/collection_delete")};foreach(var control in new Control[]{_savesInfo,_duplicate,_revert,_delete}){header.AddChild(control);header.MoveChild(control,_json.GetIndex());}_duplicate.Pressed+=OnDuplicatePressed;_revert.Pressed+=OnRevertPressed;_delete.Pressed+=OnDeletePressed;CthangoverTheme.ApplyPrimary(_apply);CthangoverTheme.ApplyPrimary(_save);_dirty.AddThemeColorOverride("font_color",UiTokens.Warning);_detailMeta.AddThemeColorOverride("font_color",UiTokens.Muted);_detailDescription.AddThemeColorOverride("font_color",UiTokens.Body);_bannerPanel.AddThemeStyleboxOverride("panel",ModManagerTheme.MakeBox(UiTokens.Alpha(UiTokens.Warning,0.15f),UiTokens.Alpha(UiTokens.Warning,0.45f),UiTokens.RadiusMd,UiTokens.Space4,UiTokens.Space2));_bannerLabel.AddThemeColorOverride("font_color",UiTokens.Warning);_bannerPanel.Visible=false;BuildCrumbs();BuildBuildBar();if(Embedded)ApplyEmbeddedLayout();}/// <summary> Fills the breadcrumb row: "Collections › [game selector] › Scheme" plus the scheme controls that used to crowd the header. The game selector node itself is reparented here so its items and events keep working unchanged. </summary>
private void BuildCrumbs(){_crumbs=GetNode<HBoxContainer>("Margin/Root/Crumbs");_crumbs.AddThemeConstantOverride("separation",UiTokens.Space2);_crumbCollections=UiKit.Button(TranslationServer.Translate("mod_manager/hub_tab_collections"));_crumbCollections.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);_crumbCollections.Pressed+=()=>GalleryRequested?.Invoke();_crumbCollections.TooltipText=TranslationServer.Translate("mod_manager/gallery_crumb");_crumbs.AddChild(_crumbCollections);_crumbs.AddChild(UiKit.Label("›",UiTokens.FontBodySmall,UiTokens.Faint));GetNode("Margin/Root/Header/GameSelect").Reparent(_crumbs);_crumbs.AddChild(UiKit.Label("›",UiTokens.FontBodySmall,UiTokens.Faint));_crumbs.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/collection_crumb_scheme"),UiTokens.FontBodySmall,UiTokens.Body));_crumbs.AddChild(UiKit.Spacer());GetNode("Margin/Root/Header/AddMechanic").Reparent(_crumbs);GetNode("Margin/Root/Header/EdgesToggle").Reparent(_crumbs);GetNode("Margin/Root/Header/Problems").Reparent(_crumbs);GetNode("Margin/Root/Header/ModsCount").Reparent(_crumbs);}/// <summary> Builds the permanent bottom build bar from the mockup: build name, install/enable/problems counters, a hint and the apply + open manager actions. Replaces the old plan-tab-only apply button as the always-visible place to run the plan. </summary>
private void BuildBuildBar(){var bar=GetNode<PanelContainer>("Margin/Root/BuildBar");bar.AddThemeStyleboxOverride("panel",UiKit.Frame(0,UiTokens.Surface2,UiTokens.Line));bar.CustomMinimumSize=new Vector2(0,54);var row=GetNode<HBoxContainer>("Margin/Root/BuildBar/BarRow");row.AddThemeConstantOverride("separation",UiTokens.Space5);var nameColumn=UiKit.Column(0);string buildCaption=TranslationServer.Translate("mod_manager/build_caption");nameColumn.AddChild(UiKit.Label(buildCaption.ToUpperInvariant(),UiTokens.FontMicro,UiTokens.Faint));_buildName=UiKit.Label(string.Empty,UiTokens.FontBodyLarge,UiTokens.InkBright);nameColumn.AddChild(_buildName);row.AddChild(nameColumn);row.AddChild(BuildStat(TranslationServer.Translate("mod_manager/build_stat_install"),out _buildInstall));row.AddChild(BuildStat(TranslationServer.Translate("mod_manager/build_stat_enable"),out _buildEnable));row.AddChild(BuildStat(TranslationServer.Translate("mod_manager/build_stat_problems"),out _buildProblems));_buildHint=UiKit.Label(string.Empty,UiTokens.FontBodySmall,UiTokens.Warning);_buildHint.SizeFlagsVertical=Control.SizeFlags.ShrinkCenter;row.AddChild(_buildHint);row.AddChild(UiKit.Spacer());_buildManager=UiKit.Button(TranslationServer.Translate("mod_manager/build_open_manager"));_buildManager.Pressed+=()=>OpenManagerRequested?.Invoke(null);row.AddChild(_buildManager);_buildApply=UiKit.Button(TranslationServer.Translate("mod_manager/collection_apply"),ButtonKind.Primary);_buildApply.Pressed+=OnApplyPressed;row.AddChild(_buildApply);}/// <summary>One "k/v" counter column of the build bar; the value label is returned to the caller.</summary>
private Control BuildStat(string caption,out Label value){var column=UiKit.Column(0);column.AddChild(UiKit.Label(caption.ToUpperInvariant(),UiTokens.FontMicro,UiTokens.Faint));value=UiKit.Label("0",UiTokens.FontBodyLarge,UiTokens.InkBright);column.AddChild(value);return column;}/// <summary>Slims the header down to save/reset/json/dirty as the mockup requires.</summary>
private void ApplyEmbeddedLayout(){GetNode<Label>("Margin/Root/Header/Title").Visible=false;_close.Visible=false;_refresh.Visible=false;_newGame.Visible=false;}private void SubscribeEvents(){_close.Pressed+=OnClosePressed;_refresh.Pressed+=OnRefreshPressed;_addMechanic.Pressed+=OnAddMechanicPressed;_newGame.Pressed+=OnNewGamePressed;_edgesToggle.Toggled+=_canvas.SetEdgesVisible;_pillProblems.Pressed+=()=>SelectTab("problems");_pillPlan.Pressed+=()=>SelectTab("plan");_json.Pressed+=OpenJsonDialog;_reset.Pressed+=ResetDrafts;_save.Pressed+=OpenSaveDialog;_gameSelect.ItemSelected+=OnGameSelected;_tabInspector.Pressed+=()=>SelectTab("inspector");_tabProblems.Pressed+=()=>SelectTab("problems");_tabPlan.Pressed+=()=>SelectTab("plan");_apply.Pressed+=OnApplyPressed;_canvas.GroupSelected+=OnGroupSelected;_canvas.ModSelected+=OnModSelected;_canvas.GroupCommand+=OnGroupCommand;_canvas.LayoutChanged+=OnLayoutChanged;ModDownloader.OnProgress+=OnInstallProgress;CatalogService.OnRefreshed+=OnCatalogRefreshed;}private void UnsubscribeEvents(){ModDownloader.OnProgress-=OnInstallProgress;CatalogService.OnRefreshed-=OnCatalogRefreshed;}/* ═══════════════ Data flow ═══════════════ */private void ReloadCollections(){_collections=ModCollectionStore.LoadAll();if(string.IsNullOrEmpty(_currentId)||EffectiveCollection(_currentId)==null)_currentId=FirstGameId();RebuildGameSelect();Rebuild();}private void Rebuild(){_graph=Resolve();_layout=_currentId==null?new ModCollectionLayout():CollectionLayoutStore.Load(_currentId);_plan=ModCollectionPlan.Build(_graph,CatalogService.CachedEntries??new List<CatalogExportEntry>());_canvas.Build(_graph,_layout);_canvas.SetSelection(_selectedGroupKey,_selectedModId);_canvas.SetEdgesVisible(_edgesToggle.ButtonPressed);UpdateHeader();UpdateInspector();UpdateBanner();UpdateBuildBar();ViewChanged?.Invoke();}private ModCollectionGraph Resolve(){var game=EffectiveCollection(_currentId);if(game==null)return new ModCollectionGraph();return ModCollectionResolver.Resolve(game,BuildCollectionIndex(),ModRegistry.Instance.AllDiscoveredMods,DisabledModsStore.Load(),CatalogService.CachedEntries??new List<CatalogExportEntry>(),_sessionInstalled,TranslationServer.GetLocale(),CollectionText.Resolve);}private Dictionary<string,ModCollection>BuildCollectionIndex(){var index=new Dictionary<string,ModCollection>(StringComparer.OrdinalIgnoreCase);foreach(var collection in _collections){if(collection?.Id!=null)index[collection.Id]=collection;}foreach(var pair in _overrides)index[pair.Key]=pair.Value;return index;}private ModCollection EffectiveCollection(string id){if(string.IsNullOrEmpty(id))return null;if(_overrides.TryGetValue(id,out var draft))return draft;return _collections.FirstOrDefault(c=>string.Equals(c.Id,id,StringComparison.OrdinalIgnoreCase));}private ModCollection OriginalCollection(string id)=>_collections.FirstOrDefault(c=>string.Equals(c.Id,id,StringComparison.OrdinalIgnoreCase));/// <summary>Returns the editable draft of a collection, cloning the loaded document on first touch.</summary>
private ModCollection Draft(string id){if(string.IsNullOrEmpty(id))return null;if(_overrides.TryGetValue(id,out var draft))return draft;var source=OriginalCollection(id);if(source==null)return null;draft=CloneCollection(source);_overrides[id]=draft;return draft;}private ModCollection DraftOfGroup(ModCollectionGroup group){if(group==null)return null;return Draft(string.IsNullOrEmpty(group.CollectionId)?group.ParentCollectionId:group.CollectionId);}private static ModCollection CloneCollection(ModCollection source){var json=JsonSerializer.Serialize(source,JsonOptionsProvider.WriteIndentedOptions);return JsonSerializer.Deserialize<ModCollection>(json,JsonOptionsProvider.ReadOptions);}private string FirstGameId(){var game=_collections.Where(c=>c.Kind==ModCollectionKind.Game).OrderBy(c=>c.Name??c.Id,StringComparer.OrdinalIgnoreCase).FirstOrDefault();return game?.Id??_collections.FirstOrDefault()?.Id;}/* ═══════════════ Header ═══════════════ */private void RebuildGameSelect(){_gameSelect.Clear();var language=TranslationServer.GetLocale();var games=_collections.Where(c=>c.Kind==ModCollectionKind.Game).OrderBy(c=>c.GetLocalizedName(language),StringComparer.OrdinalIgnoreCase).ToList();var selected=0;for(var i=0;i<games.Count;i++){_gameSelect.AddItem(games[i].GetLocalizedName(language));_gameSelect.SetItemMetadata(i,games[i].Id);if(string.Equals(games[i].Id,_currentId,StringComparison.OrdinalIgnoreCase))selected=i;}if(games.Count>0)_gameSelect.Selected=selected;}private void UpdateHeader(){var dirty=_overrides.Count>0;_dirty.Visible=dirty;_save.Disabled= !dirty;_reset.Disabled= !dirty;var current=EffectiveCollection(_currentId);var isGame=current?.Kind==ModCollectionKind.Game;_savesInfo.Visible=isGame;if(isGame)_savesInfo.Text=string.Format(TranslationServer.Translate("mod_manager/collection_saves"),SaveService.ListSaves(_currentId).Count);_duplicate.Disabled=current==null;_revert.Visible=OriginalCollection(_currentId)?.Origin==ModCollectionOrigin.User&&ModCollectionStore.HasShippedVersion(_currentId);_delete.Visible=CollectionDeletion.CanDelete(current);if(_graph!=null){var installed=_graph.Nodes.Count(n=>n.State==ModCollectionNodeState.Installed);_modsCount.Text=string.Format(TranslationServer.Translate("mod_manager/collection_result"),installed,_graph.Nodes.Count);}else{_modsCount.Text=string.Empty;}var errors=CountErrors();_pillProblems.Text=errors>0?$"{TranslationServer.Translate("mod_manager/collection_problems")}: {errors}":TranslationServer.Translate("mod_manager/collection_problems");if(_plan==null){_pillPlan.Text=TranslationServer.Translate("mod_manager/collection_plan");return;}var parts=new List<string>();if(_plan.Install.Count>0)parts.Add($"{TranslationServer.Translate("mod_manager/collection_plan_install")}: {_plan.Install.Count}");if(_plan.Enable.Count>0)parts.Add($"{TranslationServer.Translate("mod_manager/collection_plan_enable")}: {_plan.Enable.Count}");_pillPlan.Text=parts.Count>0?$"{TranslationServer.Translate("mod_manager/collection_plan")}: {string.Join(" · ", parts)}":TranslationServer.Translate("mod_manager/collection_plan_ready");}private int CountErrors()=>_graph?.Problems?.Count??0;private void OnGameSelected(long index){var id=_gameSelect.GetItemMetadata((int)index).AsString();if(string.IsNullOrEmpty(id)||string.Equals(id,_currentId,StringComparison.OrdinalIgnoreCase))return;_currentId=id;_selectedGroupKey=null;_selectedModId=null;Rebuild();}private void OnNewGamePressed(){var id=UniqueGameId();var draft=new ModCollection{Id=id,Kind=ModCollectionKind.Game,Name=TranslationServer.Translate("mod_manager/collection_new_game_name"),Author="player",Version="0.1.0",Description=TranslationServer.Translate("mod_manager/collection_new_game_desc"),Tags=new List<string>(),Provides=new List<string>(),Requires=new List<string>(),Includes=new List<ModCollectionInclude>(),Mechanics=new List<ModCollectionMechanic>(),};_overrides[id]=draft;_currentId=id;_selectedGroupKey=null;_selectedModId=null;RebuildGameSelect();Rebuild();OpenSaveDialog();}private string UniqueGameId(){var index=1;while(EffectiveCollection($"my_game_{index}")!=null)index++;return$"my_game_{index}";}/* ═══════════════ Canvas events ═══════════════ */private void OnGroupSelected(string groupKey){_selectedGroupKey=groupKey;_selectedModId=null;_canvas.SetSelection(_selectedGroupKey,_selectedModId);SelectTab("inspector");}private void OnModSelected(string modId){_selectedModId=modId;_selectedGroupKey=_canvas.GetGroupKeyOfMod(modId);_canvas.SetSelection(_selectedGroupKey,_selectedModId);SelectTab("inspector");}private void OnGroupCommand(ModCollectionGroup group,string command){switch(command){case"add-mod":OpenModPicker(group);break;case"rename":OpenRenameDialog(group);break;case"toggle":ToggleGroup(group);break;case"replace":OpenMechanicPicker(group,true);break;case"remove":RemoveGroup(group);break;}}private void OnLayoutChanged(){if(!string.IsNullOrEmpty(_currentId))CollectionLayoutStore.Save(_currentId,_layout);}private void ToggleGroup(ModCollectionGroup group){if(group.IsInclude){var include=FindInclude(group);if(include==null)return;include.Selected=group.Selected?false:null;}else{var mechanic=FindMechanic(group);if(mechanic==null)return;mechanic.Selected=group.Selected?false:null;}Rebuild();}private void RemoveGroup(ModCollectionGroup group){if(group.IsInclude){var include=FindInclude(group);if(include==null)return;Draft(group.ParentCollectionId)?.Includes.Remove(include);}else{var mechanic=FindMechanic(group);if(mechanic==null)return;Draft(group.CollectionId)?.Mechanics.Remove(mechanic);}_selectedGroupKey=null;_selectedModId=null;Rebuild();}private ModCollectionInclude FindInclude(ModCollectionGroup group){var parent=Draft(group.ParentCollectionId);if(parent?.Includes==null)return null;return parent.Includes.FirstOrDefault(i=>string.Equals(IncludeId(i),group.CollectionId,StringComparison.OrdinalIgnoreCase));}private ModCollectionMechanic FindMechanic(ModCollectionGroup group){var collection=Draft(group.CollectionId);if(collection?.Mechanics==null)return null;return collection.Mechanics.FirstOrDefault(m=>string.Equals(m.Id,group.MechanicId,StringComparison.OrdinalIgnoreCase));}private static string IncludeId(ModCollectionInclude include){if(include==null||string.IsNullOrWhiteSpace(include.Ref))return null;var dependency=ModDependency.Parse(include.Ref);return dependency.IsValid?dependency.ModId:include.Ref.Trim();}/* ═══════════════ Inspector ═══════════════ */private void SelectTab(string tab){_tab=tab;UpdateInspector();}private void UpdateInspector(){_tabInspector.ButtonPressed=_tab=="inspector";_tabProblems.ButtonPressed=_tab=="problems";_tabPlan.ButtonPressed=_tab=="plan";ClearChildren(_detailExtra);_apply.Visible= !Embedded&&_tab=="plan";if(_tab=="problems"){RenderProblems();return;}if(_tab=="plan"){RenderPlan();return;}if(_selectedModId!=null){RenderModInspector(_selectedModId);return;}var group=_selectedGroupKey==null?null:FindGroup(_selectedGroupKey);if(group!=null){RenderGroupInspector(group);return;}_detailTitle.Text=TranslationServer.Translate("mod_manager/collections_title");_detailMeta.Text=string.Empty;_detailDescription.Text=TranslationServer.Translate("mod_manager/collection_hint");UpdateApplyButton();}private void RenderGroupInspector(ModCollectionGroup group){_detailTitle.Text=group.Name??group.CollectionId;var metaParts=new List<string>{group.IsInclude?TranslationServer.Translate("mod_manager/collection_mechanic"):TranslationServer.Translate("mod_manager/collection_group"),roleOrNull(group.Role),group.Optional?TranslationServer.Translate("mod_manager/collection_optional"):null,$"{group.Mods.Count} {TranslationServer.Translate("mod_manager/collection_mods")}",};metaParts.RemoveAll(string.IsNullOrEmpty);_detailMeta.Text=string.Join(" · ",metaParts);_detailDescription.Text=string.IsNullOrEmpty(group.Description)?"—":group.Description;var collection=EffectiveCollection(group.CollectionId);if(collection!=null)AddInfoLine(BuildCollectionMeta(collection));var actions=UiKit.Row(UiTokens.Space2);actions.AddChild(MakeInspectorButton(TranslationServer.Translate("mod_manager/collection_add_mod"),()=>OpenModPicker(group)));if(group.IsInclude){actions.AddChild(MakeInspectorButton(TranslationServer.Translate("mod_manager/collection_replace"),()=>OpenMechanicPicker(group,true)));}actions.AddChild(MakeInspectorButton(TranslationServer.Translate("mod_manager/collection_rename"),()=>OpenRenameDialog(group)));if(group.Optional){actions.AddChild(MakeInspectorButton(group.Selected?TranslationServer.Translate("mod_manager/collection_turn_off"):TranslationServer.Translate("mod_manager/collection_turn_on"),()=>ToggleGroup(group)));}actions.AddChild(MakeInspectorButton(TranslationServer.Translate("mod_manager/collection_remove"),()=>RemoveGroup(group),true));_detailExtra.AddChild(actions);UpdateApplyButton();}private void RenderModInspector(string modId){var node=_graph?.Nodes?.FirstOrDefault(n=>string.Equals(n.ModId,modId,StringComparison.OrdinalIgnoreCase));_detailTitle.Text=node?.Title??modId;_detailMeta.Text=$"{modId} · {CollectionStateNames.Translate(node?.State ?? ModCollectionNodeState.Missing)}";_detailDescription.Text=LookupModDescription(modId);if(node!=null&& !string.IsNullOrEmpty(node.RequiredVersion))AddInfoLine($"{TranslationServer.Translate("mod_manager/collection_requires")}: {node.RequiredVersion}");if(node!=null&& !string.IsNullOrEmpty(node.InstalledVersion))AddInfoLine($"{TranslationServer.Translate("mod_manager/installed_version")}: {node.InstalledVersion}");var group=_selectedGroupKey==null?null:FindGroup(_selectedGroupKey);if(group!=null){AddInfoLine($"{TranslationServer.Translate("mod_manager/collection_in_group")}: {group.Name}");var remove=MakeInspectorButton(TranslationServer.Translate("mod_manager/collection_remove_mod"),()=>RemoveModFromGroup(group,modId),true);_detailExtra.AddChild(remove);}UpdateApplyButton();}private void RemoveModFromGroup(ModCollectionGroup group,string modId){var refs=group.Mods.Where(m=>string.Equals(m.ModId,modId,StringComparison.OrdinalIgnoreCase)).ToList();foreach(var reference in refs){var collection=DraftForEdit(group,reference.CollectionId);var mechanic=collection?.Mechanics?.FirstOrDefault(m=>string.Equals(m.Id,reference.MechanicId,StringComparison.OrdinalIgnoreCase));if(mechanic?.Mods==null)continue;mechanic.Mods.RemoveAll(m=>string.Equals(ParseModId(m),modId,StringComparison.OrdinalIgnoreCase));}_selectedModId=null;Rebuild();}private static string ParseModId(string spec){var dependency=ModDependency.Parse(spec);return dependency.IsValid?dependency.ModId:spec?.Trim();}private void RenderProblems(){_detailTitle.Text=TranslationServer.Translate("mod_manager/collection_tab_problems");_detailMeta.Text=string.Empty;_detailDescription.Text=string.Empty;if(_graph==null||_graph.Problems.Count==0){_detailExtra.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/collection_problems_none"),UiTokens.FontBody,UiTokens.SuccessText));UpdateApplyButton();return;}foreach(var problem in _graph.Problems){var row=UiKit.Column(UiTokens.Space1);row.AddChild(UiKit.Label(FormatProblem(problem),UiTokens.FontBodySmall,UiTokens.DangerText));if(!string.IsNullOrEmpty(problem.CollectionId)|| !string.IsNullOrEmpty(problem.ModId)){var target=problem;row.AddChild(MakeInspectorButton(TranslationServer.Translate("mod_manager/collection_show"),()=>ShowProblemTarget(target)));}// Cross link from the mockup: a missing block can be searched in the mods catalog (jumps to the mods tab with the id).
if(problem.Kind==ModCollectionProblemKind.MissingCollection&& !string.IsNullOrEmpty(problem.Ref)){var missingId=problem.Ref;row.AddChild(MakeInspectorButton(TranslationServer.Translate("mod_manager/collection_find_in_catalog"),()=>OpenManagerRequested?.Invoke(missingId)));}_detailExtra.AddChild(row);}UpdateApplyButton();}private string FormatProblem(ModCollectionProblem problem){switch(problem.Kind){case ModCollectionProblemKind.MissingCollection:return string.Format(TranslationServer.Translate("mod_manager/collection_problem_missing"),problem.Ref);case ModCollectionProblemKind.Cycle:return string.Format(TranslationServer.Translate("mod_manager/collection_problem_cycle"),problem.CollectionId,problem.Ref);case ModCollectionProblemKind.RoleConflict:return string.Format(TranslationServer.Translate("mod_manager/collection_problem_role"),problem.Ref);case ModCollectionProblemKind.RequirementMissing:return string.Format(TranslationServer.Translate("mod_manager/collection_problem_require"),problem.CollectionId,RerequireCapability(problem.Title));default:return problem.Title;}}private static string RerequireCapability(string title){var parts=(title??string.Empty).Split('\'');return parts.Length>=4?parts[3]:title;}private void ShowProblemTarget(ModCollectionProblem problem){if(!string.IsNullOrEmpty(problem.ModId)){OnModSelected(problem.ModId);return;}OnGroupSelected(problem.GroupKey);}private void RenderPlan(){_detailTitle.Text=TranslationServer.Translate("mod_manager/collection_tab_plan");_detailMeta.Text=string.Empty;_detailDescription.Text=string.Empty;if(_plan==null)return;if(_plan.IsEmpty&& !_plan.HasProblems){_detailExtra.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/collection_plan_ready"),UiTokens.FontBody,UiTokens.SuccessText));return;}AddPlanSection("mod_manager/collection_install_count",_plan.Install.Select(i=>i.ModId),UiTokens.Info);AddPlanSection("mod_manager/collection_enable_count",_plan.Enable,UiTokens.Warning);AddPlanSection("mod_manager/collection_missing",_plan.Missing,UiTokens.Danger,findInCatalog:true);AddPlanSection("mod_manager/collection_mismatch",_plan.Mismatches,UiTokens.Warning);UpdateApplyButton();}private void AddPlanSection(string captionKey,IEnumerable<string>ids,Color color,bool findInCatalog=false){var list=ids?.ToList();if(list==null||list.Count==0)return;_detailExtra.AddChild(UiKit.Label(string.Format(TranslationServer.Translate(captionKey),list.Count),UiTokens.FontBodySmall,color));foreach(var id in list){if(!findInCatalog){_detailExtra.AddChild(UiKit.Label($"  {id}",UiTokens.FontSmall,UiTokens.Body));continue;}var row=UiKit.Row(UiTokens.Space2);var label=UiKit.Label($"  {id}",UiTokens.FontSmall,UiTokens.Body);label.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;row.AddChild(label);row.AddChild(MakeInspectorButton(TranslationServer.Translate("mod_manager/collection_find_in_catalog"),()=>OpenManagerRequested?.Invoke(id)));_detailExtra.AddChild(row);}}private void UpdateApplyButton(){// The mockup rule: "Apply collection" is disabled while problems (missing mods or resolution errors) are on the board.
var canApply= !_installing&&_plan!=null&& !_plan.IsEmpty&&_plan.Missing.Count==0&&CountErrors()==0;_apply.Disabled= !canApply;if(_buildApply!=null)_buildApply.Disabled= !canApply;UpdateBuildBar();}/// <summary> Refreshes the bottom build bar: current game name, install/enable/ problems counters and the hint. Safe during BindSceneNodes — every field is guarded. </summary>
private void UpdateBuildBar(){if(_buildName==null)return;_buildName.Text=CurrentCollectionTitle??"—";var install=_plan?.Install.Count??0;var enable=_plan?.Enable.Count??0;var problems=CountErrors();var blocked=problems>0||(_plan?.Missing.Count??0)>0||(_plan?.Mismatches.Count??0)>0;_buildInstall.Text=install.ToString();_buildEnable.Text=enable.ToString();_buildEnable.AddThemeColorOverride("font_color",enable>0?UiTokens.Warning:UiTokens.InkBright);_buildProblems.Text=problems.ToString();_buildProblems.AddThemeColorOverride("font_color",problems>0?UiTokens.DangerText:UiTokens.InkBright);_buildHint.Text=TranslationServer.Translate(blocked?"mod_manager/build_hint_problems":"mod_manager/collection_plan_ready");_buildHint.AddThemeColorOverride("font_color",blocked?UiTokens.Warning:UiTokens.SuccessText);}private void AddInfoLine(string text){if(string.IsNullOrEmpty(text))return;_detailExtra.AddChild(UiKit.Label(text,UiTokens.FontSmall,UiTokens.Muted));}private string BuildCollectionMeta(ModCollection collection){var parts=new List<string>{TranslationServer.Translate(OriginKey(collection.Origin))};if(!string.IsNullOrEmpty(collection.Version))parts.Add($"v{collection.Version}");if(!string.IsNullOrEmpty(collection.Author))parts.Add(collection.Author);if(collection.Provides?.Count>0)parts.Add("+"+string.Join(", ",collection.Provides));if(collection.Requires?.Count>0)parts.Add("!"+string.Join(", ",collection.Requires));return string.Join(" · ",parts);}private static string OriginKey(ModCollectionOrigin origin)=>origin switch{ModCollectionOrigin.User=>"mod_manager/collection_origin_user",ModCollectionOrigin.Catalog=>"mod_manager/collection_origin_catalog",_=>"mod_manager/collection_origin_builtin",};private static string LookupModDescription(string modId){if(ModRegistry.Instance.AllDiscoveredMods.TryGetValue(modId,out var info)&& !string.IsNullOrEmpty(info.LocalizedDescription))return info.LocalizedDescription;var entry=CatalogService.CachedEntries?.FirstOrDefault(e=>string.Equals(e.Id,modId,StringComparison.OrdinalIgnoreCase));if(entry!=null&& !string.IsNullOrEmpty(entry.Description))return entry.Description;return"—";}private Button MakeInspectorButton(string text,Action action,bool danger=false){var button=UiKit.Button(text,danger?ButtonKind.Danger:ButtonKind.Secondary);button.AddThemeFontSizeOverride("font_size",UiTokens.FontSmall);button.Pressed+=action;return button;}private static string roleOrNull(string role)=>string.IsNullOrEmpty(role)?null:role;private static void ClearChildren(Node node){foreach(var child in node.GetChildren()){node.RemoveChild(child);child.QueueFree();}}private ModCollectionGroup FindGroup(string groupKey){if(_graph?.Groups==null||string.IsNullOrEmpty(groupKey))return null;return _graph.Groups.FirstOrDefault(g=>string.Equals(g.Key,groupKey,StringComparison.OrdinalIgnoreCase));}/* ═══════════════ Dialogs ═══════════════ */private void OpenRenameDialog(ModCollectionGroup group){var include=group.IsInclude?FindInclude(group):null;var mechanic=group.IsInclude?null:FindMechanic(group);if(group.IsInclude&&include==null)return;if(!group.IsInclude&&mechanic==null)return;var nameEdit=new LineEdit{Text=group.Name??string.Empty};var noteEdit=new TextEdit{Text=group.Description??string.Empty,CustomMinimumSize=new Vector2(460,90)};var content=UiKit.Column(UiTokens.Space2);content.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/collection_field_name"),UiTokens.FontTiny,UiTokens.Muted));content.AddChild(nameEdit);content.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/collection_field_note"),UiTokens.FontTiny,UiTokens.Muted));content.AddChild(noteEdit);var dialog=new AcceptDialog{Title=TranslationServer.Translate("mod_manager/collection_rename_title"),OkButtonText=TranslationServer.Translate("mod_manager/collection_save"),Exclusive=true,};dialog.AddChild(content);dialog.Confirmed+=()=>{ApplyRename(group,nameEdit.Text,noteEdit.Text);dialog.QueueFree();};dialog.Canceled+=dialog.QueueFree;AddChild(dialog);dialog.PopupCentered(new Vector2I(520,300));}private void ApplyRename(ModCollectionGroup group,string name,string note){if(group.IsInclude){var include=FindInclude(group);if(include==null)return;include.Label=string.IsNullOrWhiteSpace(name)?null:name.Trim();include.Note=string.IsNullOrWhiteSpace(note)?null:note.Trim();}else{var mechanic=FindMechanic(group);if(mechanic==null)return;mechanic.Name=name?.Trim();mechanic.Description=note?.Trim();}Rebuild();}private void OpenMechanicPicker(ModCollectionGroup replaceTarget,bool replace){var content=UiKit.Column(UiTokens.Space2);var search=new LineEdit{PlaceholderText=TranslationServer.Translate("mod_manager/collection_search_mechanic")};content.AddChild(search);var list=new VBoxContainer();list.SizeFlagsHorizontal=SizeFlags.ExpandFill;var scroll=new ScrollContainer{CustomMinimumSize=new Vector2(520,340),HorizontalScrollMode=ScrollContainer.ScrollMode.Disabled};scroll.AddChild(list);content.AddChild(scroll);var dialog=new AcceptDialog{Title=TranslationServer.Translate(replace?"mod_manager/collection_pick_replace":"mod_manager/collection_pick_mechanic"),OkButtonText=TranslationServer.Translate("mod_manager/back"),Exclusive=true,};dialog.AddChild(content);void Fill(){ClearChildren(list);var query=search.Text?.Trim().ToLowerInvariant()??string.Empty;if(!replace){list.AddChild(BuildPickerRow(TranslationServer.Translate("mod_manager/collection_empty_mechanic"),TranslationServer.Translate("mod_manager/collection_empty_mechanic_desc"),TranslationServer.Translate("mod_manager/collection_create"),()=>{CreateEmptyMechanic();dialog.QueueFree();}));}var used=new HashSet<string>(_graph?.Groups.Select(g=>g.CollectionId)??new List<string>(),StringComparer.OrdinalIgnoreCase);var language=TranslationServer.GetLocale();var candidates=BuildCollectionIndex().Values.Where(c=>c.Kind!=ModCollectionKind.Game).Where(c=> !used.Contains(c.Id)||(replace&&string.Equals(c.Id,replaceTarget?.CollectionId,StringComparison.OrdinalIgnoreCase))).Where(c=>string.IsNullOrEmpty(query)||c.GetLocalizedName(language).ToLowerInvariant().Contains(query)).OrderBy(c=>c.GetLocalizedName(language),StringComparer.OrdinalIgnoreCase).ToList();foreach(var candidate in candidates){var target=candidate;list.AddChild(BuildPickerRow(target.GetLocalizedName(language),target.GetLocalizedDescription(language),TranslationServer.Translate(replace?"mod_manager/collection_replace":"mod_manager/collection_choose"),()=>{ApplyMechanicChoice(replaceTarget,replace,target);dialog.QueueFree();}));}}search.TextChanged+=_=>Fill();dialog.Canceled+=dialog.QueueFree;AddChild(dialog);dialog.PopupCentered(new Vector2I(560,460));Fill();}private Control BuildPickerRow(string title,string description,string buttonText,Action action){var row=new HBoxContainer();row.AddThemeConstantOverride("separation",UiTokens.Space3);var column=UiKit.Column(UiTokens.Space1);column.SizeFlagsHorizontal=SizeFlags.ExpandFill;column.AddChild(UiKit.Label(title,UiTokens.FontBody,UiTokens.InkBright));if(!string.IsNullOrEmpty(description))column.AddChild(UiKit.Label(description,UiTokens.FontSmall,UiTokens.Muted));row.AddChild(column);var button=UiKit.Button(buttonText,ButtonKind.Primary);button.AddThemeFontSizeOverride("font_size",UiTokens.FontSmall);button.Pressed+=action;row.AddChild(button);return row;}private void CreateEmptyMechanic(){var draft=Draft(_currentId);if(draft==null)return;draft.Mechanics??=new List<ModCollectionMechanic>();draft.Mechanics.Add(new ModCollectionMechanic{Id=UniqueMechanicId(draft),Name=TranslationServer.Translate("mod_manager/collection_new_mechanic_name"),Role=RoleExtra,Description=string.Empty,Mods=new List<string>(),});Rebuild();}private void ApplyMechanicChoice(ModCollectionGroup replaceTarget,bool replace,ModCollection candidate){var draft=Draft(_currentId);if(draft==null)return;if(replace&&replaceTarget!=null){var include=FindInclude(replaceTarget);if(include==null)return;include.Ref=candidate.Id;include.Label=null;include.Note=null;include.Selected=null;Rebuild();return;}draft.Includes??=new List<ModCollectionInclude>();draft.Includes.Add(new ModCollectionInclude{Ref=candidate.Id,Role=GuessRole(candidate),Optional=false,});Rebuild();}private static readonly string[]KnownRoles={"systems","foundation","battle","story","ui","content","locale"};private static string GuessRole(ModCollection collection){foreach(var role in KnownRoles){if(collection.Tags!=null&&collection.Tags.Contains(role,StringComparer.OrdinalIgnoreCase))return role;}return RoleExtra;}private static string UniqueMechanicId(ModCollection collection){var index=1;while(collection.Mechanics.Any(m=>string.Equals(m.Id,$"extra_{index}",StringComparison.OrdinalIgnoreCase)))index++;return$"extra_{index}";}private void OpenModPicker(ModCollectionGroup group){if(group==null)return;var child=group.IsInclude?EffectiveCollection(group.CollectionId):null;var targetSelect=new OptionButton();if(group.IsInclude){foreach(var mechanic in child?.Mechanics??new List<ModCollectionMechanic>())targetSelect.AddItem(mechanic.Name??mechanic.Id);targetSelect.AddItem(TranslationServer.Translate("mod_manager/collection_new_extra"));}var content=UiKit.Column(UiTokens.Space2);if(group.IsInclude){content.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/collection_where"),UiTokens.FontTiny,UiTokens.Muted));content.AddChild(targetSelect);}var search=new LineEdit{PlaceholderText=TranslationServer.Translate("mod_manager/collection_search_mod")};content.AddChild(search);var list=new VBoxContainer();var scroll=new ScrollContainer{CustomMinimumSize=new Vector2(520,320),HorizontalScrollMode=ScrollContainer.ScrollMode.Disabled};scroll.AddChild(list);content.AddChild(scroll);var dialog=new AcceptDialog{Title=TranslationServer.Translate("mod_manager/collection_add_mod"),OkButtonText=TranslationServer.Translate("mod_manager/back"),Exclusive=true,};dialog.AddChild(content);void Fill(){ClearChildren(list);var query=search.Text?.Trim().ToLowerInvariant()??string.Empty;var used=new HashSet<string>(_graph?.Nodes.Select(n=>n.ModId)??new List<string>(),StringComparer.OrdinalIgnoreCase);foreach(var choice in BuildModChoices(used,query)){var picked=choice;list.AddChild(BuildPickerRow(picked.Title,picked.Description,TranslationServer.Translate("mod_manager/collection_choose"),()=>{ApplyModChoice(group,picked,targetSelect);dialog.QueueFree();}));}}search.TextChanged+=_=>Fill();dialog.Canceled+=dialog.QueueFree;AddChild(dialog);dialog.PopupCentered(new Vector2I(560,440));Fill();}/// <summary> Mods that can still be added to the current scheme: everything known to the registry plus catalog entries, minus the ids already resolved, filtered by the search text. Ordered by title so the picker is stable. </summary>
private List<ModChoice>BuildModChoices(HashSet<string>used,string query){var choices=new Dictionary<string,ModChoice>(StringComparer.OrdinalIgnoreCase);foreach(var info in ModRegistry.Instance.AllDiscoveredMods.Values){if(used.Contains(info.Id))continue;choices[info.Id]=new ModChoice{Id=info.Id,Title=info.DisplayTitle,Description=info.LocalizedDescription,};}foreach(var entry in CatalogService.CachedEntries??new List<CatalogExportEntry>()){if(used.Contains(entry.Id)||choices.ContainsKey(entry.Id))continue;choices[entry.Id]=new ModChoice{Id=entry.Id,Title=entry.GetLocalizedName(TranslationServer.GetLocale()),Description=entry.GetLocalizedDescription(TranslationServer.GetLocale()),};}return choices.Values.Where(c=>string.IsNullOrEmpty(query)||c.Title.ToLowerInvariant().Contains(query)).OrderBy(c=>c.Title,StringComparer.OrdinalIgnoreCase).ToList();}private void ApplyModChoice(ModCollectionGroup group,ModChoice choice,OptionButton targetSelect){ModCollection collection;ModCollectionMechanic mechanic;if(group.IsInclude){collection=DraftForEdit(group,group.CollectionId);if(collection==null)return;collection.Mechanics??=new List<ModCollectionMechanic>();var selected=targetSelect.ItemCount==0? -1:targetSelect.Selected;if(selected>=0&&selected<collection.Mechanics.Count){mechanic=collection.Mechanics[selected];}else{mechanic=new ModCollectionMechanic{Id=UniqueMechanicId(collection),Name=TranslationServer.Translate("mod_manager/collection_extra_name"),Role=RoleExtra,Description=TranslationServer.Translate("mod_manager/collection_extra_note"),Mods=new List<string>(),};collection.Mechanics.Add(mechanic);}}else{collection=Draft(group.CollectionId);mechanic=collection?.Mechanics?.FirstOrDefault(m=>string.Equals(m.Id,group.MechanicId,StringComparison.OrdinalIgnoreCase));}if(mechanic==null)return;mechanic.Mods??=new List<string>();if(!mechanic.Mods.Any(m=>string.Equals(ParseModId(m),choice.Id,StringComparison.OrdinalIgnoreCase)))mechanic.Mods.Add(choice.Id);Rebuild();}private void OpenSaveDialog(){var draft=EffectiveCollection(_currentId);if(draft==null)return;var nameEdit=new LineEdit{Text=draft.Name??string.Empty};var content=UiKit.Column(UiTokens.Space2);content.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/collection_field_name"),UiTokens.FontTiny,UiTokens.Muted));content.AddChild(nameEdit);var dialog=new AcceptDialog{Title=TranslationServer.Translate("mod_manager/collection_save"),OkButtonText=TranslationServer.Translate("mod_manager/collection_save"),Exclusive=true,};dialog.AddChild(content);dialog.Confirmed+=()=>{draft.Name=nameEdit.Text?.Trim();SaveDrafts();dialog.QueueFree();};dialog.Canceled+=dialog.QueueFree;AddChild(dialog);dialog.PopupCentered(new Vector2I(480,200));}private void SaveDrafts(){if(_overrides.Count==0){_statusMessage=TranslationServer.Translate("mod_manager/collection_no_changes");UpdateBanner();return;}var user=ModCollectionStore.LoadUser();foreach(var pair in _overrides){var draft=pair.Value;draft.Origin=ModCollectionOrigin.User;user.RemoveAll(c=>string.Equals(c.Id,draft.Id,StringComparison.OrdinalIgnoreCase));user.Add(draft);}ModCollectionStore.SaveUser(user);var duplicates=_overrides.Keys.Where(id=>_duplicatedFrom.ContainsKey(id)&&SaveService.ListSaves(_duplicatedFrom[id]).Count>0).Select(id=>(Copy:id,Source:_duplicatedFrom[id])).ToList();_overrides.Clear();_duplicatedFrom.Clear();// A game's mod set is computed at launch: edits apply on the next start.
_statusMessage=TranslationServer.Translate("mod_manager/collection_save_ok")+" "+TranslationServer.Translate("mod_manager/collection_next_launch");ReloadCollections();foreach(var(copy,source)in duplicates)OfferSavesCopy(source,copy);}private void ResetDrafts(){if(_overrides.Count==0){_statusMessage=TranslationServer.Translate("mod_manager/collection_no_changes");UpdateBanner();return;}var dialog=new ConfirmationDialog{Title=TranslationServer.Translate("mod_manager/collection_reset"),DialogText=TranslationServer.Translate("mod_manager/collection_reset_confirm"),OkButtonText=TranslationServer.Translate("mod_manager/collection_reset"),CancelButtonText=TranslationServer.Translate("mod_manager/cancel"),Exclusive=true,};dialog.Confirmed+=()=>{_overrides.Clear();_selectedGroupKey=null;_selectedModId=null;_statusMessage=null;ReloadCollections();dialog.QueueFree();};dialog.Canceled+=dialog.QueueFree;AddChild(dialog);dialog.PopupCentered();}/// <summary> Shows the current collection as JSON and lets the player edit it: "Apply" parses the text into the draft (the id must stay the same), so documents can be fixed or extended beyond what the canvas offers. </summary>
private void OpenJsonDialog(){var collection=EffectiveCollection(_currentId);if(collection==null)return;var view=new TextEdit{Text=JsonSerializer.Serialize(collection,JsonOptionsProvider.WriteIndentedOptions),Editable=true,CustomMinimumSize=new Vector2(620,420),};var dialog=new ConfirmationDialog{Title=TranslationServer.Translate("mod_manager/collection_json"),OkButtonText=TranslationServer.Translate("mod_manager/collection_json_apply"),CancelButtonText=TranslationServer.Translate("mod_manager/cancel"),Exclusive=true,};dialog.AddChild(view);dialog.Confirmed+=()=>{var error=ApplyJson(view.Text);dialog.QueueFree();if(error!=null)ShowNotice(TranslationServer.Translate("mod_manager/collection_json"),error);};dialog.Canceled+=dialog.QueueFree;AddChild(dialog);dialog.PopupCentered(new Vector2I(700,520));}/// <summary>Replaces the current draft with parsed JSON; returns an error message or null.</summary>
private string ApplyJson(string text){ModCollection parsed;try{parsed=JsonSerializer.Deserialize<ModCollection>(text,JsonOptionsProvider.ReadOptions);}catch(JsonException ex){return string.Format(TranslationServer.Translate("mod_manager/collection_json_invalid"),ex.Message);}if(parsed==null|| !string.Equals(parsed.Id,_currentId,StringComparison.OrdinalIgnoreCase))return TranslationServer.Translate("mod_manager/collection_json_id_changed");parsed.Origin=ModCollectionOrigin.User;parsed.Package=OriginalCollection(_currentId)?.Package;_overrides[_currentId]=parsed;RebuildGameSelect();Rebuild();return null;}/// <summary>Copies the current game under a new id (its saves are offered on save).</summary>
private void OnDuplicatePressed(){var source=EffectiveCollection(_currentId);if(source==null)return;var copy=CloneCollection(source);copy.Id=UniqueGameId();copy.Kind=source.Kind;copy.Origin=ModCollectionOrigin.User;copy.Package=source.Package;copy.Name=string.Format(TranslationServer.Translate("mod_manager/collection_copy_name"),CollectionText.Resolve(source.GetLocalizedName(TranslationServer.GetLocale())));copy.Locale=null;_overrides[copy.Id]=copy;_duplicatedFrom[copy.Id]=source.Id;_currentId=copy.Id;_selectedGroupKey=null;_selectedModId=null;RebuildGameSelect();Rebuild();OpenSaveDialog();}private void OfferSavesCopy(string sourceId,string copyId){var dialog=new ConfirmationDialog{Title=TranslationServer.Translate("mod_manager/collection_duplicate"),DialogText=string.Format(TranslationServer.Translate("mod_manager/collection_copy_saves"),SaveService.ListSaves(sourceId).Count),OkButtonText=TranslationServer.Translate("mod_manager/collection_copy_saves_ok"),CancelButtonText=TranslationServer.Translate("mod_manager/cancel"),Exclusive=true,};dialog.Confirmed+=()=>{SaveService.CopyGameSaves(sourceId,copyId);dialog.QueueFree();UpdateHeader();};dialog.Canceled+=dialog.QueueFree;AddChild(dialog);dialog.PopupCentered();}/// <summary>Drops the player's version of a shipped collection after confirmation.</summary>
private void OnRevertPressed(){var dialog=new ConfirmationDialog{Title=TranslationServer.Translate("mod_manager/collection_revert"),DialogText=TranslationServer.Translate("mod_manager/collection_revert_confirm"),OkButtonText=TranslationServer.Translate("mod_manager/collection_revert"),CancelButtonText=TranslationServer.Translate("mod_manager/cancel"),Exclusive=true,};dialog.Confirmed+=()=>{ModCollectionStore.RemoveUser(_currentId);_overrides.Remove(_currentId);_statusMessage=TranslationServer.Translate("mod_manager/collection_revert_done")+" "+TranslationServer.Translate("mod_manager/collection_next_launch");dialog.QueueFree();ReloadCollections();};dialog.Canceled+=dialog.QueueFree;AddChild(dialog);dialog.PopupCentered();}/// <summary> Deletes the current player-made game after confirmation (its unsaved draft goes too) and switches to the first remaining game. </summary>
private void OnDeletePressed(){var deletedId=_currentId;CollectionDeletion.Confirm(this,EffectiveCollection(deletedId),()=>{_overrides.Remove(deletedId);_duplicatedFrom.Remove(deletedId);_currentId=null;_selectedGroupKey=null;_selectedModId=null;_statusMessage=TranslationServer.Translate("mod_manager/collection_delete_done");ReloadCollections();});}private void ShowNotice(string title,string text){var notice=new AcceptDialog{Title=title,DialogText=text,Exclusive=true};notice.Confirmed+=notice.QueueFree;notice.Canceled+=notice.QueueFree;AddChild(notice);notice.PopupCentered();}/// <summary> Copy-on-write for shared blocks: an edit inside an included collection must not change every game that includes it. The first edit forks the block into "&lt;block&gt;.&lt;game&gt;" (a component owned by this game) and repoints the include; later edits reuse it. </summary>
private ModCollection DraftForEdit(ModCollectionGroup group,string collectionId){if(!group.IsInclude||string.Equals(collectionId,_currentId,StringComparison.OrdinalIgnoreCase))return Draft(collectionId);if(!string.Equals(collectionId,group.CollectionId,StringComparison.OrdinalIgnoreCase)){_statusMessage=TranslationServer.Translate("mod_manager/collection_nested_readonly");UpdateBanner();return null;}var include=FindInclude(group);if(include==null)return null;var forkId=$"{collectionId}.{_currentId}";if(EffectiveCollection(forkId)==null){var source=EffectiveCollection(collectionId);if(source==null)return null;var fork=CloneCollection(source);fork.Id=forkId;fork.Kind=ModCollectionKind.Component;fork.Origin=ModCollectionOrigin.User;fork.Package=source.Package;_overrides[forkId]=fork;_statusMessage=string.Format(TranslationServer.Translate("mod_manager/collection_forked"),CollectionText.Resolve(source.GetLocalizedName(TranslationServer.GetLocale())));UpdateBanner();}include.Ref=forkId;return Draft(forkId);}/* ═══════════════ Actions ═══════════════ */private void OnAddMechanicPressed()=>OpenMechanicPicker(null,false);private void OnClosePressed()=>QueueFree();private void OnRefreshPressed()=>RefreshCatalog();private async void RefreshCatalog(){if(GodotObject.IsInstanceValid(_refresh))_refresh.Disabled=true;UpdateBanner();try{await CatalogService.RefreshAsync(forceRefresh:true);}catch(Exception ex){GameLogger.Log("MOD_COLLECTIONS",$"Catalog refresh failed: {ex.Message}",LogLevel.Warning);}if(!GodotObject.IsInstanceValid(this))return;if(GodotObject.IsInstanceValid(_refresh))_refresh.Disabled=false;UpdateBanner();}private void OnCatalogRefreshed(){if(GodotObject.IsInstanceValid(this))ReloadCollections();}private void OnInstallProgress(InstallProgress progress){if(!_installing||progress==null)return;SetBanner(progress.Message);}private async void OnApplyPressed(){if(_installing||_plan==null||_plan.IsEmpty||_plan.Missing.Count>0||CountErrors()>0)return;var plan=_plan;_installing=true;_statusMessage=null;SetBanner(string.Format(TranslationServer.Translate("mod_manager/collection_progress"),EffectiveCollection(_currentId)?.DisplayTitle??_currentId));UpdateApplyButton();var failed= !await ModCollectionInstaller.ApplyAsync(plan,_sessionInstalled);if(!failed){_restartRequired=true;_statusMessage=null;}else{_statusMessage=TranslationServer.Translate("mod_manager/collection_apply_failed");}_installing=false;if(!GodotObject.IsInstanceValid(this))return;Rebuild();}private void StartCatalogRefreshIfNeeded(){if(CatalogService.CachedEntries==null&&ModConfig.Instance.CatalogUrls.Count>0)RefreshCatalog();else UpdateBanner();}private void UpdateBanner(){string text;if(_restartRequired)text=TranslationServer.Translate("mod_manager/restart_required");else if(CatalogService.IsFetching)text=TranslationServer.Translate("mod_manager/loading");else text=_statusMessage;SetBanner(text);}private void SetBanner(string text){if(!GodotObject.IsInstanceValid(_bannerPanel))return;_bannerPanel.Visible= !string.IsNullOrEmpty(text);if(!string.IsNullOrEmpty(text))_bannerLabel.Text=text;}}}

===== FILE mods/mod_manager/src/ModHubScreen.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Mods;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Cthangover.Core.Scenes;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Mods.ModManager{/// <summary> Single entry point of the mod UI (the manifest <c>main_menu</c> button opens this hub instead of two separate screens). A slim header — back button, title and a two-position segment control "Mods | Collections" — hosts the child screens: the embedded <see cref="ModManagerScreen"/> (default tab) and, on the collections tab, the <see cref="CollectionsGallery"/> plus the scheme editor <see cref="ModCollectionsScreen"/> as its second level. The selected tab lives in a static field, so it survives closing and reopening the hub within one game session. <para/> The hub also carries the mockup's cross links: a collection link on a mod card switches to the collections tab, a "not found" problem in the editor jumps back to the mods tab with the id filled into the search box (<see cref="ModManagerScreen.SetSearch"/>), and Escape walks back editor → gallery → mods tab. </summary>
public partial class ModHubScreen:ModWidget{private const string TabMods="mods";private const string TabCollections="collections";private const string ModsScene="scenes/mod_manager_screen.tscn";private const string EditorScene="scenes/mod_collections_screen.tscn";/// <summary>Active tab, remembered for the whole game session (mockup: "state is remembered").</summary>
private static string _activeTab=TabMods;private Button _back;private Label _title;private HBoxContainer _segment;private Button _tabMods;private Button _tabCollections;private LineEdit _gallerySearch;private Button _galleryRefresh;private PanelContainer _bannerPanel;private Label _bannerLabel;private Control _body;private ModManagerScreen _mods;private CollectionsGallery _gallery;private ModCollectionsScreen _editor;private bool _editorOpen;/// <summary>Mods installed this session, shared by the gallery and the editor so both stay honest before a restart.</summary>
private readonly HashSet<string>_sessionInstalled=new(StringComparer.OrdinalIgnoreCase);protected override void Construct(){MouseFilter=MouseFilterEnum.Stop;CthangoverTheme.Apply(this);BuildHeader();BuildBanner();BuildBody();ShowTab(_activeTab);}/* ═══════════════ Header and body ═══════════════ */private void BuildHeader(){var root=GetNode<VBoxContainer>("Margin/Root");root.AddThemeConstantOverride("separation",UiTokens.Space3);var header=UiKit.Row(UiTokens.Space3);root.AddChild(header);_back=UiKit.Button(TranslationServer.Translate("mod_manager/hub_back_to_menu"));_back.Pressed+=OnBackPressed;header.AddChild(_back);_title=UiKit.Label(TranslationServer.Translate("mod_manager/menu_title"),UiTokens.FontLead,UiTokens.Ink);_title.Uppercase=true;header.AddChild(_title);_segment=UiKit.Row(0);var segmentPanel=new PanelContainer{MouseFilter=Control.MouseFilterEnum.Ignore};segmentPanel.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusSm,UiTokens.Well,UiTokens.Line));segmentPanel.AddChild(_segment);header.AddChild(segmentPanel);var group=new ButtonGroup();_tabMods=UiKit.Button(TranslationServer.Translate("mod_manager/hub_tab_mods"),ButtonKind.Tab);_tabMods.ToggleMode=true;_tabMods.ButtonGroup=group;_tabMods.Pressed+=()=>ShowTab(TabMods);_segment.AddChild(_tabMods);_tabCollections=UiKit.Button(TranslationServer.Translate("mod_manager/hub_tab_collections"),ButtonKind.Tab);_tabCollections.ToggleMode=true;_tabCollections.ButtonGroup=group;_tabCollections.Pressed+=()=>ShowTab(TabCollections);_segment.AddChild(_tabCollections);header.AddChild(UiKit.Spacer());_gallerySearch=new LineEdit{PlaceholderText=TranslationServer.Translate("mod_manager/gallery_search_placeholder"),CustomMinimumSize=new Vector2(220,0),SizeFlagsVertical=Control.SizeFlags.ShrinkCenter,};_gallerySearch.TextChanged+=OnGallerySearchChanged;header.AddChild(_gallerySearch);_galleryRefresh=UiKit.Button(TranslationServer.Translate("mod_manager/refresh"));_galleryRefresh.Pressed+=OnGalleryRefreshPressed;header.AddChild(_galleryRefresh);}private void BuildBanner(){var root=GetNode<VBoxContainer>("Margin/Root");_bannerPanel=new PanelContainer{Visible=false};_bannerPanel.AddThemeStyleboxOverride("panel",ModManagerTheme.MakeBox(UiTokens.Alpha(UiTokens.Warning,0.15f),UiTokens.Alpha(UiTokens.Warning,0.45f),UiTokens.RadiusMd,UiTokens.Space4,UiTokens.Space2));_bannerLabel=UiKit.Label(string.Empty,UiTokens.FontBodySmall,UiTokens.Warning);_bannerLabel.HorizontalAlignment=HorizontalAlignment.Center;_bannerPanel.AddChild(_bannerLabel);root.AddChild(_bannerPanel);}private void BuildBody(){_body=new Control{SizeFlagsVertical=Control.SizeFlags.ExpandFill,SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,};GetNode<VBoxContainer>("Margin/Root").AddChild(_body);}/* ═══════════════ Tabs and levels ═══════════════ */private void ShowTab(string tab){_activeTab=tab;_editorOpen=false;if(tab==TabMods){EnsureMods();_mods.Visible=true;if(_gallery!=null)_gallery.Visible=false;if(_editor!=null)_editor.Visible=false;}else{EnsureGallery();_gallery.Visible=true;if(_mods!=null)_mods.Visible=false;if(_editor!=null)_editor.Visible=false;}UpdateHeader();}/// <summary>Leaves the editor back to the games gallery (crumbs, back button, Escape).</summary>
private void ShowGallery(){_editorOpen=false;if(_editor!=null)_editor.Visible=false;EnsureGallery();_gallery.Visible=true;UpdateHeader();}/// <summary> Deep link used by the main menu's game picker ("Configure"): shows the collections tab with the scheme editor open on the game. </summary>
public void OpenCollectionEditor(string collectionId)=>OpenEditor(collectionId,false);/// <summary>Opens the editor on the second level of the collections tab.</summary>
private void OpenEditor(string collectionId,bool newGame){EnsureGallery();EnsureEditor();_gallery.Visible=false;if(_mods!=null)_mods.Visible=false;_editor.Visible=true;_editorOpen=true;if(newGame)_editor.OpenNewGame();else _editor.OpenCollection(collectionId);UpdateHeader();}private void UpdateHeader(){if(_editorOpen&&_editor!=null){_back.Text=TranslationServer.Translate("mod_manager/hub_back_to_gallery");_back.TooltipText=_back.Text;_title.Text=_editor.CurrentCollectionTitle??TranslationServer.Translate("mod_manager/collections_title");_segment.Visible=false;_gallerySearch.Visible=false;_galleryRefresh.Visible=false;return;}_back.Text=TranslationServer.Translate("mod_manager/hub_back_to_menu");_back.TooltipText=_back.Text;_title.Text=TranslationServer.Translate("mod_manager/menu_title");_segment.Visible=true;_tabMods.ButtonPressed=_activeTab==TabMods;_tabCollections.ButtonPressed=_activeTab==TabCollections;var collections=_activeTab==TabCollections;_gallerySearch.Visible=collections;_galleryRefresh.Visible=collections;}private void OnBackPressed(){if(_editorOpen)ShowGallery();else QueueFree();}/* ═══════════════ Hosted screens ═══════════════ */private void EnsureMods(){if(_mods!=null)return;_mods=TscnScenes.LoadAndBuild(ModsScene)as ModManagerScreen;if(_mods==null){GameLogger.Log("MOD_HUB",$"Failed to build '{ModsScene}'",LogLevel.Error);return;}_mods.Embedded=true;_body.AddChild(_mods);_mods.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);_mods.EnsureConstructed();_mods.CollectionsLinkRequested+=()=>ShowTab(TabCollections);}private void EnsureGallery(){if(_gallery!=null)return;_gallery=new CollectionsGallery();_gallery.SetSessionInstalled(_sessionInstalled);_gallery.StatusMessage+=ShowBanner;_gallery.EditSchemeRequested+=id=>OpenEditor(id,newGame:false);_gallery.NewGameRequested+=()=>OpenEditor(null,newGame:true);_body.AddChild(_gallery);_gallery.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);_gallery.Refresh();}private void EnsureEditor(){if(_editor!=null)return;_editor=TscnScenes.LoadAndBuild(EditorScene)as ModCollectionsScreen;if(_editor==null){GameLogger.Log("MOD_HUB",$"Failed to build '{EditorScene}'",LogLevel.Error);return;}_editor.Embedded=true;_editor.SetSessionInstalled(_sessionInstalled);_editor.GalleryRequested+=ShowGallery;_editor.OpenManagerRequested+=OnOpenManagerRequested;_editor.ViewChanged+=UpdateHeader;_body.AddChild(_editor);_editor.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);_editor.EnsureConstructed();}private void OnOpenManagerRequested(string searchText){ShowTab(TabMods);_mods?.SetSearch(searchText);}/* ═══════════════ Gallery header controls ═══════════════ */private void OnGallerySearchChanged(string text){_gallery?.ApplyFilter(text);}private async void OnGalleryRefreshPressed(){if(_galleryRefresh.Disabled)return;_galleryRefresh.Disabled=true;ShowBanner(TranslationServer.Translate("mod_manager/loading"));try{await CatalogService.RefreshAsync(forceRefresh:true);}catch(Exception ex){GameLogger.Log("MOD_HUB",$"Catalog refresh failed: {ex.Message}",LogLevel.Warning);}if(!GodotObject.IsInstanceValid(this))return;_galleryRefresh.Disabled=false;ShowBanner(null);}private void ShowBanner(string text){if(!GodotObject.IsInstanceValid(_bannerPanel))return;_bannerPanel.Visible= !string.IsNullOrEmpty(text);if(!string.IsNullOrEmpty(text))_bannerLabel.Text=text;}/* ═══════════════ Escape chain ═══════════════ */ /// <summary>Escape walks back one level: editor → gallery → mods tab.</summary>
public override void _UnhandledInput(InputEvent@event){if(!@event.IsActionPressed("ui_cancel"))return;if(_editorOpen){ShowGallery();GetViewport().SetInputAsHandled();}else if(_activeTab==TabCollections){ShowTab(TabMods);GetViewport().SetInputAsHandled();}}}}

===== FILE mods/mod_manager/src/ModManagerScreen.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Mods;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Base.Lists;namespace Mods.ModManager{/// <summary> Root of the mod manager screen. Opened from the main menu through the manifest <c>main_menu</c> button as a fullscreen overlay; closing frees the scene. The visual layout is decomposed into editable scenes (<c>mod_filter_panel</c>, <c>mod_grid_view</c>/<c>mod_grid_group</c>, <c>mod_card</c>, <c>mod_detail_view</c>) referenced from <c>scenes/mod_manager_screen.tscn</c>; this class only wires them to the model, runs mod operations (install/update/delete/enable) and reflects that all of them only take effect after a restart. </summary>
public partial class ModManagerScreen:ModWidget{private ModManagerModel _model;private ModFilterState _filter;private ModGridView _grid;private ModDetailView _detail;private ModFilterPanel _filterPanel;private LineEdit _search;private OptionButton _sort;private Label _resultLabel;private PanelContainer _bannerPanel;private Label _bannerLabel;private Button _refreshButton;private string _installingId;private bool _restartRequired;private string _statusMessage;/// <summary> Merged collection store keyed by id (as produced by <see cref="ModCollectionStore.LoadAll"/>), refreshed together with the model; feeds the "in N collections" badge on every tile. </summary>
private Dictionary<string,ModCollection>_collectionIndex;/// <summary> When true the screen runs as a tab of the mods hub: its own header close button and title are hidden and closing is handled by the hosting <see cref="ModHubScreen"/>. Must be set before construction; filters, search, install and delete behaviour are unchanged. </summary>
public bool Embedded{get;set;}/// <summary>Raised when a tile or the detail page asks to jump to the collections tab.</summary>
public event Action CollectionsLinkRequested;/// <summary> Applies an external search query — collection cross-links jump to this tab with a mod id taken from a scheme problem. Updates the search box and refreshes the grid without touching other filters. </summary>
public void SetSearch(string text){var query=text??string.Empty;_filter.Search=query;if(GodotObject.IsInstanceValid(_search)&& !string.Equals(_search.Text,query,StringComparison.Ordinal)){_search.Text=query;}RefreshView();}protected override void Construct(){MouseFilter=MouseFilterEnum.Stop;CthangoverTheme.Apply(this);_model=new ModManagerModel();_filter=new ModFilterState();BindSceneNodes();SubscribeEvents();CatalogService.LoadCache();_model.Reload();_filterPanel.Bind(_filter);RefreshAll();StartCatalogRefreshIfNeeded();}protected override void Destruct(){UnsubscribeEvents();}/// <summary>Fetches the scene-built nodes, sets static captions and hooks the layout controls.</summary>
private void BindSceneNodes(){var close=GetNode<Button>("Margin/Root/Header/Close");var title=GetNode<Label>("Margin/Root/Header/Title");_search=GetNode<LineEdit>("Margin/Root/Header/Search");_sort=GetNode<OptionButton>("Margin/Root/Header/Sort");_refreshButton=GetNode<Button>("Margin/Root/Header/Refresh");_resultLabel=GetNode<Label>("Margin/Root/Header/Result");_grid=GetNode<ModGridView>("Margin/Root/Body/Content/Grid");_detail=GetNode<ModDetailView>("Margin/Root/Body/Content/Detail");_filterPanel=GetNode<ModFilterPanel>("Margin/Root/Body/Sidebar/SidebarScroll/FilterPanel");_bannerPanel=GetNode<PanelContainer>("Margin/Root/Banner");_bannerLabel=GetNode<Label>("Margin/Root/Banner/BannerLabel");close.Text=TranslationServer.Translate("mod_manager/back");title.Text=TranslationServer.Translate("mod_manager/title");if(Embedded){// The hub header owns closing and the title; this row keeps only the search/sort/refresh controls.
close.Visible=false;title.Visible=false;}_search.PlaceholderText=TranslationServer.Translate("mod_manager/search_placeholder");_refreshButton.Text=TranslationServer.Translate("mod_manager/refresh");_resultLabel.AddThemeColorOverride("font_color",UiTokens.Muted);_sort.AddItem(TranslationServer.Translate("mod_manager/sort_name"));_sort.AddItem(TranslationServer.Translate("mod_manager/sort_author"));_sort.AddItem(TranslationServer.Translate("mod_manager/sort_status"));_sort.Selected=(int)_filter.Sort;_bannerPanel.AddThemeStyleboxOverride("panel",ModManagerTheme.MakeBox(UiTokens.Alpha(UiTokens.Warning,0.15f),UiTokens.Alpha(UiTokens.Warning,0.45f),UiTokens.RadiusMd,UiTokens.Space4,UiTokens.Space2));_bannerLabel.AddThemeColorOverride("font_color",UiTokens.Warning);_bannerPanel.Visible=false;_detail.Visible=false;close.Pressed+=OnClosePressed;_search.TextChanged+=OnSearchChanged;_sort.ItemSelected+=OnSortSelected;_refreshButton.Pressed+=OnRefreshPressed;_grid.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);_detail.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);}private void SubscribeEvents(){_filterPanel.Changed+=OnFilterChanged;_filterPanel.ResetRequested+=OnFilterResetRequested;_grid.CardClicked+=OnCardClicked;_grid.ActionPressed+=OnActionPressed;_grid.EnabledToggled+=OnEnabledToggled;_grid.VersionChanged+=OnVersionChanged;_grid.CollectionsLinkRequested+=OnCollectionsLinkRequested;_detail.BackRequested+=OnDetailBackRequested;_detail.ActionPressed+=OnActionPressed;_detail.DeleteRequested+=OnDeleteRequested;_detail.EnabledToggled+=OnEnabledToggled;_detail.VersionChanged+=OnVersionChanged;_detail.CollectionsLinkRequested+=OnCollectionsLinkRequested;ModDownloader.OnProgress+=OnInstallProgress;CatalogService.OnRefreshed+=OnCatalogRefreshed;}private void UnsubscribeEvents(){_filterPanel.Changed-=OnFilterChanged;_filterPanel.ResetRequested-=OnFilterResetRequested;_grid.CardClicked-=OnCardClicked;_grid.ActionPressed-=OnActionPressed;_grid.EnabledToggled-=OnEnabledToggled;_grid.VersionChanged-=OnVersionChanged;_grid.CollectionsLinkRequested-=OnCollectionsLinkRequested;_detail.BackRequested-=OnDetailBackRequested;_detail.ActionPressed-=OnActionPressed;_detail.DeleteRequested-=OnDeleteRequested;_detail.EnabledToggled-=OnEnabledToggled;_detail.VersionChanged-=OnVersionChanged;_detail.CollectionsLinkRequested-=OnCollectionsLinkRequested;ModDownloader.OnProgress-=OnInstallProgress;CatalogService.OnRefreshed-=OnCatalogRefreshed;}private void RefreshAll(){_collectionIndex=BuildCollectionIndex();_model.Reload();RefreshView();}/// <summary>Rebuilds the merged collection index from the live store.</summary>
private static Dictionary<string,ModCollection>BuildCollectionIndex(){var index=new Dictionary<string,ModCollection>(StringComparer.OrdinalIgnoreCase);foreach(var collection in ModCollectionStore.LoadAll()){if(!string.IsNullOrEmpty(collection?.Id))index[collection.Id]=collection;}return index;}/// <summary>Counts playable games referencing the mod; zero when the store is unavailable.</summary>
private int CountGamesUsing(string modId){if(_collectionIndex==null||_collectionIndex.Count==0||string.IsNullOrEmpty(modId))return 0;return ModCollectionGallery.FindGamesUsingMod(_collectionIndex,modId).TryGetValue(modId,out var games)?games.Count:0;}/// <summary> Rebuilds grid and counters from the current model without re-reading the registry/catalog. Filter, search and sort changes take this cheap path; install/delete/enable and catalog refreshes call <see cref="RefreshAll"/> first. </summary>
private void RefreshView(){var items=_filter.Apply(_model.Items);foreach(var item in items)item.CollectionsCount=CountGamesUsing(item.Id);_grid.SetItems(items,_filter.GroupByCategory);_filterPanel.RefreshCounts(_model.CountCategories(_filter),_model.CountStatuses(_filter));_resultLabel.Text=string.Format(TranslationServer.Translate("mod_manager/results"),items.Count);UpdateBanner();}private void UpdateBanner(){string text;if(_restartRequired)text=TranslationServer.Translate("mod_manager/restart_required");else if(CatalogService.IsFetching)text=TranslationServer.Translate("mod_manager/loading");else text=_statusMessage;SetBanner(text);}private void SetBanner(string text){if(!GodotObject.IsInstanceValid(_bannerPanel))return;_bannerPanel.Visible= !string.IsNullOrEmpty(text);if(!string.IsNullOrEmpty(text))_bannerLabel.Text=text;}/// <summary>Finds the refreshed model row matching a card, including its catalog variant.</summary>
private ModCardData FindItem(ModCardData data){foreach(var item in _model.Items){if(string.Equals(item.MatchKey,data.MatchKey,StringComparison.OrdinalIgnoreCase))return item;}return null;}private void StartCatalogRefreshIfNeeded(){if(CatalogService.CachedEntries==null&&ModConfig.Instance.CatalogUrls.Count>0)RefreshCatalog();else UpdateBanner();}private async void RefreshCatalog(){if(GodotObject.IsInstanceValid(_refreshButton))_refreshButton.Disabled=true;UpdateBanner();try{await CatalogService.RefreshAsync(forceRefresh:true);}catch(Exception ex){GameLogger.Log("MOD_MANAGER",$"Catalog refresh failed: {ex.Message}",LogLevel.Warning);}if(!GodotObject.IsInstanceValid(this))return;if(GodotObject.IsInstanceValid(_refreshButton))_refreshButton.Disabled=false;UpdateBanner();}private void OnCatalogRefreshed(){if(!GodotObject.IsInstanceValid(this))return;RefreshAll();}private void OnInstallProgress(InstallProgress progress){if(string.IsNullOrEmpty(_installingId)||progress==null)return;SetBanner(progress.Message);}private void OnClosePressed(){QueueFree();}private void OnDetailBackRequested(){_detail.Visible=false;_grid.Visible=true;}private void OnCardClicked(ModCardData data){_detail.Open(data);_detail.Visible=true;_grid.Visible=false;}private void OnSearchChanged(string text){_filter.Search=text;RefreshView();}private void OnSortSelected(long index){_filter.Sort=(ModSortMode)(int)index;RefreshView();}private void OnFilterChanged(){RefreshView();}private void OnCollectionsLinkRequested(){CollectionsLinkRequested?.Invoke();}private void OnFilterResetRequested(){if(!string.IsNullOrEmpty(_search.Text))_search.Text=string.Empty;}private void OnVersionChanged(ModCardData data){_filterPanel.RefreshCounts(_model.CountCategories(_filter),_model.CountStatuses(_filter));}private void OnActionPressed(ModCardData data){var kind=ModActions.GetPrimary(data);if(kind==null)return;ExecuteAction(data,kind.Value);}private void OnDeleteRequested(ModCardData data){ExecuteAction(data,ModActionKind.Delete);}private void OnEnabledToggled(ModCardData data){_model.SetEnabled(data.Id,data.IsDisabled);_restartRequired=true;_statusMessage=null;RefreshAll();if(_detail.Visible){var updated=FindItem(data);if(updated!=null)_detail.Open(updated);else OnDetailBackRequested();}}private void ExecuteAction(ModCardData data,ModActionKind kind){if(kind==ModActionKind.Delete){ConfirmDelete(data);return;}StartInstall(data,kind);}private async void StartInstall(ModCardData data,ModActionKind kind){if(!string.IsNullOrEmpty(_installingId))return;var version=data.SelectedDownload?.Version;_installingId=data.Id;_statusMessage=null;SetBanner(string.Format(TranslationServer.Translate("mod_manager/progress_installing"),data.Title));bool success;try{success=await ModDownloader.InstallAsync(data.Id,version,data.PrimarySource);}catch(Exception ex){GameLogger.Log("MOD_MANAGER",$"Install '{data.Id}' failed: {ex.Message}",LogLevel.Error);success=false;}_installingId=null;if(!GodotObject.IsInstanceValid(this))return;if(success){_model.MarkInstalled(data.Id,version);_restartRequired=true;_statusMessage=null;}else{_statusMessage=TranslationServer.Translate("mod_manager/install_failed");}RefreshAll();if(_detail.Visible){var updated=FindItem(data);if(updated!=null)_detail.Open(updated);}}private void ConfirmDelete(ModCardData data){var dialog=new ConfirmationDialog{Title=TranslationServer.Translate("mod_manager/delete_title"),DialogText=string.Format(TranslationServer.Translate("mod_manager/delete_text"),data.Title),OkButtonText=TranslationServer.Translate("mod_manager/delete_confirm"),CancelButtonText=TranslationServer.Translate("mod_manager/cancel"),Exclusive=true,};var modId=data.Id;dialog.Confirmed+=()=>{OnDeleteConfirmed(modId);dialog.QueueFree();};dialog.Canceled+=dialog.QueueFree;AddChild(dialog);dialog.PopupCentered();}private void OnDeleteConfirmed(string modId){if(!ModUninstaller.Uninstall(modId)){_statusMessage=TranslationServer.Translate("mod_manager/delete_failed");UpdateBanner();return;}_model.MarkRemoved(modId);_restartRequired=true;_statusMessage=null;if(_detail.Visible){_detail.Visible=false;_grid.Visible=true;}RefreshAll();}private void OnRefreshPressed(){RefreshCatalog();}}}

===== FILE mods/mod_manager/src/Model/ModActionKind.cs =====
namespace Mods.ModManager{/// <summary> User-triggered mod operation. The screen resolves a tile's primary action via <see cref="ModActions.GetPrimary"/> and executes the kind here; the detail view maps its buttons to the same kinds. Every kind is persisted to disk immediately but only takes effect after a restart. </summary>
public enum ModActionKind{/// <summary>Download and install the selected catalog version.</summary>
Install,/// <summary>Install a newer catalog version over the local copy.</summary>
Update,/// <summary>Install an older catalog version over the local copy.</summary>
Downgrade,/// <summary>Reinstall the same version to replace locally modified files.</summary>
Reinstall,/// <summary>Delete the installed mod (user mods root only), then clean per-mod state.</summary>
Delete,/// <summary>Allow the mod to load on next start.</summary>
Enable,/// <summary>Prevent the mod from loading on next start.</summary>
Disable,}}

===== FILE mods/mod_manager/src/Model/ModActions.cs =====
namespace Mods.ModManager{/// <summary> Business rules deciding which operation a mod tile offers. The tile shows a single contextual button; its meaning follows the state machine: catalog-only → Install, newer remote → Update, older remote → Downgrade, same version but edited locally → Reinstall, otherwise → Delete for deletable local mods and nothing for protected/shipped ones. </summary>
public static class ModActions{/// <summary>Primary action for a tile/detail page, or null when there is nothing to do.</summary>
public static ModActionKind?GetPrimary(ModCardData data){if(data==null)return null;if(!data.IsInstalled)return data.HasRemote?ModActionKind.Install:null;if(data.HasRemote){switch(data.Status){case ModItemStatus.UpdateAvailable:return ModActionKind.Update;case ModItemStatus.DowngradeAvailable:return ModActionKind.Downgrade;case ModItemStatus.ModifiedLocally:return ModActionKind.Reinstall;}}return data.IsDeletable?ModActionKind.Delete:null;}/// <summary>True for actions that download and install files.</summary>
public static bool IsInstall(ModActionKind kind)=>kind==ModActionKind.Install||kind==ModActionKind.Update||kind==ModActionKind.Downgrade||kind==ModActionKind.Reinstall;}}

===== FILE mods/mod_manager/src/Model/ModCardData.cs =====
using System.Collections.Generic;using Cthangover.Core.Mods;using Cthangover.Core.Utils;namespace Mods.ModManager{/// <summary> One row of the mod manager grid: everything the tile and the detail page need to render a mod, regardless of whether it exists locally, in the online catalog, or both. The manager builds these in <see cref="ModManagerModel.Reload"/> by merging <c>ModRegistry.AllDiscoveredMods</c> with <c>CatalogService.CachedEntries</c>. <para/> <see cref="Status"/> is derived (not stored by the registry) from the local version, the selected catalog download and the local install hash — call <see cref="RecomputeStatus"/> after changing <see cref="SelectedDownload"/>, <see cref="IsDisabled"/> or the install state. </summary>
public class ModCardData{/// <summary>Stable mod id shared by the local mod and the catalog entry.</summary>
public string Id{get;set;}/// <summary>Localized display title (local manifest name or catalog locale name).</summary>
public string Title{get;set;}/// <summary>Author string from whichever source is available.</summary>
public string Author{get;set;}/// <summary>Localized description.</summary>
public string Description{get;set;}/// <summary>Installed version string, or null for catalog-only mods.</summary>
public string LocalVersion{get;set;}/// <summary>Manifest category (local) or catalog category (remote), flags combined.</summary>
public CategoryType Category{get;set;}=CategoryType.Other;/// <summary>Loaded local mod, or null when only the catalog knows this id.</summary>
public ModInfo LocalInfo{get;set;}/// <summary>Merged catalog entry, or null for local-only (development) mods.</summary>
public CatalogExportEntry Catalog{get;set;}/// <summary>True when a local copy exists (including an install done in this session).</summary>
public bool IsInstalled{get;set;}/// <summary>True when the local copy is listed in <c>disabled_mods.json</c>.</summary>
public bool IsDisabled{get;set;}/// <summary>True when <see cref="ModUninstaller"/> may delete the local copy.</summary>
public bool IsDeletable{get;set;}/// <summary>True for the mod manager itself, which cannot be disabled or deleted.</summary>
public bool IsProtected{get;set;}/// <summary>Derived tile state; see <see cref="RecomputeStatus"/>.</summary>
public ModItemStatus Status{get;set;}/// <summary>Catalog downloads, newest first (as merged by the catalog service).</summary>
public List<CatalogEntryDownload>Downloads{get;set;}=new();/// <summary>Version chosen in the tile/detail combo box; defaults to the newest download.</summary>
public CatalogEntryDownload SelectedDownload{get;set;}/// <summary>True when an online catalog entry exists for this mod.</summary>
public bool HasRemote=>Catalog!=null;/// <summary> True when several catalogs publish this id with the same version and title but different content — the tile and detail page warn about it. </summary>
public bool IsConflict=>Catalog?.VariantConflict==true;/// <summary> How many <see cref="ModCollectionKind.Game"/> collections reference this mod (directly or through includes). Computed by the manager screen via <see cref="ModCollectionGallery.FindGamesUsingMod"/> on every grid refresh; zero means the mod belongs to no playable game. </summary>
public int CollectionsCount{get;set;}/// <summary>Catalog URLs that published this variant, or null for local-only mods.</summary>
public IReadOnlyList<string>SourceUrls=>Catalog?.SourceUrls;/// <summary>First catalog URL; used to install exactly this variant and to match cards after reload.</summary>
public string PrimarySource=>ModCatalogSource.FirstUrl(SourceUrls);/// <summary> Identity of a card across model reloads: the mod id plus the catalog variant it represents. Needed because conflicting variants share an id. </summary>
public string MatchKey=>$"{Id}|{PrimarySource ?? string.Empty}";/// <summary>True when the tile should show the enable/disable switch.</summary>
public bool CanToggleEnabled=>IsInstalled&& !IsProtected;/// <summary>Version to display next to the title: installed version or newest catalog version.</summary>
public string DisplayVersion=> !string.IsNullOrEmpty(LocalVersion)?LocalVersion:SelectedDownload?.Version??Catalog?.Version;/// <summary> Derives <see cref="Status"/> from the local and selected remote versions. Priority: not installed → disabled → version comparison → hash comparison. A missing catalog entry always yields <see cref="ModItemStatus.Installed"/> for a local copy. </summary>
public void RecomputeStatus(){if(!IsInstalled){Status=ModItemStatus.NotInstalled;return;}if(IsDisabled){Status=ModItemStatus.Disabled;return;}var remoteVersion=SelectedDownload?.Version??Catalog?.Version;if(string.IsNullOrEmpty(remoteVersion)||string.IsNullOrEmpty(LocalVersion)){Status=ModItemStatus.Installed;return;}var comparison=VersionHelper.Compare(remoteVersion,LocalVersion);if(comparison>0){Status=ModItemStatus.UpdateAvailable;return;}if(comparison<0){Status=ModItemStatus.DowngradeAvailable;return;}var remoteHash=SelectedDownload?.InstallSha256;if(!string.IsNullOrEmpty(remoteHash)&& !string.IsNullOrEmpty(LocalInfo?.InstallSha256)&& !string.Equals(LocalInfo.InstallSha256,remoteHash,System.StringComparison.OrdinalIgnoreCase)){Status=ModItemStatus.ModifiedLocally;return;}Status=ModItemStatus.Installed;}}}

===== FILE mods/mod_manager/src/Model/ModChoice.cs =====
namespace Mods.ModManager{/// <summary> One row of the "+ мод" picker: the mods known to the local registry and the online catalog cache, merged by id. The picker excludes ids that are already resolved in the scheme, so this entity only ever describes mods the player can still add; <see cref="ModChoice.Id"/> becomes a new string in the target mechanic's <c>mods</c> array. </summary>
public class ModChoice{/// <summary>Canonical mod id written into the collection document.</summary>
public string Id{get;set;}/// <summary>Display title (localized catalog name or manifest name).</summary>
public string Title{get;set;}/// <summary>Localized description shown under the title.</summary>
public string Description{get;set;}}}

===== FILE mods/mod_manager/src/Model/ModFilterState.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Mods;namespace Mods.ModManager{/// <summary> Live filter state of the manager. Empty status/category sets mean "all", which matches Nexus-style sidebars where checkboxes only ever narrow the list. Search matches title, id and author case-insensitively. The state also owns the sort mode and the optional grouping toggle. </summary>
public class ModFilterState{/// <summary>Free-text query matched against title, id and author.</summary>
public string Search{get;set;}=string.Empty;/// <summary>Selected statuses; empty means no status restriction.</summary>
public HashSet<ModItemStatus>Statuses{get;}=new();/// <summary>Selected categories; empty means no category restriction.</summary>
public HashSet<CategoryType>Categories{get;}=new();/// <summary>Grid ordering mode.</summary>
public ModSortMode Sort{get;set;}=ModSortMode.Name;/// <summary>When true the grid renders category headers between groups.</summary>
public bool GroupByCategory{get;set;}/// <summary>Full match: search + status + category.</summary>
public bool Matches(ModCardData item)=>MatchesSearch(item)&&MatchesStatuses(item)&&MatchesCategories(item);/// <summary>Match ignoring the category set — used to compute category counts.</summary>
public bool MatchesExceptCategory(ModCardData item)=>MatchesSearch(item)&&MatchesStatuses(item);/// <summary>Match ignoring the status set — used to compute status counts.</summary>
public bool MatchesExceptStatus(ModCardData item)=>MatchesSearch(item)&&MatchesCategories(item);/// <summary>Returns matching items in the configured order.</summary>
public List<ModCardData>Apply(IEnumerable<ModCardData>source){var result=new List<ModCardData>();foreach(var item in source){if(Matches(item))result.Add(item);}result.Sort(Compare);return result;}/// <summary>Clears search, statuses and categories; keeps sort and grouping.</summary>
public void Reset(){Search=string.Empty;Statuses.Clear();Categories.Clear();}private int Compare(ModCardData a,ModCardData b){switch(Sort){case ModSortMode.Author:var byAuthor=string.Compare(a.Author,b.Author,StringComparison.OrdinalIgnoreCase);return byAuthor!=0?byAuthor:CompareTitle(a,b);case ModSortMode.Status:var byStatus=ModStatusNames.SortRank(a.Status).CompareTo(ModStatusNames.SortRank(b.Status));return byStatus!=0?byStatus:CompareTitle(a,b);default:return CompareTitle(a,b);}}private static int CompareTitle(ModCardData a,ModCardData b)=>string.Compare(a.Title,b.Title,StringComparison.OrdinalIgnoreCase);private bool MatchesSearch(ModCardData item){if(string.IsNullOrWhiteSpace(Search))return true;var query=Search.Trim();return Contains(item.Title,query)||Contains(item.Id,query)||Contains(item.Author,query);}private bool MatchesStatuses(ModCardData item)=>Statuses.Count==0||Statuses.Contains(item.Status);private bool MatchesCategories(ModCardData item)=>Categories.Count==0||(item.Category&AsFlags())!=0;private CategoryType AsFlags(){var flags=(CategoryType)0;foreach(var category in Categories)flags|=category;return flags;}private static bool Contains(string value,string query)=> !string.IsNullOrEmpty(value)&&value.Contains(query,StringComparison.OrdinalIgnoreCase);}}

===== FILE mods/mod_manager/src/Model/ModItemStatus.cs =====
namespace Mods.ModManager{/// <summary> UI-level state of a mod tile. Combines the local install state with the online catalog comparison so a single value drives the tile badge and the contextual action button. <see cref="ModifiedLocally"/> means the local content hash differs from the catalog entry of the same version — the player edited the mod and overwriting would lose their changes. </summary>
public enum ModItemStatus{/// <summary>No local copy exists; only the catalog entry is shown.</summary>
NotInstalled,/// <summary>Installed and up-to-date with the selected catalog version (or no catalog entry).</summary>
Installed,/// <summary>The catalog has a newer version than the installed one.</summary>
UpdateAvailable,/// <summary>The catalog's selected version is older than the installed one.</summary>
DowngradeAvailable,/// <summary>Same version as the catalog, but the local content hash differs.</summary>
ModifiedLocally,/// <summary>Installed but disabled by the player (<c>disabled_mods.json</c>).</summary>
Disabled,}}

===== FILE mods/mod_manager/src/Model/ModManagerModel.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Mods;using Godot;namespace Mods.ModManager{/// <summary> Merges everything known about mods into the manager's grid model: local mods discovered by <see cref="ModRegistry"/> (with enable state and deletability) and online catalog entries from <see cref="CatalogService"/>. A mod present in both sources becomes one combined card; catalog-only mods appear as installable tiles. <para/> The model also owns the session overrides that keep the UI honest after file operations: a mod installed or deleted from the manager is not yet reflected by the registry (which is loaded once per launch), so <see cref="MarkInstalled"/> / <see cref="MarkRemoved"/> adjust the view and the restart banner tells the player when the change becomes real. </summary>
public class ModManagerModel{/// <summary>Id of the mod that renders this UI; it can never be disabled or removed.</summary>
public const string SelfModId="mod_manager";private readonly List<ModCardData>_items=new();private readonly HashSet<string>_removed=new(StringComparer.OrdinalIgnoreCase);private readonly Dictionary<string,string>_installed=new(StringComparer.OrdinalIgnoreCase);/// <summary>All cards, unsorted and unfiltered; rebuild with <see cref="Reload"/>.</summary>
public IReadOnlyList<ModCardData>Items=>_items;/// <summary> Rebuilds the card list from the current registry, disabled store and catalog cache. Safe to call on every action completion and after a catalog refresh. </summary>
public void Reload(){_items.Clear();var disabled=DisabledModsStore.Load();var variantsById=BuildCatalogVariants();var localIds=new HashSet<string>(StringComparer.OrdinalIgnoreCase);var ordered=ModRegistry.Instance.GetOrderedModIds(filterDisabled:false);foreach(var id in ordered){if(_removed.Contains(id))continue;if(!ModRegistry.Instance.AllDiscoveredMods.TryGetValue(id,out var info)||info is not ModInfo modInfo)continue;localIds.Add(id);var isDisabled=disabled.Contains(id);if(variantsById.TryGetValue(id,out var variants)){foreach(var variant in variants)_items.Add(BuildLocalCard(id,modInfo,isDisabled,variant));}else{_items.Add(BuildLocalCard(id,modInfo,isDisabled,null));}}foreach(var pair in variantsById){if(localIds.Contains(pair.Key)||_removed.Contains(pair.Key))continue;foreach(var variant in pair.Value)_items.Add(BuildRemoteCard(variant));}}/// <summary>Remembers that a mod was deleted this session so it stays hidden until restart.</summary>
public void MarkRemoved(string modId){_removed.Add(modId);_installed.Remove(modId);}/// <summary>Remembers a successful install so the card shows as installed before restart.</summary>
public void MarkInstalled(string modId,string version){_removed.Remove(modId);_installed[modId]=version??string.Empty;}/// <summary> Applies an enable/disable toggle with the same cascade as the legacy menu: disabling a mod also disables its dependents, enabling one also re-enables the dependencies it needs. </summary>
public void SetEnabled(string modId,bool enabled){var disabled=DisabledModsStore.Load();if(enabled){disabled.Remove(modId);var info=ModRegistry.Instance.GetMod(modId)as ModInfo;var dependencies=info?.Manifest?.DependencyIds;if(dependencies!=null){foreach(var dependency in dependencies)disabled.Remove(dependency);}}else{disabled.Add(modId);CollectDependents(modId,disabled);}DisabledModsStore.Save(disabled);}/// <summary>Category buckets for the sidebar, respecting every filter except the category set.</summary>
public Dictionary<CategoryType,int>CountCategories(ModFilterState filter){var counts=new Dictionary<CategoryType,int>();foreach(var item in _items){if(!filter.MatchesExceptCategory(item))continue;foreach(var category in ModCategoryNames.Ordered){if((item.Category&category)==0)continue;counts[category]=counts.GetValueOrDefault(category)+1;}}return counts;}/// <summary>Status buckets for the sidebar, respecting every filter except the status set.</summary>
public Dictionary<ModItemStatus,int>CountStatuses(ModFilterState filter){var counts=new Dictionary<ModItemStatus,int>();foreach(var item in _items){if(!filter.MatchesExceptStatus(item))continue;counts[item.Status]=counts.GetValueOrDefault(item.Status)+1;}return counts;}/// <summary> Indexes catalog entries by mod id. Normally one entry per id, but a variant conflict (same id/version/title, different content across catalogs) produces several entries that the UI shows as separate mods. </summary>
private Dictionary<string,List<CatalogExportEntry>>BuildCatalogVariants(){var index=new Dictionary<string,List<CatalogExportEntry>>(StringComparer.OrdinalIgnoreCase);var entries=CatalogService.CachedEntries;if(entries==null)return index;foreach(var entry in entries){if(string.IsNullOrEmpty(entry?.Id))continue;if(!index.TryGetValue(entry.Id,out var list)){list=new List<CatalogExportEntry>();index[entry.Id]=list;}list.Add(entry);}return index;}private ModCardData BuildLocalCard(string id,ModInfo modInfo,bool disabled,CatalogExportEntry catalog){var data=new ModCardData{Id=id,Title=modInfo.DisplayTitle,Author=modInfo.Author,Description=modInfo.LocalizedDescription,LocalVersion=modInfo.Version,Category=modInfo.Manifest?.Category??CategoryType.Other,LocalInfo=modInfo,Catalog=catalog,IsInstalled=true,IsDisabled=disabled,IsDeletable=ModUninstaller.CanUninstall(modInfo),IsProtected=string.Equals(id,SelfModId,StringComparison.OrdinalIgnoreCase),};if(_installed.TryGetValue(id,out var installedVersion))data.LocalVersion=installedVersion;ApplyDownloads(data,catalog);data.RecomputeStatus();return data;}private ModCardData BuildRemoteCard(CatalogExportEntry catalog){var locale=GetCatalogLocale();var data=new ModCardData{Id=catalog.Id,Title=catalog.GetLocalizedName(locale),Author=catalog.Author,Description=catalog.GetLocalizedDescription(locale),Category=catalog.Category,Catalog=catalog,IsInstalled=false,};if(_installed.TryGetValue(catalog.Id,out var installedVersion)){data.IsInstalled=true;data.LocalVersion=installedVersion;}ApplyDownloads(data,catalog);data.RecomputeStatus();return data;}private static void ApplyDownloads(ModCardData data,CatalogExportEntry catalog){data.Downloads=catalog?.Downloads??new List<CatalogEntryDownload>();data.SelectedDownload=data.Downloads.Count>0?data.Downloads[0]:null;}private static string GetCatalogLocale(){var locale=TranslationServer.GetLocale();return string.IsNullOrEmpty(locale)?"en":locale;}private static void CollectDependents(string modId,HashSet<string>disabled){var allMods=ModRegistry.Instance.AllDiscoveredMods;foreach(var pair in allMods){if(disabled.Contains(pair.Key)||pair.Value is not ModInfo info)continue;var dependencies=info.Manifest?.DependencyIds;if(dependencies!=null&&dependencies.Contains(modId)){disabled.Add(pair.Key);CollectDependents(pair.Key,disabled);}}}}}

===== FILE mods/mod_manager/src/Model/ModQueries.cs =====
using System.Collections.Generic;using Cthangover.Core.Mods;namespace Mods.ModManager{/// <summary> Read-only cross-mod lookups used by the detail page: direct dependencies, reverse dependencies (which mods depend on this one) and declared conflicts. Prefers local manifest data and falls back to the catalog entry for mods that are not installed. </summary>
public static class ModQueries{/// <summary>Dependency strings (<c>core&gt;=1</c> format) of a mod, whatever source knows it.</summary>
public static List<string>GetDependencies(ModCardData data){var manifestDeps=data?.LocalInfo?.Manifest?.DependencyIds;if(manifestDeps!=null&&manifestDeps.Count>0)return manifestDeps;return data?.Catalog?.Depends??new List<string>();}/// <summary>Ids of installed mods whose manifests depend on <paramref name="modId"/>.</summary>
public static List<string>GetDependents(string modId){var result=new List<string>();if(string.IsNullOrEmpty(modId))return result;var allMods=ModRegistry.Instance.AllDiscoveredMods;if(allMods==null)return result;foreach(var pair in allMods){if(pair.Key==modId||pair.Value is not ModInfo info)continue;var deps=info.Manifest?.DependencyIds;if(deps!=null&&deps.Contains(modId))result.Add(pair.Key);}return result;}/// <summary>Declared mutually exclusive mod ids (manifest first, catalog fallback).</summary>
public static List<string>GetConflicts(ModCardData data){var manifestConflicts=data?.LocalInfo?.Manifest?.ConflictsWith;if(manifestConflicts!=null&&manifestConflicts.Count>0)return manifestConflicts;return data?.Catalog?.ConflictsWith??new List<string>();}}}

===== FILE mods/mod_manager/src/Model/ModSortMode.cs =====
namespace Mods.ModManager{/// <summary>Ordering mode of the mod grid; grouping by category is a separate toggle.</summary>
public enum ModSortMode{/// <summary>Alphabetical by display title.</summary>
Name,/// <summary>Alphabetical by author, then title.</summary>
Author,/// <summary>By install/update state (installed, updates, catalog-only).</summary>
Status,}}

===== FILE mods/mod_manager/src/UI/CollectionEdge.cs =====
using Cthangover.Core.Mods;namespace Mods.ModManager{/// <summary> One dependency line of the scheme: the two chips it connects plus the flags that decide its paint style. Edges are rebuilt on every canvas rebuild and drawn by <see cref="CollectionEdgeLayer"/> from the chips' live global rectangles, so dragging a chip or a whole group keeps the lines attached without recomputing them. <para/> <see cref="Cross"/> is the "dangerous layout" flag: the dependency's two mods live in different mechanics, which means the player separated what belongs together. Cross edges are painted amber (red when the selection touches them) so the mistake is visible across the whole canvas. </summary>
public class CollectionEdge{/// <summary>Mod id of the dependency (the side the arrow starts at).</summary>
public string FromModId{get;set;}/// <summary>Mod id of the dependent mod.</summary>
public string ToModId{get;set;}/// <summary>True when the two mods belong to different groups.</summary>
public bool Cross{get;set;}/// <summary>True when the selection touches one of the endpoints.</summary>
public bool Active{get;set;}}}

===== FILE mods/mod_manager/src/UI/CollectionEdgeLayer.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;namespace Mods.ModManager{/// <summary> Transparent overlay that paints the scheme's dependency lines above the group cards, the Godot counterpart of the mockup's SVG layer. Drawing is connected through the <c>"draw"</c> signal rather than a <c>_Draw</c> override, because virtual overrides are unreliable in mod assemblies. <para/> The layer stores only edge descriptors and chip references; coordinates are read from the chips' global rectangles at paint time, so the canvas never has to update line geometry — it only calls <see cref="QueueRedraw"/> after a drag or resize. </summary>
public partial class CollectionEdgeLayer:ModWidget{private readonly List<CollectionEdge>_edges=new();private readonly Dictionary<string,CollectionModChip>_chips=new();private Control _canvas;/// <summary>Replaces the drawn edges. <paramref name="canvas"/> anchors the local coordinate space.</summary>
public void SetEdges(IEnumerable<CollectionEdge>edges,Dictionary<string,CollectionModChip>chips,Control canvas){EnsureConstructed();_canvas=canvas;_edges.Clear();_chips.Clear();if(edges!=null){foreach(var edge in edges)_edges.Add(edge);}if(chips!=null){foreach(var pair in chips)_chips[pair.Key]=pair.Value;}QueueRedraw();}protected override void Construct(){MouseFilter=MouseFilterEnum.Ignore;SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);Connect("draw",Callable.From((Action)DrawAll));}private void DrawAll(){if(_canvas==null|| !GodotObject.IsInstanceValid(_canvas))return;var origin=_canvas.GetGlobalRect().Position;foreach(var edge in _edges){if(!_chips.TryGetValue(edge.FromModId,out var from)|| !GodotObject.IsInstanceValid(from))continue;if(!_chips.TryGetValue(edge.ToModId,out var to)|| !GodotObject.IsInstanceValid(to))continue;var a=from.GetGlobalRect().GetCenter()-origin;var b=to.GetGlobalRect().GetCenter()-origin;var points=BuildCurve(a,b);var color=edge.Cross?(edge.Active?UiTokens.Danger:UiTokens.Warning):(edge.Active?UiTokens.Accent:UiTokens.LineStrong);var width=edge.Cross?(edge.Active?3f:2f):(edge.Active?2.5f:1.5f);if(edge.Cross){for(var i=0;i<points.Count-1;i++)DrawDashedLine(points[i],points[i+1],color,width,5f);DrawCircle(points[^1],2.5f,color);}else{DrawPolyline(points.ToArray(),color,width,true);}}}private static List<Vector2>BuildCurve(Vector2 a,Vector2 b){var dx=b.X-a.X;var dy=b.Y-a.Y;Vector2 c1,c2;if(Mathf.Abs(dx)>=Mathf.Abs(dy)){c1=new Vector2(a.X+dx*0.4f,a.Y);c2=new Vector2(b.X-dx*0.4f,b.Y);}else{c1=new Vector2(a.X,a.Y+dy*0.4f);c2=new Vector2(b.X,b.Y-dy*0.4f);}var points=new List<Vector2>();const int steps=24;for(var i=0;i<=steps;i++)points.Add(Cubic(a,c1,c2,b,i/(float)steps));return points;}private static Vector2 Cubic(Vector2 a,Vector2 c1,Vector2 c2,Vector2 b,float t){var u=1f-t;return u*u*u*a+3f*u*u*t*c1+3f*u*t*t*c2+t*t*t*b;}}}

===== FILE mods/mod_manager/src/UI/CollectionGroupCard.cs =====
using System;using Cthangover.Core.Mods;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Mods.ModManager{/// <summary> One contour of the collection scheme: the Godot counterpart of the mockup's <c>.ce-group</c>. Renders a movable and resizable frame with the mechanic role, name, state badge and description, hosts the draggable mod chips on an absolute layer and draws the faint watermark label on the background. <para/> The card owns no data beyond the resolved <see cref="ModCollectionGroup"/> it displays: the header carries only the state pill and the optional contour's on/off toggle (raising <see cref="Command"/> with "toggle"), while editing commands live in the screen's inspector panel. Group and chip coordinates are reported through <see cref="Moved"/>, <see cref="Resized"/> and <see cref="DragFinished"/> so the canvas can persist them through <see cref="CollectionLayoutStore"/>. </summary>
public partial class CollectionGroupCard:ModWidget{/// <summary>Minimum frame width enforced while resizing.</summary>
public const int MinWidth=300;/// <summary>Minimum frame height enforced while resizing.</summary>
public const int MinHeight=140;/// <summary>Stable group key matching <see cref="ModCollectionGroup.Key"/>.</summary>
public string GroupKey{get;private set;}/// <summary>Resolved group shown by this card.</summary>
public ModCollectionGroup Group{get;private set;}/// <summary>Layer the canvas adds mod chips to; absolute coordinates start at the frame's top-left.</summary>
public Control ModsLayer{get;private set;}/// <summary> Card-local Y where the free chip area begins: the bottom of the header and description block plus a small gap. <see cref="CollectionModChip"/> clamps vertical drags to this line, so a chip can never be parked on top of the group title, its header buttons or its description. The canvas keeps its own default for freshly packed slots. </summary>
public float ModsTop{get{EnsureConstructed();var block=_content.Position.Y+_desc.Position.Y+_desc.Size.Y;return Mathf.Max(0f,block+UiTokens.Space2);}}/// <summary>Raised on left click anywhere on the card that is not a chip or button.</summary>
public event Action<string>Selected;/// <summary>Raised by the header buttons with a command id (see class summary).</summary>
public event Action<string,string>Command;/// <summary>Raised while dragging the header with the desired canvas position.</summary>
public event Action<string,Vector2>Moved;/// <summary>Raised while dragging the resize corner with the desired frame size.</summary>
public event Action<string,Vector2>Resized;/// <summary>Raised once a header drag or resize ends, so layout can be flushed.</summary>
public event Action DragFinished;private PanelContainer _panel;private VBoxContainer _content;private HBoxContainer _header;private Label _role;private Label _name;private Label _desc;private Label _watermark;private PanelContainer _stateChip;private Label _stateLabel;private CheckButton _optional;private CollectionResizeHandle _resize;private StyleBoxFlat _style;private Color _baseBorder=UiTokens.LineCard;private bool _dragging;private bool _resizing;private bool _moved;private bool _updatingOptional;/// <summary> Binds the card to a resolved group. Must be called after the card is added to the tree; safe to call again to refresh texts and states. </summary>
public void Init(ModCollectionGroup group,string stateLabel,Color stateColor,bool selected){EnsureConstructed();Group=group;GroupKey=group.Key;_role.Text=string.IsNullOrEmpty(group.Role)?string.Empty:group.Role.ToUpperInvariant();_name.Text=group.Name;_desc.Text=group.Description??string.Empty;_watermark.Text=group.Name??string.Empty;_stateLabel.Text=stateLabel;_stateLabel.AddThemeColorOverride("font_color",stateColor);_stateChip.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusXs,UiTokens.BadgeFill(stateColor),UiTokens.BadgeLine(stateColor)));if(_optional!=null){_updatingOptional=true;_optional.ButtonPressed=group.Selected;_updatingOptional=false;_optional.Visible=group.Optional;}_header.TooltipText=group.Description??string.Empty;Modulate=new Color(1,1,1,group.Selected?1f:0.55f);SetSelected(selected);}/// <summary>Moves and sizes the frame in canvas coordinates.</summary>
public void SetFrame(Rect2 rect){Position=rect.Position;Size=rect.Size;_watermark.Size=new Vector2(Mathf.Max(0f,rect.Size.X-24f),52f);_watermark.Position=new Vector2(8f,Mathf.Max(0f,rect.Size.Y-56f));_resize.Position=new Vector2(Mathf.Max(0f,rect.Size.X-CollectionResizeHandle.HandleSize),Mathf.Max(0f,rect.Size.Y-CollectionResizeHandle.HandleSize));}/// <summary>Highlights the frame as the current canvas/inspector selection.</summary>
public void SetSelected(bool selected){EnsureConstructed();_style.BorderColor=selected?UiTokens.Accent:_baseBorder;_style.BorderWidthTop=1;_style.BorderWidthRight=1;_style.BorderWidthBottom=1;_style.BorderWidthLeft=1;}/// <summary>Updates only the state pill, used when the plan changes the availability.</summary>
public void SetState(string stateLabel,Color stateColor){EnsureConstructed();_stateLabel.Text=stateLabel;_stateLabel.AddThemeColorOverride("font_color",stateColor);_stateChip.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusXs,UiTokens.BadgeFill(stateColor),UiTokens.BadgeLine(stateColor)));}protected override void Construct(){MouseFilter=MouseFilterEnum.Ignore;_baseBorder=UiTokens.LineCard;_style=UiKit.Frame(12,new Color(UiTokens.Surface1,0.92f),_baseBorder);_panel=new PanelContainer{MouseFilter=MouseFilterEnum.Stop};_panel.AddThemeStyleboxOverride("panel",_style);_panel.GuiInput+=OnPanelGuiInput;AddChild(_panel);_panel.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);ModsLayer=new Control{MouseFilter=MouseFilterEnum.Ignore};AddChild(ModsLayer);ModsLayer.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);var contentHost=new Control{MouseFilter=MouseFilterEnum.Ignore};AddChild(contentHost);contentHost.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);var margin=new MarginContainer{MouseFilter=MouseFilterEnum.Ignore};margin.AddThemeConstantOverride("margin_left",UiTokens.Space6);margin.AddThemeConstantOverride("margin_right",UiTokens.Space6);margin.AddThemeConstantOverride("margin_top",UiTokens.Space5);margin.AddThemeConstantOverride("margin_bottom",UiTokens.Space5);contentHost.AddChild(margin);margin.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);_content=new VBoxContainer{MouseFilter=MouseFilterEnum.Ignore};_content.AddThemeConstantOverride("separation",6);margin.AddChild(_content);_header=new HBoxContainer{MouseFilter=MouseFilterEnum.Stop};_header.AddThemeConstantOverride("separation",UiTokens.Space3);_header.GuiInput+=OnHeaderGuiInput;_content.AddChild(_header);_role=UiKit.Label(string.Empty,UiTokens.FontMicro,UiTokens.AccentDim);_role.Uppercase=true;_role.SizeFlagsVertical=SizeFlags.ShrinkCenter;_header.AddChild(_role);_name=UiKit.Label(string.Empty,UiTokens.FontLead,UiTokens.InkBright);_name.SizeFlagsVertical=SizeFlags.ShrinkCenter;_name.SizeFlagsHorizontal=SizeFlags.ExpandFill;_name.ClipText=true;_header.AddChild(_name);_stateLabel=UiKit.Label(string.Empty,UiTokens.FontMicro,UiTokens.Success);_stateLabel.SizeFlagsVertical=SizeFlags.ShrinkCenter;_stateChip=new PanelContainer{MouseFilter=MouseFilterEnum.Ignore};_stateChip.AddChild(_stateLabel);_header.AddChild(_stateChip);// Commands (add mod, rename, replace, remove) live only in the right-hand inspector — the header stays a compact title row with the state pill and, for optional contours, the on/off toggle.
_optional=new CheckButton{Visible=false};_optional.Toggled+=OnOptionalToggled;_header.AddChild(_optional);_desc=UiKit.Label(string.Empty,UiTokens.FontSmall,UiTokens.Muted);_desc.AutowrapMode=TextServer.AutowrapMode.WordSmart;_desc.MaxLinesVisible=3;_content.AddChild(_desc);_watermark=UiKit.Label(string.Empty,42,new Color(1f,1f,1f,0.04f));_watermark.Uppercase=true;_watermark.ClipText=true;_watermark.HorizontalAlignment=HorizontalAlignment.Right;AddChild(_watermark);_resize=new CollectionResizeHandle();_resize.GuiInput+=OnResizeGuiInput;AddChild(_resize);_resize.EnsureConstructed();}private void OnOptionalToggled(bool pressed){if(_updatingOptional)return;Command?.Invoke(GroupKey,"toggle");}private void OnPanelGuiInput(InputEvent@event){if(@event is InputEventMouseButton button&&button.ButtonIndex==MouseButton.Left&&button.Pressed){Selected?.Invoke(GroupKey);AcceptEvent();}}private void OnHeaderGuiInput(InputEvent@event){if(@event is InputEventMouseButton button&&button.ButtonIndex==MouseButton.Left){if(button.Pressed){_dragging=true;_moved=false;Selected?.Invoke(GroupKey);}else{_dragging=false;if(_moved)DragFinished?.Invoke();}AcceptEvent();return;}if(@event is InputEventMouseMotion motion&&_dragging){var desired=Position+motion.Relative;var clamped=new Vector2(Mathf.Max(0f,desired.X),Mathf.Max(0f,desired.Y));if(clamped.DistanceTo(Position)>0.5f)_moved=true;Position=clamped;Moved?.Invoke(GroupKey,clamped);AcceptEvent();}}private void OnResizeGuiInput(InputEvent@event){if(@event is InputEventMouseButton button&&button.ButtonIndex==MouseButton.Left){if(button.Pressed){_resizing=true;_moved=false;}else{_resizing=false;if(_moved)DragFinished?.Invoke();}AcceptEvent();return;}if(@event is InputEventMouseMotion motion&&_resizing){var size=new Vector2(Mathf.Max(MinWidth,Size.X+motion.Relative.X),Mathf.Max(MinHeight,Size.Y+motion.Relative.Y));if(size.DistanceTo(Size)>0.5f)_moved=true;SetFrame(new Rect2(Position,size));Resized?.Invoke(GroupKey,size);AcceptEvent();}}protected override void Destruct(){_optional.Toggled-=OnOptionalToggled;}}}

===== FILE mods/mod_manager/src/UI/CollectionModChip.cs =====
using System;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Mods.ModManager{/// <summary> Draggable chip of one mod inside a scheme group, the Godot counterpart of the mockup's <c>.ce-mod</c>. Built fully in code and used through the <see cref="ModWidget"/> contract: the canvas adds it to the mods layer first and only then calls <see cref="Init"/>, which builds the visuals under a valid tree. <para/> The chip is click-selectable and freely draggable inside its group: the drag pattern keeps the pointer over the panel because the control follows the mouse by the same delta (the same trick the map editor handles use). It owns no data — the canvas persists the resulting position into <see cref="Cthangover.Core.Mods.ModCollectionLayout"/> and decides what a click means. </summary>
public partial class CollectionModChip:ModWidget{/// <summary>Fixed chip width in canvas pixels; the canvas layout math depends on it.</summary>
public const int ChipWidth=210;/// <summary>Fixed chip height in canvas pixels.</summary>
public const int ChipHeight=94;/// <summary>Canonical mod id this chip represents.</summary>
public string ModId{get;private set;}/// <summary>Raised on left click; the canvas turns it into a selection.</summary>
public event Action<string>Selected;/// <summary>Raised while dragging with the desired position inside the group's mods layer.</summary>
public event Action<string,Vector2>Moved;/// <summary>Raised when a drag ends, after the last <see cref="Moved"/>.</summary>
public event Action DragFinished;private PanelContainer _panel;private Label _role;private Label _name;private Label _meta;private ColorRect _dot;private StyleBoxFlat _style;private Color _stateColor=UiTokens.Success;private bool _dragging;private bool _moved;/// <summary> Binds the chip to a resolved mod and paints it. Must be called after the chip is added to the tree; safe to call again to refresh the status without rebuilding the node. </summary>
public void Init(string modId,string role,string title,string version,string stateLabel,Color stateColor,string tooltip){EnsureConstructed();ModId=modId;_stateColor=stateColor;_role.Text=string.IsNullOrEmpty(role)?string.Empty:role.ToUpperInvariant();_name.Text=title;_meta.Text=string.IsNullOrEmpty(version)?stateLabel:$"{version} · {stateLabel}";_dot.Color=stateColor;_style.BorderColor=stateColor;_panel.TooltipText=tooltip??string.Empty;}/// <summary>Highlights the chip as the current canvas/inspector selection.</summary>
public void SetSelected(bool selected){EnsureConstructed();_style.BorderColor=selected?UiTokens.Accent:_stateColor;}protected override void Construct(){MouseFilter=MouseFilterEnum.Ignore;CustomMinimumSize=new Vector2(ChipWidth,ChipHeight);Size=new Vector2(ChipWidth,ChipHeight);_style=UiKit.Frame(UiTokens.RadiusSm,UiTokens.Surface1,UiTokens.LineCard);_style.BorderWidthLeft=3;_panel=new PanelContainer{MouseFilter=MouseFilterEnum.Stop};_panel.AddThemeStyleboxOverride("panel",_style);_panel.GuiInput+=OnGuiInput;AddChild(_panel);_panel.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);var margin=new MarginContainer();margin.AddThemeConstantOverride("margin_left",UiTokens.Space3);margin.AddThemeConstantOverride("margin_right",18);margin.AddThemeConstantOverride("margin_top",6);margin.AddThemeConstantOverride("margin_bottom",7);_panel.AddChild(margin);var column=new VBoxContainer();column.AddThemeConstantOverride("separation",3);margin.AddChild(column);_role=UiKit.Label(string.Empty,UiTokens.FontMicro,UiTokens.Faint);_role.Uppercase=true;_role.ClipText=true;column.AddChild(_role);var row=new HBoxContainer();row.AddThemeConstantOverride("separation",6);_dot=new ColorRect{Color=UiTokens.Success,CustomMinimumSize=new Vector2(7,7),MouseFilter=MouseFilterEnum.Ignore};_dot.SizeFlagsVertical=SizeFlags.ShrinkBegin;row.AddChild(_dot);_name=UiKit.Label(string.Empty,UiTokens.FontBodySmall,UiTokens.InkBright);_name.AutowrapMode=TextServer.AutowrapMode.WordSmart;_name.SizeFlagsHorizontal=SizeFlags.ExpandFill;_name.MaxLinesVisible=3;row.AddChild(_name);column.AddChild(row);_meta=UiKit.Label(string.Empty,UiTokens.FontTiny,UiTokens.Faint);_meta.SizeFlagsVertical=SizeFlags.ExpandFill;_meta.VerticalAlignment=VerticalAlignment.Bottom;column.AddChild(_meta);}private void OnGuiInput(InputEvent@event){if(@event is InputEventMouseButton button&&button.ButtonIndex==MouseButton.Left){if(button.Pressed){_dragging=true;_moved=false;Selected?.Invoke(ModId);}else{_dragging=false;if(_moved)DragFinished?.Invoke();}AcceptEvent();return;}if(@event is InputEventMouseMotion motion&&_dragging){var layer=GetParent<Control>();var card=layer?.GetParent<Control>()as CollectionGroupCard;var bounds=layer?.Size??Vector2.Zero;if(card!=null)bounds=new Vector2(Mathf.Max(bounds.X,card.Size.X),Mathf.Max(bounds.Y,card.Size.Y));var max=new Vector2(Mathf.Max(0f,bounds.X-Size.X-2f),Mathf.Max(0f,bounds.Y-Size.Y-2f));var minY=Mathf.Min(card?.ModsTop??0f,max.Y);var desired=Position+motion.Relative;var clamped=new Vector2(Mathf.Clamp(desired.X,0f,max.X),Mathf.Clamp(desired.Y,minY,max.Y));if(clamped.DistanceTo(Position)>0.5f)_moved=true;Position=clamped;Moved?.Invoke(ModId,clamped);AcceptEvent();}}}}

===== FILE mods/mod_manager/src/UI/CollectionResizeHandle.cs =====
using System;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;namespace Mods.ModManager{/// <summary> Visible resize grip in the bottom-right corner of a scheme group. The mockup showed three diagonal stripes there; in Godot they are painted through the <c>"draw"</c> signal because virtual overrides are unreliable in mod assemblies, and the control itself only reports raw GuiInput to its <see cref="CollectionGroupCard"/> owner. </summary>
public partial class CollectionResizeHandle:ModWidget{/// <summary>Grip side in pixels; the card positions this control at its bottom-right corner.</summary>
public const int HandleSize=18;public CollectionResizeHandle(){MouseFilter=MouseFilterEnum.Stop;CustomMinimumSize=new Vector2(HandleSize,HandleSize);Size=new Vector2(HandleSize,HandleSize);MouseDefaultCursorShape=CursorShape.Fdiagsize;}protected override void Construct(){Connect("draw",Callable.From((Action)DrawStripes));}private void DrawStripes(){for(var i=0;i<3;i++){var offset=5f+i*5f;DrawLine(new Vector2(Size.X-offset,Size.Y),new Vector2(Size.X,Size.Y-offset),UiTokens.LineStrong,1.5f,true);}}}}

===== FILE mods/mod_manager/src/UI/CollectionSchemeCanvas.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;namespace Mods.ModManager{/// <summary> Free-form scheme canvas: the whole game on one "whatman", exactly like the approved mockup. Renders one <see cref="CollectionGroupCard"/> per resolved <see cref="ModCollectionGroup"/> with its draggable <see cref="CollectionModChip"/>s and paints dependency lines through <see cref="CollectionEdgeLayer"/>. <para/> The canvas owns only presentation and the layout dictionary: it computes a packed default arrangement for groups the player has not placed yet (new groups appear below the existing ones so nothing overlaps), clamps drags to the canvas, persists nothing itself and reports edits upward via <see cref="LayoutChanged"/>, <see cref="GroupSelected"/>, <see cref="ModSelected"/> and <see cref="GroupCommand"/>. The screen decides what a command means for the current draft and when to save. </summary>
public partial class CollectionSchemeCanvas:ModWidget{private const float ColumnWidth=460f;private const float GapX=18f;private const float GapY=16f;private const float Margin=10f;private const float ModX0=14f;private const float ModY0=76f;private const float ModGapX=8f;private const float ModGapY=8f;private const int ModColumns=2;/// <summary> Floor for the scrollable workspace. A draggable canvas is useless if the scrollbars disappear once the packed layout happens to fit the window, so the workspace is always at least this large; content that grows beyond it extends the area further. </summary>
private const float MinCanvasWidth=3200f;/// <summary>See <see cref="MinCanvasWidth"/>.</summary>
private const float MinCanvasHeight=2000f;/// <summary>Raised when a group card is clicked; payload is the group key.</summary>
public event Action<string>GroupSelected;/// <summary>Raised when a mod chip is clicked; payload is the mod id.</summary>
public event Action<string>ModSelected;/// <summary>Raised by group header buttons; payload is the group and the command id.</summary>
public event Action<ModCollectionGroup,string>GroupCommand;/// <summary>Raised after any drag or resize ends, so the layout can be flushed to the store.</summary>
public event Action LayoutChanged;/// <summary>Whether dependency lines are painted; toggled from the header.</summary>
public bool ShowEdges{get;set;}=true;/// <summary>Currently highlighted group key, if any.</summary>
public string SelectedGroupKey{get;private set;}/// <summary>Currently highlighted mod id, if any.</summary>
public string SelectedModId{get;private set;}private CollectionEdgeLayer _edges;private ModCollectionGraph _graph;private ModCollectionLayout _layout=new();private readonly Dictionary<string,CollectionGroupCard>_cards=new(StringComparer.OrdinalIgnoreCase);private readonly Dictionary<string,CollectionModChip>_chips=new(StringComparer.OrdinalIgnoreCase);private readonly Dictionary<string,string>_chipGroups=new(StringComparer.OrdinalIgnoreCase);private bool _panning;private Vector2 _panStartMouse;private Vector2 _panStartScroll;/// <summary> Rebuilds the canvas for a resolved graph and its saved layout. The layout object is mutated in place (new groups/mods get default coordinates); pass the instance owned by the screen so the next <see cref="LayoutChanged"/> can persist it. </summary>
public void Build(ModCollectionGraph graph,ModCollectionLayout layout){EnsureConstructed();_graph=graph;_layout=layout??new ModCollectionLayout();_layout.Groups??=new Dictionary<string,ModCollectionLayoutRect>();_layout.Mods??=new Dictionary<string,ModCollectionLayoutPoint>();foreach(var child in GetChildren()){if(child!=_edges)child.QueueFree();}_cards.Clear();_chips.Clear();_chipGroups.Clear();if(graph!=null&&graph.Groups.Count>0){EnsureLayout();BuildCards();}_edges.SetEdges(null,_chips,this);MoveChild(_edges,GetChildCount()-1);_edges.Visible=ShowEdges;UpdateCanvasSize();RebuildEdges();}/// <summary>Highlights one group and/or one mod; pass nulls to clear.</summary>
public void SetSelection(string groupKey,string modId){EnsureConstructed();SelectedGroupKey=groupKey;SelectedModId=modId;foreach(var pair in _cards)pair.Value.SetSelected(string.Equals(pair.Key,groupKey,StringComparison.OrdinalIgnoreCase));foreach(var pair in _chips)pair.Value.SetSelected(string.Equals(pair.Key,modId,StringComparison.OrdinalIgnoreCase));RebuildEdges();}/// <summary>Shows or hides dependency lines.</summary>
public void SetEdgesVisible(bool visible){EnsureConstructed();ShowEdges=visible;_edges.Visible=visible;if(visible)_edges.QueueRedraw();}/// <summary>Finds the group key owning a rendered mod chip, for inspector navigation.</summary>
public string GetGroupKeyOfMod(string modId)=>modId!=null&&_chipGroups.TryGetValue(modId,out var key)?key:null;protected override void Construct(){MouseFilter=MouseFilterEnum.Stop;GuiInput+=OnCanvasGuiInput;_edges=new CollectionEdgeLayer();AddChild(_edges);_edges.EnsureConstructed();}private void BuildCards(){foreach(var group in _graph.Groups){var rect=_layout.Groups[group.Key];var card=new CollectionGroupCard();AddChild(card);var summary=Summarize(group);card.Init(group,summary.Label,summary.Color,string.Equals(group.Key,SelectedGroupKey,StringComparison.OrdinalIgnoreCase));card.SetFrame(new Rect2(rect.X??0f,rect.Y??0f,rect.W,rect.H));card.Selected+=OnCardSelected;card.Command+=OnCardCommand;card.Moved+=OnCardMoved;card.Resized+=OnCardResized;card.DragFinished+=OnDragFinished;_cards[group.Key]=card;foreach(var mod in group.Mods){var key=group.Key+"|"+mod.ModId;var point=_layout.Mods.TryGetValue(key,out var stored)?stored:null;var chip=new CollectionModChip();card.ModsLayer.AddChild(chip);var node=FindNode(mod.ModId);chip.Init(mod.ModId,mod.Role,node?.Title??mod.ModId,VersionOf(node),StateLabel(node),StateColor(node),BuildModTooltip(mod,node));chip.Position=new Vector2(point?.X??ModX0,point?.Y??ModY0);chip.Selected+=OnChipSelected;chip.Moved+=OnChipMoved;chip.DragFinished+=OnDragFinished;if(!_chips.ContainsKey(mod.ModId)){_chips[mod.ModId]=chip;_chipGroups[mod.ModId]=group.Key;}}}}/// <summary> Packs groups the player has not placed yet: a fresh canvas uses a masonry layout, an already arranged canvas appends new groups below everything so an added mechanic never lands on top of the work. </summary>
private void EnsureLayout(){var unplaced=_graph.Groups.Where(g=> !_layout.Groups.TryGetValue(g.Key,out var rect)||rect==null|| !rect.X.HasValue|| !rect.Y.HasValue).Select(g=>g.Key).ToList();var hasPlaced=unplaced.Count<_graph.Groups.Count;foreach(var group in _graph.Groups){if(!_layout.Groups.TryGetValue(group.Key,out var rect)||rect==null){rect=new ModCollectionLayoutRect();_layout.Groups[group.Key]=rect;}var used=new List<ModCollectionLayoutPoint>();foreach(var mod in group.Mods){var key=group.Key+"|"+mod.ModId;if(!_layout.Mods.TryGetValue(key,out var point)||point==null){point=NextFreeModSlot(used);_layout.Mods[key]=point;}used.Add(point);}if(rect.W<=0f||rect.H<=0f){var maxX=used.Count>0?used.Max(p=>p.X+CollectionModChip.ChipWidth):0f;var maxY=used.Count>0?used.Max(p=>p.Y+CollectionModChip.ChipHeight):0f;rect.W=Mathf.Max(CollectionGroupCard.MinWidth,maxX+16f);rect.H=Mathf.Max(CollectionGroupCard.MinHeight,maxY+16f);}}var defaults=ComputeDefaultLayout();var bottom=Margin;foreach(var group in _graph.Groups){var rect=_layout.Groups[group.Key];if(rect.X.HasValue&&rect.Y.HasValue&& !unplaced.Contains(group.Key))bottom=Mathf.Max(bottom,rect.Y.Value+rect.H+GapY);}foreach(var group in _graph.Groups){var rect=_layout.Groups[group.Key];if(!rect.X.HasValue|| !rect.Y.HasValue){if(!hasPlaced&&defaults.TryGetValue(group.Key,out var packed)){rect.X=packed.X;rect.Y=packed.Y;}else{rect.X=Margin;rect.Y=bottom;bottom+=rect.H+GapY;}}}}private Dictionary<string,Vector2>ComputeDefaultLayout(){var result=new Dictionary<string,Vector2>();var groups=_graph?.Groups??new List<ModCollectionGroup>();if(groups.Count==0)return result;var maxCols=Mathf.Min(3,Mathf.Max(1,Mathf.CeilToInt(groups.Count/2f)));var heights=new float[maxCols];foreach(var group in groups){var height=_layout.Groups[group.Key].H;var column=0;for(var i=1;i<maxCols;i++){if(heights[i]<heights[column])column=i;}result[group.Key]=new Vector2(Margin+column*(ColumnWidth+GapX),Margin+heights[column]);heights[column]+=height+GapY;}return result;}private static ModCollectionLayoutPoint NextFreeModSlot(List<ModCollectionLayoutPoint>used){for(var i=0;i<400;i++){var column=i%ModColumns;var row=i/ModColumns;var point=new ModCollectionLayoutPoint{X=ModX0+column*(CollectionModChip.ChipWidth+ModGapX),Y=ModY0+row*(CollectionModChip.ChipHeight+ModGapY),};if(!used.Any(u=>Mathf.Abs(u.X-point.X)<CollectionModChip.ChipWidth*0.9f&&Mathf.Abs(u.Y-point.Y)<CollectionModChip.ChipHeight*0.9f)){return point;}}var bottom=used.Count>0?used.Max(p=>p.Y):ModY0;return new ModCollectionLayoutPoint{X=ModX0,Y=bottom+CollectionModChip.ChipHeight+ModGapY};}private ModCollectionNode FindNode(string modId){if(_graph?.Nodes==null)return null;foreach(var node in _graph.Nodes){if(string.Equals(node.ModId,modId,StringComparison.OrdinalIgnoreCase))return node;}return null;}private static string VersionOf(ModCollectionNode node){var version=node?.InstalledVersion;return string.IsNullOrEmpty(version)?null:"v"+version;}private static string StateLabel(ModCollectionNode node)=>node==null?CollectionStateNames.Translate(ModCollectionNodeState.Missing):CollectionStateNames.Translate(node.State);private static Color StateColor(ModCollectionNode node)=>node==null?CollectionStateNames.Color(ModCollectionNodeState.Missing):CollectionStateNames.Color(node.State);private static string BuildModTooltip(ModCollectionModRef mod,ModCollectionNode node){var label=node?.Title??mod.ModId;return$"{label} · {mod.ModId} · {StateLabel(node)}";}/// <summary>Worst-state summary shown on the group card: missing beats mismatch beats downloads beats disable.</summary>
private(string Label,Color Color)Summarize(ModCollectionGroup group){if(!group.Selected)return(TranslationServer.Translate("mod_manager/collection_group_off"),UiTokens.Warning);var states=new List<ModCollectionNodeState>();foreach(var mod in group.Mods){var node=FindNode(mod.ModId);states.Add(node?.State??ModCollectionNodeState.Missing);}if(states.Count==0)return(TranslationServer.Translate("mod_manager/collection_group_empty"),UiTokens.Info);if(states.Any(s=>s==ModCollectionNodeState.Missing))return(CountLabel(ModCollectionNodeState.Missing,states),UiTokens.Danger);if(states.Any(s=>s==ModCollectionNodeState.VersionMismatch))return(CountLabel(ModCollectionNodeState.VersionMismatch,states),UiTokens.Warning);if(states.Any(s=>s==ModCollectionNodeState.InCatalog))return(CountLabel(ModCollectionNodeState.InCatalog,states),UiTokens.Info);if(states.Any(s=>s==ModCollectionNodeState.Disabled))return(CountLabel(ModCollectionNodeState.Disabled,states),UiTokens.Warning);return(TranslationServer.Translate("mod_manager/collection_group_ready"),UiTokens.Success);}private static string CountLabel(ModCollectionNodeState state,List<ModCollectionNodeState>states)=>$"{CollectionStateNames.Translate(state)}: {states.Count(s => s == state)}";private void OnCardSelected(string groupKey){SelectedGroupKey=groupKey;SelectedModId=null;GroupSelected?.Invoke(groupKey);}private void OnChipSelected(string modId){SelectedModId=modId;SelectedGroupKey=null;ModSelected?.Invoke(modId);}private void OnCardCommand(string groupKey,string command){if(_cards.TryGetValue(groupKey,out var card))GroupCommand?.Invoke(card.Group,command);}private void OnCardMoved(string groupKey,Vector2 position){var rect=Rect(groupKey);rect.X=position.X;rect.Y=position.Y;UpdateCanvasSize();RebuildEdges();}private void OnCardResized(string groupKey,Vector2 size){var rect=Rect(groupKey);rect.W=size.X;rect.H=size.Y;UpdateCanvasSize();RebuildEdges();}private void OnChipMoved(string modId,Vector2 position){if(!_chipGroups.TryGetValue(modId,out var groupKey))return;var key=groupKey+"|"+modId;if(!_layout.Mods.TryGetValue(key,out var point)||point==null){point=new ModCollectionLayoutPoint();_layout.Mods[key]=point;}point.X=position.X;point.Y=position.Y;RebuildEdges();}private void OnDragFinished()=>LayoutChanged?.Invoke();private ModCollectionLayoutRect Rect(string groupKey){if(!_layout.Groups.TryGetValue(groupKey,out var rect)||rect==null){rect=new ModCollectionLayoutRect();_layout.Groups[groupKey]=rect;}return rect;}private void UpdateCanvasSize(){var maxX=0f;var maxY=0f;foreach(var card in _cards.Values){if(!GodotObject.IsInstanceValid(card))continue;maxX=Mathf.Max(maxX,card.Position.X+card.Size.X);maxY=Mathf.Max(maxY,card.Position.Y+card.Size.Y);}var size=new Vector2(Mathf.Max(maxX+GapX+160f,MinCanvasWidth),Mathf.Max(maxY+GapY+120f,MinCanvasHeight));// Drives the canvas rect through the surrounding ScrollContainer. _edges carries FullRect anchors (CollectionEdgeLayer.Construct), so it follows this resize automatically; assigning its Size directly would be overridden and logs an anchors warning.
CustomMinimumSize=size;}private void RebuildEdges(){var edges=new List<CollectionEdge>();if(_graph?.Edges!=null){foreach(var edge in _graph.Edges){if(!_chips.TryGetValue(edge.FromModId,out _)|| !_chips.TryGetValue(edge.ToModId,out _))continue;var fromGroup=GetGroupKeyOfMod(edge.FromModId);var toGroup=GetGroupKeyOfMod(edge.ToModId);edges.Add(new CollectionEdge{FromModId=edge.FromModId,ToModId=edge.ToModId,Cross= !string.Equals(fromGroup,toGroup,StringComparison.OrdinalIgnoreCase),Active=string.Equals(edge.FromModId,SelectedModId,StringComparison.OrdinalIgnoreCase)||string.Equals(edge.ToModId,SelectedModId,StringComparison.OrdinalIgnoreCase),});}}_edges.SetEdges(edges,_chips,this);}/// <summary> Pans the surrounding ScrollContainer by dragging the empty canvas with the left button held. The canvas floor is the only control that receives this event: group cards, chips, headers and buttons consume their presses first (Godot then keeps them as the mouse focus until release), so their own drags stay untouched. Positions are anchored to the press point instead of accumulated deltas, so a scroll edge does not build up drift once the pointer returns. </summary>
private void OnCanvasGuiInput(InputEvent@event){if(@event is InputEventMouseButton button&&button.ButtonIndex==MouseButton.Left){if(button.Pressed){var scroll=GetParent<ScrollContainer>();if(scroll==null)return;_panning=true;_panStartMouse=button.GlobalPosition;_panStartScroll=new Vector2(scroll.ScrollHorizontal,scroll.ScrollVertical);MouseDefaultCursorShape=CursorShape.Drag;}else if(_panning){EndPan();}AcceptEvent();return;}if(@event is InputEventMouseMotion motion&&_panning){if(!motion.ButtonMask.HasFlag(MouseButtonMask.Left)){EndPan();return;}var scroll=GetParent<ScrollContainer>();if(scroll!=null){var delta=motion.GlobalPosition-_panStartMouse;scroll.ScrollHorizontal=Mathf.RoundToInt(_panStartScroll.X-delta.X);scroll.ScrollVertical=Mathf.RoundToInt(_panStartScroll.Y-delta.Y);}AcceptEvent();}}private void EndPan(){_panning=false;MouseDefaultCursorShape=CursorShape.Arrow;}}}

===== FILE mods/mod_manager/src/UI/CollectionsGallery.cs =====
using System;using System.Collections.Generic;using System.Globalization;using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Theming;namespace Mods.ModManager{/// <summary> Level 1 of the collections tab: the gallery of ready-to-play games from the hub mockup. One card per <see cref="ModCollectionKind.Game"/> collection, built exclusively from <see cref="ModCollectionGallery.Build"/> over the live <see cref="ModCollectionStore.LoadAll"/> merge — there are no hardcoded collection ids, so any mod, catalog or player document appears here automatically. Every card shows the source badge from <see cref="ModCollection.Origin"/>, status badges derived from the summary (draft, missing, problems, enable, ready), the Installed/Total progress bar and its action buttons; the plan is applied through <see cref="ModCollectionInstaller"/> exactly like the scheme editor does. <para/> The whole layout is code-built (no .tscn) following the UiKit helper style; a dashed "build your own game" card and the empty state with a hint finish the screen. </summary>
public partial class CollectionsGallery:ScrollContainer{/// <summary>Requested to open the scheme editor for the collection id.</summary>
public event Action<string>EditSchemeRequested;/// <summary>Requested to create a fresh player game in the editor.</summary>
public event Action NewGameRequested;/// <summary>Progress/status line for the hosting hub banner; null hides it.</summary>
public event Action<string>StatusMessage;private VBoxContainer _host;private HFlowContainer _grid;private VBoxContainer _emptyBox;private Label _emptyHint;private readonly Dictionary<string,ModCollection>_index=new(StringComparer.OrdinalIgnoreCase);private readonly Dictionary<string,ModCollectionPlan>_plans=new(StringComparer.OrdinalIgnoreCase);private readonly List<ModCollectionGameSummary>_summaries=new();private HashSet<string>_sessionInstalled=new(StringComparer.OrdinalIgnoreCase);private string _query=string.Empty;private string _installingId;public CollectionsGallery(){SizeFlagsHorizontal=SizeFlags.ExpandFill;SizeFlagsVertical=SizeFlags.ExpandFill;HorizontalScrollMode=ScrollMode.Disabled;VerticalScrollMode=ScrollMode.Auto;var margin=new MarginContainer();margin.AddThemeConstantOverride("margin_left",UiTokens.Space8);margin.AddThemeConstantOverride("margin_top",UiTokens.Space6);margin.AddThemeConstantOverride("margin_right",UiTokens.Space8);margin.AddThemeConstantOverride("margin_bottom",UiTokens.Space7);margin.SizeFlagsHorizontal=SizeFlags.ExpandFill;AddChild(margin);_host=UiKit.Column(UiTokens.Space5);_host.SizeFlagsHorizontal=SizeFlags.ExpandFill;margin.AddChild(_host);BuildCrumbs();BuildHead();BuildGrid();BuildEmptyState();ModDownloader.OnProgress+=OnInstallProgress;CatalogService.OnRefreshed+=OnCatalogRefreshed;}/// <summary>Unsubscribes the static events; the gallery is freed together with the hub.</summary>
public override void _ExitTree(){ModDownloader.OnProgress-=OnInstallProgress;CatalogService.OnRefreshed-=OnCatalogRefreshed;}/// <summary>Shares the hub-wide set of mods installed during this session.</summary>
public void SetSessionInstalled(HashSet<string>sessionInstalled)=>_sessionInstalled=sessionInstalled??new HashSet<string>(StringComparer.OrdinalIgnoreCase);/// <summary>Applies the hub header search query; empty text shows everything.</summary>
public void ApplyFilter(string query){_query=query??string.Empty;RenderGrid();}/// <summary> Reloads the merged collection store, rebuilds the summaries through the core gallery and the per-game apply plans, then re-renders. Called on open, after a catalog refresh and after applying a plan. </summary>
public void Refresh(){_index.Clear();foreach(var collection in ModCollectionStore.LoadAll()){if(!string.IsNullOrEmpty(collection?.Id))_index[collection.Id]=collection;}var catalog=CatalogService.CachedEntries??new List<CatalogExportEntry>();var summaries=ModCollectionGallery.Build(_index,ModRegistry.Instance.AllDiscoveredMods,DisabledModsStore.Load(),catalog,_sessionInstalled,CollectionText.Resolve);_summaries.Clear();_plans.Clear();foreach(var summary in summaries){_summaries.Add(summary);_plans[summary.Collection.Id]=BuildPlan(summary,catalog);}RenderGrid();}/* ═══════════════ Static skeleton ═══════════════ */private void BuildCrumbs(){var row=UiKit.Row(UiTokens.Space2);row.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/hub_tab_collections"),UiTokens.FontBodySmall,UiTokens.Faint));row.AddChild(UiKit.Label("›",UiTokens.FontBodySmall,UiTokens.Faint));row.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/gallery_crumb"),UiTokens.FontBodySmall,UiTokens.Body));row.AddChild(UiKit.Label("·",UiTokens.FontBodySmall,UiTokens.Faint));row.AddChild(UiKit.Label(TranslationServer.Translate("mod_manager/gallery_hint"),UiTokens.FontBodySmall,UiTokens.Faint));_host.AddChild(row);}private void BuildHead(){var row=UiKit.Row(UiTokens.Space3);row.AddChild(UiKit.SectionTitle(TranslationServer.Translate("mod_manager/gallery_title"),UiTokens.FontLead));row.AddChild(UiKit.Spacer());var newGame=UiKit.Button(TranslationServer.Translate("mod_manager/gallery_new_game"),ButtonKind.Primary);newGame.Pressed+=()=>NewGameRequested?.Invoke();row.AddChild(newGame);_host.AddChild(row);}private void BuildGrid(){_grid=new HFlowContainer();_grid.AddThemeConstantOverride("h_separation",UiTokens.Space5);_grid.AddThemeConstantOverride("v_separation",UiTokens.Space5);_grid.SizeFlagsHorizontal=SizeFlags.ExpandFill;_grid.SizeFlagsVertical=SizeFlags.ExpandFill;_host.AddChild(_grid);}private void BuildEmptyState(){_emptyBox=UiKit.Column(UiTokens.Space2);_emptyBox.SizeFlagsHorizontal=SizeFlags.ExpandFill;_emptyBox.SizeFlagsVertical=SizeFlags.ExpandFill;_emptyBox.Alignment=BoxContainer.AlignmentMode.Center;_emptyBox.Visible=false;var title=UiKit.EmptyState(TranslationServer.Translate("mod_manager/collections_empty"),UiTokens.FontLead);_emptyHint=UiKit.Label(TranslationServer.Translate("mod_manager/gallery_empty_hint"),UiTokens.FontBodySmall,UiTokens.Faint);_emptyHint.HorizontalAlignment=HorizontalAlignment.Center;_emptyHint.AutowrapMode=TextServer.AutowrapMode.WordSmart;_emptyBox.AddChild(title);_emptyBox.AddChild(_emptyHint);_host.AddChild(_emptyBox);}/* ═══════════════ Cards ═══════════════ */private void RenderGrid(){ClearChildren(_grid);var items=Filtered();_emptyBox.Visible=items.Count==0;_grid.Visible=items.Count>0;foreach(var summary in items)_grid.AddChild(BuildCard(summary));}private List<ModCollectionGameSummary>Filtered(){var query=_query.Trim();if(query.Length==0)return _summaries;return _summaries.Where(s=>(s.Name??string.Empty).Contains(query,StringComparison.OrdinalIgnoreCase)||(s.Description??string.Empty).Contains(query,StringComparison.OrdinalIgnoreCase)||(s.Collection?.Id??string.Empty).Contains(query,StringComparison.OrdinalIgnoreCase)||MatchesTags(s.Collection?.Tags,query)).ToList();}private static bool MatchesTags(List<string>tags,string query){if(tags==null)return false;foreach(var tag in tags){if(!string.IsNullOrEmpty(tag)&&tag.Contains(query,StringComparison.OrdinalIgnoreCase))return true;}return false;}private Control BuildCard(ModCollectionGameSummary summary){var card=new PanelContainer{CustomMinimumSize=new Vector2(360,0)};card.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusLg,UiTokens.Surface2,UiTokens.Line));var column=UiKit.Column(UiTokens.Space3);column.SizeFlagsHorizontal=SizeFlags.ExpandFill;card.AddChild(column);var top=UiKit.Row(UiTokens.Space3);top.AddChild(BuildCover(summary.Name??summary.Collection.Id));var headColumn=UiKit.Column(2);headColumn.SizeFlagsHorizontal=SizeFlags.ExpandFill;headColumn.AddChild(UiKit.Label(summary.Name??summary.Collection.Id,UiTokens.FontBodyLarge,UiTokens.InkBright));headColumn.AddChild(UiKit.Label(BuildSubtitle(summary),UiTokens.FontTiny,UiTokens.Faint));top.AddChild(headColumn);column.AddChild(top);var description=UiKit.Label(string.IsNullOrEmpty(summary.Description)?"—":summary.Description,UiTokens.FontBodySmall,UiTokens.Muted);description.AutowrapMode=TextServer.AutowrapMode.WordSmart;column.AddChild(description);var badges=UiKit.Row(UiTokens.Space1);foreach(var(text,color)in BuildStatusBadges(summary))badges.AddChild(UiKit.Badge(text,color));column.AddChild(badges);if(summary.TotalMods>0){var progress=new ProgressBar{MaxValue=100,Value=100f*summary.InstalledCount/summary.TotalMods,ShowPercentage=false,CustomMinimumSize=new Vector2(0,6),};progress.AddThemeStyleboxOverride("background",UiKit.Frame(UiTokens.RadiusXs,UiTokens.Surface1,UiTokens.LineSoft));progress.AddThemeStyleboxOverride("fill",UiKit.Frame(UiTokens.RadiusXs,UiTokens.Accent,null));column.AddChild(progress);}var foot=UiKit.Row(UiTokens.Space2);foot.AddChild(BuildPrimaryButton(summary));foot.AddChild(BuildEditButton(summary));if(CollectionDeletion.CanDelete(summary.Collection))foot.AddChild(BuildDeleteButton(summary));foot.AddChild(UiKit.Spacer());foot.AddChild(UiKit.Label(string.Format(TranslationServer.Translate("mod_manager/collection_result"),summary.InstalledCount,summary.TotalMods),UiTokens.FontSmall,UiTokens.Faint));column.AddChild(foot);return card;}/// <summary>Cover box with the first grapheme of the game name (mockup's emoji slot).</summary>
private static Control BuildCover(string title){var panel=new PanelContainer{CustomMinimumSize=new Vector2(64,64)};panel.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusMd,UiTokens.Surface1,UiTokens.Line));var letter="·";var text=title?.Trim();if(!string.IsNullOrEmpty(text)){// First grapheme cluster so surrogate-pair emoji covers survive.
var info=new StringInfo(text);if(info.LengthInTextElements>0)letter=info.SubstringByTextElements(0,1).ToUpperInvariant();}var label=UiKit.Label(letter,UiTokens.FontH3,UiTokens.AccentBright);label.HorizontalAlignment=HorizontalAlignment.Center;label.VerticalAlignment=VerticalAlignment.Center;panel.AddChild(label);return panel;}private static string BuildSubtitle(ModCollectionGameSummary summary){var collection=summary.Collection;var parts=new List<string>();if(!string.IsNullOrEmpty(collection.Author))parts.Add(collection.Author);if(!string.IsNullOrEmpty(collection.Version))parts.Add("v"+collection.Version);parts.Add(TranslationServer.Translate(OriginKey(collection.Origin)));parts.Add($"{summary.TotalMods} {TranslationServer.Translate("mod_manager/collection_mods")}");return string.Join(" · ",parts);}/// <summary>Source badge colours per <see cref="ModCollection.Origin"/> (blue built-in/catalog, violet player).</summary>
private static string OriginKey(ModCollectionOrigin origin)=>origin switch{ModCollectionOrigin.User=>"mod_manager/collection_origin_user",ModCollectionOrigin.Catalog=>"mod_manager/collection_origin_catalog",_=>"mod_manager/collection_origin_builtin",};/// <summary> Status badges exactly per the hub rules: draft for empty games; red/yellow "missing N" plus "problems: K" for unplayable ones; amber "enable N" when only disabled mods hold the game back; green "ready to play" otherwise. A disabled mod alone never breaks <see cref="ModCollectionGameSummary.IsReady"/>, so the badges follow the summary, not raw node states. </summary>
private static List<(string Text,Color Color)>BuildStatusBadges(ModCollectionGameSummary summary){var badges=new List<(string,Color)>();Func<string,string>translate=key=>TranslationServer.Translate(key);if(summary.TotalMods==0){badges.Add((translate("mod_manager/gallery_draft"),UiTokens.Warning));return badges;}if(!summary.IsReady){var gaps=summary.MissingIds.Count+summary.MismatchIds.Count;if(gaps>0){var color=summary.MissingIds.Count>0?UiTokens.DangerText:UiTokens.Warning;badges.Add((string.Format(translate("mod_manager/gallery_missing_n"),gaps),color));}if(summary.ProblemsCount>0)badges.Add((string.Format(translate("mod_manager/gallery_problems_n"),summary.ProblemsCount),UiTokens.DangerText));return badges;}if(summary.PlanInstallCount==0&&summary.PlanEnableCount>0)badges.Add((string.Format(translate("mod_manager/gallery_enable_badge_n"),summary.PlanEnableCount),UiTokens.Warning));else badges.Add((translate("mod_manager/gallery_ready"),UiTokens.SuccessText));return badges;}/// <summary>The card's primary action: install the plan, enable the disabled mods, continue a draft or rest on a ready game.</summary>
private Button BuildPrimaryButton(ModCollectionGameSummary summary){Func<string,string>translate=key=>TranslationServer.Translate(key);Button button;Action action;if(summary.PlanInstallCount>0){button=UiKit.Button(string.Format(translate("mod_manager/gallery_install_btn_n"),summary.PlanInstallCount),ButtonKind.Primary);action=()=>ApplyPlan(summary.Collection.Id);button.Disabled=summary.MissingIds.Count>0||_installingId!=null;}else if(summary.PlanEnableCount>0){button=UiKit.Button(string.Format(translate("mod_manager/gallery_enable_btn_n"),summary.PlanEnableCount),ButtonKind.Primary);action=()=>ApplyPlan(summary.Collection.Id);button.Disabled=_installingId!=null;}else if(summary.TotalMods==0){button=UiKit.Button(translate("mod_manager/gallery_continue"));action=()=>EditSchemeRequested?.Invoke(summary.Collection.Id);button.Disabled=_installingId!=null;}else{button=UiKit.Button(translate("mod_manager/gallery_ready_done"));action=null;button.Disabled=true;}if(action!=null)button.Pressed+=action;return button;}private Button BuildEditButton(ModCollectionGameSummary summary){var button=UiKit.Button(TranslationServer.Translate("mod_manager/gallery_edit_scheme"));button.Disabled=_installingId!=null;button.Pressed+=()=>EditSchemeRequested?.Invoke(summary.Collection.Id);return button;}private Button BuildDeleteButton(ModCollectionGameSummary summary){var button=UiKit.Button(TranslationServer.Translate("mod_manager/collection_delete"),ButtonKind.Danger);button.Disabled=_installingId!=null;button.Pressed+=()=>CollectionDeletion.Confirm(this,summary.Collection,()=>{StatusMessage?.Invoke(TranslationServer.Translate("mod_manager/collection_delete_done"));Refresh();});return button;}/* ═══════════════ Apply ═══════════════ */private ModCollectionPlan BuildPlan(ModCollectionGameSummary summary,List<CatalogExportEntry>catalog){var graph=ModCollectionResolver.Resolve(summary.Collection,_index,ModRegistry.Instance.AllDiscoveredMods,DisabledModsStore.Load(),catalog,_sessionInstalled,null,CollectionText.Resolve);return ModCollectionPlan.Build(graph,catalog);}/// <summary> Applies the plan of one card through the shared runner — the same install-then-enable routine the scheme editor performs — and refreshes the gallery so badges and buttons reflect the new state. </summary>
private async void ApplyPlan(string collectionId){if(_installingId!=null|| !_plans.TryGetValue(collectionId,out var plan)||plan.IsEmpty||plan.Missing.Count>0){return;}var summary=_summaries.FirstOrDefault(s=>string.Equals(s.Collection?.Id,collectionId,StringComparison.OrdinalIgnoreCase));var title=summary?.Name??collectionId;_installingId=collectionId;StatusMessage?.Invoke(string.Format(TranslationServer.Translate("mod_manager/collection_progress"),title));RenderGrid();bool success;try{success=await ModCollectionInstaller.ApplyAsync(plan,_sessionInstalled);}catch(Exception ex){GameLogger.Log("MOD_HUB",$"Gallery apply '{collectionId}' failed: {ex.Message}",LogLevel.Error);success=false;}_installingId=null;if(!GodotObject.IsInstanceValid(this))return;StatusMessage?.Invoke(TranslationServer.Translate(success?"mod_manager/restart_required":"mod_manager/collection_apply_failed"));Refresh();}private void OnInstallProgress(InstallProgress progress){if(_installingId==null||progress==null)return;StatusMessage?.Invoke(progress.Message);}private void OnCatalogRefreshed(){if(GodotObject.IsInstanceValid(this))Refresh();}private static void ClearChildren(Node node){foreach(var child in node.GetChildren()){node.RemoveChild(child);child.QueueFree();}}}}

===== FILE mods/mod_manager/src/UI/ModCard.cs =====
using System;using Cthangover.Core.UI.Theming;using Godot;namespace Mods.ModManager{/// <summary> Nexus-style mod tile. The layout lives in <c>scenes/components/mod_card.tscn</c> so it can be edited in the Godot editor; this script only binds data and reacts to input. It exposes a cover image, title (always two lines tall), author, catalog source, status badge, version combo box, a single contextual action button and an enable/disable switch. <para/> All rows are reserved to the same height on purpose: tiles in the grid must look identical regardless of whether a mod has a catalog entry, versions, an author or an action. Version selection mutates the shared <see cref="ModCardData.SelectedDownload"/> and refreshes the tile in place, so changing versions never rebuilds the grid or loses scroll position. </summary>
public partial class ModCard:PanelContainer{private ModCardData _data;private TextureRect _image;private Label _title;private Label _author;private Label _source;private Label _status;private OptionButton _versions;private Button _action;private CheckButton _enabled;private HBoxContainer _metaRow;private Button _collectionsLink;private bool _updatingEnabled;private bool _initialized;/// <summary>Raised when the card body (not a control) is clicked.</summary>
public event Action<ModCardData>Clicked;/// <summary>Raised when the "in N collections" cross-link badge is pressed.</summary>
public event Action CollectionsLinkRequested;/// <summary>Raised when the contextual action button is pressed.</summary>
public event Action<ModCardData>ActionPressed;/// <summary>Raised when the enable switch is flipped; the status is already updated.</summary>
public event Action<ModCardData>EnabledToggled;/// <summary>Raised after a different catalog version was selected.</summary>
public event Action<ModCardData>VersionChanged;/// <summary> Binds the tile to its data. Called once by <see cref="ModGridView"/> right after the scene is instantiated; the card cannot be used before. </summary>
public void Init(ModCardData data){if(_initialized)return;_initialized=true;_data=data;_image=GetNode<TextureRect>("VBox/Cover/Image");_title=GetNode<Label>("VBox/Body/Box/Title");_author=GetNode<Label>("VBox/Body/Box/Author");_source=GetNode<Label>("VBox/Body/Box/Source");_status=GetNode<Label>("VBox/Body/Box/MetaRow/Status");_versions=GetNode<OptionButton>("VBox/Body/Box/MetaRow/VersionHost/Versions");_action=GetNode<Button>("VBox/Body/Box/ActionRow/ActionHost/Action");_enabled=GetNode<CheckButton>("VBox/Body/Box/ActionRow/EnabledHost/Enabled");_metaRow=GetNode<HBoxContainer>("VBox/Body/Box/MetaRow");_author.AddThemeColorOverride("font_color",UiTokens.Muted);_source.AddThemeColorOverride("font_color",UiTokens.Muted);AddThemeStyleboxOverride("panel",ModManagerTheme.CreateCardStyle(data.IsConflict));_versions.ItemSelected+=OnVersionSelected;_action.Pressed+=OnActionPressed;_enabled.Toggled+=OnEnabledPressed;GuiInput+=OnGuiInput;BuildCollectionsLink();PopulateVersions();RefreshState();LoadPreview();}/// <summary>Resynchronizes badge, version list, action and switch with the data.</summary>
public void RefreshState(){if(!_initialized)return;_title.Text=_data.Title??_data.Id;_author.Text=_data.Author??string.Empty;UpdateSourceLabel();UpdateCollectionsLink();if(_data.IsConflict){_status.Text=TranslationServer.Translate("mod_manager/status_conflict");_status.AddThemeColorOverride("font_color",UiTokens.Danger);_status.TooltipText=TranslationServer.Translate("mod_manager/conflict_warning");}else{_status.Text=ModStatusNames.Translate(_data.Status);_status.AddThemeColorOverride("font_color",ModStatusNames.Color(_data.Status));_status.TooltipText=string.Empty;}UpdateActionButton();UpdateEnabledToggle();}private void UpdateSourceLabel(){if(_data.Catalog==null){_source.Text=TranslationServer.Translate("mod_manager/source_local");_source.TooltipText=string.Empty;return;}var label=ModCatalogSource.Label(_data.SourceUrls)??"—";_source.Text=string.Format(TranslationServer.Translate("mod_manager/source_catalog"),label);_source.TooltipText=ModCatalogSource.Tooltip(_data.SourceUrls)??string.Empty;}private void PopulateVersions(){_versions.Clear();if(_data.Downloads.Count==0){_versions.AddItem("v"+(_data.LocalVersion??"—"));_versions.Disabled=true;return;}_versions.Disabled=false;var selectedIndex=0;for(var i=0;i<_data.Downloads.Count;i++){var download=_data.Downloads[i];var label="v"+(download.Version??"?");if(i==0)label+=" · "+TranslationServer.Translate("mod_manager/latest");if(_data.IsInstalled&& !string.IsNullOrEmpty(download.Version)&&string.Equals(download.Version,_data.LocalVersion,StringComparison.OrdinalIgnoreCase)){label+=" · "+TranslationServer.Translate("mod_manager/installed_marker");}_versions.AddItem(label);if(ReferenceEquals(download,_data.SelectedDownload))selectedIndex=i;}_versions.Selected=selectedIndex;}/// <summary> Builds the flat "in N collections" cross-link shown between the status label and the version combo. The link is part of the hub mockup: pressing it jumps to the collections tab. Hidden entirely for mods that no playable game references. </summary>
private void BuildCollectionsLink(){_collectionsLink=new Button{FocusMode=FocusModeEnum.None,Visible=false,};_collectionsLink.AddThemeFontSizeOverride("font_size",UiTokens.FontTiny);_collectionsLink.AddThemeColorOverride("font_color",UiTokens.TypeTarget);_collectionsLink.AddThemeColorOverride("font_hover_color",UiTokens.TypeTarget);_collectionsLink.AddThemeColorOverride("font_pressed_color",UiTokens.TypeTarget);_collectionsLink.AddThemeColorOverride("font_focus_color",UiTokens.TypeTarget);foreach(var state in new[]{"normal","hover","pressed","disabled","focus"})_collectionsLink.AddThemeStyleboxOverride(state,new StyleBoxEmpty());_collectionsLink.Pressed+=()=>CollectionsLinkRequested?.Invoke();_metaRow.AddChild(_collectionsLink);_metaRow.MoveChild(_collectionsLink,1);}private void UpdateCollectionsLink(){var text=ModCollectionLinks.BadgeText(_data.CollectionsCount);_collectionsLink.Visible=text.Length>0;_collectionsLink.Text=text;_collectionsLink.TooltipText=text;}private void UpdateActionButton(){var kind=ModActions.GetPrimary(_data);if(kind==null){_action.Visible=false;return;}_action.Visible=true;_action.Text=ModActionNames.Translate(kind.Value);if(ModActionNames.IsDanger(kind.Value))CthangoverTheme.ApplyDanger(_action);else CthangoverTheme.ApplyPrimary(_action);}private void UpdateEnabledToggle(){_enabled.Visible=_data.CanToggleEnabled;if(!_data.CanToggleEnabled)return;_updatingEnabled=true;_enabled.ButtonPressed= !_data.IsDisabled;_updatingEnabled=false;_enabled.TooltipText=TranslationServer.Translate(_data.IsDisabled?"mod_manager/action_enable":"mod_manager/action_disable");}private void LoadPreview(){if(_data.LocalInfo!=null){var local=ModPreviewLoader.LoadLocal(_data.LocalInfo);if(local!=null){_image.Texture=local;return;}}if(_data.Catalog!=null)ModPreviewLoader.LoadRemote(_data.Id,_data.Catalog.IconUrl,OnRemotePreviewLoaded);}private void OnRemotePreviewLoaded(Texture2D texture){if(texture!=null&&GodotObject.IsInstanceValid(_image))_image.Texture=texture;}private void OnGuiInput(InputEvent@event){if(@event is InputEventMouseButton mouseButton&&mouseButton.Pressed&&mouseButton.ButtonIndex==MouseButton.Left){Clicked?.Invoke(_data);AcceptEvent();}}private void OnActionPressed(){ActionPressed?.Invoke(_data);}private void OnEnabledPressed(bool pressed){if(_updatingEnabled|| !_data.CanToggleEnabled)return;_data.IsDisabled= !pressed;_data.RecomputeStatus();RefreshState();EnabledToggled?.Invoke(_data);}private void OnVersionSelected(long index){if(index<0||index>=_data.Downloads.Count)return;_data.SelectedDownload=_data.Downloads[(int)index];_data.RecomputeStatus();RefreshState();VersionChanged?.Invoke(_data);}}}

===== FILE mods/mod_manager/src/UI/ModDetailView.cs =====
using System;using Cthangover.Core.UI.Theming;using Godot;namespace Mods.ModManager{/// <summary> Full-screen page dedicated to one mod (opened by clicking a tile). The layout lives in <c>scenes/components/mod_detail_view.tscn</c> so it can be edited visually; this script fills it with data, rebuilds the contextual action row and reports user intent through events — the manager screen performs the actual file operations. <para/> Conflicting catalog variants (same version, different content) get a red warning block explaining both sources, on top of the red tile border the card already shows. </summary>
public partial class ModDetailView:VBoxContainer{private ModCardData _data;private bool _nodesReady;private TextureRect _coverImage;private Button _back;private Label _title;private Label _author;private Label _meta;private OptionButton _versions;private Label _installed;private Label _size;private HBoxContainer _actionsRow;private VBoxContainer _conflictBox;private Label _conflictText;private Label _descriptionText;private Label _changelogText;private VBoxContainer _dependsBox;private Label _dependsText;private VBoxContainer _usedByBox;private Label _usedByText;private VBoxContainer _conflictsBox;private Label _conflictsText;/// <summary>Raised by the back button.</summary>
public event Action BackRequested;/// <summary>Raised when the primary contextual action is pressed.</summary>
public event Action<ModCardData>ActionPressed;/// <summary>Raised when the standalone delete button is pressed.</summary>
public event Action<ModCardData>DeleteRequested;/// <summary>Raised when the enable/disable button is pressed.</summary>
public event Action<ModCardData>EnabledToggled;/// <summary>Raised after a different catalog version is selected.</summary>
public event Action<ModCardData>VersionChanged;/// <summary>Raised when the "in N collections" cross-link is pressed.</summary>
public event Action CollectionsLinkRequested;/// <summary>Displays the given mod; call again with refreshed data after actions.</summary>
public void Open(ModCardData data){_data=data;Rebuild();}private void EnsureNodes(){if(_nodesReady)return;_nodesReady=true;_coverImage=GetNode<TextureRect>("Scroll/Content/Hero/HeroMargin/HeroRow/Cover/Image");_back=GetNode<Button>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/Back");_title=GetNode<Label>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/Title");_author=GetNode<Label>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/Author");_meta=GetNode<Label>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/Meta");_versions=GetNode<OptionButton>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/VersionRow/Versions");_installed=GetNode<Label>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/VersionRow/Installed");_size=GetNode<Label>("Scroll/Content/Hero/HeroMargin/HeroRow/Info/VersionRow/Size");_actionsRow=GetNode<HBoxContainer>("Scroll/Content/ActionsRow");_conflictBox=GetNode<VBoxContainer>("Scroll/Content/ConflictBox");_conflictText=GetNode<Label>("Scroll/Content/ConflictBox/Text");_descriptionText=GetNode<Label>("Scroll/Content/DescriptionBox/Text");_changelogText=GetNode<Label>("Scroll/Content/ChangelogBox/Text");_dependsBox=GetNode<VBoxContainer>("Scroll/Content/DependsBox");_dependsText=GetNode<Label>("Scroll/Content/DependsBox/Text");_usedByBox=GetNode<VBoxContainer>("Scroll/Content/UsedByBox");_usedByText=GetNode<Label>("Scroll/Content/UsedByBox/Text");_conflictsBox=GetNode<VBoxContainer>("Scroll/Content/ConflictsBox");_conflictsText=GetNode<Label>("Scroll/Content/ConflictsBox/Text");_back.Text=TranslationServer.Translate("mod_manager/back_to_list");_back.Pressed+=OnBackPressed;_versions.ItemSelected+=OnVersionSelected;_author.AddThemeColorOverride("font_color",UiTokens.Muted);_installed.AddThemeColorOverride("font_color",UiTokens.Muted);_size.AddThemeColorOverride("font_color",UiTokens.Muted);_conflictText.AddThemeColorOverride("font_color",UiTokens.Danger);SetCaption("Scroll/Content/ConflictBox/Caption","mod_manager/conflict_title");SetCaption("Scroll/Content/DescriptionBox/Caption","mod_manager/description");SetCaption("Scroll/Content/ChangelogBox/Caption","mod_manager/changelog");SetCaption("Scroll/Content/DependsBox/Caption","mod_manager/depends");SetCaption("Scroll/Content/UsedByBox/Caption","mod_manager/used_by");SetCaption("Scroll/Content/ConflictsBox/Caption","mod_manager/conflicts");SetCaption("Scroll/Content/Hero/HeroMargin/HeroRow/Info/VersionRow/Caption","mod_manager/version");}private void SetCaption(string path,string localeKey){var caption=GetNode<Label>(path);caption.Text=TranslationServer.Translate(localeKey);caption.AddThemeColorOverride("font_color",UiTokens.Muted);}private void Rebuild(){EnsureNodes();if(_data==null)return;_title.Text=_data.Title??_data.Id;_author.Text=_data.Author??string.Empty;if(_data.IsConflict){_meta.Text=TranslationServer.Translate("mod_manager/status_conflict");_meta.AddThemeColorOverride("font_color",UiTokens.Danger);}else{_meta.Text=$"{ModCategoryNames.Translate(ModCategoryNames.Primary(_data.Category))}  ·  {ModStatusNames.Translate(_data.Status)}";_meta.AddThemeColorOverride("font_color",ModStatusNames.Color(_data.Status));}PopulateVersions();_installed.Text=_data.IsInstalled?string.Format(TranslationServer.Translate("mod_manager/installed_version"),_data.LocalVersion??"—"):TranslationServer.Translate("mod_manager/not_installed_hint").ToString();var size=_data.SelectedDownload?.SizeBytes??0;_size.Text=size>0?FormatSize(size):string.Empty;BuildActions();BuildConflictBox();_descriptionText.Text=NullIfEmpty(_data.Description)??"—";_changelogText.Text=NullIfEmpty(_data.SelectedDownload?.Changelog)??"—";SetOptional(_dependsBox,_dependsText,JoinOrEmpty(ModQueries.GetDependencies(_data)));SetOptional(_usedByBox,_usedByText,JoinOrEmpty(ModQueries.GetDependents(_data.Id)));SetOptional(_conflictsBox,_conflictsText,JoinOrEmpty(ModQueries.GetConflicts(_data)));LoadCover();}private void PopulateVersions(){_versions.Clear();_versions.Visible=_data.Downloads.Count>0;if(_data.Downloads.Count==0)return;var selectedIndex=0;for(var i=0;i<_data.Downloads.Count;i++){var download=_data.Downloads[i];var label="v"+(download.Version??"?");if(i==0)label+=" · "+TranslationServer.Translate("mod_manager/latest");if(_data.IsInstalled&& !string.IsNullOrEmpty(download.Version)&&string.Equals(download.Version,_data.LocalVersion,StringComparison.OrdinalIgnoreCase)){label+=" · "+TranslationServer.Translate("mod_manager/installed_marker");}_versions.AddItem(label);if(ReferenceEquals(download,_data.SelectedDownload))selectedIndex=i;}_versions.Selected=selectedIndex;}private void BuildActions(){foreach(var child in _actionsRow.GetChildren()){_actionsRow.RemoveChild(child);child.QueueFree();}var primary=ModActions.GetPrimary(_data);if(primary!=null){var button=new Button{Text=ModActionNames.Translate(primary.Value)};if(ModActionNames.IsDanger(primary.Value))CthangoverTheme.ApplyDanger(button);else CthangoverTheme.ApplyPrimary(button);button.Pressed+=OnPrimaryPressed;_actionsRow.AddChild(button);}if(primary!=ModActionKind.Delete&&_data.IsDeletable){var delete=new Button{Text=ModActionNames.Translate(ModActionKind.Delete)};CthangoverTheme.ApplyDanger(delete);delete.Pressed+=OnDeletePressed;_actionsRow.AddChild(delete);}if(_data.CanToggleEnabled){var toggle=new Button{Text=ModActionNames.Translate(_data.IsDisabled?ModActionKind.Enable:ModActionKind.Disable),};if(_data.IsDisabled)CthangoverTheme.ApplyPrimary(toggle);else CthangoverTheme.ApplyDanger(toggle);toggle.Pressed+=OnTogglePressed;_actionsRow.AddChild(toggle);}if(_data.IsProtected){var hint=new Label{Text=TranslationServer.Translate("mod_manager/self_hint"),VerticalAlignment=VerticalAlignment.Center,};hint.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);hint.AddThemeColorOverride("font_color",UiTokens.Muted);_actionsRow.AddChild(hint);}var collectionsText=ModCollectionLinks.BadgeText(_data.CollectionsCount);if(collectionsText.Length>0){var link=new Button{Text=collectionsText,TooltipText=collectionsText,FocusMode=FocusModeEnum.None,};link.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);link.AddThemeColorOverride("font_color",UiTokens.TypeTarget);link.AddThemeColorOverride("font_hover_color",UiTokens.TypeTarget);link.Pressed+=()=>CollectionsLinkRequested?.Invoke();_actionsRow.AddChild(link);}}private void BuildConflictBox(){_conflictBox.Visible=_data.IsConflict;if(!_data.IsConflict){_conflictText.Text=string.Empty;return;}var sources=ModCatalogSource.Tooltip(_data.SourceUrls);_conflictText.Text=sources==null?TranslationServer.Translate("mod_manager/conflict_warning"):$"{TranslationServer.Translate("mod_manager/conflict_warning")}\n{sources}";}private void LoadCover(){_coverImage.Texture=null;if(_data.LocalInfo!=null){var local=ModPreviewLoader.LoadLocal(_data.LocalInfo);if(local!=null){_coverImage.Texture=local;return;}}if(_data.Catalog!=null)ModPreviewLoader.LoadRemote(_data.Id,_data.Catalog.IconUrl,OnRemotePreviewLoaded);}private void OnRemotePreviewLoaded(Texture2D texture){if(texture!=null&&GodotObject.IsInstanceValid(_coverImage))_coverImage.Texture=texture;}private void OnBackPressed()=>BackRequested?.Invoke();private void OnPrimaryPressed()=>ActionPressed?.Invoke(_data);private void OnDeletePressed()=>DeleteRequested?.Invoke(_data);private void OnTogglePressed()=>EnabledToggled?.Invoke(_data);private void OnVersionSelected(long index){if(index<0||index>=_data.Downloads.Count)return;_data.SelectedDownload=_data.Downloads[(int)index];_data.RecomputeStatus();Rebuild();VersionChanged?.Invoke(_data);}private static void SetOptional(VBoxContainer box,Label text,string value){box.Visible= !string.IsNullOrEmpty(value);text.Text=value??string.Empty;}private static string NullIfEmpty(string value)=>string.IsNullOrWhiteSpace(value)?null:value;private static string JoinOrEmpty(System.Collections.Generic.List<string>values)=>values==null||values.Count==0?null:string.Join(", ",values);private static string FormatSize(long bytes){if(bytes>=1024L*1024L)return$"{bytes / (1024f * 1024f):F1} MB";if(bytes>=1024L)return$"{bytes / 1024f:F0} KB";return$"{bytes} B";}}}

===== FILE mods/mod_manager/src/UI/ModFilterPanel.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Mods;using Cthangover.Core.UI.Theming;using Godot;namespace Mods.ModManager{/// <summary> Left filter sidebar. The frame (title, reset button, section captions and the grouping toggle) lives in <c>scenes/components/mod_filter_panel.tscn</c> for visual editing; this script fills the two list containers with per-status and per-category checkboxes and keeps the shared <see cref="ModFilterState"/> in sync. The panel does not own the state and never rebuilds the grid itself — it announces every change through <see cref="Changed"/>. </summary>
public partial class ModFilterPanel:VBoxContainer{private ModFilterState _state;private Label _title;private Button _reset;private Label _statusCaption;private Label _categoryCaption;private CheckButton _groupToggle;private VBoxContainer _statusList;private VBoxContainer _categoryList;private readonly Dictionary<ModItemStatus,CheckBox>_statusBoxes=new();private readonly Dictionary<CategoryType,CheckBox>_categoryBoxes=new();/// <summary>Raised after any filter or grouping change.</summary>
public event Action Changed;/// <summary> Raised after the reset button cleared the state. The panel cannot touch the header's search box, so the screen clears it in response. </summary>
public event Action ResetRequested;/// <summary>Binds the sidebar to the shared filter state; call once, after the scene is built.</summary>
public void Bind(ModFilterState state){_state=state;_title=GetNode<Label>("Title");_reset=GetNode<Button>("Reset");_statusCaption=GetNode<Label>("StatusCaption");_categoryCaption=GetNode<Label>("CategoryCaption");_groupToggle=GetNode<CheckButton>("GroupToggle");_statusList=GetNode<VBoxContainer>("StatusList");_categoryList=GetNode<VBoxContainer>("CategoryList");_title.Text=TranslationServer.Translate("mod_manager/filters");_reset.Text=TranslationServer.Translate("mod_manager/reset_filters");_statusCaption.Text=TranslationServer.Translate("mod_manager/filter_status");_categoryCaption.Text=TranslationServer.Translate("mod_manager/filter_categories");_groupToggle.Text=TranslationServer.Translate("mod_manager/group_by_category");_groupToggle.ButtonPressed=_state.GroupByCategory;_statusCaption.AddThemeColorOverride("font_color",UiTokens.Muted);_categoryCaption.AddThemeColorOverride("font_color",UiTokens.Muted);_reset.Pressed+=OnResetPressed;_groupToggle.Toggled+=OnGroupToggled;foreach(var status in Enum.GetValues<ModItemStatus>())AddStatusCheck(status);foreach(var category in ModCategoryNames.Ordered)AddCategoryCheck(category);}/// <summary>Refreshes the numbers in the checkbox captions.</summary>
public void RefreshCounts(Dictionary<CategoryType,int>categoryCounts,Dictionary<ModItemStatus,int>statusCounts){foreach(var pair in _statusBoxes){var count=statusCounts.GetValueOrDefault(pair.Key);pair.Value.Text=$"{ModStatusNames.Translate(pair.Key)} ({count})";}foreach(var pair in _categoryBoxes){var count=categoryCounts.GetValueOrDefault(pair.Key);pair.Value.Text=$"{ModCategoryNames.Translate(pair.Key)} ({count})";}}private void AddStatusCheck(ModItemStatus status){var box=new CheckBox{Text=ModStatusNames.Translate(status),ButtonPressed=_state.Statuses.Contains(status),};box.Toggled+=pressed=>OnStatusToggled(status,pressed);_statusBoxes[status]=box;_statusList.AddChild(box);}private void AddCategoryCheck(CategoryType category){var box=new CheckBox{Text=ModCategoryNames.Translate(category),ButtonPressed=_state.Categories.Contains(category),};box.Toggled+=pressed=>OnCategoryToggled(category,pressed);_categoryBoxes[category]=box;_categoryList.AddChild(box);}private void OnStatusToggled(ModItemStatus status,bool pressed){if(pressed)_state.Statuses.Add(status);else _state.Statuses.Remove(status);Changed?.Invoke();}private void OnCategoryToggled(CategoryType category,bool pressed){if(pressed)_state.Categories.Add(category);else _state.Categories.Remove(category);Changed?.Invoke();}private void OnGroupToggled(bool pressed){_state.GroupByCategory=pressed;Changed?.Invoke();}private void OnResetPressed(){_state.Reset();foreach(var box in _statusBoxes.Values)box.SetPressedNoSignal(false);foreach(var box in _categoryBoxes.Values)box.SetPressedNoSignal(false);ResetRequested?.Invoke();Changed?.Invoke();}}}

===== FILE mods/mod_manager/src/UI/ModGridGroup.cs =====
using Godot;namespace Mods.ModManager{/// <summary> One section of the mod grid: an optional category header plus the <see cref="HFlowContainer"/> that wraps the tiles. The layout lives in <c>scenes/components/mod_grid_group.tscn</c> so the header style and flow spacing can be tuned in the editor; the grid instantiates one group per category when grouping is enabled, or a single header-less group otherwise. </summary>
public partial class ModGridGroup:VBoxContainer{private Label _header;private HFlowContainer _flow;/// <summary>Container the grid adds <see cref="ModCard"/> tiles to.</summary>
public HFlowContainer Flow{get{EnsureNodes();return _flow;}}/// <summary>Sets (or hides with null/empty) the section header text.</summary>
public void SetHeader(string text){EnsureNodes();_header.Text=text??string.Empty;_header.Visible= !string.IsNullOrEmpty(text);}private void EnsureNodes(){if(_flow!=null)return;_header=GetNode<Label>("Header");_flow=GetNode<HFlowContainer>("Flow");}}}

===== FILE mods/mod_manager/src/UI/ModGridView.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Theming;namespace Mods.ModManager{/// <summary> Scrollable grid of <see cref="ModCard"/> tiles, composed of <see cref="ModGridGroup"/> sections (see <c>scenes/components/mod_grid_view.tscn</c>). Cards flow inside <see cref="HFlowContainer"/>s, so the number of columns adapts to the viewport width — the Godot answer to Nexus's responsive auto-fill grid. In grouping mode one section per category is created with a header; in flat mode a single header-less section is used. </summary>
public partial class ModGridView:ScrollContainer{private const string GroupScene="scenes/components/mod_grid_group.tscn";private const string CardScene="scenes/components/mod_card.tscn";private VBoxContainer _host;/// <summary>Raised when a card body is clicked.</summary>
public event Action<ModCardData>CardClicked;/// <summary>Raised when a card's action button is pressed.</summary>
public event Action<ModCardData>ActionPressed;/// <summary>Raised when a card's enable switch is flipped.</summary>
public event Action<ModCardData>EnabledToggled;/// <summary>Raised after a card's version selection changes.</summary>
public event Action<ModCardData>VersionChanged;/// <summary>Raised when any card's "in N collections" cross-link is pressed.</summary>
public event Action CollectionsLinkRequested;/// <summary>Rebuilds the grid from scratch; called on every filter change.</summary>
public void SetItems(IReadOnlyList<ModCardData>items,bool groupByCategory){if(_host==null)_host=GetNode<VBoxContainer>("Host");foreach(var child in _host.GetChildren())child.QueueFree();if(items==null||items.Count==0){_host.AddChild(BuildEmptyLabel());return;}if(!groupByCategory){FillGroup(AddGroup(null),items);return;}foreach(var category in ModCategoryNames.Ordered){var group=new List<ModCardData>();foreach(var item in items){if(ModCategoryNames.Primary(item.Category)==category)group.Add(item);}if(group.Count==0)continue;var header=$"{ModCategoryNames.Translate(category)}  ·  {group.Count}";FillGroup(AddGroup(header),group);}}private ModGridGroup AddGroup(string header){var group=TscnScenes.LoadAndBuild(GroupScene)as ModGridGroup;if(group==null){GameLogger.Log("MOD_MANAGER",$"Grid: failed to build '{GroupScene}'",LogLevel.Error);return null;}group.SetHeader(header);_host.AddChild(group);return group;}private void FillGroup(ModGridGroup group,IReadOnlyList<ModCardData>items){if(group==null)return;foreach(var data in items){var card=TscnScenes.LoadAndBuild(CardScene)as ModCard;if(card==null){GameLogger.Log("MOD_MANAGER",$"Grid: failed to build '{CardScene}'",LogLevel.Error);continue;}group.Flow.AddChild(card);card.Clicked+=OnCardClicked;card.ActionPressed+=OnCardActionPressed;card.EnabledToggled+=OnCardEnabledToggled;card.VersionChanged+=OnCardVersionChanged;card.CollectionsLinkRequested+=()=>CollectionsLinkRequested?.Invoke();card.Init(data);}}private static Label BuildEmptyLabel(){return UiKit.EmptyState(TranslationServer.Translate("mod_manager/no_results"));}private void OnCardClicked(ModCardData data)=>CardClicked?.Invoke(data);private void OnCardActionPressed(ModCardData data)=>ActionPressed?.Invoke(data);private void OnCardEnabledToggled(ModCardData data)=>EnabledToggled?.Invoke(data);private void OnCardVersionChanged(ModCardData data)=>VersionChanged?.Invoke(data);}}

===== FILE mods/mod_manager/src/UI/ModManagerTheme.cs =====
using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Theming;namespace Mods.ModManager{/// <summary> Compatibility facade over the central <see cref="CthangoverTheme"/> and <see cref="UiTokens"/>. The mod manager once carried its own Nexus-like palette; now every colour is a design token and the shared theme cascades from the screen root. Public names are preserved so existing widgets keep compiling, but each one is an alias or a thin wrapper around the core theming API. New code should reference <see cref="UiTokens"/>, <see cref="CthangoverTheme"/> and <see cref="UiKit"/> directly. </summary>
public static class ModManagerTheme{/// <summary>Page background.</summary>
public static readonly Color BgBase=UiTokens.Surface0;/// <summary>Card and sidebar surface.</summary>
public static readonly Color SurfaceLow=UiTokens.Surface1;/// <summary>Raised control surface.</summary>
public static readonly Color SurfaceRaised=UiTokens.Surface3;/// <summary>Primary text.</summary>
public static readonly Color TextStrong=UiTokens.InkBright;/// <summary>Secondary text.</summary>
public static readonly Color TextMuted=UiTokens.Muted;/// <summary>Hairline border.</summary>
public static readonly Color BorderWeak=UiTokens.Line;/// <summary>Control border.</summary>
public static readonly Color BorderStrong=UiTokens.LineStrong;/// <summary>Gold accent.</summary>
public static readonly Color Accent=UiTokens.Accent;/// <summary>Text on accent buttons.</summary>
public static readonly Color AccentText=UiTokens.Surface0;/// <summary>Danger red for destructive actions.</summary>
public static readonly Color Danger=UiTokens.Danger;/// <summary>Returns the single shared theme; the manager no longer owns a private one.</summary>
public static Theme Create()=>CthangoverTheme.Get();/// <summary> Style for a mod tile. The negative top/left content margins shift the cover image 1px up and make it 1px wider so it sits flush over the card border. A conflicting catalog variant gets a danger border. </summary>
public static StyleBoxFlat CreateCardStyle(bool conflict){var style=UiKit.Frame(UiTokens.RadiusMd,UiTokens.Surface1,conflict?UiTokens.Danger:UiTokens.Line);style.ContentMarginTop= -1;style.ContentMarginLeft= -1;return style;}/// <summary>Applies the shared primary accent variation to a button.</summary>
public static void ApplyPrimaryButton(Button button)=>CthangoverTheme.ApplyPrimary(button);/// <summary>Applies the shared danger variation to a button.</summary>
public static void ApplyDangerButton(Button button)=>CthangoverTheme.ApplyDanger(button);/// <summary>Creates a flat style box with an optional border and padding.</summary>
public static StyleBoxFlat MakeBox(Color background,Color border,int radius,int paddingH,int paddingV){var style=UiKit.Frame(radius,background,border);style.ContentMarginLeft=paddingH;style.ContentMarginRight=paddingH;style.ContentMarginTop=paddingV;style.ContentMarginBottom=paddingV;return style;}}}

===== FILE mods/mod_manager/src/Util/CollectionDeletion.cs =====
using System;using Cthangover.Core.Mods;using Cthangover.Core.Settings;using Godot;namespace Mods.ModManager{/// <summary> Deleting a player's game collection, shared by the gallery card and the scheme editor header so both ask the same question. Only the player's own collections can go: a collection a mod or the catalog ships would come back on the next merge (its edited version is dropped with "Revert to built-in" instead). Deleting touches only <c>user://collections/</c> — the mods of the collection stay installed and its saves stay on disk (they reappear if a collection with the same id comes back), which the confirmation spells out. </summary>
public static class CollectionDeletion{/// <summary>True for a player-made collection (saved or still a draft) that no mod or catalog ships.</summary>
public static bool CanDelete(ModCollection collection){return collection!=null&&collection.Origin==ModCollectionOrigin.User&& !ModCollectionStore.HasShippedVersion(collection.Id);}/// <summary> Asks for confirmation under <paramref name="host"/>; on "delete" removes the saved collection and calls <paramref name="deleted"/> (the caller also drops its unsaved draft and refreshes its view). </summary>
public static void Confirm(Node host,ModCollection collection,Action deleted){if(host==null|| !CanDelete(collection))return;var name=CollectionText.Resolve(collection.GetLocalizedName(TranslationServer.GetLocale()));var dialog=new ConfirmationDialog{Title=TranslationServer.Translate("mod_manager/collection_delete"),DialogText=string.Format(TranslationServer.Translate("mod_manager/collection_delete_confirm"),name,SaveService.ListSaves(collection.Id).Count),OkButtonText=TranslationServer.Translate("mod_manager/collection_delete"),CancelButtonText=TranslationServer.Translate("mod_manager/cancel"),Exclusive=true,};dialog.Confirmed+=()=>{ModCollectionStore.RemoveUser(collection.Id);dialog.QueueFree();deleted?.Invoke();};dialog.Canceled+=dialog.QueueFree;host.AddChild(dialog);dialog.PopupCentered();}}}

===== FILE mods/mod_manager/src/Util/CollectionStateNames.cs =====
using Cthangover.Core.Mods;using Cthangover.Core.UI.Theming;using Godot;namespace Mods.ModManager{/// <summary> Locale keys and node colors for <see cref="ModCollectionNodeState"/>, following the same pattern as <see cref="ModStatusNames"/> so the collections graph reads like the rest of the manager: green — ready to play, amber — needs an action (enable, resolve a version pin), blue — downloadable from the catalog, red — impossible to resolve. </summary>
public static class CollectionStateNames{/// <summary>Localized state label shown inside graph nodes and in the details panel.</summary>
public static string Translate(ModCollectionNodeState state)=>TranslationServer.Translate(Key(state));/// <summary>Translation key of a state.</summary>
public static string Key(ModCollectionNodeState state)=>state switch{ModCollectionNodeState.Installed=>"mod_manager/collection_state_installed",ModCollectionNodeState.Disabled=>"mod_manager/collection_state_disabled",ModCollectionNodeState.InCatalog=>"mod_manager/collection_state_catalog",ModCollectionNodeState.VersionMismatch=>"mod_manager/collection_state_mismatch",_=>"mod_manager/collection_state_missing",};/// <summary>Titlebar/badge color for a state.</summary>
public static Color Color(ModCollectionNodeState state)=>state switch{ModCollectionNodeState.Installed=>UiTokens.Success,ModCollectionNodeState.Disabled=>UiTokens.Warning,ModCollectionNodeState.InCatalog=>UiTokens.Info,ModCollectionNodeState.VersionMismatch=>UiTokens.Warning,_=>UiTokens.Danger,};}}

===== FILE mods/mod_manager/src/Util/ModActionNames.cs =====
using Godot;namespace Mods.ModManager{/// <summary>Locale keys and styling hints for <see cref="ModActionKind"/>.</summary>
public static class ModActionNames{/// <summary>Localized action button caption.</summary>
public static string Translate(ModActionKind kind)=>TranslationServer.Translate(Key(kind));/// <summary>Translation key of an action.</summary>
public static string Key(ModActionKind kind)=>kind switch{ModActionKind.Install=>"mod_manager/action_install",ModActionKind.Update=>"mod_manager/action_update",ModActionKind.Downgrade=>"mod_manager/action_downgrade",ModActionKind.Reinstall=>"mod_manager/action_reinstall",ModActionKind.Delete=>"mod_manager/action_delete",ModActionKind.Enable=>"mod_manager/action_enable",_=>"mod_manager/action_disable",};/// <summary>True for destructive actions that deserve the danger button style.</summary>
public static bool IsDanger(ModActionKind kind)=>kind==ModActionKind.Delete;}}

===== FILE mods/mod_manager/src/Util/ModCatalogSource.cs =====
using System;using System.Collections.Generic;namespace Mods.ModManager{/// <summary> Presentation helpers for catalog provenance. The manager shows where a mod comes from without cluttering the tile: a short host label plus the full URL list in the tooltip, and a "+N" marker when several catalogs publish the same build. </summary>
public static class ModCatalogSource{/// <summary>First source URL, or null when there are none.</summary>
public static string FirstUrl(IReadOnlyList<string>urls)=>urls!=null&&urls.Count>0?urls[0]:null;/// <summary>Host of a URL (fallback: the raw string when it does not parse).</summary>
public static string Host(string url){if(string.IsNullOrWhiteSpace(url))return null;if(Uri.TryCreate(url,UriKind.Absolute,out var uri)&& !string.IsNullOrEmpty(uri.Host))return uri.Host;return url;}/// <summary>Short label: host plus "+N" for extra catalogs, or null when unknown.</summary>
public static string Label(IReadOnlyList<string>urls){var first=FirstUrl(urls);if(first==null)return null;var host=Host(first);var extra=urls.Count-1;return extra>0?$"{host} +{extra}":host;}/// <summary>Newline-joined full URL list for tooltips.</summary>
public static string Tooltip(IReadOnlyList<string>urls)=>urls==null||urls.Count==0?null:string.Join("\n",urls);}}

===== FILE mods/mod_manager/src/Util/ModCategoryNames.cs =====
using Cthangover.Core.Mods;namespace Mods.ModManager{/// <summary> Presentation names for <see cref="CategoryType"/>. Kept inside the mod_manager mod (keys prefixed <c>mod_manager/category_</c>) so the manager does not depend on the interface mod's locale for its filter sidebar. </summary>
public static class ModCategoryNames{/// <summary>Display order of category filters and group headers.</summary>
public static readonly CategoryType[]Ordered={CategoryType.Story,CategoryType.Characters,CategoryType.Gameplay,CategoryType.Visual,CategoryType.Audio,CategoryType.UI,CategoryType.Locale,CategoryType.Library,CategoryType.Engine,CategoryType.Overhaul,CategoryType.Tools,CategoryType.Editor,CategoryType.Debug,CategoryType.Other,};/// <summary> First single category of a (possibly combined) flags value, used to place a mod into exactly one grid group and one count bucket per category it belongs to. </summary>
public static CategoryType Primary(CategoryType category){foreach(var candidate in Ordered){if((category&candidate)!=0)return candidate;}return CategoryType.Other;}/// <summary>Localized category name for filters and group headers.</summary>
public static string Translate(CategoryType category)=>Godot.TranslationServer.Translate(Key(category));/// <summary>Translation key of a single (non-combined) category.</summary>
public static string Key(CategoryType category)=>category switch{CategoryType.Story=>"mod_manager/category_story",CategoryType.Characters=>"mod_manager/category_characters",CategoryType.Gameplay=>"mod_manager/category_gameplay",CategoryType.Visual=>"mod_manager/category_visual",CategoryType.Audio=>"mod_manager/category_audio",CategoryType.UI=>"mod_manager/category_ui",CategoryType.Locale=>"mod_manager/category_locale",CategoryType.Library=>"mod_manager/category_library",CategoryType.Engine=>"mod_manager/category_engine",CategoryType.Overhaul=>"mod_manager/category_overhaul",CategoryType.Tools=>"mod_manager/category_tools",CategoryType.Editor=>"mod_manager/category_editor",CategoryType.Debug=>"mod_manager/category_debug",_=>"mod_manager/category_other",};}}

===== FILE mods/mod_manager/src/Util/ModCollectionLinks.cs =====
using Godot;namespace Mods.ModManager{/// <summary> Shared texts for the mod-to-collection cross links required by the hub mockup: mod tiles and the detail page show a clickable "in N collections" badge that jumps to the collections tab. Centralized so the card and the detail page always render the same pluralized string. </summary>
public static class ModCollectionLinks{/// <summary>Localized "in N collection(s)" label for the given count; empty when the mod belongs to no game.</summary>
public static string BadgeText(int count){if(count<=0)return string.Empty;var key=count==1?"mod_manager/hub_in_one_collection":"mod_manager/hub_in_many_collections";return string.Format(TranslationServer.Translate(key),count);}}}

===== FILE mods/mod_manager/src/Util/ModPreviewLoader.cs =====
using System;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Caches;using Godot;namespace Mods.ModManager{/// <summary> Loads preview images for mod tiles and the detail page. Local mods use their bundled <c>mod.png</c> / <c>mod.jpg</c>; catalog-only mods fall back to <see cref="ModIconCache"/>, which downloads and caches the entry's <c>icon_url</c> under <c>user://mod_cache/icons</c>. Returning null is a normal outcome — the card then keeps its flat placeholder. </summary>
public static class ModPreviewLoader{/// <summary>Reads the mod's bundled preview image, or null when absent/unreadable.</summary>
public static Texture2D LoadLocal(ModInfo info){var provider=info?.FileProvider;if(provider==null)return null;byte[]data=null;if(provider.FileExists("mod.png"))data=provider.ReadFileBinary("mod.png");else if(provider.FileExists("mod.jpg"))data=provider.ReadFileBinary("mod.jpg");if(data==null||data.Length==0)return null;var image=new Image();Error error;if(data[0]==0x89)error=image.LoadPngFromBuffer(data);else if(data[0]==0xFF)error=image.LoadJpgFromBuffer(data);else return null;if(error!=Error.Ok){image.Dispose();return null;}var texture=ImageTexture.CreateFromImage(image);image.Dispose();return texture;}/// <summary> Asynchronously resolves a catalog icon; the callback may fire later (network) or immediately (memory/disk cache). </summary>
public static void LoadRemote(string modId,string iconUrl,Action<Texture2D>onLoaded){if(string.IsNullOrEmpty(iconUrl))return;ModIconCache.GetOrDownload(modId,iconUrl,onLoaded);}}}

===== FILE mods/mod_manager/src/Util/ModStatusNames.cs =====
using Cthangover.Core.UI.Theming;using Godot;namespace Mods.ModManager{/// <summary> Locale keys, badge colors and sort ranks for <see cref="ModItemStatus"/>. Colors are semantic <see cref="UiTokens"/> entries shared with the rest of the UI: success installed, warning update, info downgrade, danger locally modified, muted disabled, faint not installed. </summary>
public static class ModStatusNames{/// <summary>Localized status label shown on tiles and the detail page.</summary>
public static string Translate(ModItemStatus status)=>TranslationServer.Translate(Key(status));/// <summary>Translation key of a status.</summary>
public static string Key(ModItemStatus status)=>status switch{ModItemStatus.Installed=>"mod_manager/status_installed",ModItemStatus.UpdateAvailable=>"mod_manager/status_update",ModItemStatus.DowngradeAvailable=>"mod_manager/status_downgrade",ModItemStatus.ModifiedLocally=>"mod_manager/status_modified",ModItemStatus.Disabled=>"mod_manager/status_disabled",_=>"mod_manager/status_not_installed",};/// <summary>Badge color for a status.</summary>
public static Color Color(ModItemStatus status)=>status switch{ModItemStatus.Installed=>UiTokens.Success,ModItemStatus.UpdateAvailable=>UiTokens.Warning,ModItemStatus.DowngradeAvailable=>UiTokens.Info,ModItemStatus.ModifiedLocally=>UiTokens.Danger,ModItemStatus.Disabled=>UiTokens.Muted,_=>UiTokens.Faint,};/// <summary> Rank used by <see cref="ModSortMode.Status"/>: highlights actionable states (update, modified) before plain installed and catalog-only mods. </summary>
public static int SortRank(ModItemStatus status)=>status switch{ModItemStatus.UpdateAvailable=>0,ModItemStatus.ModifiedLocally=>1,ModItemStatus.Installed=>2,ModItemStatus.DowngradeAvailable=>3,ModItemStatus.Disabled=>4,_=>5,};}}

===== FILE mods/quests_base/src/Actions/Atomic/QuestAddTagAction.cs =====
namespace Cthangover.Core.Actions.Atomic{/// <summary> Adds a string tag to a quest. Tags are used for conditional logic in scenario scripts — a quest can be checked for tag presence to branch dialog. The tag is added via QuestBase.AddTag, which likely stores it in the quest's save data. </summary>
public class QuestAddTagAction:IScenarioAction{/// <summary> Registered as "quest.add_tag" — attaches a string tag to a quest for use in conditional logic. Tags are queried by the scenario DSL's "has_tag" condition to branch dialog or gate quest progression. Both "quest_id" and "tag" dialog variables are required — returns early with a warning if either is missing. </summary>
public string Name=>"quest.add_tag";/// <summary> Reads "quest_id" and "tag" from dialog variables and delegates to ctx.Quests.AddTag. Routes through QuestServiceImpl.TryGet — missing quests are silently skipped. Tags are persisted with save data and survive game restart. </summary>
public void Run(IActionContext ctx){var questId=ctx.GetParam("quest_id");var tag=ctx.GetParam("tag");if(string.IsNullOrEmpty(questId)||string.IsNullOrEmpty(tag)){ctx.Log("EVENT","QuestAddTagAction: missing 'quest_id' or 'tag' variable");return;}ctx.Quests.AddTag(questId,tag);ctx.Log("QUEST",$"QuestAddTagAction: {questId}.AddTag('{tag}')");}}}

===== FILE mods/quests_base/src/Actions/Atomic/QuestRemoveTagAction.cs =====
namespace Cthangover.Core.Actions.Atomic{/// <summary> Removes a string tag from a quest. Paired with QuestAddTagAction for reversible quest state. Used in branching scenarios where a tag marks a temporary condition that should be cleared when resolved. </summary>
public class QuestRemoveTagAction:IScenarioAction{/// <summary> Registered as "quest.remove_tag" — the inverse of QuestAddTagAction. Removes a tag from a quest to clear temporary conditions (e.g. removing a "witnessed_event" tag after the event has been resolved). Both "quest_id" and "tag" are required. </summary>
public string Name=>"quest.remove_tag";/// <summary> Reads "quest_id" and "tag" from dialog variables and delegates to ctx.Quests.RemoveTag. Removing a non-existent tag is a no-op at the collection level. Missing quests are silently skipped via TryGet. </summary>
public void Run(IActionContext ctx){var questId=ctx.GetParam("quest_id");var tag=ctx.GetParam("tag");if(string.IsNullOrEmpty(questId)||string.IsNullOrEmpty(tag)){ctx.Log("EVENT","QuestRemoveTagAction: missing 'quest_id' or 'tag' variable");return;}ctx.Quests.RemoveTag(questId,tag);ctx.Log("QUEST",$"QuestRemoveTagAction: {questId}.RemoveTag('{tag}')");}}}

===== FILE mods/quests_base/src/Actions/Atomic/QuestSendNotificationAction.cs =====
namespace Cthangover.Core.Actions.Atomic{/// <summary> Triggers a UI notification for a quest (e.g. "Quest Updated" popup). This is the visual feedback companion to quest state changes — the notification itself doesn't change quest state, it just informs the player that something happened. Call this after SetStatus/SetDataStatus to make the UI react. </summary>
public class QuestSendNotificationAction:IScenarioAction{/// <summary> Registered as "quest.send_notification" — triggers a UI popup for a quest (e.g. "Quest Updated" or "New Quest"). This is the visual companion to state changes — call after SetStatus or SetDataStatus so the player sees the update. The notification itself does not modify quest state. </summary>
public string Name=>"quest.send_notification";/// <summary> Reads the "quest_id" variable and delegates to ctx.Quests.SendNotification. The notification content is derived from the quest's current state (title, description, status) — ensure the quest state is updated before calling this. Missing quests are silently skipped via TryGet. </summary>
public void Run(IActionContext ctx){var questId=ctx.GetParam("quest_id");if(string.IsNullOrEmpty(questId)){ctx.Log("EVENT","QuestSendNotificationAction: missing 'quest_id' variable");return;}ctx.Quests.SendNotification(questId);ctx.Log("QUEST",$"QuestSendNotificationAction: sent notification for '{questId}'");}}}

===== FILE mods/quests_base/src/Actions/Atomic/QuestSetDataStatusAction.cs =====
namespace Cthangover.Core.Actions.Atomic{/// <summary> Sets the numeric progress level within a quest (quest.Data.Status). Unlike QuestSetStatusAction which changes the global quest state (Active/Completed), this handles incremental progress — e.g. "kill 3/5 enemies". The "level" param is parsed as int (no fallback) so scenario authors must ensure the value is always a valid integer. </summary>
public class QuestSetDataStatusAction:IScenarioAction{/// <summary> Registered as "quest.set_data_status" — sets the numeric progress level within a quest (quest.Data.Status). This is the incremental counter (e.g. "2/5 wolves killed"), separate from the global quest state managed by QuestSetStatusAction. The "level" dialog variable is parsed as int with no fallback — scenario authors must ensure it's always a valid integer. </summary>
public string Name=>"quest.set_data_status";/// <summary> Reads "quest_id" and "level" from dialog variables, parses level as int, and delegates to ctx.Quests.SetDataStatus. No bounds checking on the level value — the quest's data object is responsible for validating the range. Missing quests are silently skipped. Returns early with a warning if either variable is missing. </summary>
public void Run(IActionContext ctx){var questId=ctx.GetParam("quest_id");var levelRaw=ctx.GetParam("level");if(string.IsNullOrEmpty(questId)||string.IsNullOrEmpty(levelRaw)){ctx.Log("EVENT","QuestSetDataStatusAction: missing 'quest_id' or 'level' variable");return;}var level=int.Parse(levelRaw);ctx.Quests.SetDataStatus(questId,level);ctx.Log("QUEST",$"QuestSetDataStatusAction: {questId}.Data.Status = {level}");}}}

===== FILE mods/quests_base/src/Actions/Atomic/QuestSetStatusAction.cs =====
namespace Cthangover.Core.Actions.Atomic{/// <summary> Sets the global state of a quest (e.g. "Active", "Completed", "Failed"). The "status" param is parsed via Enums&lt;QuestStatus&gt;.Parse, which is case-insensitive — invalid status strings are caught by QuestServiceImpl's TryGet pattern and logged as errors rather than crashing the dialog. </summary>
public class QuestSetStatusAction:IScenarioAction{/// <summary> Registered as "quest.set_status" — sets the global lifecycle state of a quest (Active, Completed, Failed, etc.). The "status" dialog variable is parsed via Enums&lt;QuestStatus&gt;.Parse as a case-insensitive QuestStatus enum name (NotStarted / Progress / End). Invalid status strings are caught by QuestServiceImpl and logged as errors without crashing the dialog. </summary>
public string Name=>"quest.set_status";/// <summary> Reads "quest_id" and "status" from dialog variables and delegates to ctx.Quests.SetStatus. Missing quests are silently skipped. Invalid status values are logged and ignored. </summary>
public void Run(IActionContext ctx){var questId=ctx.GetParam("quest_id");var statusRaw=ctx.GetParam("status");if(string.IsNullOrEmpty(questId)||string.IsNullOrEmpty(statusRaw)){ctx.Log("EVENT","QuestSetStatusAction: missing 'quest_id' or 'status' variable");return;}ctx.Quests.SetStatus(questId,statusRaw);ctx.Log("QUEST",$"QuestSetStatusAction: {questId}.Status = {statusRaw}");}}}

===== FILE mods/quests_base/src/Actions/QuestServiceImpl.cs =====
using System;using Cthangover.Core.Events;using Cthangover.Core.Quests;using Cthangover.Core.Utils;namespace Cthangover.Core.Actions{/// <summary> Quest service implementation with internal TryGet for safe fallback. Public methods (SetStatus, SetDataStatus, AddTag, etc.) use TryGet which catches KeyNotFoundException from QuestFactory — this prevents scenario scripts from crashing when referencing non-existent quests. The separation of SetStatus (global quest state like Active/Completed) from SetDataStatus (numeric progress level) matches QuestBase's dual-state design. Exists() returns false on any exception, so missing or broken quest data is treated as "doesn't exist" rather than throwing. </summary>
internal class QuestServiceImpl:IQuestService{/// <summary> Direct lookup from QuestFactory by ID. Throws KeyNotFoundException if the quest doesn't exist — use Exists() or TryGet() when the quest may legitimately be absent from the registry. </summary>
public IQuest Get(string id)=>QuestFactory.Instance.Get(id);/// <summary> Safe existence check. Wraps QuestFactory.Get in try/catch and returns false on any exception — corrupted save data or broken quest definitions are treated as "doesn't exist" rather than propagating errors to the dialog engine. </summary>
public bool Exists(string id){try{return QuestFactory.Instance.Get(id)!=null;}catch{return false;}}/// <summary> Internal safe-get pattern that catches KeyNotFoundException from QuestFactory. Returns null for missing quests so callers can use the null-conditional operator (?.) rather than try/catch blocks. All mutating methods (SetStatus, AddTag, etc.) route through TryGet to prevent scenario scripts from crashing on broken quest references. </summary>
public QuestBase TryGet(string id){QuestBase result=null;try{result=QuestFactory.Instance.Get(id);}catch(Exception ex){GameLogger.Log("QUEST",$"try get quest '{id}' exception - {ex.Message}");}return result;}/// <summary> Sets the global quest status (Active, Completed, Failed, etc.) by parsing the status string via Enums&lt;QuestStatus&gt;.Parse. Invalid status strings are caught and logged as errors without throwing — the dialog continues with the quest unchanged. Routes through TryGet so missing quests are silently skipped. Publishes a <see cref="QuestStatusChangedEvent"/> only when the status actually changed; setting the same status is a no-op that publishes nothing. </summary>
public void SetStatus(string id,string status){var quest=TryGet(id);if(quest==null)return;try{var newStatus=Enums<QuestStatus>.Parse(status);if(quest.Status==newStatus)return;var oldStatus=quest.Status.ToString();quest.Status=newStatus;ModEventBus.Publish(new QuestStatusChangedEvent{QuestId=id,OldStatus=oldStatus,NewStatus=status,});}catch(Exception ex){GameLogger.Log("QUEST",$"status '{status}' invalid - {ex.Message}",LogLevel.Error);}}/// <summary> Updates the numeric progress level within the quest's data object (quest.Data.Status). Separate from SetStatus which controls the global lifecycle state. Routes through TryGet with null-conditional access — if the quest doesn't exist, the call is silently skipped. No bounds checking: the caller is responsible for meaningful level values. The <see cref="QuestDataStatusChangedEvent"/> is published by <see cref="QuestData.SetStatus"/> itself. </summary>
public void SetDataStatus(string id,int level){TryGet(id)?.Data.SetStatus(level);}/// <summary> Attaches a string tag to the quest's tag collection via QuestBase.AddTag. Tags are persisted with save data and queried by scenario conditions (has_tag). Adding a duplicate tag is handled by the underlying collection. Routes through TryGet for safe fallback on missing quests. The <see cref="QuestTagChangedEvent"/> is published by <see cref="QuestBase.AddTag"/> itself (change-only). </summary>
public void AddTag(string id,string tag){TryGet(id)?.AddTag(tag);}/// <summary> Removes a tag from the quest. Used for clearing temporary conditions in branching scenarios. Removing a non-existent tag is a collection-level no-op. Routes through TryGet. The <see cref="QuestTagChangedEvent"/> is published by <see cref="QuestBase.RemoveTag"/> itself (change-only). </summary>
public void RemoveTag(string id,string tag){TryGet(id)?.RemoveTag(tag);}/// <summary> Dispatches a UI notification for the quest (e.g. "Quest Updated" popup) via QuestBase.SendNotification. Does not modify quest state — call SetStatus/SetDataStatus before this to ensure the notification reflects the new state. Routes through TryGet. </summary>
public void SendNotification(string id){TryGet(id)?.SendNotification();}}}

===== FILE mods/quests_base/src/Quests/IQuestExtensions.cs =====
using Cthangover.Core.UI.Messages;using Cthangover.Core.Quests;using Godot;using Cthangover.Mods.Interface.Messages;namespace Cthangover.Core.Quests{/// <summary> Extension methods layered on top of <see cref="IQuest"/> so that dialogue scripts and event handlers can trigger common side-effects (such as notification banner display) without forcing every quest implementation to carry UI dependencies. </summary>
public static class IQuestExtensions{/// <summary> Posts a localised "New Journal Entry" notification to the message queue via <see cref="MessagesHelper"/>. The notification text prepends the translation of the <c>"ui/journal/new"</c> key to the translated quest <see cref="IQuest.Name"/>, producing a journal‑style announcement that the in‑game HUD renders as a floating banner. </summary>
public static void SendNotification(this IQuest quest){MessagesHelper.AddMessage(TranslationServer.Translate("ui/journal/new")+" - "+TranslationServer.Translate(quest.Name));}}}

===== FILE mods/quests_base/src/Quests/QuestBase.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Events;namespace Cthangover.Core.Quests{/// <summary> Primary concrete implementation of <see cref="IQuest"/> that serves as both the runtime quest instance (created by <see cref="QuestFactory"/> from <see cref="QuestDefinition"/> data) and the serialization unit written into save files. Because it is marked <c>[Serializable]</c>, Godot's JSON serialization can round-trip the entire quest graph — including the mutable <see cref="Data"/> payload — when the player saves or loads a game. The <see cref="StatusToDescription"/> dictionary maps raw <see cref="QuestStatus"/> integer values to localised description strings that the journal UI renders for each progression stage. </summary>
[Serializable]public class QuestBase:IQuest{/// <summary> Unique string identifier matching the quest's definition key in the mod data. Used by <see cref="QuestFactory.Get"/> for lookup and preserved in save files so that loaded state can be reattached to the correct definition. </summary>
public string ID{get;set;}/// <summary> Localised display name shown in the quest journal and notification banners. This value originates from the mod definition file and is translated before display. </summary>
public string Name{get;set;}/// <summary> Current progression state of the quest. Defaults to <see cref="QuestStatus.NotStarted"/>. This property is updated at runtime by quest‑advancement logic and restored from save data by <see cref="QuestFactory.SetAll"/>. </summary>
public QuestStatus Status{get;set;}=QuestStatus.NotStarted;/// <summary> Mutable runtime payload holding the quest's numeric status and the collection of string tags accumulated during gameplay. The factory overwrites this object when restoring a save file via <see cref="QuestFactory.SetAll"/>, so any modifications to tags or status through <see cref="AddTag"/>, <see cref="RemoveTag"/>, or <see cref="QuestData.SetStatus"/> persist until the next load. </summary>
public QuestData Data{get;set;}=new QuestData();/// <summary> Lookup table that maps integer <see cref="QuestStatus"/> values to human‑readable description strings. The journal window uses this to select which narrative text to display underneath the quest title as the player advances through <c>NotStarted</c>, <c>Progress</c>, and <c>End</c> states. </summary>
public IDictionary<int,string>StatusToDescription{get;set;}=new Dictionary<int,string>();/// <summary> Returns <c>true</c> when <paramref name="tag"/> is present in the underlying <see cref="Data"/>.<see cref="QuestData.Tags"/> set. Tags act as boolean flags for sub‑objective completion; dialogue conditions and scene‑transition gates call this to decide whether a particular branch should be available. </summary>
public bool ContainsTag(string tag){return Data.Tags.Contains(tag);}/// <summary> Syntactic negation helper that returns <c>true</c> when <paramref name="tag"/> is absent from the quest's tag set. Exists so that scenario scripts can express "tag not yet earned" conditions as a direct method call without writing a negation operator in YAML condition strings. </summary>
public bool NotContainsTag(string tag){return!ContainsTag(tag);}/// <summary> Inserts <paramref name="tag"/> into the mutable <see cref="Data"/>.<see cref="QuestData.Tags"/> set, marking a sub‑objective or milestone as completed. Typically called from scenario action nodes or dialogue event handlers when the player reaches a logical checkpoint. Publishes a <see cref="QuestTagChangedEvent"/> only when the tag was actually added (duplicates are a no-op and publish nothing), so condition caches invalidate exactly on real change. </summary>
public void AddTag(string tag){if(Data.Tags.Add(tag))ModEventBus.Publish(new QuestTagChangedEvent{QuestId=ID,Tag=tag,Added=true});}/// <summary> Removes <paramref name="tag"/> from the mutable <see cref="Data"/>.<see cref="QuestData.Tags"/> set, effectively revoking a previously earned milestone. Useful for branching narratives where the player can lose access to a completed sub‑objective through a different choice path. Publishes a <see cref="QuestTagChangedEvent"/> only when the tag was actually present and removed. </summary>
public void RemoveTag(string tag){if(Data.Tags.Remove(tag))ModEventBus.Publish(new QuestTagChangedEvent{QuestId=ID,Tag=tag,Added=false});}}}

===== FILE mods/quests_base/src/Quests/QuestData.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Events;namespace Cthangover.Core.Quests{/// <summary> Serialisable payload that holds the mutable, per‑save‑file runtime state of a single quest. Separating this from the immutable <see cref="QuestDefinition"/> allows the save system to persist only the dirty data (current status and earned tags) without duplicating the static definition fields that are reloaded from mod JSON every session. An instance of this class lives inside <see cref="QuestBase.Data"/> and is replaced wholesale by <see cref="QuestFactory.SetAll"/> when the player loads a game. </summary>
[Serializable]public class QuestData{/// <summary> Numeric representation of the current quest stage, castable to <see cref="QuestStatus"/>. The journal UI maps this to a description string via <see cref="QuestBase.StatusToDescription"/>. </summary>
public int Status{get;set;}/// <summary> Set of string tags earned during this playthrough. Each tag represents a branching milestone or sub‑objective; scenario scripts query membership via <see cref="IQuest.ContainsTag"/> to gate dialogue options and scene transitions. </summary>
public HashSet<string>Tags{get;set;}=new();/// <summary> Sets the numeric status to <paramref name="value"/>, advancing (or potentially resetting) the quest's progression stage. Called by scenario action nodes that handle quest advancement commands. Publishes a <see cref="QuestDataStatusChangedEvent"/> only when the value actually changed, so cached condition results invalidate on real change rather than on every redundant assignment. </summary>
public void SetStatus(int value){if(Status==value)return;ModEventBus.Publish(new QuestDataStatusChangedEvent{Old=Status,New=value});Status=value;}}}

===== FILE mods/quests_base/src/Quests/QuestDefinition.cs =====
using System.Collections.Generic;using Cthangover.Core.Factories;namespace Cthangover.Core.Quests{/// <summary> Immutable data template loaded from a mod JSON file whose envelope declares the <c>quest</c> kind. Each definition carries the quest's identity, display name, and a mapping from <see cref="QuestStatus"/> integer values to localised description strings. <see cref="QuestFactory"/> reads these definitions once on initialisation and uses them to seed <see cref="QuestBase"/> runtime instances. The definition itself never changes during gameplay; all mutable progression lives in <see cref="QuestData"/>. Implements <see cref="IIdentifiable"/> so the factory can store quest definitions in a keyed collection. </summary>
[EntityKind("quest")]public class QuestDefinition:IIdentifiable{/// <summary> Unique string key used both as the JSON filename (minus extension) in the mod archive and as the lookup key inside <see cref="QuestFactory"/>'s internal dictionary. </summary>
public string ID{get;set;}/// <summary> Display name of the quest as written in the mod definition; passed through <c>TranslationServer.Translate</c> before being shown in the journal or notification banners. </summary>
public string Name{get;set;}/// <summary> Dictionary mapping integer status codes to localised description strings. During initialisation <see cref="QuestFactory"/> copies this dictionary into the corresponding <see cref="QuestBase.StatusToDescription"/> property of the runtime quest instance. </summary>
public Dictionary<int,string>StatusToDescription{get;set;}}}

===== FILE mods/quests_base/src/Quests/QuestFactory.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Bootstrap;using Cthangover.Core.Events;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;namespace Cthangover.Core.Quests{/// <summary> Thread‑safe singleton that manages the entire lifecycle of <see cref="QuestBase"/> instances: lazy initialisation from mod JSON definitions, on‑demand lookup by ID, and restoration of mutable state when the player loads a save file. The factory defers loading until the first access (via <see cref="Get"/> or <see cref="GetAll"/>) and guards against re‑initialisation through the <c>isLoaded</c> flag. Because quest definitions come from <see cref="QuestDefinition"/> objects collected by <see cref="ModManager.GetResolver{EntityResolver}()"/> through the DTO's <c>[EntityKind("quest")]</c> attribute, the factory remains decoupled from any directory layout — new mods register quests simply by shipping a JSON file whose envelope declares the quest kind. <para/> The single instance is process-wide and deliberately not owned by <see cref="AppServices"/>: quest state is identity-sensitive across the whole process (save/restore, scenario conditions), and a swappable owner would break parallel test runs. The new-game reset is wired through the AppServices resettable adapter registered by QuestsModInitializer (see <see cref="ResetAll"/>). </summary>
public class QuestFactory{/// <summary> Owns the process-wide instance; laziness defers construction to the first access, avoiding order-dependent initialisation races with other subsystems. </summary>
private static Lazy<QuestFactory>instance=new(()=>new QuestFactory());/// <summary> Process-wide singleton. Deliberately NOT owned by AppServices: quest state is identity-sensitive across the whole process (save/restore, scenario conditions), and a swappable owner breaks that contract in parallel test runs. Reset goes through the AppServices resettable adapter, construction timing stays lazy. </summary>
public static QuestFactory Instance=>instance.Value;private readonly IDictionary<string,QuestBase>dataByID=new Dictionary<string,QuestBase>();private bool isLoaded;private QuestFactory(){}/// <summary> Seeds the internal <c>dataByID</c> dictionary from mod JSON if it has not been seeded yet. When <see cref="ModManager"/> is still mid‑initialisation, the call is silently deferred (logged at debug level) so that early code paths don't crash; the next access will retry. Each <see cref="QuestDefinition"/> is converted into a fresh <see cref="QuestBase"/> with default status and an empty tag set. </summary>
private void EnsureLoaded(){if(isLoaded)return;if(!ModManager.Instance.IsInitialized){GameLogger.Log("QUEST","mods not initialized, quest loading deferred");return;}isLoaded=true;try{var definitions=Entities.Collect<QuestDefinition>();if(definitions!=null){foreach(var(id,def)in definitions){var quest=new QuestBase{ID=def.ID,Name=def.Name,StatusToDescription=def.StatusToDescription??new Dictionary<int,string>(),};dataByID.Add(quest.ID,quest);}}}catch(Exception ex){GameLogger.Log("QUEST","failed to load quests from mods: "+ex.Message,LogLevel.Error);}GameLogger.Log("QUEST","loaded '"+dataByID.Count+"' quests");}/// <summary> Looks up a quest by its unique string identifier and returns the shared <see cref="QuestBase"/> runtime instance. Throws <c>KeyNotFoundException</c> when no definition for <paramref name="id"/> was loaded from mod data, which typically indicates a typo in a scenario script's quest reference. </summary>
public QuestBase Get(string id){EnsureLoaded();if(dataByID.TryGetValue(id,out var quest))return quest;GameLogger.Log("QUEST","quest '"+id+"' not registered",LogLevel.Error);throw new KeyNotFoundException(id);}/// <summary> Returns a snapshot list of every <see cref="QuestBase"/> instance known to the factory. Used primarily by the save system to serialise the full quest state for the save file, and by the journal UI to populate the quest list window. </summary>
public List<QuestBase>GetAll(){EnsureLoaded();return dataByID.Values.ToList();}/// <summary> Restores mutable quest state from a deserialised save file. Iterates over every quest in the provided list, looks up the corresponding entry by ID, and overwrites its <see cref="QuestBase.Data"/> and <see cref="QuestBase.Status"/> properties. Entries whose ID is not registered (a removed mod or a quest renamed between sessions) are skipped with a warning so that an ageing save cannot block loading. Reads from empty or <c>null</c> lists are silently ignored so that save files created before the quest system existed (or with no quest progress) do not break the pipeline. Publishes a single <see cref="QuestStateRestoredEvent"/> marker after the restore loop (not per-quest) so condition caches invalidate once without a per-quest event storm. </summary>
public void SetAll(List<QuestBase>quests){EnsureLoaded();if(Lists.IsEmpty(quests))return;foreach(var questFromSave in quests){if(!dataByID.TryGetValue(questFromSave.ID,out var quest)){GameLogger.Log("QUEST","quest '"+questFromSave.ID+"' from save is not registered, skipped",LogLevel.Warning);continue;}quest.Data=questFromSave.Data;quest.Status=questFromSave.Status;}ModEventBus.Publish(new QuestStateRestoredEvent());}/// <summary> Returns every quest to the state it has right after mod loading: <see cref="QuestStatus.NotStarted"/>, numeric stage zero, and an empty tag set. Called by <c>GameData.StartNewGame</c> because the factory is a process‑wide singleton that survives returning to the main menu — without this reset, tags and statuses earned in the previous playthrough would leak into a new game and satisfy (or block) prologue conditions that test for absent tags. Publishes <see cref="QuestStateRestoredEvent"/> so cached scenario conditions invalidate once. </summary>
public void ResetAll(){EnsureLoaded();foreach(var quest in dataByID.Values){quest.Status=QuestStatus.NotStarted;quest.Data=new QuestData();}ModEventBus.Publish(new QuestStateRestoredEvent());}}}

===== FILE mods/quests_base/src/Quests/QuestTagLocale.cs =====
using Cthangover.Core.Localization;namespace Cthangover.Core.Quests{/// <summary> Maps quest tags to locale keys and resolves their display text. A bare tag id is only unique inside its quest — the same word may mean different milestones in different quests — so the key is composed from the quest's locale prefix (<see cref="IQuest.Name"/>) and the tag: <c>{quest.Name}/tag/{tag}</c>. For <c>MeetingMurakamiQuest</c> (<c>Name = quest/meeting/murakami</c>) the tag <c>go_home</c> maps to <c>quest/meeting/murakami/tag/go_home</c>, sharing the namespace of the quest's own name and stage descriptions. The journal renders every earned tag as a stamp through <see cref="Translate(IQuest, string)"/>; when no translation exists the raw tag id is shown, which is the behaviour tags had before locale support was added. </summary>
public static class QuestTagLocale{/// <summary> Builds the composite locale key for <paramref name="tag"/> inside <paramref name="quest"/>: <c>{quest.Name}/tag/{tag}</c>. Returns <c>null</c> when the quest is missing, has no locale prefix, or the tag is empty, so callers can distinguish "no key to look up" from "key not translated" and fall back to the raw tag. </summary>
public static string Key(IQuest quest,string tag){if(quest==null||string.IsNullOrEmpty(quest.Name)||string.IsNullOrEmpty(tag))return null;return quest.Name+"/tag/"+tag;}/// <summary> Returns the display text of <paramref name="tag"/> in the player's current language, falling back to the raw tag id when the composite key is absent from all loaded mods' properties files. </summary>
public static string Translate(IQuest quest,string tag){return Translate(quest,tag,GodotTranslationProvider.Instance);}/// <summary> Provider-injected variant of <see cref="Translate(IQuest, string)"/> for tests and for callers that already hold an <see cref="ILocalizationProvider"/>. A provider that returns <c>null</c> for the composite key (the "untranslated" signal of <see cref="ILocalizationProvider"/>) also yields the raw tag. </summary>
public static string Translate(IQuest quest,string tag,ILocalizationProvider provider){var key=Key(quest,tag);if(key==null||provider==null)return tag;return provider.Get(key)??tag;}}}

===== FILE mods/quests_base/src/QuestsModInitializer.cs =====
using Cthangover.Core.Actions;using Cthangover.Core.Bootstrap;using Cthangover.Core.Mods;using Cthangover.Core.Quests;using Cthangover.Core.Scenes.Parsing;using Cthangover.Core.Scenes.Parsing.Impls;using Cthangover.Core.Utils;namespace Cthangover.Mods.Quests{/// <summary> Engine-mod initializer for the quests subsystem. Publishes the services living in this assembly to the kernel contracts as soon as the assembly is registered, so core-side consumers — dialog action contexts (<c>ctx.Quests</c>) and scenario condition evaluation — work through the core registry without referencing a runtime-compiled mod assembly. <para/> Three publications happen here: <list type="bullet"> <item><see cref="QuestServiceRegistry.Provider"/> — the <see cref="IQuestService"/> implementation used by <c>ScenarioActionContext</c> and <c>GDQuestService</c>;</item> <item><see cref="LeafParserRegistry.RegisterFirst"/> — the quest leaf parser for scene conditions (<c>QuestId.method(...)</c> / <c>QuestId.Property op n</c>); the parser moved out of the core registry so that the parser set is fully extensible;</item> <item>the new-game resettable (id <c>quests</c>) so <c>GameData.StartNewGame</c> clears quest state through the common AppServices loop (the core used to register this lambda itself).</item> </list> Registration happens in <see cref="OnModLoaded"/> (the earliest and most frequent initializer callback) rather than on resources-ready, so any mod or scenario evaluated later already resolves quests. Registration is guarded and idempotent because <c>ModManager.Reload()</c> re-runs <see cref="OnModLoaded"/> for every mod — the guard prevents duplicate parsers and provider overwrites; the resettable registration is deduplicated by AppServices itself. </summary>
public class QuestsModInitializer:IModInitializer{private static bool registered;/// <summary>Publishes the service provider, leaf parser and reset owner; safe to call again after a mod reload.</summary>
public void OnModLoaded(string modId){if(registered)return;registered=true;QuestServiceRegistry.Provider=new QuestServiceImpl();LeafParserRegistry.RegisterFirst(new QuestParser());GameLogger.Log("MODS","QuestsModInitializer: quest service provider and QuestParser registered");}/// <summary>Registers the new-game quest reset owner with AppServices (deduplicated by id).</summary>
public void OnModResourcesReady(){AppServices.Current.RegisterResettable(new QuestsResettable());}}/// <summary> New-game state owner of the quests mod: returns every quest to its fresh state (<see cref="QuestFactory.ResetAll"/>). Registered from <see cref="QuestsModInitializer.OnModResourcesReady"/>; AppServices deduplicates by <see cref="Id"/>, so repeated registration is a no-op. </summary>
internal sealed class QuestsResettable:IResettable{/// <summary>Resettable key under which AppServices deduplicates registration.</summary>
public string Id=>"quests";/// <summary>Resets all quest statuses, stages and tags on "New Game".</summary>
public void ResetForNewGame()=>QuestFactory.Instance.ResetAll();}}

===== FILE mods/quests_base/src/QuestsSaveable.cs =====
using System.Collections.Generic;using Cthangover.Core.Mods;using Cthangover.Core.Settings;namespace Cthangover.Core.Quests{/// <summary> Persists quest state through the generic <see cref="IModSaveable"/> contract under the mod id <c>quests_base</c>. The core save used to carry a <c>"Quests"</c> blob inside the <c>__core__</c> slot (<c>RuntimeSerializer.BuildCoreSlot</c>), but quest state now belongs to the quests engine mod — so it saves through the mod slot like every other migrated subsystem. <para/> <see cref="OnLoad"/> feeds the deserialised list into <see cref="QuestFactory.SetAll"/>, which restores mutable state per quest ID, skips quests that are no longer registered and publishes the single <c>QuestStateRestoredEvent</c> invalidation marker — exactly the behaviour the core save path had. Backward compatibility with the old core blob is intentionally not provided (same decision as the recruiting mod): old saves start with fresh quest state. </summary>
public class QuestsSaveable:IModSaveable{private const string BlobKey="Quests";/// <summary>Writes the full quest state list into the mod slot.</summary>
public void OnSave(ModSlot slot){slot.Data[BlobKey]=new DataBlob(1,QuestFactory.Instance.GetAll());}/// <summary>Restores quest state from the mod slot; an absent blob keeps fresh quest state.</summary>
public bool OnLoad(ModSlot slot,out string errorMessage){errorMessage=null;if(slot?.Data!=null&&slot.Data.TryGetValue(BlobKey,out var blob))QuestFactory.Instance.SetAll(blob.ReadJson<List<QuestBase>>());return true;}}}

===== FILE mods/quests_base/src/Scenes/Parsing/Impls/QuestParser.cs =====
using System;namespace Cthangover.Core.Scenes.Parsing.Impls{/// <summary> Parses quest expressions: <c>QuestId.method("arg")</c> and <c>QuestId.Property comparison</c>. Matches when the stream contains an identifier followed by a dot that is NOT <c>true.</c> or <c>false.</c> (those are quest IDs, not boolean literals followed by quest access — handled here because <see cref="BoolLiteralParser"/> already rejected them). </summary>
public class QuestParser:ILeafParser{/// <summary>True for identifiers followed by a dot (dot-forms that survived the boolean-literal check are quest references).</summary>
public bool CanParse(TokenPeeker peeker){if(peeker.AtEnd||peeker.Peek.Type!=ConditionParser.TokenType.Ident)return false;var next=peeker.PeekAhead(1);return next.Type==ConditionParser.TokenType.Dot;}/// <summary>Dispatches on the member after the dot: <c>hasTag</c>/<c>notHasTag</c>, <c>IsActive</c>, or <c>Status</c>/<c>DataStatus</c> comparisons; unknown members throw.</summary>
public ICondition Parse(TokenPeeker peeker,Func<ICondition>parseSubExpr){var questId=peeker.Advance().Value;peeker.Expect(ConditionParser.TokenType.Dot,"expected '.' after quest id");var methodToken=peeker.Expect(ConditionParser.TokenType.Ident,"expected method or property name after '.'");var method=methodToken.Value;if(method=="hasTag"||method=="notHasTag"){peeker.Expect(ConditionParser.TokenType.LParen,"expected '('");var tag=peeker.Expect(ConditionParser.TokenType.String,"expected string argument");peeker.Expect(ConditionParser.TokenType.RParen,"expected ')'");return new QuestMethodCondition(questId,method,tag.Value);}if(method=="IsActive"){return new QuestBoolPropCondition(questId,method);}if(method=="Status"||method=="DataStatus"){var op=ParseCompOp(peeker);var num=peeker.Expect(ConditionParser.TokenType.Number,"expected number after comparison");return new QuestPropCondition(questId,method,op,int.Parse(num.Value));}throw new FormatException($"Condition parse error: unknown quest property/method '{method}'");}private static string ParseCompOp(TokenPeeker peeker){if(peeker.Check(ConditionParser.TokenType.Eq)){peeker.Advance();return"==";}if(peeker.Check(ConditionParser.TokenType.Neq)){peeker.Advance();return"!=";}if(peeker.Check(ConditionParser.TokenType.Ge)){peeker.Advance();return">=";}if(peeker.Check(ConditionParser.TokenType.Le)){peeker.Advance();return"<=";}if(peeker.Check(ConditionParser.TokenType.Gt)){peeker.Advance();return">";}if(peeker.Check(ConditionParser.TokenType.Lt)){peeker.Advance();return"<";}throw new FormatException($"Condition parse error: expected comparison operator, got '{peeker.Peek.Value}'");}}}

===== FILE mods/quests_base/src/Scenes/QuestBoolPropCondition.cs =====
using System.Collections.Generic;using Cthangover.Core.Quests;using Cthangover.Core.Utils;namespace Cthangover.Core.Scenes{/// <summary> An AST leaf node that checks a boolean property on a quest instance. Currently the only supported property is <c>IsActive</c>, which returns <c>true</c> when the quest status equals <see cref="QuestStatus.Progress"/>. Fetches the quest via <see cref="EvaluationContext.QuestFactory"/> and returns <c>false</c> (with a log warning) when the quest ID is not found. </summary>
public class QuestBoolPropCondition:ConditionNode{/// <summary>The quest identifier to query.</summary>
public string QuestId{get;}/// <summary>The property name: currently only <c>"IsActive"</c>.</summary>
public string Property{get;}/// <summary>Creates a boolean quest-property condition node.</summary> <param name="questId">The quest identifier.</param> <param name="property">The property name (currently only <c>"IsActive"</c>).</param>
public QuestBoolPropCondition(string questId,string property){QuestId=questId;Property=property;}/// <summary>Resolves the quest and reads the boolean property; unknown quest IDs evaluate to <c>false</c>.</summary>
public override bool Evaluate(EvaluationContext ctx){try{var quest=ctx.QuestFactory.Get(QuestId);if(Property=="IsActive")return quest.Status==QuestStatus.Progress;return false;}catch(KeyNotFoundException){GameLogger.Log("COND",$"quest '{QuestId}' not found, condition → false",LogLevel.Error);return false;}}}}

===== FILE mods/quests_base/src/Scenes/QuestMethodCondition.cs =====
using System.Collections.Generic;using Cthangover.Core.Utils;namespace Cthangover.Core.Scenes{/// <summary> An AST leaf node invoking a boolean method on a quest instance: either <c>hasTag</c> or <c>notHasTag</c>. Fetches the quest via <see cref="EvaluationContext.QuestFactory"/> at evaluation time and calls the corresponding method with the given tag string. Returns <c>false</c> (with a log warning) when the quest ID is not found in the factory. </summary>
public class QuestMethodCondition:ConditionNode{/// <summary>The quest identifier to query.</summary>
public string QuestId{get;}/// <summary>The method name: <c>"hasTag"</c> or <c>"notHasTag"</c>.</summary>
public string Method{get;}/// <summary>The tag string passed as the method argument.</summary>
public string Tag{get;}/// <summary> Creates a quest method invocation condition node. </summary> <param name="questId">The quest identifier.</param> <param name="method">The method name (<c>"hasTag"</c> or <c>"notHasTag"</c>).</param> <param name="tag">The tag argument for the method call.</param>
public QuestMethodCondition(string questId,string method,string tag){QuestId=questId;Method=method;Tag=tag;}/// <summary>Resolves the quest and invokes the tag check; unknown quest IDs evaluate to <c>false</c>.</summary>
public override bool Evaluate(EvaluationContext ctx){try{var quest=ctx.QuestFactory.Get(QuestId);return Method=="hasTag"?quest.ContainsTag(Tag):!quest.ContainsTag(Tag);}catch(KeyNotFoundException){GameLogger.Log("COND",$"quest '{QuestId}' not found, condition → false",LogLevel.Error);return false;}}}}

===== FILE mods/quests_base/src/Scenes/QuestPropCondition.cs =====
using System.Collections.Generic;using Cthangover.Core.Quests;using Cthangover.Core.Utils;namespace Cthangover.Core.Scenes{/// <summary> An AST leaf node comparing a quest's integer property (<c>Status</c> or <c>DataStatus</c>) against a numeric value. Fetches the quest via <see cref="EvaluationContext.QuestFactory"/> at evaluation time, reads the corresponding property, and compares using <see cref="ConditionCompare.Compare"/>. Returns <c>false</c> (with a log warning) when the quest ID is not found. </summary>
public class QuestPropCondition:ConditionNode{/// <summary>The quest identifier to query.</summary>
public string QuestId{get;}/// <summary>The property name: <c>"Status"</c> or <c>"DataStatus"</c>.</summary>
public string Property{get;}/// <summary>The comparison operator (<c>==</c>, <c>!=</c>, <c>&gt;=</c>, etc.).</summary>
public string Op{get;}/// <summary>The integer value to compare the quest property against.</summary>
public int Value{get;}/// <summary> Creates a quest property comparison condition node. </summary> <param name="questId">The quest identifier.</param> <param name="property">The property name (<c>"Status"</c> or <c>"DataStatus"</c>).</param> <param name="op">The comparison operator.</param> <param name="value">The integer value to compare against.</param>
public QuestPropCondition(string questId,string property,string op,int value){QuestId=questId;Property=property;Op=op;Value=value;}/// <summary>Resolves the quest and compares the numeric property; unknown quest IDs evaluate to <c>false</c>.</summary>
public override bool Evaluate(EvaluationContext ctx){try{var quest=ctx.QuestFactory.Get(QuestId);var actual=Property=="Status"?(int)quest.Status:((QuestBase)quest).Data.Status;return ConditionCompare.Compare(Op,actual,Value);}catch(KeyNotFoundException){GameLogger.Log("COND",$"quest '{QuestId}' not found, condition → false",LogLevel.Error);return false;}}}}

===== FILE mods/quests_base/src/SubsystemRegistration.cs =====
namespace Cthangover.Mods.Quests{/// <summary> Migration marker scanned by the integration coverage test (<c>SubsystemsBridge</c>): declares the quests subsystem as migrated out of the core assembly into this engine mod. The subsystem id must match the entry in <c>SubsystemsCoverageTestSetup.DeclaredTests</c> and <c>tests/tests.manifest.json</c>. </summary>
public static class SubsystemRegistration{/// <summary>Subsystem id reported to the coverage test.</summary>
public static string SubsystemId=>"quests";}}

===== FILE mods/quests_base/src/UI/Quests/QuestJournalBehaviour.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Text;using Cthangover.Core.Quests;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.Core.UI.Quests{/// <summary> Quest journal widget styled as an open folio: a two-page spread on a dark backdrop, with filter tabs tucked into the top of the left page. The left page is a table of contents listing quests filtered by active (<see cref="QuestStatus.Progress"/>) or completed (<see cref="QuestStatus.End"/>) state; <see cref="QuestStatus.NotStarted"/> quests are never shown. The right page renders the selected quest as a journal entry: entries with index less than the current <see cref="QuestData.Status"/> are struck through, the entry at the current status is highlighted, and entries beyond it are hidden. For completed quests every entry is struck through. Earned <see cref="QuestData.Tags"/> are displayed as gold tags under the entry. All decoration (surface gradients, inner borders, spine shading, tags) is generated in code because mod scenes ignore theme overrides and the project ships no font or texture assets. The palette is the shared graphite/gold one (<see cref="UiTokens"/>) so the journal does not stand out from the inventory and character panels. </summary>
public partial class QuestJournalBehaviour:Widget{private enum FilterType{Active,Completed}private static readonly Color InkColor=UiTokens.Ink;private static readonly Color InkBrightColor=UiTokens.InkBright;private static readonly Color InkDimColor=UiTokens.Muted;private static readonly Color InkFaintColor=UiTokens.Faint;private static readonly Color PageTopColor=UiTokens.Surface1;private static readonly Color PageColor=UiTokens.Surface0;private static readonly Color PageLineColor=UiTokens.LineSoft;private static readonly Color RuleColor=UiTokens.LineFaint;private static readonly Color CoverLineColor=UiTokens.Line;private static readonly Color GoldColor=UiTokens.Accent;private static readonly Color GoldGlowColor=UiTokens.AccentGlow;private static readonly Color GoldTraceColor=UiTokens.AccentFaint;private static readonly Color SealColor=UiTokens.AccentLine;private static readonly Color SealTextColor=UiTokens.AccentBright;private static readonly Color SealFillColor=UiTokens.AccentSoft;private const int FilterTabsHeight=34;private const int FilterTabsGap=14;private FilterType _currentFilter=FilterType.Active;private string _selectedQuestId;private VBoxContainer _questListContainer;private RichTextLabel _entryText;private Label _questNameLabel;private Label _stageLabel;private Label _emptyLabel;private MarginContainer _sealsBlock;private HFlowContainer _sealsContainer;private Button _activeFilterBtn;private Button _completedFilterBtn;// Scene nodes (quest_journal.tscn): the folio layout lives in the scene, the behaviour resolves it and applies gradient textures, styles and the dynamic quest data.
private Label _title;private Button _closeButton;private Label _tocHeader;private Label _entryHeader;private Label _sealsHeader;private TextureRect _leftPageGradient;private TextureRect _spineShade;private TextureRect _rightPageGradient;private Panel _diamond;private TextureRect _dividerLineL;private TextureRect _dividerLineR;protected override void OnceConstruct(){MouseFilter=MouseFilterEnum.Stop;_title=GetNode<Label>("Title");_closeButton=GetNode<Button>("CloseButton");_activeFilterBtn=GetNode<Button>("BookArea/Book/LeftPage/Content/Column/FilterTabs/ActiveFilterBtn");_completedFilterBtn=GetNode<Button>("BookArea/Book/LeftPage/Content/Column/FilterTabs/CompletedFilterBtn");_tocHeader=GetNode<Label>("BookArea/Book/LeftPage/Content/Column/TocHeader");_emptyLabel=GetNode<Label>("BookArea/Book/LeftPage/Content/Column/EmptyLabel");_questListContainer=GetNode<VBoxContainer>("BookArea/Book/LeftPage/Content/Column/Scroll/QuestListContainer");_entryHeader=GetNode<Label>("BookArea/Book/RightPage/Content/Scroll/Column/EntryHeader");_questNameLabel=GetNode<Label>("BookArea/Book/RightPage/Content/Scroll/Column/NameMargin/QuestNameLabel");_stageLabel=GetNode<Label>("BookArea/Book/RightPage/Content/Scroll/Column/StageLabel");_entryText=GetNode<RichTextLabel>("BookArea/Book/RightPage/Content/Scroll/Column/EntryText");_sealsBlock=GetNode<MarginContainer>("BookArea/Book/RightPage/Content/Scroll/Column/SealsBlock");_sealsContainer=GetNode<HFlowContainer>("BookArea/Book/RightPage/Content/Scroll/Column/SealsBlock/Column/SealsContainer");_sealsHeader=GetNode<Label>("BookArea/Book/RightPage/Content/Scroll/Column/SealsBlock/Column/SealsHeader");_leftPageGradient=GetNode<TextureRect>("BookArea/Book/LeftPage/PageGradient");_spineShade=GetNode<TextureRect>("BookArea/Book/Spine/SpineShade");_rightPageGradient=GetNode<TextureRect>("BookArea/Book/RightPage/PageGradient");_diamond=GetNode<Panel>("BookArea/Book/RightPage/Content/Scroll/Column/Divider/Row/Diamond");_dividerLineL=GetNode<TextureRect>("BookArea/Book/RightPage/Content/Scroll/Column/Divider/Row/LineL");_dividerLineR=GetNode<TextureRect>("BookArea/Book/RightPage/Content/Scroll/Column/Divider/Row/LineR");ApplyTheme();ApplyCaptions();_activeFilterBtn.Pressed+=()=>ApplyFilter(FilterType.Active);_completedFilterBtn.Pressed+=()=>ApplyFilter(FilterType.Completed);_closeButton.Pressed+=()=>Hide();}/// <summary>Gradient textures, page styles and decorations for scene nodes (texture assets ship no assets, so gradients are generated here).</summary>
private void ApplyTheme(){UiKit.ApplyPanelBackdrop(GetNode<ColorRect>("Bg"));GetNode<Panel>("BookArea/Book/LeftPage").AddThemeStyleboxOverride("panel",MakePageStyle(true));GetNode<Panel>("BookArea/Book/RightPage").AddThemeStyleboxOverride("panel",MakePageStyle(false));GetNode<Panel>("BookArea/Book/Spine").AddThemeStyleboxOverride("panel",MakeSpineStyle());GetNode<Panel>("BookArea/Book/LeftPage/InnerBorder").AddThemeStyleboxOverride("panel",MakeInnerBorderStyle());GetNode<Panel>("BookArea/Book/RightPage/InnerBorder").AddThemeStyleboxOverride("panel",MakeInnerBorderStyle());_leftPageGradient.Texture=MakePageGradientTexture();_rightPageGradient.Texture=MakePageGradientTexture();_spineShade.Texture=MakeSpineShadeTexture();_title.AddThemeFontSizeOverride("font_size",UiTokens.FontTitle);_title.AddThemeColorOverride("font_color",UiTokens.InkBright);_tocHeader.Text=TranslateUpper("ui/journal/toc");_entryHeader.Text=TranslateUpper("ui/journal/entry_header");_sealsHeader.Text=TranslateUpper("ui/journal/seals");foreach(var header in new[]{_tocHeader,_entryHeader,_sealsHeader}){header.AddThemeFontSizeOverride("font_size",14);header.AddThemeColorOverride("font_color",UiTokens.AccentDim);}_questNameLabel.AddThemeColorOverride("font_color",InkBrightColor);_entryText.AddThemeFontSizeOverride("normal_font_size",16);_entryText.AddThemeConstantOverride("line_separation",7);_entryText.AddThemeColorOverride("default_color",InkColor);_emptyLabel.AddThemeColorOverride("font_color",InkFaintColor);StyleFilterTab(_activeFilterBtn,_currentFilter==FilterType.Active);StyleFilterTab(_completedFilterBtn,_currentFilter==FilterType.Completed);_diamond.AddThemeStyleboxOverride("panel",MakeSolidStyle(GoldColor));_dividerLineL.Texture=MakeFadingLineTexture(false);_dividerLineR.Texture=MakeFadingLineTexture(true);CthangoverTheme.ApplyIcon(_closeButton);}/// <summary>Localized captions for all static labels of the scene.</summary>
private void ApplyCaptions(){_title.Text=TranslateUpper("ui/journal/title");_closeButton.TooltipText=TranslationServer.Translate("ui/journal/close");_activeFilterBtn.Text=TranslationServer.Translate("ui/journal/active");_completedFilterBtn.Text=TranslationServer.Translate("ui/journal/completed");_emptyLabel.Text=TranslationServer.Translate("ui/journal/empty");}/// <summary>Translation-safe upper-casing: a missing key yields an empty string instead of dereferencing a null StringName.</summary>
private static string TranslateUpper(string key){var translated=TranslationServer.Translate(key)?.ToString();return string.IsNullOrEmpty(translated)?string.Empty:translated.ToUpperInvariant();}private static StyleBoxFlat MakeSolidStyle(Color color){var style=new StyleBoxFlat();style.BgColor=color;return style;}private static StyleBoxFlat MakeSpineStyle(){var style=new StyleBoxFlat();style.BgColor=PageColor;style.BorderColor=CoverLineColor;style.BorderWidthTop=1;style.BorderWidthBottom=1;return style;}private static StyleBoxFlat MakeInnerBorderStyle(){var style=new StyleBoxFlat();style.BgColor=UiTokens.Alpha(Colors.Black,0f);style.BorderColor=PageLineColor;style.BorderWidthLeft=1;style.BorderWidthRight=1;style.BorderWidthTop=1;style.BorderWidthBottom=1;style.CornerRadiusTopLeft=3;style.CornerRadiusTopRight=3;style.CornerRadiusBottomLeft=3;style.CornerRadiusBottomRight=3;return style;}private static GradientTexture2D MakePageGradientTexture(){var gradient=new Gradient();gradient.SetColor(0,PageTopColor);gradient.SetColor(1,PageColor);var texture=new GradientTexture2D();texture.Gradient=gradient;texture.Width=8;texture.Height=256;texture.FillFrom=new Vector2(0.5f,0f);texture.FillTo=new Vector2(0.5f,1f);return texture;}private static GradientTexture2D MakeSpineShadeTexture(){var gradient=new Gradient();gradient.SetColor(0,UiTokens.Alpha(Colors.Black,0f));gradient.SetOffset(0,0f);gradient.SetColor(1,UiTokens.Alpha(Colors.Black,0f));gradient.SetOffset(1,1f);gradient.AddPoint(0.5f,UiTokens.Alpha(Colors.Black,0.62f));var texture=new GradientTexture2D();texture.Gradient=gradient;texture.Width=128;texture.Height=4;texture.FillFrom=new Vector2(0f,0.5f);texture.FillTo=new Vector2(1f,0.5f);return texture;}private static GradientTexture2D MakeFadingLineTexture(bool fadeToRight){var gradient=new Gradient();gradient.SetColor(0,fadeToRight?RuleColor:UiTokens.Alpha(Colors.Black,0f));gradient.SetColor(1,fadeToRight?UiTokens.Alpha(Colors.Black,0f):RuleColor);var texture=new GradientTexture2D();texture.Gradient=gradient;texture.Width=256;texture.Height=1;texture.FillFrom=new Vector2(0f,0.5f);texture.FillTo=new Vector2(1f,0.5f);return texture;}protected override void ShowConstruct(){_selectedQuestId=null;ApplyFilter(_currentFilter);}protected override void HideDestruct(){ClearChildren(_questListContainer);ClearChildren(_sealsContainer);_selectedQuestId=null;}private static void StyleFilterTab(Button button,bool active){button.AddThemeFontSizeOverride("font_size",UiTokens.FontBody);button.AddThemeStyleboxOverride("normal",MakeFilterTabStyle(active,false));button.AddThemeStyleboxOverride("hover",MakeFilterTabStyle(active,true));button.AddThemeStyleboxOverride("pressed",MakeFilterTabStyle(active,true));button.AddThemeStyleboxOverride("focus",new StyleBoxEmpty());button.AddThemeColorOverride("font_color",active?UiTokens.AccentBright:UiTokens.Muted);button.AddThemeColorOverride("font_hover_color",active?UiTokens.AccentBright:UiTokens.Ink);button.AddThemeColorOverride("font_pressed_color",active?UiTokens.AccentBright:UiTokens.Ink);}private static StyleBoxFlat MakeFilterTabStyle(bool active,bool hover){var style=new StyleBoxFlat();style.BgColor=active?UiTokens.AccentSoft:(hover?UiTokens.WhiteTrace:Colors.Transparent);style.BorderColor=active?UiTokens.AccentLine:Colors.Transparent;style.BorderWidthLeft=1;style.BorderWidthRight=1;style.BorderWidthTop=1;style.BorderWidthBottom=1;style.CornerRadiusTopLeft=UiTokens.RadiusSm;style.CornerRadiusTopRight=UiTokens.RadiusSm;style.CornerRadiusBottomLeft=UiTokens.RadiusSm;style.CornerRadiusBottomRight=UiTokens.RadiusSm;style.ContentMarginLeft=13;style.ContentMarginRight=13;style.ContentMarginTop=7;style.ContentMarginBottom=7;return style;}private static StyleBoxFlat MakePageStyle(bool left){var style=new StyleBoxFlat();style.BgColor=PageColor;style.BorderColor=CoverLineColor;style.BorderWidthLeft=1;style.BorderWidthRight=1;style.BorderWidthTop=1;style.BorderWidthBottom=1;style.ShadowColor=UiTokens.Alpha(Colors.Black,0.85f);style.ShadowSize=24;style.ShadowOffset=new Vector2(0f,10f);if(left){style.CornerRadiusTopLeft=6;style.CornerRadiusBottomLeft=6;}else{style.CornerRadiusTopRight=6;style.CornerRadiusBottomRight=6;}return style;}private void ApplyFilter(FilterType filter){_currentFilter=filter;StyleFilterTab(_activeFilterBtn,filter==FilterType.Active);StyleFilterTab(_completedFilterBtn,filter==FilterType.Completed);ClearChildren(_questListContainer);var allQuests=QuestFactory.Instance.GetAll();List<QuestBase>filtered=null;if(allQuests!=null&&allQuests.Count>0){if(filter==FilterType.Active)filtered=allQuests.Where(q=>q.Status==QuestStatus.Progress).ToList();else filtered=allQuests.Where(q=>q.Status==QuestStatus.End).ToList();}if(filtered==null||filtered.Count==0){_emptyLabel.Visible=true;_selectedQuestId=null;ShowPlaceholder();return;}_emptyLabel.Visible=false;if(!filtered.Any(q=>q.ID==_selectedQuestId))_selectedQuestId=filtered[0].ID;foreach(var quest in filtered)_questListContainer.AddChild(BuildQuestItem(quest));BuildDetailPanel(filtered.First(q=>q.ID==_selectedQuestId));}private Button BuildQuestItem(QuestBase quest){var selected=quest.ID==_selectedQuestId;var button=new Button();button.FocusMode=FocusModeEnum.None;button.CustomMinimumSize=new Vector2(0,46);button.SizeFlagsHorizontal=SizeFlags.ExpandFill;button.AddThemeStyleboxOverride("normal",MakeItemStyle(selected,false));button.AddThemeStyleboxOverride("hover",MakeItemStyle(selected,true));button.AddThemeStyleboxOverride("pressed",MakeItemStyle(selected,true));button.AddThemeStyleboxOverride("focus",new StyleBoxEmpty());button.Pressed+=()=>OnQuestClicked(quest);var row=new HBoxContainer();row.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);row.OffsetLeft=14;row.OffsetRight= -12;row.AddThemeConstantOverride("separation",10);row.MouseFilter=MouseFilterEnum.Ignore;button.AddChild(row);var marker=MakeDiamond(quest.Status==QuestStatus.End?InkFaintColor:GoldColor,9);row.AddChild(marker);var name=new Label();name.Text=TranslationServer.Translate(quest.Name);name.AddThemeFontSizeOverride("font_size",17);name.AddThemeColorOverride("font_color",selected?InkBrightColor:(quest.Status==QuestStatus.End?InkDimColor:InkColor));name.TextOverrunBehavior=TextServer.OverrunBehavior.TrimEllipsis;name.ClipText=true;name.VerticalAlignment=VerticalAlignment.Center;name.SizeFlagsHorizontal=SizeFlags.ExpandFill;name.SizeFlagsStretchRatio=2f;name.SizeFlagsVertical=SizeFlags.ShrinkCenter;name.MouseFilter=MouseFilterEnum.Ignore;row.AddChild(name);row.AddChild(MakeFadingLine(true));var stage=new Label();stage.Text=quest.Status==QuestStatus.End?TranslationServer.Translate("ui/journal/finished"):(quest.StatusToDescription!=null&&quest.StatusToDescription.Count>0?ToRoman(Math.Min(quest.Data.Status+1,quest.StatusToDescription.Count)):"—");stage.AddThemeFontSizeOverride("font_size",14);stage.AddThemeColorOverride("font_color",quest.Status==QuestStatus.End?InkFaintColor:UiTokens.AccentDim);stage.VerticalAlignment=VerticalAlignment.Center;stage.SizeFlagsVertical=SizeFlags.ShrinkCenter;stage.MouseFilter=MouseFilterEnum.Ignore;row.AddChild(stage);return button;}private static StyleBoxFlat MakeItemStyle(bool selected,bool hover){var style=new StyleBoxFlat();style.BgColor=selected?GoldGlowColor:(hover?GoldTraceColor:UiTokens.Alpha(Colors.Black,0f));style.ContentMarginLeft=2;style.ContentMarginRight=2;style.CornerRadiusTopRight=4;style.CornerRadiusBottomRight=4;if(selected){style.BorderColor=GoldColor;style.BorderWidthLeft=2;}return style;}private void OnQuestClicked(QuestBase quest){_selectedQuestId=quest.ID;ApplyFilter(_currentFilter);}private void BuildDetailPanel(QuestBase quest){_questNameLabel.Text=TranslationServer.Translate(quest.Name);var descriptions=quest.StatusToDescription;var hasEntries=descriptions!=null&&descriptions.Count>0;var isClosed=quest.Status==QuestStatus.End;var currentStatus=quest.Data.Status;if(isClosed){_stageLabel.Text=TranslationServer.Translate("ui/journal/finished");_stageLabel.AddThemeColorOverride("font_color",InkDimColor);}else if(hasEntries){_stageLabel.Text=string.Format(TranslationServer.Translate("ui/journal/entry_of"),ToRoman(Math.Min(currentStatus+1,descriptions.Count)),ToRoman(descriptions.Count));_stageLabel.AddThemeColorOverride("font_color",GoldColor);}else{_stageLabel.Text="";}BuildEntryText(descriptions,hasEntries,isClosed,currentStatus);BuildSeals(quest);}private void BuildEntryText(IDictionary<int,string>descriptions,bool hasEntries,bool isClosed,int currentStatus){if(!hasEntries){_entryText.Text="[i][color=#"+UiTokens.Faint.ToHtml(false)+"]"+TranslationServer.Translate("ui/journal/no_entries")+"[/color][/i]";return;}var builder=new StringBuilder();foreach(var key in descriptions.Keys.OrderBy(k=>k)){if(key>currentStatus)continue;var text=TranslationServer.Translate(descriptions[key]);if(isClosed||key<currentStatus){builder.Append("[color=#").Append(UiTokens.Faint.ToHtml(false)).Append("][s]").Append(text).Append("[/s][/color]");}else{builder.Append("[color=#").Append(UiTokens.Accent.ToHtml(false)).Append("]• [/color]").Append("[color=#").Append(UiTokens.InkBright.ToHtml(false)).Append("]").Append(text).Append("[/color]");}builder.Append("\n\n");}_entryText.Text=builder.ToString();}private void BuildSeals(QuestBase quest){ClearChildren(_sealsContainer);var tags=quest.Data?.Tags;_sealsBlock.Visible=tags!=null&&tags.Count>0;if(tags==null||tags.Count==0)return;foreach(var tag in tags.OrderBy(t=>t))_sealsContainer.AddChild(MakeSeal(quest,tag));}private static Control MakeSeal(QuestBase quest,string tag){var seal=new PanelContainer();var style=new StyleBoxFlat();style.BgColor=SealFillColor;style.BorderColor=SealColor;style.BorderWidthLeft=1;style.BorderWidthRight=1;style.BorderWidthTop=1;style.BorderWidthBottom=1;style.ContentMarginLeft=12;style.ContentMarginRight=12;style.ContentMarginTop=5;style.ContentMarginBottom=5;style.CornerRadiusTopLeft=4;style.CornerRadiusTopRight=4;style.CornerRadiusBottomLeft=4;style.CornerRadiusBottomRight=4;seal.AddThemeStyleboxOverride("panel",style);seal.PivotOffset=Vector2.Zero;seal.Rotation= -0.0436f;seal.Resized+=()=>seal.PivotOffset=seal.Size/2f;var label=new Label();label.Text=QuestTagLocale.Translate(quest,tag);label.AddThemeFontSizeOverride("font_size",13);label.AddThemeColorOverride("font_color",SealTextColor);label.MouseFilter=MouseFilterEnum.Ignore;seal.AddChild(label);return seal;}private void ShowPlaceholder(){_questNameLabel.Text="";_stageLabel.Text="";_entryText.Text="[i][color=#"+UiTokens.Faint.ToHtml(false)+"]"+TranslationServer.Translate("ui/journal/empty")+"[/color][/i]";_sealsBlock.Visible=false;}private static void ClearChildren(Node container){foreach(var child in container.GetChildren()){container.RemoveChild(child);child.QueueFree();}}private static Panel MakeDiamond(Color color,int size){var diamond=new Panel();diamond.CustomMinimumSize=new Vector2(size,size);diamond.SizeFlagsVertical=SizeFlags.ShrinkCenter;diamond.PivotOffset=new Vector2(size/2f,size/2f);diamond.Rotation=0.7854f;var style=new StyleBoxFlat();style.BgColor=color;diamond.AddThemeStyleboxOverride("panel",style);diamond.MouseFilter=MouseFilterEnum.Ignore;return diamond;}private static TextureRect MakeFadingLine(bool fadeToRight){var rect=new TextureRect();rect.Texture=MakeFadingLineTexture(fadeToRight);rect.CustomMinimumSize=new Vector2(30,1);rect.SizeFlagsHorizontal=SizeFlags.ExpandFill;rect.SizeFlagsVertical=SizeFlags.ShrinkCenter;rect.ExpandMode=TextureRect.ExpandModeEnum.IgnoreSize;rect.StretchMode=TextureRect.StretchModeEnum.Scale;rect.MouseFilter=MouseFilterEnum.Ignore;return rect;}private static string ToRoman(int value){if(value<=0)return"—";int[]values={50,40,10,9,5,4,1};string[]symbols={"L","XL","X","IX","V","IV","I"};var builder=new StringBuilder();for(var i=0;i<values.Length;i++){while(value>=values[i]){builder.Append(symbols[i]);value-=values[i];}}return builder.ToString();}}}

===== FILE mods/recruiting/src/DefeatedCharactersRecruiter.cs =====
using Cthangover.Core.Characters;using Cthangover.Core.Events;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Settings;using Cthangover.Core.Utils;namespace Cthangover.Core.Relationship{/// <summary> Turns a <see cref="CharactersDefeatedEvent"/> into recruit attempts. The recruiting mod subscribes to the core-neutral event, so the system that defeated the characters (battle, trap, script) does not know about recruiting — and recruiting does not depend on any combat mod. This replaces the old hardcoded call inside the victory screen. <para/> For every reported id the character template is resolved through <see cref="CharacterFactory"/> (the battle copy and the template share <c>RecruitmentChance</c>), skipped when that enemy is already captured (only one recruit per enemy type is allowed), checked against the condition gate (<see cref="RecruitBehaviourRegistry.CanRecruit"/>), rolled 1–100 and, on success, added to the roster. The roll is skipped entirely when no conditions are registered or the chance is zero, matching the old flow. <para/> The event also marks the end of a fight for the recruiting mod, so the final health of every squad recruit is pulled back from its placeholder card before new recruits are resolved. </summary>
public static class DefeatedCharactersRecruiter{/// <summary>Event-bus handler; safe to call with a null or empty payload.</summary>
public static void Process(CharactersDefeatedEvent evt){if(evt?.CharacterIds==null)return;var runtime=GameData.Instance?.Runtime;if(runtime==null)return;var registry=RecruitBehaviourRegistry.Instance;// A defeat resolves the battle: pull the final health of every squad recruit back from its placeholder card, since battle writes into the shared attributes object while the mod keeps its own data.
foreach(var recruit in RecruitingData.Instance.Data)RecruitPartyBridge.SyncFromCard(recruit);foreach(var id in evt.CharacterIds){if(string.IsNullOrEmpty(id))continue;var enemy=CharacterFactory.Instance.Get(id);if(enemy==null||enemy.RecruitmentChance<=0)continue;if(RecruitingData.Instance.HasCharacterID(enemy.ID))continue;if(!registry.CanRecruit(enemy,runtime))continue;if(System.Random.Shared.Next(1,101)>enemy.RecruitmentChance)continue;RecruitingData.Instance.Add(enemy.ID,enemy.ID);GameLogger.Log("RECRUIT",$"Recruited '{enemy.ID}' after '{evt.Source}' defeat",LogLevel.Message);}}}}

===== FILE mods/recruiting/src/EnslaveCondition.cs =====
using Cthangover.Core.Characters;using Cthangover.Core.Relationship;using Cthangover.Core.Settings;namespace Cthangover.Core.Relationship{/// <summary> Recruitment condition that gates the "enslave" recruit option behind the <c>slave_master</c> skill. When the player attempts to recruit an NPC, the recruitment system queries all registered <see cref="IRecruitCondition"/> implementations; if this condition is present and the player does not have the skill, the enslavement option is hidden or disabled. </summary>
public class EnslaveCondition:IRecruitCondition{/// <summary> Identifies this condition as the enslavement gate for the recruitment system (maps to <c>enslave</c> option). </summary>
public string Id=>"enslave";/// <summary> Determines whether enslavement is available for the given enemy. Checks the runtime skill data for the <c>slave_master</c> skill. The target <paramref name="enemy"/> is provided but not inspected because the condition depends solely on the player's skills. </summary>
public bool CanRecruit(Character enemy,RuntimeData runtime){return runtime.SkillData.HasSkill("slave_master");}}}

===== FILE mods/recruiting/src/IRecruitBehaviour.cs =====
using Cthangover.Core.Settings;namespace Cthangover.Core.Relationship{/// <summary> Per-recruit behaviour plugin discovered via reflection from mod assemblies. Each implementation is registered into <see cref="RecruitBehaviourRegistry"/> by <see cref="Mods.ModAssemblyLoader.RegisterAssembly"/> and then invoked for <b>every</b> active recruit — not just those from the owning mod — during tick, configure, and removal lifecycle events. This cross-recruit dispatch pattern is intentional: a behaviour might want to introspect all recruits (e.g. a "rivalry" system that checks for conflicting personalities), so the registry passes the full recruit list to every behaviour rather than filtering by ownership. </summary>
public interface IRecruitBehaviour{/// <summary>Unique identifier for lookup via <c>RecruitBehaviourRegistry.Get</c>.</summary>
string Id{get;}/// <summary> Called once when a recruit is added to the party (via <c>RecruitingData.Add</c>). Use for initial stat setup, buff application, or spawning companion nodes. </summary>
void ConfigureRecruit(Recruit recruit,RuntimeData runtime);/// <summary> Called every game tick for each active recruit. The <paramref name="currentTick"/> value is the global <c>RuntimeData.Time.Tick</c> counter — use it for cooldown-based or periodic logic rather than maintaining a private timer. </summary>
void OnTick(Recruit recruit,RuntimeData runtime,long currentTick);/// <summary> Called when a recruit is removed from the party (via <c>RecruitingData.Remove</c>). Clean up any persisted state, buffs, or companion nodes. </summary>
void OnRemove(Recruit recruit,RuntimeData runtime);}}

===== FILE mods/recruiting/src/IRecruitCondition.cs =====
using Cthangover.Core.Characters;using Cthangover.Core.Settings;namespace Cthangover.Core.Relationship{/// <summary> Gating predicate for the recruit system. Implementations are auto-discovered from mod assemblies by <see cref="RecruitBehaviourRegistry.RegisterAssembly"/> and evaluated conjunctively — <b>all</b> conditions must return <c>true</c> for recruitment to succeed. Conditions receive the full <see cref="Character"/> and <see cref="RuntimeData"/> so they can inspect character stats, inventory, relationship flags, quest state, or any other runtime-accessible data when deciding whether a given enemy is recruitable. </summary>
public interface IRecruitCondition{/// <summary>Unique identifier for debugging and override lookups.</summary>
string Id{get;}/// <summary> Returns <c>true</c> if the given <paramref name="enemy"/> can be recruited under current game state. Called from <c>RecruitBehaviourRegistry.CanRecruit</c> as part of a conjunctive gate — a single <c>false</c> blocks recruitment. </summary>
bool CanRecruit(Character enemy,RuntimeData runtime);}}

===== FILE mods/recruiting/src/NutritionBehaviour.cs =====
using Cthangover.Core.Relationship;using Cthangover.Core.Settings;namespace Cthangover.Core.Relationship{/// <summary> Tracks nutrition and starvation for party recruits. Works by recording the tick when a recruit last ate (<c>FullnessTime</c>), then each tick computes the elapsed time as a percentage of a 24-hour window. Once the fullness window expires (100%+), the recruit loses 1 HP per tick until fed again via a ration or meal action. Registered with the <see cref="Recruit"/> behaviour pipeline and called by the time system on each global tick. </summary>
public class NutritionBehaviour:IRecruitBehaviour{/// <summary> Unique behaviour identifier used to look up and configure this behaviour from <see cref="Recruit"/>'s behaviour collection. </summary>
public string Id=>"nutrition";/// <summary> Called when the behaviour is first attached to a recruit. Seeds <c>FullnessTime</c> with the current global tick so that starve-damage begins counting from this point. </summary>
public void ConfigureRecruit(Recruit recruit,RuntimeData runtime){recruit.Properties.SetLong("FullnessTime",runtime.Time.Tick);}/// <summary> Evaluates whether the recruit has gone beyond the fullness window (24 in-game hours, represented as <c>24 * 4 * 60</c> ticks) since <c>FullnessTime</c>. If the window is exceeded, reduces the recruit's <c>PROP_HEALTH</c> by 1 per tick until they are fed. Tick-driven starvation allows gradual death when rations run out. </summary>
public void OnTick(Recruit recruit,RuntimeData runtime,long currentTick){var fullnessTime=recruit.Properties.GetLong("FullnessTime");if(fullnessTime==0)return;var windowSize=24*4*60L;var deltaTime=currentTick-fullnessTime;var fullness=(float)deltaTime/windowSize*100f;if(fullness>100){var hp=recruit.Properties.GetInt(Recruit.PROP_HEALTH);if(hp>0){recruit.Properties.SetInt(Recruit.PROP_HEALTH,hp-1);RecruitPartyBridge.SyncToCard(recruit);}}}/// <summary> Cleanup hook invoked when the behaviour is detached from a recruit. No persistent state requires teardown; exists for interface contract. </summary>
public void OnRemove(Recruit recruit,RuntimeData runtime){}}}

===== FILE mods/recruiting/src/Recruit.cs =====
using System;using Cthangover.Core.Utils;namespace Cthangover.Core.Relationship{/// <summary> Runtime model for a recruited party member. Stores the recruit's unique ID, the character template it was created from, and a modifiable <see cref="Utility.PropertyData"/> bag that behaviours read and mutate during tick/configure events. Marked <c>[Serializable]</c> so the recruiting roster survives save/load — <c>PropertyData</c> serialises its nested values automatically. The <c>PROP_HEALTH</c> constant is the conventional key for health tracking; behaviours that need custom state (e.g. morale, loyalty timers) add their own keys to the <c>Properties</c> bag at configure time. </summary>
[Serializable]public class Recruit{/// <summary>Conventional key for the recruit's health value in the <c>Properties</c> bag.</summary>
public const string PROP_HEALTH="Health";/// <summary>Unique instance ID (auto-generated via <c>RecruitingData.GenID</c>).</summary>
public string ID{get;set;}/// <summary>Character template ID this recruit was spawned from.</summary>
public string CharacterID{get;set;}/// <summary>Mutable property bag — behaviours read/write custom state here.</summary>
public PropertyData Properties{get;set;}=new();}}

===== FILE mods/recruiting/src/RecruitBehaviourConsumer.cs =====
namespace Cthangover.Core.Relationship{using Cthangover.Core.Mods.Consumers;/// <summary> Feeds <see cref="RecruitBehaviourRegistry"/> with every mod's <see cref="IRecruitBehaviour"/> implementations as the mods load and removes them when their mod unloads. </summary>
public class RecruitBehaviourConsumer:ModTypeConsumer<IRecruitBehaviour>{/// <inheritdoc />
public override string Id=>"recruiting.behaviours";/// <inheritdoc />
protected override void Add(IRecruitBehaviour instance,string modId)=>RecruitBehaviourRegistry.Instance.RegisterBehaviour(instance);/// <inheritdoc />
protected override void Remove(IRecruitBehaviour instance,string modId)=>RecruitBehaviourRegistry.Instance.UnregisterBehaviour(instance);}}

===== FILE mods/recruiting/src/RecruitBehaviourRegistry.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Characters;using Cthangover.Core.Settings;using Cthangover.Core.Utils;namespace Cthangover.Core.Relationship{/// <summary> Central registry and lifecycle dispatcher for recruit behaviours and conditions. Behaviours (<see cref="IRecruitBehaviour"/>) and conditions (<see cref="IRecruitCondition"/>) of every mod arrive through the kernel's assembly consumers (<see cref="RecruitBehaviourConsumer"/>, <see cref="RecruitConditionConsumer"/>) as mods load, and leave again when their mod unloads — the kernel stays the only place that enumerates assemblies. <b>Condition gating</b> — <see cref="CanRecruit"/> evaluates every registered condition conjunctively (logical AND), so a single <c>false</c> blocks recruitment. This allows mods to compose recruitment gates without coordination. <b>Id-based deduplication</b> — a registration replaces any earlier implementation with the same <c>Id</c>, so two mods shipping the same id never double-dispatch notifications. <b>Tick registration</b> — the first call to any lifecycle method triggers <see cref="RecruitTickController.EnsureRegistered"/>, which registers for the scene's timer event. This avoids paying the tick overhead unless at least one behaviour is active. <b>Singleton</b> — accessed via the static <c>Instance</c> field rather than a <c>Lazy&lt;T&gt;</c> wrapper because Godot reflection-based discovery must tolerate partial-initialisation scenarios where <c>Lazy</c>'s thread-safety check could deadlock. </summary>
public class RecruitBehaviourRegistry{/// <summary>Eagerly-initialised singleton for reflection-safe access.</summary>
public static readonly RecruitBehaviourRegistry Instance=new();private readonly List<IRecruitBehaviour>_behaviours=new();private readonly List<IRecruitCondition>_conditions=new();private readonly RecruitTickController _tickController=new();private void EnsureTickRegistered(){_tickController.EnsureRegistered();}/// <summary> Registers a behaviour, replacing an earlier one with the same Id. Fed by <see cref="RecruitBehaviourConsumer"/> for every mod assembly. </summary>
public void RegisterBehaviour(IRecruitBehaviour behaviour){if(behaviour==null)return;_behaviours.RemoveAll(existing=>existing.Id==behaviour.Id);_behaviours.Add(behaviour);}/// <summary>Removes exactly <paramref name="behaviour"/> (its mod is being unloaded).</summary>
public void UnregisterBehaviour(IRecruitBehaviour behaviour){_behaviours.Remove(behaviour);}/// <summary> Registers a condition, replacing an earlier one with the same Id. Fed by <see cref="RecruitConditionConsumer"/> for every mod assembly. </summary>
public void RegisterCondition(IRecruitCondition condition){if(condition==null)return;_conditions.RemoveAll(existing=>existing.Id==condition.Id);_conditions.Add(condition);}/// <summary>Removes exactly <paramref name="condition"/> (its mod is being unloaded).</summary>
public void UnregisterCondition(IRecruitCondition condition){_conditions.Remove(condition);}/// <summary> Conjunctive gate: returns <c>true</c> only if every registered <see cref="IRecruitCondition"/> returns <c>true</c> for the given enemy and runtime state. </summary>
public bool CanRecruit(Character enemy,RuntimeData runtime){return _conditions.All(c=>c.CanRecruit(enemy,runtime));}/// <summary> Looks up a behaviour by its <c>Id</c> (linear scan — behaviour lists are small enough that a dictionary is unnecessary). Returns <c>null</c> if not found. </summary>
public IRecruitBehaviour Get(string id){return _behaviours.Find(b=>b.Id==id);}/// <summary>Returns all registered behaviours for external enumeration.</summary>
public IEnumerable<IRecruitBehaviour>All(){return _behaviours;}/// <summary> Dispatches <see cref="IRecruitBehaviour.OnTick"/> to every behaviour for every active recruit. Ensures tick registration on first call via lazy init. </summary>
public void OnTick(){EnsureTickRegistered();var runtime=GameData.Instance.Runtime;if(runtime==null)return;var recruits=RecruitingData.Instance.Data;foreach(var behaviour in _behaviours){foreach(var recruit in recruits){behaviour.OnTick(recruit,runtime,runtime.Time.Tick);}}}/// <summary> Dispatches <see cref="IRecruitBehaviour.ConfigureRecruit"/> to every behaviour for the newly-added <paramref name="recruit"/>. </summary>
public void OnConfigure(Recruit recruit){EnsureTickRegistered();var runtime=GameData.Instance.Runtime;if(runtime==null)return;foreach(var behaviour in _behaviours)behaviour.ConfigureRecruit(recruit,runtime);}/// <summary> Dispatches <see cref="IRecruitBehaviour.OnRemove"/> to every behaviour for the departing <paramref name="recruit"/>. </summary>
public void OnRemove(Recruit recruit){EnsureTickRegistered();var runtime=GameData.Instance.Runtime;if(runtime==null)return;foreach(var behaviour in _behaviours)behaviour.OnRemove(recruit,runtime);}}}

===== FILE mods/recruiting/src/RecruitConditionConsumer.cs =====
namespace Cthangover.Core.Relationship{using Cthangover.Core.Mods.Consumers;/// <summary> Feeds <see cref="RecruitBehaviourRegistry"/> with every mod's <see cref="IRecruitCondition"/> implementations as the mods load and removes them when their mod unloads. </summary>
public class RecruitConditionConsumer:ModTypeConsumer<IRecruitCondition>{/// <inheritdoc />
public override string Id=>"recruiting.conditions";/// <inheritdoc />
protected override void Add(IRecruitCondition instance,string modId)=>RecruitBehaviourRegistry.Instance.RegisterCondition(instance);/// <inheritdoc />
protected override void Remove(IRecruitCondition instance,string modId)=>RecruitBehaviourRegistry.Instance.UnregisterCondition(instance);}}

===== FILE mods/recruiting/src/RecruitJoinedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Published by the recruiting mod when a captured recruit is placed into the active party (see <c>RecruitPartyBridge.AddToSquad</c>). Purely informational — the core party data is already updated when subscribers see the event. </summary>
[EventKey("recruit_joined")]public class RecruitJoinedEvent:ModEvent{/// <summary>Instance id of the recruit.</summary>
public string RecruitId{get;init;}/// <summary>Character template id the recruit was spawned from.</summary>
public string RecruitName{get;init;}}}

===== FILE mods/recruiting/src/RecruitLeftEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Published by the recruiting mod when a recruit leaves the active party (see <c>RecruitPartyBridge.RemoveFromSquad</c>). The placeholder card and its progression survive the leave — only the party slot is dropped. </summary>
[EventKey("recruit_left")]public class RecruitLeftEvent:ModEvent{/// <summary>Instance id of the recruit.</summary>
public string RecruitId{get;init;}/// <summary>Character template id the recruit was spawned from.</summary>
public string RecruitName{get;init;}}}

===== FILE mods/recruiting/src/RecruitPartyBridge.cs =====
using Cthangover.Core.Characters;using Cthangover.Core.Settings;using Cthangover.Core.Utils;namespace Cthangover.Core.Relationship{/// <summary> Bridge between the mod-owned recruit roster and the core party model. Core knows nothing about recruiting, so the mod materializes a placeholder <see cref="CharacterInfoData"/> for every recruit the player puts into the squad: the entry is keyed by the template <see cref="Recruit.CharacterID"/> (the roster guarantees at most one recruit per enemy, so the template id stays a unique party key) and therefore needs no core changes — the character panel, battle, item targeting and the save system all pick the recruit up as a normal party member. <para/> The recruit itself remains the source of truth outside battle: the mod calls <see cref="SyncToCard"/> whenever its own data changes (starvation tick, joining the squad) and <see cref="SyncFromCard"/> once battle is over, where the battle systems have already written the final health back through the shared <see cref="CharacterInfoData.Attributes"/> reference. <see cref="SyncToCard"/> must mutate the attribute in place — reassigning the attributes object would detach it from the battle copies that still point at the original instance. </summary>
public static class RecruitPartyBridge{/// <summary>True when the recruit currently occupies a slot in the active party.</summary>
public static bool IsInSquad(Recruit recruit){var characterData=GetCharacterData();return characterData!=null&&recruit!=null&&characterData.BattleSet.Contains(recruit.CharacterID);}/// <summary> Adds the recruit to the active party. On the first join a placeholder card is created from the character template (attributes, portrait and default action slots); the card is then immediately synchronized with the recruit's own health. Repeated calls are idempotent. <para/> The template is resolved by <c>CharacterData.AddCharacterToParty</c>, which refuses unknown IDs; a refusal aborts the join so the battle set never references a card that the roster does not have (the battle scene indexes the roster directly for every battle-set entry). </summary>
public static void AddToSquad(Recruit recruit){var characterData=GetCharacterData();if(characterData==null||recruit==null)return;if(!characterData.Characters.ContainsKey(recruit.CharacterID)&& !characterData.AddCharacterToParty(recruit.CharacterID)){GameLogger.Log("RECRUIT",$"AddToSquad: no template for recruit '{recruit.CharacterID}' — join aborted",LogLevel.Error);return;}characterData.BattleSet.Add(recruit.CharacterID);SyncToCard(recruit);GameLogger.Log("RECRUIT",$"Recruit '{recruit.CharacterID}' joined the squad",LogLevel.Message);}/// <summary> Removes the recruit from the active party but keeps the placeholder card in <see cref="CharacterData.Characters"/> — the player's action assignments and battle-earned experience survive a temporary leave and are restored when the recruit joins again. </summary>
public static void RemoveFromSquad(Recruit recruit){var characterData=GetCharacterData();if(characterData==null||recruit==null)return;if(characterData.BattleSet.Remove(recruit.CharacterID))GameLogger.Log("RECRUIT",$"Recruit '{recruit.CharacterID}' left the squad",LogLevel.Message);}/// <summary> Drops every trace of the recruit: leaves the party and deletes the placeholder card. Called when the recruit is removed from the roster for good, so no orphan party entry survives in the core save. </summary>
public static void Forget(Recruit recruit){var characterData=GetCharacterData();if(characterData==null||recruit==null)return;RemoveFromSquad(recruit);characterData.Characters.Remove(recruit.CharacterID);}/// <summary> Pushes the recruit's health from its property bag into the placeholder card. The attribute is mutated in place: battle copies share the same <see cref="CharacterAttributes"/> instance, so a reassignment here would leave the fight running on the stale object. </summary>
public static void SyncToCard(Recruit recruit){var info=GetCard(recruit);if(info==null)return;info.Attributes??=new CharacterAttributes();info.Attributes.Health.Value=recruit.Properties.GetInt(Recruit.PROP_HEALTH);}/// <summary> Pulls the health written by battle back into the recruit's property bag. Called on the core-neutral <c>characters_defeated</c> event, which the battle mod publishes after the victory screen resolves, so the mod's own data stays authoritative once the fight is over. </summary>
public static void SyncFromCard(Recruit recruit){var info=GetCard(recruit);if(info?.Attributes?.Health==null)return;recruit.Properties.SetInt(Recruit.PROP_HEALTH,info.Attributes.Health.Value);}private static CharacterInfoData GetCard(Recruit recruit){var characterData=GetCharacterData();if(characterData?.Characters==null||recruit==null)return null;return characterData.Characters.TryGetValue(recruit.CharacterID,out var info)?info:null;}private static CharacterData GetCharacterData(){return GameData.Instance?.Runtime?.CharacterData;}}}

===== FILE mods/recruiting/src/RecruitTickController.cs =====
using Cthangover.Core.Scenes;using Cthangover.Core.UI.Event;namespace Cthangover.Core.Relationship{/// <summary> Lazy bridge between the scene timer system and the recruit behaviour tick loop. Implements <see cref="IOnTimeEvent"/> so it can be registered with <see cref="SceneEventController"/> via <c>AddTimerTickEventListener</c>. Registration is lazy (<see cref="EnsureRegistered"/> is a no-op after the first call) because the recruit system should not force the timer subsystem to activate if no behaviours are actually loaded. The first behaviour lifecycle method in <see cref="RecruitBehaviourRegistry"/> calls <c>EnsureRegistered</c>, which finds the scene's <c>EventController</c> node and subscribes. <c>Priority = 0</c> so recruit ticks fire at a neutral point in the timer event chain — before or after other listeners as determined by their relative priorities. </summary>
public class RecruitTickController:IOnTimeEvent{/// <summary>Neutral priority — doesn't preempt or follow any specific listener.</summary>
public int Priority=>0;private bool _registered;/// <summary> Finds the scene's <c>EventController</c> node and subscribes this controller as a timer-tick listener. Idempotent — subsequent calls do nothing. </summary>
public void EnsureRegistered(){if(_registered)return;var eventController=SceneContextNode.FindNode<SceneEventController>("EventController");if(eventController==null)return;eventController.AddTimerTickEventListener(this);_registered=true;}/// <summary> Called by <see cref="SceneEventController"/> on every timer tick. Delegates to <see cref="RecruitBehaviourRegistry.OnTick"/>. Ticks also fire during the launcher boot window, so the registry side must not touch <c>GameData.Instance.Runtime</c> or mod data until <c>Boot.ModsReady</c> (see the boot-window contract on <c>IOnTimeEvent</c>; core-side reference: TimeTickController). </summary>
public void OnTimerTick(){RecruitBehaviourRegistry.Instance.OnTick();}}}

===== FILE mods/recruiting/src/RecruitingData.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Utils;namespace Cthangover.Core.Relationship{/// <summary> Container and factory for the party's recruit roster. The roster is owned by the recruiting mod through the static <see cref="Instance"/> instead of living in the core <c>RuntimeData</c>, so the core knows nothing about recruiting. Persistence is handled by the mod's <c>RecruitingSaveable</c> (<c>IModSaveable</c>), and a new game clears the roster through the <c>RecruitingReset</c> <c>IResettable</c> that <c>RecruitingInitializer</c> registers with AppServices. <para/> Provides ID-based lookup, duplicate-safe addition (auto-generates a unique <c>Guid</c> if the requested ID collides, and rejects a second recruit of the same enemy type), and removal that notifies <see cref="RecruitBehaviourRegistry"/> so behaviours can clean up and detaches the recruit from the squad through <see cref="RecruitPartyBridge"/>. <see cref="Add"/> initialises the recruit's <c>Properties</c> with a default health of 10 via <see cref="Recruit.PROP_HEALTH"/> and immediately calls <c>RecruitBehaviourRegistry.OnConfigure</c>, giving every registered behaviour a chance to set up per-recruit state before the next tick fires. </summary>
public class RecruitingData{/// <summary> Session-wide roster. There is exactly one roster per playthrough — starting a new game clears it, loading a save replaces its contents. </summary>
public static readonly RecruitingData Instance=new();/// <summary>The current recruit roster.</summary>
public List<Recruit>Data{get;set;}=new();/// <summary>True if a recruit with the given ID exists in the roster.</summary>
public bool HasID(string id){return GetByID(id)!=null;}/// <summary>Returns the recruit with the given ID, or null if not found.</summary>
public Recruit GetByID(string id){foreach(var item in Data){if(item.ID==id)return item;}return null;}/// <summary> True if a recruit spawned from the given character template is already in the roster. Recruitment allows only one instance per enemy type, which also keeps the template id usable as a unique party key. </summary>
public bool HasCharacterID(string characterID){return GetByCharacterID(characterID)!=null;}/// <summary>Returns the recruit spawned from the given template, or null if not found.</summary>
public Recruit GetByCharacterID(string characterID){foreach(var item in Data){if(item.CharacterID==characterID)return item;}return null;}/// <summary> Generates a unique ID: if <paramref name="id"/> is free, returns it unchanged; otherwise appends a <c>Guid</c> suffix. Loops until an unused ID is found (guaranteed because Guid is unique enough for any practical roster size). </summary>
private string GenID(string id){for(;;){if(!HasID(id))return id;id=Guid.NewGuid().ToString();}}/// <summary> Creates a new <see cref="Recruit"/> from the given <paramref name="characterID"/>, assigns a unique instance ID, initialises default health, appends to the roster, and fires <c>RecruitBehaviourRegistry.OnConfigure</c> so behaviours can set up per-recruit state. <para/> Only one recruit per enemy type is allowed: a second attempt for an already captured <paramref name="characterID"/> is ignored and the existing entry is returned, so callers cannot create duplicates and the template id remains a unique party card key. </summary>
public Recruit Add(string id,string characterID){var existing=GetByCharacterID(characterID);if(existing!=null){GameLogger.Log("RECRUIT",$"Recruit '{characterID}' already captured, duplicate ignored",LogLevel.Warning);return existing;}var data=new Recruit{ID=GenID(id),CharacterID=characterID,};data.Properties.SetInt(Recruit.PROP_HEALTH,10);Data.Add(data);RecruitBehaviourRegistry.Instance.OnConfigure(data);return data;}/// <summary> Removes the recruit with the given ID from the roster. Notifies <see cref="RecruitBehaviourRegistry.OnRemove"/> before deleting the entry so behaviours can persist state or clean up companion nodes, then detaches the recruit from the party and deletes its placeholder card via <see cref="RecruitPartyBridge.Forget"/>, so no orphan party member survives in the core save. </summary>
public void Remove(string id){for(int i=Data.Count-1;i>=0;i--){if(Data[i].ID==id){RecruitBehaviourRegistry.Instance.OnRemove(Data[i]);RecruitPartyBridge.Forget(Data[i]);Data.RemoveAt(i);return;}}}}}

===== FILE mods/recruiting/src/RecruitingInitializer.cs =====
using Cthangover.Core.Bootstrap;using Cthangover.Core.Events;using Cthangover.Core.Mods;namespace Cthangover.Core.Relationship{/// <summary> Lifecycle entry point of the recruiting mod. Behaviours and conditions of all mods are not discovered here: the kernel delivers them through <see cref="RecruitBehaviourConsumer"/> and <see cref="RecruitConditionConsumer"/>. <list type="number"> <item>It registers a <see cref="RecruitingReset"/> with <see cref="AppServices"/>, so the core's "New Game" pipeline (<c>GameData.StartNewGame</c> → <c>AppServices.ResetForNewGame</c>) clears the roster — the core used to discard it implicitly with RuntimeData, but the roster now lives in <see cref="RecruitingData.Instance"/> and must be reset explicitly on "New Game". Registration is idempotent: AppServices deduplicates by <c>IResettable.Id</c>, so a repeated <c>OnModResourcesReady</c> is safe.</item> <item>It subscribes to <see cref="CharactersDefeatedEvent"/> and routes it into <see cref="DefeatedCharactersRecruiter"/> — the publisher (battle, trap, script) stays unaware of recruiting, so the whole system can be disabled by removing this mod.</item> </list> Persistence is not handled here: <see cref="RecruitingSaveable"/> is an <c>IModSaveable</c> and is discovered by ModSaveableRegistry automatically. </summary>
public class RecruitingInitializer:IModInitializer{private static bool _defeatedSubscribed;/// <summary>Nothing to do per mod: recruit plugins arrive through the kernel's assembly consumers.</summary>
public void OnModLoaded(string modId){}/// <summary> Registers the roster reset owner with AppServices (idempotent — duplicates by Id are ignored) and installs the <see cref="CharactersDefeatedEvent"/> subscription. The roster is no longer cleared through a <c>NewGameStartedEvent</c> subscription: the core's reset loop invokes <see cref="RecruitingReset"/> instead. </summary>
public void OnModResourcesReady(){AppServices.Current.RegisterResettable(new RecruitingReset());EnsureDefeatedSubscription();}private static void EnsureDefeatedSubscription(){if(_defeatedSubscribed)return;_defeatedSubscribed=true;ModEventBus.Subscribe<CharactersDefeatedEvent>(DefeatedCharactersRecruiter.Process);}}/// <summary> New-game state owner of the recruiting mod: clears the session roster (<see cref="RecruitingData.Data"/>). Registered from <see cref="RecruitingInitializer.OnModResourcesReady"/>; <see cref="AppServices"/> deduplicates by Id, so repeated registration is a no-op. The clear is a plain list clear with no per-recruit side effects — unlike <see cref="RecruitingData.Remove"/> it does not notify <c>RecruitBehaviourRegistry.OnRemove</c> or detach companions through <c>RecruitPartyBridge</c> — matching the previous <c>NewGameStartedEvent</c> handler's behavior. Save loading is a separate path: <c>RecruitingSaveable</c> replaces the roster contents when a save is loaded. </summary>
internal sealed class RecruitingReset:IResettable{/// <summary>Resettable key under which AppServices deduplicates registration.</summary>
public string Id=>"recruiting";/// <summary>Clears the roster wholesale on "New Game", without per-recruit notifications.</summary>
public void ResetForNewGame()=>RecruitingData.Instance.Data.Clear();}}

===== FILE mods/recruiting/src/RecruitingSaveable.cs =====
using System.Collections.Generic;using Cthangover.Core.Mods;using Cthangover.Core.Settings;namespace Cthangover.Core.Relationship{/// <summary> Persists the recruit roster in the mod's own save slot. The core save used to carry a <c>"Recruits"</c> blob inside the <c>__core__</c> slot, but the roster now belongs to the recruiting mod — so it saves through the generic <see cref="IModSaveable"/> contract under the mod id <c>recruiting</c>. <para/> <see cref="OnLoad"/> replaces the roster wholesale and does not re-fire <c>ConfigureRecruit</c>: persisted per-recruit properties (for example the nutrition timer) are already in the blob and behaviours continue their tick logic from the restored state, matching the old core behaviour. Backward compatibility with the old core blob is intentionally not provided — old saves start with an empty roster. </summary>
public class RecruitingSaveable:IModSaveable{private const string BlobKey="Recruits";/// <summary>Writes the session roster into the mod slot.</summary>
public void OnSave(ModSlot slot){slot.Data[BlobKey]=new DataBlob(1,RecruitingData.Instance.Data);}/// <summary>Restores the roster from the mod slot; an absent blob yields an empty roster.</summary>
public bool OnLoad(ModSlot slot,out string errorMessage){errorMessage=null;var data=new List<Recruit>();if(slot?.Data!=null&&slot.Data.TryGetValue(BlobKey,out var blob))data=blob.ReadJson<List<Recruit>>()??new List<Recruit>();RecruitingData.Instance.Data=data;// The core slot (party cards) is restored before mod slots, so any squad recruit already has a placeholder card again. The mod owns its data outside battle, so push the saved health onto that card.
foreach(var recruit in data)RecruitPartyBridge.SyncToCard(recruit);return true;}}}

===== FILE mods/recruiting/src/UI/RecruitPanelBehaviour.cs =====
using Cthangover.Core.Characters;using Cthangover.Core.Factories.Impls;using Cthangover.Core.UI;using Cthangover.Core.UI.CharacterPanel;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;namespace Cthangover.Core.Relationship{/// <summary> Mod-owned panel of captured recruits. It is declared in the mod manifest as a `kind:panel` GUI entry, so the core toolbar renders its button and the host caches the instance for the whole session; the core character panel stays unaware of recruiting. <para/> The static layout (backdrop, framed panel, header row, scroll host, rules and status footer) lives in <c>scenes/recruit_panel.tscn</c>; this behaviour resolves those nodes and owns the dynamic layer: roster rows rebuilt from <see cref="RecruitingData.Instance"/> on every show and after every squad toggle. A recruit joins the active party through <see cref="RecruitPartyBridge"/>, which materializes the placeholder card the core character panel and battle systems consume. </summary>
public class RecruitPanelBehaviour:Widget{// Scene nodes (recruit_panel.tscn)
private Label _title;private Label _sub;private Button _closeButton;private VBoxContainer _list;private Label _statusLabel;/// <summary>Binds scene nodes and applies theme and localized captions.</summary>
protected override void OnceConstruct(){MouseFilter=MouseFilterEnum.Stop;_title=GetNode<Label>("Panel/Margin/Root/Header/Title");_sub=GetNode<Label>("Panel/Margin/Root/Header/Sub");_closeButton=GetNode<Button>("Panel/Margin/Root/Header/CloseButton");_list=GetNode<VBoxContainer>("Panel/Margin/Root/Scroll/List");_statusLabel=GetNode<Label>("Panel/Margin/Root/StatusLabel");ApplyTheme();ApplyCaptions();_closeButton.Pressed+=Hide;}/// <summary>Theme overrides for scene nodes that depend on UiKit tokens.</summary>
private void ApplyTheme(){GetNode<Panel>("Panel").AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusLg,UiTokens.Surface0,UiTokens.Line));_title.AddThemeColorOverride("font_color",ActionCharacterUi.InkBright);_sub.AddThemeColorOverride("font_color",UiTokens.Faint);_statusLabel.AddThemeColorOverride("font_color",UiTokens.Faint);var backdrop=GetNode<ColorRect>("Backdrop");UiKit.ApplyPanelBackdrop(backdrop);foreach(var ruleName in new[]{"Rule1","Rule2"}){var rule=GetNode<Panel>($"Panel/Margin/Root/{ruleName}");rule.AddThemeStyleboxOverride("panel",UiKit.Frame(0,UiTokens.Line,UiTokens.Line));}CthangoverTheme.ApplyIcon(_closeButton);}/// <summary>Localized captions for all static labels of the scene.</summary>
private void ApplyCaptions(){_title.Text=ActionCharacterUi.Translate("ui/recruit/title").ToUpperInvariant();_sub.Text=ActionCharacterUi.Translate("ui/recruit/sub");_closeButton.TooltipText=ActionCharacterUi.Translate("ui/recruit/close");}/// <summary>Rebuilds the roster rows from current data each time the panel is opened.</summary>
protected override void ShowConstruct(){Rebuild();}/// <summary>Frees the row nodes when the panel is hidden so the list does not go stale.</summary>
protected override void HideDestruct(){ClearList();}private void Rebuild(){ClearList();var roster=RecruitingData.Instance.Data;foreach(var recruit in roster)_list.AddChild(BuildRow(recruit));if(roster.Count==0)_list.AddChild(BuildEmptyLabel());_statusLabel.Text=string.Format(ActionCharacterUi.Translate("ui/recruit/status"),roster.Count,CountInSquad());}private Control BuildRow(Recruit recruit){var template=CharacterFactory.Instance.Get(recruit.CharacterID);var inSquad=RecruitPartyBridge.IsInSquad(recruit);var card=new PanelContainer();card.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusMd,UiTokens.SurfaceRaised,inSquad?UiTokens.AccentLine:UiTokens.Line));var margin=new MarginContainer();margin.AddThemeConstantOverride("margin_left",12);margin.AddThemeConstantOverride("margin_right",12);margin.AddThemeConstantOverride("margin_top",10);margin.AddThemeConstantOverride("margin_bottom",10);card.AddChild(margin);var row=new HBoxContainer();row.AddThemeConstantOverride("separation",12);row.MouseFilter=MouseFilterEnum.Ignore;margin.AddChild(row);row.AddChild(BuildPortrait(template));var info=new VBoxContainer();info.SizeFlagsHorizontal=SizeFlags.ExpandFill;info.AddThemeConstantOverride("separation",4);info.MouseFilter=MouseFilterEnum.Ignore;row.AddChild(info);var nameLabel=ActionCharacterUi.MakeLabel(ActionCharacterUi.Translate(template?.Name??recruit.CharacterID),18,UiTokens.InkBright);nameLabel.ClipText=true;info.AddChild(nameLabel);var subLabel=ActionCharacterUi.MakeLabel(recruit.CharacterID,10,UiTokens.Faint);subLabel.ClipText=true;info.AddChild(subLabel);var health=recruit.Properties.GetInt(Recruit.PROP_HEALTH);var maxHealth=template?.Attributes?.Health?.BaseValue??0;var healthText=maxHealth>0?$"{health}/{maxHealth}":health.ToString();info.AddChild(UiKit.StatChip(ActionCharacterUi.Translate("ui/recruit/hp"),healthText,UiTokens.HpText));var button=UiKit.Button(ActionCharacterUi.Translate(inSquad?"ui/recruit/remove":"ui/recruit/add"),inSquad?ButtonKind.Danger:ButtonKind.Primary);button.FocusMode=FocusModeEnum.None;button.SizeFlagsVertical=SizeFlags.ShrinkCenter;var captured=recruit;button.Pressed+=()=>OnToggleSquad(captured);row.AddChild(button);return card;}private static Control BuildPortrait(Character template){var portraitPanel=new PanelContainer();portraitPanel.CustomMinimumSize=new Vector2(58,58);portraitPanel.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusMd,UiTokens.Surface2,UiTokens.Line));portraitPanel.MouseFilter=MouseFilterEnum.Ignore;var portrait=new TextureRect{Texture=template?.Image,ExpandMode=TextureRect.ExpandModeEnum.IgnoreSize,StretchMode=TextureRect.StretchModeEnum.KeepAspectCovered,MouseFilter=MouseFilterEnum.Ignore,};portraitPanel.AddChild(portrait);return portraitPanel;}private Control BuildEmptyLabel(){var empty=UiKit.EmptyState(ActionCharacterUi.Translate("ui/recruit/empty"));empty.CustomMinimumSize=new Vector2(0,120);return empty;}private void OnToggleSquad(Recruit recruit){if(RecruitPartyBridge.IsInSquad(recruit))RecruitPartyBridge.RemoveFromSquad(recruit);else RecruitPartyBridge.AddToSquad(recruit);Rebuild();}private int CountInSquad(){var count=0;foreach(var recruit in RecruitingData.Instance.Data){if(RecruitPartyBridge.IsInSquad(recruit))count++;}return count;}private void ClearList(){if(_list==null)return;foreach(var child in _list.GetChildren())child.QueueFree();}}}

===== FILE mods/resource_atlas/src/AtlasModel.cs =====
using System.Collections.Generic;namespace Cthangover.ResourceAtlas{/// <summary>One resource a mod makes available under a resolver key.</summary>
public sealed class AtlasProvision{/// <summary>Resource kind: validator kinds (background, sound, character, scene...) plus texture, shader, action, entity_kind, locale, scene_file.</summary>
public string Kind{get;init;}/// <summary>Resolver key inside the kind (texture basename, sound path, entity id, "lang:key"...).</summary>
public string Id{get;init;}public string ModId{get;init;}public string File{get;init;}}/// <summary>One place where a mod asks for a resource by key.</summary>
public sealed class AtlasRequest{public string Kind{get;init;}public string Id{get;init;}public string ModId{get;init;}public string File{get;init;}public int Line{get;init;}/// <summary>Channel the request was found in: data, code, scenario, res, entity_data.</summary>
public string Via{get;init;}/// <summary>Provider named by the reference itself (<c>res://mods/&lt;id&gt;/...</c>); null when resolved by key.</summary>
public string ExplicitProvider{get;init;}}/// <summary>An intentional edit of another mod's resource through <c>patches/</c>.</summary>
public sealed class AtlasPatch{public string Kind{get;init;}public string Id{get;init;}public string ModId{get;init;}public string File{get;init;}/// <summary>Explicit target owner from the patch header, or null to resolve by key.</summary>
public string ExplicitOwner{get;init;}}public enum AtlasSeverity{Info,Warning,Error,}public enum AtlasFindingKind{/// <summary>A mod uses another mod's resource and declares that mod in its depends closure.</summary>
DeclaredUse,/// <summary>A mod uses another mod's resource without a depends path to it — breaks once that mod is absent.</summary>
UndeclaredUse,/// <summary>No loaded mod provides the requested key.</summary>
Unresolved,/// <summary>The winner of a shared key depends on the losers — the override is intended and ordered.</summary>
DeclaredOverride,/// <summary>Several unrelated mods provide one key; the winner is decided by incidental load order.</summary>
UnorderedOverride,/// <summary>A mod patches another mod's resource and depends on it.</summary>
DeclaredPatch,/// <summary>A mod patches another mod's resource without depending on it.</summary>
UndeclaredPatch,/// <summary>A patch targets a key no other loaded mod provides.</summary>
PatchTargetMissing,/// <summary> Inside a game: the key is provided only by an installed mod that is not part of the game — the isolated game will not find it. </summary>
OutsideGame,}public sealed class AtlasFinding{public AtlasFindingKind Kind{get;init;}public AtlasSeverity Severity{get;init;}/// <summary>Mod the finding is attributed to (requester, overriding winner, patcher).</summary>
public string ModId{get;init;}/// <summary>The other side: providers, overridden mods or patch owners.</summary>
public IReadOnlyList<string>OtherMods{get;init;}=new List<string>();public string ResourceKind{get;init;}public string ResourceId{get;init;}public string File{get;init;}public int Line{get;init;}public string Message{get;init;}}}

===== FILE mods/resource_atlas/src/ResourceAtlasBuilder.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text.Json;using System.Text.RegularExpressions;using Cthangover.Core.Mods;using Cthangover.Validator;namespace Cthangover.ResourceAtlas{/// <summary> Builds the cross-mod resource map. Keys follow the runtime resolvers so the atlas answers the same question the game does — "which mod serves this key?": <list type="bullet"> <item>validator graph — entities, scene definitions, interactives and the background/avatar/music/sound/effect assets, plus every reference found in scenarios and data;</item> <item>textures — basename across <c>ModConfig.TextureGroups</c> (one global namespace, like <c>TextureResolver</c>);</item> <item>shaders (<c>shaders/</c> path without extension), <c>.tscn</c> scenes (path under <c>scenes/</c>), locale keys per language, scenario actions and entity kinds registered by mod code;</item> <item>requests from C# literals (<c>PlaySound</c>, <c>PlayMusic</c>, <c>PlayAmbient</c>, <c>Textures.Resolve</c>, <c>Shaders.Resolve</c>), <c>res://mods/&lt;id&gt;/</c> paths, scenario <c>action</c> commands and entity data needing a kind;</item> <item>patches — <c>patches/scenes/**.tscn</c> and <c>patches/&lt;kind&gt;.json</c>.</item> </list> A link between two mods is "declared" when the dependant has the other mod in its transitive <c>depends</c>; for shared keys the later mod in load order wins, which is only deterministic along a depends path. </summary>
public static class ResourceAtlasBuilder{private static readonly Regex ResModReference=new(@"res://mods/([A-Za-z0-9_]+)/([^""'\s)\]>,;]+)",RegexOptions.Compiled);private static readonly Regex ScenarioAction=new(@"^\s*action\s+([a-z_]+\.[a-z_]+)",RegexOptions.Compiled);private static readonly Regex CSharpActionName=new(@"Name\s*=>\s*""([a-z_]+\.[a-z_]+)""",RegexOptions.Compiled);private static readonly Regex GdActionName=new(@"func\s+get_name\s*\(\s*\)\s*:\s*\n\s*return\s+""([a-z_]+\.[a-z_]+)""",RegexOptions.Compiled);private static readonly Regex EntityKindAttribute=new(@"EntityKind\(""([a-z_]+)""",RegexOptions.Compiled);private static readonly Regex PatchTarget=new(@"\[ext_resource[^\]]*type=""PackedScene""[^\]]*path=""([^""]+)""[^\]]*id=""([^""]+)""",RegexOptions.Compiled);private static readonly Regex RootInstance=new(@"\[node name=""[^""]+""\s+instance=ExtResource\(""([^""]+)""\)\]",RegexOptions.Compiled);private static readonly(string Kind,Regex Pattern)[]CodeRequests={("sound",new Regex(@"PlaySound\(\s*""([^""]+)""(?:\s*,\s*(\d+))?",RegexOptions.Compiled)),("music",new Regex(@"Play(?:Music|Ambient)\(\s*""([^""]+)""",RegexOptions.Compiled)),("texture",new Regex(@"Textures\.Resolve[A-Za-z]*\(\s*""([^""]+)""",RegexOptions.Compiled)),("shader",new Regex(@"Shaders\.Resolve[A-Za-z]*\(\s*""([^""]+)""",RegexOptions.Compiled)),("locale_key",new Regex(@"\b(?:Translate|Tr)\(\s*""([a-z_]+(?:/[A-Za-z0-9_]+)+)""",RegexOptions.Compiled)),("locale_key",new Regex(@"\bLocale\s*=\s*""([a-z_]+(?:/[A-Za-z0-9_]+)+)""",RegexOptions.Compiled)),};private static readonly HashSet<string>TextExtensions=new(StringComparer.OrdinalIgnoreCase){".tscn",".tres",".json",".scenario",".gd",".cs",".gdshader",};/// <param name="runtimeActions">Registered scenario actions with the name of the assembly that declares them: a mod assembly is named by its mod id, anything else is the kernel and always available.</param> <param name="outsideMods">Installed mods that are not loaded (the atlas runs inside a game); with <paramref name="outsideGraph"/> they only contribute provisions, used to explain keys the game misses.</param>
public static ResourceAtlasResult Build(IReadOnlyList<IModInfo>modsInLoadOrder,ValidationGraph graph,IReadOnlyCollection<(string Name,string Assembly)>runtimeActions=null,IReadOnlyList<IModInfo>outsideMods=null,ValidationGraph outsideGraph=null){var result=new ResourceAtlasResult();foreach(var mod in modsInLoadOrder){result.LoadOrder.Add(mod.Id);result.Depends[mod.Id]=(mod as ModInfo)?.Manifest?.DependencyIds??new List<string>();}foreach(var id in result.LoadOrder)result.Closures[id]=Closure(id,result.Depends);foreach(var declaration in graph.Declarations)result.Provisions.Add(new AtlasProvision{Kind=declaration.Kind,Id=declaration.Id,ModId=declaration.ModId,File=declaration.File});foreach(var reference in graph.References){if(reference.Kind==ValidationKind.ScenePath)continue;// manifest scene_path is always mod-relative
result.Requests.Add(new AtlasRequest{Kind=reference.Kind,Id=reference.TargetId,ModId=reference.ModId,File=reference.File,Line=reference.Line,Via="data",});}foreach(var mod in modsInLoadOrder)ScanMod(mod,result,result.Provisions,collectRequests:true);foreach(var mod in outsideMods??Array.Empty<IModInfo>())ScanMod(mod,result,result.OutsideProvisions,collectRequests:false);foreach(var declaration in outsideGraph?.Declarations??Enumerable.Empty<ValidationDeclaration>())result.OutsideProvisions.Add(new AtlasProvision{Kind=declaration.Kind,Id=declaration.Id,ModId=declaration.ModId,File=declaration.File});foreach(var(name,assembly)in runtimeActions??Array.Empty<(string,string)>()){if(result.Depends.ContainsKey(assembly??string.Empty))Provide(result,"action",name,assembly,"<runtime>");else result.KernelKeys.Add(("action",name));}Analyze(result);return result;}/// <summary> Indexes one mod. Provisions go to <paramref name="provisions"/> (loaded or outside list); requests and patches are collected only for loaded mods. </summary>
private static void ScanMod(IModInfo mod,ResourceAtlasResult result,List<AtlasProvision>provisions,bool collectRequests){var provider=mod.FileProvider;if(provider==null)return;var target=collectRequests?result:new ResourceAtlasResult();void Provide(ResourceAtlasResult _,string kind,string id,string modId,string file)=>provisions.Add(new AtlasProvision{Kind=kind,Id=id,ModId=modId,File=file});var textureExtensions=ModConfig.Instance.GetTextureExtensionSet();foreach(var group in ModConfig.Instance.TextureGroups){foreach(var file in ModFileReader.ListFiles(provider,group,true)){if(ModFileReader.HasExtension(file,textureExtensions))Provide(result,"texture",Path.GetFileNameWithoutExtension(file),mod.Id,file);}}foreach(var file in ModFileReader.ListFiles(provider,"shaders",true)){if(file.EndsWith(".gdshader",StringComparison.OrdinalIgnoreCase))Provide(result,"shader",StripExtension(file.Substring("shaders/".Length)),mod.Id,file);}foreach(var file in ModFileReader.ListFiles(provider,"scenes",true)){if(file.EndsWith(".tscn",StringComparison.OrdinalIgnoreCase))Provide(result,"scene_file",file.Substring("scenes/".Length),mod.Id,file);}foreach(var file in ModFileReader.ListFiles(provider,"scenarios",true)){// Scenarios are identified by file name: a mod replaces another mod's scenario by shipping the same name and depending on it.
if(file.EndsWith(".scenario",StringComparison.OrdinalIgnoreCase))Provide(result,"scenario",Path.GetFileNameWithoutExtension(file),mod.Id,file);}foreach(var file in ModFileReader.ListFiles(provider,"locale",true)){if(file.EndsWith(".properties",StringComparison.OrdinalIgnoreCase))ScanLocale(provider,file,mod.Id,provisions);}foreach(var file in ModFileReader.ListFiles(provider,string.Empty,true)){if(!TextExtensions.Contains(Path.GetExtension(file))||IsToolingPath(file))continue;var text=provider.ReadFileText(file);if(string.IsNullOrEmpty(text))continue;text=text.Replace("\r\n","\n");var isCode=file.EndsWith(".cs",StringComparison.OrdinalIgnoreCase);if(isCode)text=StripCSharpComments(text);ScanResReferences(text,file,mod.Id,target);if(isCode)ScanCode(text,file,mod.Id,target,provisions);else if(file.EndsWith(".gd",StringComparison.OrdinalIgnoreCase)){foreach(Match match in GdActionName.Matches(text))Provide(result,"action",match.Groups[1].Value,mod.Id,file);}else if(file.EndsWith(".scenario",StringComparison.OrdinalIgnoreCase))ScanScenario(text,file,mod.Id,target);else if(file.EndsWith(".json",StringComparison.OrdinalIgnoreCase))ScanJson(text,file,mod.Id,target);else if(file.StartsWith("patches/scenes/",StringComparison.Ordinal)&&file.EndsWith(".tscn",StringComparison.OrdinalIgnoreCase))ScanScenePatch(text,file,mod.Id,target);}}/// <summary>Tests and build output are not shipped content; their literals must not count as requests.</summary>
private static bool IsToolingPath(string file)=>file.StartsWith("tests/",StringComparison.Ordinal)||file.StartsWith("test_suites/",StringComparison.Ordinal)// mounted as their own mod (<host>__<suite>)
||file.StartsWith("source_code/",StringComparison.Ordinal)||file.Contains("/bin/",StringComparison.Ordinal)||file.Contains("/obj/",StringComparison.Ordinal);private static void ScanLocale(IModFileProvider provider,string file,string modId,List<AtlasProvision>provisions){var language=Path.GetFileNameWithoutExtension(file);var underscore=language.LastIndexOf('_');if(underscore>=0)language=language.Substring(underscore+1);var text=provider.ReadFileText(file)??string.Empty;foreach(var raw in text.Split('\n')){var line=raw.Trim();if(line.Length==0||line.StartsWith("#",StringComparison.Ordinal)||line.StartsWith("!",StringComparison.Ordinal))continue;var separator=line.IndexOf('=');if(separator<=0)continue;var key=line.Substring(0,separator).Trim();provisions.Add(new AtlasProvision{Kind="locale",Id=$"{language}:{key}",ModId=modId,File=file});// Language-agnostic key: what code asks for (Translate/Tr/Locale); overrides are analysed per language on "locale" only.
provisions.Add(new AtlasProvision{Kind="locale_key",Id=key,ModId=modId,File=file});}}private static void ScanResReferences(string text,string file,string modId,ResourceAtlasResult result){foreach(Match match in ResModReference.Matches(text)){result.Requests.Add(new AtlasRequest{Kind="file",Id=$"{match.Groups[1].Value}/{match.Groups[2].Value}",ModId=modId,File=file,Line=LineOf(text,match.Index),Via="res",ExplicitProvider=match.Groups[1].Value,});}}private static void ScanCode(string text,string file,string modId,ResourceAtlasResult result,List<AtlasProvision>provisions){foreach(Match match in CSharpActionName.Matches(text))provisions.Add(new AtlasProvision{Kind="action",Id=match.Groups[1].Value,ModId=modId,File=file});foreach(Match match in EntityKindAttribute.Matches(text))provisions.Add(new AtlasProvision{Kind="entity_kind",Id=match.Groups[1].Value,ModId=modId,File=file});foreach(var(kind,pattern)in CodeRequests){foreach(Match match in pattern.Matches(text)){// PlaySound(id, n) with n > 1 picks id_1..id_n at random (AudioService).
var variations=match.Groups.Count>2&&int.TryParse(match.Groups[2].Value,out var n)?n:1;var keys=variations>1?Enumerable.Range(1,variations).Select(i=>$"{match.Groups[1].Value}_{i}"):new[]{match.Groups[1].Value};foreach(var key in keys){result.Requests.Add(new AtlasRequest{Kind=kind,Id=key,ModId=modId,File=file,Line=LineOf(text,match.Index),Via="code",});}}}}private static void ScanScenario(string text,string file,string modId,ResourceAtlasResult result){var lines=text.Split('\n');for(var i=0;i<lines.Length;i++){var match=ScenarioAction.Match(lines[i]);if(match.Success){result.Requests.Add(new AtlasRequest{Kind="action",Id=match.Groups[1].Value,ModId=modId,File=file,Line=i+1,Via="scenario",});}}}private static void ScanJson(string text,string file,string modId,ResourceAtlasResult result){if(file=="manifest.json"||file.StartsWith("collections/",StringComparison.Ordinal))return;JsonDocument document;try{document=JsonDocument.Parse(text,new JsonDocumentOptions{CommentHandling=JsonCommentHandling.Skip,AllowTrailingCommas=true});}catch(JsonException){return;}using(document){var root=document.RootElement;if(root.ValueKind!=JsonValueKind.Object)return;var isPatch=file.StartsWith("patches/",StringComparison.Ordinal);if(isPatch){ScanEntityPatch(root,file,modId,result);return;}if(root.TryGetProperty("Kind",out var kind)&&kind.ValueKind==JsonValueKind.String){result.Requests.Add(new AtlasRequest{Kind="entity_kind",Id=kind.GetString(),ModId=modId,File=file,Line=0,Via="entity_data",});}}}/// <summary><c>patches/&lt;kind&gt;.json</c> or <c>patches/&lt;kind&gt;/*.json</c>: each item id (or <c>*</c>) patches entities of that kind.</summary>
private static void ScanEntityPatch(JsonElement root,string file,string modId,ResourceAtlasResult result){var relative=file.Substring("patches/".Length);var kind=relative.Contains('/')?relative.Substring(0,relative.IndexOf('/')):StripExtension(relative);if(!root.TryGetProperty("Items",out var items)||items.ValueKind!=JsonValueKind.Array)return;foreach(var item in items.EnumerateArray()){if(item.ValueKind!=JsonValueKind.Object)continue;var id=JsonReadHelper.GetString(item,"Id","ID","id");if(!string.IsNullOrEmpty(id))result.Patches.Add(new AtlasPatch{Kind=kind,Id=id,ModId=modId,File=file});}}/// <summary>A scene patch is an inherited scene: the root instance names the patched target.</summary>
private static void ScanScenePatch(string text,string file,string modId,ResourceAtlasResult result){var root=RootInstance.Match(text);if(!root.Success)return;foreach(Match match in PatchTarget.Matches(text)){if(match.Groups[2].Value!=root.Groups[1].Value)continue;var target=match.Groups[1].Value;if(target.StartsWith("res://",StringComparison.Ordinal))target=target.Substring("res://".Length);string owner=null;if(target.StartsWith("mods/",StringComparison.Ordinal)){var parts=target.Split('/',3);owner=parts.Length>1?parts[1]:null;target=parts.Length>2?parts[2]:string.Empty;}if(target.StartsWith("scenes/",StringComparison.Ordinal))target=target.Substring("scenes/".Length);result.Patches.Add(new AtlasPatch{Kind="scene_file",Id=target,ModId=modId,File=file,ExplicitOwner=owner});}}private static void Analyze(ResourceAtlasResult result){var loadIndex=result.LoadOrder.Select((id,index)=>(id,index)).ToDictionary(p=>p.id,p=>p.index,StringComparer.Ordinal);var providers=result.Provisions.Where(p=>loadIndex.ContainsKey(p.ModId)).GroupBy(p=>(p.Kind,p.Id)).ToDictionary(g=>g.Key,g=>g.Select(p=>p.ModId).Distinct().OrderBy(m=>loadIndex[m]).ToList());foreach(var request in result.Requests)AnalyzeRequest(result,request,providers,loadIndex);foreach(var pair in providers.Where(p=>p.Value.Count>1&&p.Key.Kind!="locale_key"))AnalyzeOverride(result,pair.Key.Kind,pair.Key.Id,pair.Value);foreach(var patch in result.Patches)AnalyzePatch(result,patch,providers,loadIndex);}private static void AnalyzeRequest(ResourceAtlasResult result,AtlasRequest request,Dictionary<(string,string),List<string>>providers,Dictionary<string,int>loadIndex){List<string>candidates;if(request.ExplicitProvider!=null)candidates=loadIndex.ContainsKey(request.ExplicitProvider)?new List<string>{request.ExplicitProvider}:new List<string>();else if(!providers.TryGetValue((request.Kind,request.Id),out candidates))candidates=new List<string>();if(candidates.Contains(request.ModId)||result.KernelKeys.Contains((request.Kind,request.Id)))return;if(candidates.Count==0){var outside=result.OutsideProvisions.Where(p=>p.Kind==request.Kind&&p.Id==request.Id).Select(p=>p.ModId).Distinct().ToList();if(request.ExplicitProvider!=null&& !loadIndex.ContainsKey(request.ExplicitProvider)&&result.OutsideProvisions.Any(p=>p.ModId==request.ExplicitProvider))outside=new List<string>{request.ExplicitProvider};if(outside.Count>0){result.Findings.Add(new AtlasFinding{Kind=AtlasFindingKind.OutsideGame,Severity=AtlasSeverity.Error,ModId=request.ModId,OtherMods=outside,ResourceKind=request.Kind,ResourceId=request.Id,File=request.File,Line=request.Line,Message=$"ресурс есть только в моде {string.Join("/", outside)}, который не входит в игру — добавьте мод в коллекцию или перенесите ресурс",});return;}result.Findings.Add(new AtlasFinding{Kind=AtlasFindingKind.Unresolved,Severity=AtlasSeverity.Warning,ModId=request.ModId,ResourceKind=request.Kind,ResourceId=request.Id,File=request.File,Line=request.Line,Message=request.ExplicitProvider!=null?$"ссылается на мод '{request.ExplicitProvider}', который не загружен":"ни один загруженный мод не предоставляет этот ключ",});return;}var closure=result.Closures[request.ModId];var declared=candidates.Any(closure.Contains);result.Findings.Add(new AtlasFinding{Kind=declared?AtlasFindingKind.DeclaredUse:AtlasFindingKind.UndeclaredUse,Severity=declared?AtlasSeverity.Info:AtlasSeverity.Error,ModId=request.ModId,OtherMods=candidates,ResourceKind=request.Kind,ResourceId=request.Id,File=request.File,Line=request.Line,Message=declared?$"использует ресурс мода {string.Join("/", candidates.Where(closure.Contains))} ({request.Via})":$"использует ресурс мода {string.Join("/", candidates)} ({request.Via}), но его нет в depends — добавьте зависимость или перенесите ресурс",});}/// <summary> The last provider in load order wins. A loser is overridden on purpose only when the winner depends on it; scene definitions merge instead (the first provider is the base, later ones extend it), which is equally order-sensitive. </summary>
private static void AnalyzeOverride(ResourceAtlasResult result,string kind,string id,List<string>ordered){var merges=kind==ValidationKind.Scene;var winner=merges?ordered[0]:ordered[^1];foreach(var other in ordered.Where(m=>m!=winner)){var later=merges?other:winner;var earlier=merges?winner:other;var declared=result.Closures[later].Contains(earlier);result.Findings.Add(new AtlasFinding{Kind=declared?AtlasFindingKind.DeclaredOverride:AtlasFindingKind.UnorderedOverride,Severity=declared?AtlasSeverity.Info:AtlasSeverity.Error,ModId=later,OtherMods=new List<string>{earlier},ResourceKind=kind,ResourceId=id,File=result.Provisions.First(p=>p.Kind==kind&&p.Id==id&&p.ModId==later).File,Message=(merges?$"расширяет определение мода {earlier}":$"переопределяет ресурс мода {earlier}")+(declared?string.Empty:" без зависимости между ними — результат зависит от случайного порядка загрузки; переименуйте ключ или объявите depends"),});}}private static void AnalyzePatch(ResourceAtlasResult result,AtlasPatch patch,Dictionary<(string,string),List<string>>providers,Dictionary<string,int>loadIndex){List<string>owners;if(patch.ExplicitOwner!=null)owners=loadIndex.ContainsKey(patch.ExplicitOwner)?new List<string>{patch.ExplicitOwner}:new List<string>();else if(patch.Id=="*")owners=providers.Where(p=>p.Key.Item1==patch.Kind).SelectMany(p=>p.Value).Distinct().ToList();else if(!providers.TryGetValue((patch.Kind,patch.Id),out owners))owners=new List<string>();owners=owners.Where(o=>o!=patch.ModId).ToList();if(owners.Count==0){result.Findings.Add(new AtlasFinding{Kind=AtlasFindingKind.PatchTargetMissing,Severity=AtlasSeverity.Warning,ModId=patch.ModId,ResourceKind=patch.Kind,ResourceId=patch.Id,File=patch.File,Message="патч не находит цель ни в одном другом загруженном моде",});return;}var closure=result.Closures[patch.ModId];var undeclared=owners.Where(o=> !closure.Contains(o)).ToList();result.Findings.Add(new AtlasFinding{Kind=undeclared.Count==0?AtlasFindingKind.DeclaredPatch:AtlasFindingKind.UndeclaredPatch,Severity=undeclared.Count==0?AtlasSeverity.Info:AtlasSeverity.Error,ModId=patch.ModId,OtherMods=owners,ResourceKind=patch.Kind,ResourceId=patch.Id,File=patch.File,Message=undeclared.Count==0?$"патчит ресурс мода {string.Join("/", owners)}":$"патчит ресурс мода {string.Join("/", undeclared)} без зависимости от него — порядок применения патча не гарантирован",});}private static void Provide(ResourceAtlasResult result,string kind,string id,string modId,string file)=>result.Provisions.Add(new AtlasProvision{Kind=kind,Id=id,ModId=modId,File=file});private static HashSet<string>Closure(string modId,Dictionary<string,List<string>>depends){var visible=new HashSet<string>(StringComparer.Ordinal);var pending=new Stack<string>(depends.TryGetValue(modId,out var direct)?direct:new List<string>());while(pending.Count>0){var id=pending.Pop();if(visible.Add(id)&&depends.TryGetValue(id,out var next)){foreach(var dependency in next)pending.Push(dependency);}}return visible;}private static string StripExtension(string path){var dot=path.LastIndexOf('.');var slash=path.LastIndexOf('/');return dot>slash?path.Substring(0,dot):path;}/// <summary> Blanks C# comments while keeping line breaks (line numbers stay valid) and string/char literals intact — a naive <c>//</c> cut would truncate <c>"res://..."</c>. </summary>
private static string StripCSharpComments(string text){var chars=text.ToCharArray();var i=0;while(i<chars.Length){var c=chars[i];var next=i+1<chars.Length?chars[i+1]:'\0';if(c=='/'&&next=='/'){while(i<chars.Length&&chars[i]!='\n')chars[i++]=' ';}else if(c=='/'&&next=='*'){while(i<chars.Length&& !(chars[i]=='*'&&i+1<chars.Length&&chars[i+1]=='/')){if(chars[i]!='\n')chars[i]=' ';i++;}if(i<chars.Length){chars[i++]=' ';chars[i++]=' ';}}else if(c=='"'||c=='\''){var verbatim=c=='"'&&i>0&&(chars[i-1]=='@'||(chars[i-1]=='$'&&i>1&&chars[i-2]=='@'));i++;while(i<chars.Length&&chars[i]!=c&&chars[i]!='\n'){if(!verbatim&&chars[i]=='\\')i++;else if(verbatim&&chars[i]=='"'&&i+1<chars.Length&&chars[i+1]=='"')i++;i++;}i++;}else{i++;}}return new string(chars);}private static int LineOf(string text,int index){var line=1;for(var i=0;i<index&&i<text.Length;i++){if(text[i]=='\n')line++;}return line;}}}

===== FILE mods/resource_atlas/src/ResourceAtlasDriverNode.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Threading;using System.Threading.Tasks;using Cthangover.Core.Bootstrap;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Cthangover.Validator;using Godot;namespace Cthangover.ResourceAtlas{/// <summary> Runs one atlas pass after the boot: snapshots the loaded mods in load order, builds the validator graph and the atlas on a worker thread, writes the report, logs every error as <c>[FAIL]</c> and prints <c>[TEST_RESULT] name=resource_atlas passed=&lt;clean mods&gt; failed=&lt;mods with errors&gt;</c> before quitting with 0/1. A watchdog ends a hung pass with a failure instead of blocking the test run. </summary>
public partial class ResourceAtlasDriverNode:Node{private const string ResultName="resource_atlas";private readonly string directory;private readonly float timeoutSeconds;private double elapsed;private bool started;private bool finished;private Task<ResourceAtlasResult>pass;public ResourceAtlasDriverNode(){}public ResourceAtlasDriverNode(string directory,float timeoutSeconds){this.directory=directory;this.timeoutSeconds=timeoutSeconds;Name="ResourceAtlasDriver";}public override void _Process(double delta){if(finished)return;elapsed+=delta;if(elapsed>timeoutSeconds){Finish(null,$"timeout after {timeoutSeconds:F0}s");return;}if(!started){if(!Boot.IsReady)return;started=true;ValidatorService.WarmUp();var mods=SnapshotMods();var actions=ScenarioActionFactory.Instance.GetAll().Select(a=>(a.Name,a.GetType().Assembly.GetName().Name)).ToList();GameLogger.Log("RESOURCE_ATLAS",$"building atlas for {mods.Count} mod(s)");var loaded=new HashSet<string>(mods.Select(m=>m.Id),StringComparer.Ordinal);var outside=ModRegistry.Instance.AllDiscoveredMods.Values.Where(m=> !loaded.Contains(m.Id)).ToList();if(outside.Count>0)GameLogger.Log("RESOURCE_ATLAS",$"{outside.Count} installed mod(s) are outside the loaded set; keys only they provide are errors");pass=Task.Run(()=>{var context=ValidatorService.Analyze(mods,null,CancellationToken.None);var outsideContext=outside.Count>0?ValidatorService.Analyze(outside,null,CancellationToken.None):null;return ResourceAtlasBuilder.Build(mods,context.Graph,actions,outside,outsideContext?.Graph);});return;}if(pass is{IsCompleted:true}){if(pass.IsFaulted)Finish(null,$"atlas pass failed: {pass.Exception?.GetBaseException().Message}");else Finish(pass.Result,null);}}private static List<IModInfo>SnapshotMods(){var registry=ModRegistry.Instance;var result=new List<IModInfo>();foreach(var id in registry.GetOrderedModIds()){if(registry.Mods.TryGetValue(id,out var info)&&info!=null)result.Add(info);}return result;}private void Finish(ResourceAtlasResult result,string failure){if(finished)return;finished=true;var passed=0;var failed=0;if(result!=null){try{ResourceAtlasReportWriter.Write(result,directory);}catch(Exception ex){failure=$"report write failed: {ex.Message}";}foreach(var error in result.Errors.GroupBy(e=>(e.Kind,e.ModId,Others:string.Join(",",e.OtherMods),e.ResourceKind,e.ResourceId)).Select(g=>g.First())){GameLogger.Log("RESOURCE_ATLAS",$"[FAIL] {error.ModId} -> {string.Join("/", error.OtherMods)}: {error.ResourceKind} '{error.ResourceId}' {error.Message} ({error.File}{(error.Line > 0 ? ":" + error.Line : string.Empty)})");}var failing=result.FailingMods();failed=failing.Count;passed=result.LoadOrder.Count-failed;GameLogger.Log("RESOURCE_ATLAS",$"atlas: {result.Errors.Count()} error(s), {result.Warnings.Count()} warning(s); report: {Path.Combine(directory, "report.md")}");}if(failure!=null){failed++;GameLogger.Log("RESOURCE_ATLAS",$"[FAIL] {failure}",LogLevel.Error);}GameLogger.Log("RESOURCE_ATLAS",$"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");GetTree().Quit(failed==0?0:1);}}}

===== FILE mods/resource_atlas/src/ResourceAtlasInitializer.cs =====
using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Cthangover.ResourceAtlas{/// <summary> Activates the atlas only on demand: the <c>--resource-atlas</c> user arg (after <c>--</c>) queues <see cref="ResourceAtlasDriverNode"/>, which waits for the boot, writes the report and quits. Optional <c>--atlas-out=&lt;dir&gt;</c> (res://, user:// or absolute) sets the report directory, <c>--timeout=&lt;s&gt;</c> the watchdog. </summary>
public class ResourceAtlasInitializer:IModInitializer{public const string Flag="--resource-atlas";private const string OutPrefix="--atlas-out=";private const string DefaultOut="user://resource_atlas";private static bool queued;public void OnModLoaded(string modId){}public void OnModResourcesReady(){if(queued)return;var args=OS.GetCmdlineUserArgs();if(!args.Contains(Flag))return;var output=args.FirstOrDefault(a=>a.StartsWith(OutPrefix,System.StringComparison.Ordinal))?.Substring(OutPrefix.Length);if(string.IsNullOrWhiteSpace(output))output=DefaultOut;queued=true;var directory=ProjectSettings.GlobalizePath(output);GameLogger.Log("RESOURCE_ATLAS",$"resource atlas requested, report directory '{directory}'");(Godot.Engine.GetMainLoop()as SceneTree)?.Root?.CallDeferred("add_child",new ResourceAtlasDriverNode(directory,ParseTimeout(args)));}private static float ParseTimeout(string[]args){var raw=args.FirstOrDefault(a=>a.StartsWith("--timeout=",System.StringComparison.Ordinal))?.Substring("--timeout=".Length);return float.TryParse(raw,System.Globalization.NumberStyles.Float,System.Globalization.CultureInfo.InvariantCulture,out var seconds)&&seconds>0?seconds:120f;}}}

===== FILE mods/resource_atlas/src/ResourceAtlasReportWriter.cs =====
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Text;using System.Text.Encodings.Web;using System.Text.Json;namespace Cthangover.ResourceAtlas{/// <summary> Writes <c>report.md</c> (read first: errors with the fix, then the mod-to-mod picture) and <c>report.json</c> (every finding, for tools). Repeated occurrences of one link are folded into a count with the first location, so the markdown stays readable for agents and people. </summary>
public static class ResourceAtlasReportWriter{private const int Samples=5;public static void Write(ResourceAtlasResult result,string directory){Directory.CreateDirectory(directory);File.WriteAllText(Path.Combine(directory,"report.md"),BuildMarkdown(result),new UTF8Encoding(false));File.WriteAllText(Path.Combine(directory,"report.json"),BuildJson(result),new UTF8Encoding(false));}public static string BuildMarkdown(ResourceAtlasResult result){var md=new StringBuilder();var errors=Fold(result.Errors).ToList();var warnings=result.Warnings.ToList();md.AppendLine("# Карта ресурсов модов");md.AppendLine();md.AppendLine($"Создано: {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC · модов: {result.LoadOrder.Count} · "+$"ресурсов: {result.Provisions.Count} · запросов: {result.Requests.Count} · патчей: {result.Patches.Count}");md.AppendLine();md.AppendLine($"**Ошибок: {errors.Count}** (моды: {string.Join(", ", result.FailingMods()).OrEmpty("нет")}) · "+$"предупреждений: {warnings.Count}");md.AppendLine();md.AppendLine("Как читать: связь «A → B» значит, что мод A берёт ресурс мода B по ключу. Связь корректна, "+"когда B есть в транзитивных `depends` мода A. При совпадении ключа выигрывает мод, загруженный позже, "+"и это детерминировано только вдоль цепочки `depends`.");md.AppendLine();md.AppendLine("## Ошибки");md.AppendLine();if(errors.Count==0)md.AppendLine("Нет.");foreach(var group in errors.GroupBy(e=>e.First.Kind)){md.AppendLine($"### {Title(group.Key)}");md.AppendLine();foreach(var error in group){var f=error.First;md.AppendLine($"- `{f.ModId}` → `{string.Join("`, `", f.OtherMods)}`: {f.ResourceKind} `{f.ResourceId}` — {f.Message}. "+$"{Location(f)}{(error.Count > 1 ? $" (+{error.Count - 1})" : string.Empty)}");}md.AppendLine();}AppendLinks(md,result);AppendOverrides(md,result);AppendPatches(md,result);AppendUnresolved(md,warnings);AppendModTable(md,result);md.AppendLine("## Порядок загрузки");md.AppendLine();md.AppendLine(string.Join(" → ",result.LoadOrder.Select(m=>$"`{m}`")));return md.ToString();}private static void AppendLinks(StringBuilder md,ResourceAtlasResult result){md.AppendLine("## Связи между модами (кто чьи ресурсы использует)");md.AppendLine();var links=result.Findings.Where(f=>f.Kind is AtlasFindingKind.DeclaredUse or AtlasFindingKind.UndeclaredUse).SelectMany(f=>f.OtherMods.Select(other=>(f,other))).GroupBy(x=>(x.f.ModId,x.other)).OrderBy(g=>g.Key.ModId,StringComparer.Ordinal).ThenBy(g=>g.Key.other,StringComparer.Ordinal).ToList();if(links.Count==0){md.AppendLine("Нет.");md.AppendLine();return;}md.AppendLine("| Мод | Берёт ресурсы у | В depends | Запросов | Виды | Примеры |");md.AppendLine("|---|---|---|---|---|---|");foreach(var link in links){var declared=result.Closures[link.Key.ModId].Contains(link.Key.other);var kinds=string.Join(", ",link.Select(x=>x.f.ResourceKind).Distinct());var samples=string.Join(", ",link.Select(x=>$"`{x.f.ResourceId}`").Distinct().Take(Samples));md.AppendLine($"| `{link.Key.ModId}` | `{link.Key.other}` | {(declared ? "да" : "**нет**")} | {link.Count()} | {kinds} | {samples} |");}md.AppendLine();}private static void AppendOverrides(StringBuilder md,ResourceAtlasResult result){md.AppendLine("## Переопределения (один ключ у нескольких модов)");md.AppendLine();var overrides=result.Findings.Where(f=>f.Kind is AtlasFindingKind.DeclaredOverride or AtlasFindingKind.UnorderedOverride).GroupBy(f=>(f.ModId,Other:f.OtherMods[0],f.Kind)).OrderBy(g=>g.Key.Kind).ThenBy(g=>g.Key.ModId,StringComparer.Ordinal).ToList();if(overrides.Count==0){md.AppendLine("Нет.");md.AppendLine();return;}md.AppendLine("| Побеждает | Перекрывает | Упорядочено depends | Ключей | Виды | Примеры |");md.AppendLine("|---|---|---|---|---|---|");foreach(var group in overrides){var kinds=string.Join(", ",group.Select(f=>f.ResourceKind).Distinct());var samples=string.Join(", ",group.Select(f=>$"`{f.ResourceId}`").Distinct().Take(Samples));var ordered=group.Key.Kind==AtlasFindingKind.DeclaredOverride?"да":"**нет**";md.AppendLine($"| `{group.Key.ModId}` | `{group.Key.Other}` | {ordered} | {group.Count()} | {kinds} | {samples} |");}md.AppendLine();}private static void AppendPatches(StringBuilder md,ResourceAtlasResult result){md.AppendLine("## Патчи чужих ресурсов");md.AppendLine();var patches=result.Findings.Where(f=>f.Kind is AtlasFindingKind.DeclaredPatch or AtlasFindingKind.UndeclaredPatch).ToList();if(patches.Count==0){md.AppendLine("Нет.");md.AppendLine();return;}md.AppendLine("| Мод | Патчит мод | В depends | Ресурс | Файл |");md.AppendLine("|---|---|---|---|---|");foreach(var patch in patches.OrderBy(p=>p.ModId,StringComparer.Ordinal)){var declared=patch.Kind==AtlasFindingKind.DeclaredPatch?"да":"**нет**";md.AppendLine($"| `{patch.ModId}` | `{string.Join("`, `", patch.OtherMods)}` | {declared} | {patch.ResourceKind} `{patch.ResourceId}` | `{patch.File}` |");}md.AppendLine();}private static void AppendUnresolved(StringBuilder md,List<AtlasFinding>warnings){md.AppendLine("## Предупреждения: ключ не найден ни в одном моде");md.AppendLine();if(warnings.Count==0){md.AppendLine("Нет.");md.AppendLine();return;}foreach(var group in Fold(warnings).OrderBy(w=>w.First.ModId,StringComparer.Ordinal)){var f=group.First;md.AppendLine($"- `{f.ModId}`: {f.ResourceKind} `{f.ResourceId}` — {f.Message}. {Location(f)}{(group.Count > 1 ? $" (+{group.Count - 1})" : string.Empty)}");}md.AppendLine();}private static void AppendModTable(StringBuilder md,ResourceAtlasResult result){md.AppendLine("## Сводка по модам");md.AppendLine();md.AppendLine("| Мод | depends | Берёт у | Его ресурсы берут | Перекрывает | Ошибок |");md.AppendLine("|---|---|---|---|---|---|");var uses=result.Findings.Where(f=>f.Kind is AtlasFindingKind.DeclaredUse or AtlasFindingKind.UndeclaredUse).ToList();var overrides=result.Findings.Where(f=>f.Kind is AtlasFindingKind.DeclaredOverride or AtlasFindingKind.UnorderedOverride).ToList();foreach(var mod in result.LoadOrder){var takesFrom=uses.Where(f=>f.ModId==mod).SelectMany(f=>f.OtherMods).Distinct().OrderBy(m=>m,StringComparer.Ordinal);var usedBy=uses.Where(f=>f.OtherMods.Contains(mod)).Select(f=>f.ModId).Distinct().OrderBy(m=>m,StringComparer.Ordinal);var covers=overrides.Where(f=>f.ModId==mod).SelectMany(f=>f.OtherMods).Distinct().OrderBy(m=>m,StringComparer.Ordinal);var errors=result.Errors.Count(f=>f.ModId==mod);md.AppendLine($"| `{mod}` | {Join(result.Depends[mod])} | {Join(takesFrom)} | {Join(usedBy)} | {Join(covers)} | {(errors > 0 ? $"**{errors}**" : "0")} |");}md.AppendLine();}public static string BuildJson(ResourceAtlasResult result){var report=new{generatedAt=DateTime.UtcNow.ToString("O"),summary=new{mods=result.LoadOrder.Count,provisions=result.Provisions.Count,requests=result.Requests.Count,patches=result.Patches.Count,errors=result.Errors.Count(),warnings=result.Warnings.Count(),failingMods=result.FailingMods(),},loadOrder=result.LoadOrder,mods=result.LoadOrder.Select(m=>new{id=m,depends=result.Depends[m],closure=result.Closures[m].OrderBy(x=>x,StringComparer.Ordinal)}),findings=result.Findings.Select(f=>new{severity=f.Severity.ToString().ToLowerInvariant(),kind=f.Kind.ToString(),mod=f.ModId,otherMods=f.OtherMods,resourceKind=f.ResourceKind,resourceId=f.ResourceId,file=f.File,line=f.Line,message=f.Message,}),};return JsonSerializer.Serialize(report,new JsonSerializerOptions{WriteIndented=true,Encoder=JavaScriptEncoder.UnsafeRelaxedJsonEscaping,});}/// <summary>Groups repeated occurrences of one logical finding (same mods, kind and key).</summary>
private static IEnumerable<(AtlasFinding First,int Count)>Fold(IEnumerable<AtlasFinding>findings)=>findings.GroupBy(f=>(f.Kind,f.ModId,Others:string.Join(",",f.OtherMods),f.ResourceKind,f.ResourceId)).Select(g=>(g.First(),g.Count()));private static string Title(AtlasFindingKind kind)=>kind switch{AtlasFindingKind.UndeclaredUse=>"Используется чужой ресурс без зависимости в depends",AtlasFindingKind.UnorderedOverride=>"Переопределение без порядка (победитель случаен)",AtlasFindingKind.UndeclaredPatch=>"Патч чужого ресурса без зависимости",AtlasFindingKind.OutsideGame=>"Ресурс есть только в модах вне игры",_=>kind.ToString(),};private static string Location(AtlasFinding finding)=>string.IsNullOrEmpty(finding.File)?string.Empty:finding.Line>0?$"`{finding.File}:{finding.Line}`":$"`{finding.File}`";private static string Join(IEnumerable<string>ids){var list=ids.ToList();return list.Count==0?"—":string.Join(", ",list);}private static string OrEmpty(this string value,string fallback)=>string.IsNullOrEmpty(value)?fallback:value;}}

===== FILE mods/resource_atlas/src/ResourceAtlasResult.cs =====
using System;using System.Collections.Generic;using System.Linq;namespace Cthangover.ResourceAtlas{/// <summary> Everything the atlas learned in one pass: load order and depends closures, every provision/request/patch, and the findings derived from them. Report writers and the test driver read only this object. </summary>
public sealed class ResourceAtlasResult{public List<string>LoadOrder{get;}=new();public Dictionary<string,List<string>>Depends{get;}=new(StringComparer.Ordinal);public Dictionary<string,HashSet<string>>Closures{get;}=new(StringComparer.Ordinal);public List<AtlasProvision>Provisions{get;}=new();/// <summary>Provisions of installed mods that are not loaded (only when the atlas runs inside a game).</summary>
public List<AtlasProvision>OutsideProvisions{get;}=new();public List<AtlasRequest>Requests{get;}=new();public List<AtlasPatch>Patches{get;}=new();public List<AtlasFinding>Findings{get;}=new();/// <summary>Keys the kernel provides (e.g. scenario actions from Core); always available, never a cross-mod link.</summary>
public HashSet<(string Kind,string Id)>KernelKeys{get;}=new();public IEnumerable<AtlasFinding>Errors=>Findings.Where(f=>f.Severity==AtlasSeverity.Error);public IEnumerable<AtlasFinding>Warnings=>Findings.Where(f=>f.Severity==AtlasSeverity.Warning);/// <summary>Mods with at least one error finding, in load order.</summary>
public List<string>FailingMods(){var failing=new HashSet<string>(Errors.Select(f=>f.ModId),StringComparer.Ordinal);return LoadOrder.Where(failing.Contains).ToList();}}}

===== FILE mods/scenario_studio/src/Model/StudioBranch.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary>An answer of the current question with its resolved outcome (<see cref="Target"/>: scene or action id).</summary>
public sealed class StudioBranch{public StudioCodeLine Line{get;set;}public StudioOutcomeKind Kind{get;set;}public string Target{get;set;}}}

===== FILE mods/scenario_studio/src/Model/StudioCandidate.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary> A scenario that targets the current scene, as the scene composition saw it: owning mod, priority, header condition (DSL, null when there is none) and its result (null when it was not evaluated), and the final status. <see cref="Reason"/> spells out a skip or a shadowing (another mod won the same scenario name) in words. </summary>
public sealed class StudioCandidate{public string Name{get;set;}public StudioModScope Scope{get;set;}public int Priority{get;set;}public string Condition{get;set;}public bool?ConditionOk{get;set;}public bool IsOneRun{get;set;}public StudioCandidateStatus Status{get;set;}public StudioText Reason{get;set;}}}

===== FILE mods/scenario_studio/src/Model/StudioCandidateStatus.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary>Outcome of a scene candidate as the dock shows it, the answer to "why did (not) it start".</summary>
public enum StudioCandidateStatus{Running,Queued,Skipped,Completed,Shadowed,Failed,}}

===== FILE mods/scenario_studio/src/Model/StudioCodeLine.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary> One line of the scenario shown in the "Now" tab. <see cref="Text"/> is the DSL source for the author mode; the novice mode reads the structure instead: <see cref="Say"/> is what the player sees (a line, a question, an answer) and <see cref="Target"/> where the line points — the label of an option or goto, the name of a label, the scene of a switch, the id of an action. <see cref="StudioMoment"/> follows those links. The flags mirror what the debugger reports: lines already executed, the line the dialog stopped on, breakpoints, lines added but not yet applied and a parse error message. </summary>
public sealed class StudioCodeLine{/// <summary>1-based line number in the scenario text (header included), as in <c>SourceLine</c> of actions.</summary>
public int Number{get;set;}/// <summary>Number of this line in the text the running dialog was built from; 0 for a line added in the studio.</summary>
public int SourceLine{get;set;}public string Text{get;set;}public StudioLineKind Kind{get;set;}public StudioText Say{get;set;}public string Target{get;set;}public bool IsDone{get;set;}public bool IsCurrent{get;set;}public bool HasBreakpoint{get;set;}public bool IsNew{get;set;}public StudioText Error{get;set;}}}

===== FILE mods/scenario_studio/src/Model/StudioLineKind.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary> What a scenario line does, as <see cref="StudioLineReader"/> classifies it. The novice view only shows <see cref="Line"/>, <see cref="Choice"/> and <see cref="Option"/>; the rest is service lines it follows or hides. </summary>
public enum StudioLineKind{Header,Comment,Light,Background,Line,Choice,Option,Label,Goto,Jump,Action,End,Command,}}

===== FILE mods/scenario_studio/src/Model/StudioLogCategory.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary>Category of a studio log entry, shown as its badge.</summary>
public enum StudioLogCategory{Scene,Skip,Scenario,Stop,End,Edit,}}

===== FILE mods/scenario_studio/src/Model/StudioLogEntry.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary>One row of the studio event log; <see cref="Time"/> is game-session time as text.</summary>
public sealed class StudioLogEntry{public string Time{get;set;}public StudioLogCategory Category{get;set;}public StudioText Text{get;set;}public bool IsWarning{get;set;}}}

===== FILE mods/scenario_studio/src/Model/StudioModScope.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary>A mod together with the way the studio may touch it; rendered as a scope badge.</summary>
public sealed class StudioModScope{public string ModId{get;}public StudioScopeKind Kind{get;}public StudioModScope(string modId,StudioScopeKind kind){ModId=modId;Kind=kind;}}}

===== FILE mods/scenario_studio/src/Model/StudioMode.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary> Presentation mode of the studio dock. Both modes show the same session; only the "Now" tab renders it differently: <see cref="Author"/> as script text with a gutter, <see cref="Novice"/> as one card per line with plain wording, so a player who never saw the DSL can still follow and extend it. </summary>
public enum StudioMode{Author,Novice,}}

===== FILE mods/scenario_studio/src/Model/StudioMoment.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System.Collections.Generic;/// <summary> What the novice mode shows instead of a listing: the line on screen now (<see cref="Say"/>), the lines said right before it, and — for a question — every answer with the place it leads to. Service lines (lights, labels, <c>end</c>) never appear; an answer's <c>-&gt; :label</c> is followed to the first meaningful line after the label (through <c>goto</c> hops), so <c>option → :exit → switch_scene</c> reads as "goes to the scene". An answer without a target or with a missing label is <see cref="StudioOutcomeKind.Broken"/>. When no dialog runs, the moment is the scenario's first line or question. </summary>
public sealed class StudioMoment{public StudioCodeLine Say{get;private set;}public List<StudioCodeLine>Before{get;}=new();public List<StudioBranch>Branches{get;}=new();public bool IsQuestion=>Say!=null&&Say.Kind==StudioLineKind.Choice;public static StudioMoment Build(StudioSession session){var moment=new StudioMoment();var lines=session.Lines;var index=FindAnchor(lines,session.CurrentIndex);if(index<0)return moment;moment.Say=lines[index];for(var i=index-1;i>=0&&lines[i].Kind==StudioLineKind.Line;i--)moment.Before.Insert(0,lines[i]);if(!moment.IsQuestion)return moment;for(var next=index+1;next<lines.Count&&lines[next].Kind==StudioLineKind.Option;next++)moment.Branches.Add(Resolve(lines,lines[next]));return moment;}private static int FindAnchor(List<StudioCodeLine>lines,int current){if(current<0)current=0;if(lines.Count==0)return-1;if(lines[current].Kind==StudioLineKind.Option){for(var i=current;i>=0;i--){if(lines[i].Kind==StudioLineKind.Choice)return i;}return-1;}for(var i=current;i<lines.Count;i++){if(lines[i].Kind==StudioLineKind.Line||lines[i].Kind==StudioLineKind.Choice)return i;}return-1;}private static StudioBranch Resolve(List<StudioCodeLine>lines,StudioCodeLine option){var branch=new StudioBranch{Line=option,Kind=StudioOutcomeKind.Broken};var label=string.IsNullOrEmpty(option.Target)? -1:FindLabel(lines,option.Target);if(label<0)return branch;var hops=0;for(var i=label+1;i<lines.Count;i++){var line=lines[i];switch(line.Kind){case StudioLineKind.Goto:i=string.IsNullOrEmpty(line.Target)? -1:FindLabel(lines,line.Target);if(i<0|| ++hops>8)return branch;continue;case StudioLineKind.Jump:branch.Kind=StudioOutcomeKind.Scene;branch.Target=line.Target;return branch;case StudioLineKind.Action:branch.Kind=StudioOutcomeKind.Action;branch.Target=line.Target;return branch;case StudioLineKind.End:branch.Kind=StudioOutcomeKind.End;return branch;case StudioLineKind.Line:case StudioLineKind.Choice:branch.Kind=StudioOutcomeKind.Continue;return branch;}}branch.Kind=StudioOutcomeKind.End;return branch;}private static int FindLabel(List<StudioCodeLine>lines,string name){for(var i=0;i<lines.Count;i++){if(lines[i].Kind==StudioLineKind.Label&&lines[i].Target==name)return i;}return-1;}}}

===== FILE mods/scenario_studio/src/Model/StudioNodeState.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary>Role of a scene on the studio map relative to where the player is.</summary>
public enum StudioNodeState{Here,Visited,Next,New,}}

===== FILE mods/scenario_studio/src/Model/StudioOutcomeKind.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary>Where an answer leads once its label jump is followed, as shown to a novice.</summary>
public enum StudioOutcomeKind{Scene,Action,End,Continue,Broken,}}

===== FILE mods/scenario_studio/src/Model/StudioRestartMode.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary>The ways the dock can restart what it is debugging (the restart menu, in menu order).</summary>
public enum StudioRestartMode{/// <summary>The scenario again from its first line; game state stays as it is now.</summary>
Scenario,/// <summary>Game state rolled back to the scenario's start, then the scenario again.</summary>
Rollback,/// <summary>The whole scene re-entered: its composition runs again.</summary>
Scene,/// <summary>The scenario again from the label above the current line.</summary>
FromLabel,}}

===== FILE mods/scenario_studio/src/Model/StudioRunState.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary>State of the dialog runtime as the dock header shows it.</summary>
public enum StudioRunState{/// <summary>A scenario runs freely.</summary>
Running,/// <summary>Pause is on: the next action will stop before it runs.</summary>
Paused,/// <summary>Stopped before an action (pause or breakpoint) until continue/step.</summary>
Held,/// <summary>No scenario dialog is running.</summary>
Idle,}}

===== FILE mods/scenario_studio/src/Model/StudioSceneNode.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary>A scene on the studio map with the mod that defines it; <see cref="Via"/> names the link (a command or a choice).</summary>
public sealed class StudioSceneNode{public string Name{get;set;}public StudioNodeState State{get;set;}public StudioModScope Scope{get;set;}public StudioText Via{get;set;}}}

===== FILE mods/scenario_studio/src/Model/StudioScopeKind.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary> How the studio may touch content of a mod, shown as the colour and caption of the mod scope badge. Players mostly run zip mods, which are read-only: edits of their scenarios land in the studio layer that overrides them, and the badge pair "layer overrides zip" tells whose content is affected. </summary>
public enum StudioScopeKind{/// <summary>Folder mod: edits are written into the mod itself.</summary>
Own,/// <summary>Zip mod: read-only, edits go to the studio layer over it.</summary>
Overlay,/// <summary>The studio layer of the current game that holds the player's edits.</summary>
Layer,/// <summary>Compiled code of a mod: shown, never edited from the studio.</summary>
ReadOnly,}}

===== FILE mods/scenario_studio/src/Model/StudioSession.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System.Collections.Generic;/// <summary> Everything the dock shows at one moment, built by <see cref="StudioSessionBuilder"/> from the live game: where the player is (scene, scenario, label), the run state, which mod owns the scenario and where edits go, the scenario lines (with pending edits) and debugger marks, the scene candidates, game state, the log and the scene map. A plain view model: pages render it and never change the game through it. </summary>
public sealed class StudioSession{public string SceneName{get;set;}public string ScenarioName{get;set;}/// <summary>Label the current line belongs to; null before the first label.</summary>
public string LabelName{get;set;}public StudioRunState RunState{get;set;}/// <summary>Mod (or layer) that owns the open scenario file; null without a scenario.</summary>
public StudioModScope FileScope{get;set;}/// <summary>Where edits are written: the file's own folder mod, or the studio layer.</summary>
public StudioModScope EditScope{get;set;}/// <summary>Unapplied edits of the open scenario.</summary>
public int PendingCount{get;set;}public List<StudioCodeLine>Lines{get;}=new();public List<StudioCandidate>Candidates{get;}=new();public List<StudioStateEntry>State{get;}=new();public List<StudioLogEntry>Log{get;}=new();public List<StudioSceneNode>Nodes{get;}=new();public bool HasScenario=> !string.IsNullOrEmpty(ScenarioName);/// <summary>Index of the line the dialog stands on, or -1.</summary>
public int CurrentIndex=>Lines.FindIndex(IsCurrentLine);/// <summary> Line index (into the scenario text, 0-based) where "insert after the current line" puts a line: right after it, or after the whole answer list of a question — a line between <c>select</c> and its options would cut the question short in the parser. Without a current line: the end. </summary>
public int InsertionTextIndexAfterCurrent(){var index=CurrentIndex;if(index<0)return Lines.Count==0?0:Lines[^1].Number;var at=index;if(Lines[index].Kind==StudioLineKind.Choice){while(at+1<Lines.Count&&Lines[at+1].Kind==StudioLineKind.Option)at++;}return Lines[at].Number;}private static bool IsCurrentLine(StudioCodeLine line)=>line.IsCurrent;}}

===== FILE mods/scenario_studio/src/Model/StudioStateEditor.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary>How a value of the "State" tab can be changed in place; <see cref="None"/> is read-only.</summary>
public enum StudioStateEditor{None,DialogVariable,Inventory,Quest,}}

===== FILE mods/scenario_studio/src/Model/StudioStateEntry.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary> A key/value of game state in the "State" tab, grouped by its owner (time, quests, inventory…). Groups compare by <see cref="StudioText.Key"/>. <see cref="Id"/> is what <see cref="Editor"/> writes to (a variable name, item id, quest id). </summary>
public sealed class StudioStateEntry{public StudioText Group{get;set;}public StudioText Key{get;set;}public StudioText Value{get;set;}public StudioStateEditor Editor{get;set;}public string Id{get;set;}}}

===== FILE mods/scenario_studio/src/Model/StudioTab.cs =====
namespace Cthangover.Mods.ScenarioStudio{/// <summary>Tabs of the studio dock body, in the order of the tab row.</summary>
public enum StudioTab{Now,Scene,State,Log,Map,}}

===== FILE mods/scenario_studio/src/Model/StudioText.cs =====
namespace Cthangover.Mods.ScenarioStudio{using Cthangover.Core.Utils;using Godot;/// <summary> Player-facing text of the studio model kept as a localization key plus format arguments and translated only when shown, so the dock follows a language switch and no prose lives in code. Arguments may themselves be <see cref="StudioText"/> (e.g. a scenario's own <c>key=</c> inside a studio phrase) and are resolved recursively; plain arguments are data — scene, scenario, mod and quest ids, numbers. <see cref="Raw"/> wraps such data where a text is expected. </summary>
public sealed class StudioText{private readonly string _raw;public string Key{get;}public object[]Args{get;}public StudioText(string key,params object[]args){Key=key;Args=args??System.Array.Empty<object>();}private StudioText(string raw,bool _){_raw=raw;Args=System.Array.Empty<object>();}/// <summary>Untranslated data (an id or a number) used where a text is expected.</summary>
public static StudioText Raw(string value)=>new(value,true);public string Resolve(){if(Key==null)return _raw??string.Empty;var format=TranslationServer.Translate(Key).ToString();if(Args.Length==0)return format;var resolved=new object[Args.Length];for(var i=0;i<Args.Length;i++)resolved[i]=Args[i]is StudioText text?text.Resolve():Args[i];try{return string.Format(format,resolved);}catch(System.FormatException){GameLogger.Log("UI",$"Scenario studio: '{Key}' expects other arguments than {Args.Length}",LogLevel.Warning);return format;}}}}

===== FILE mods/scenario_studio/src/Pages/StudioLogPage.cs =====
namespace Cthangover.Mods.ScenarioStudio{using Cthangover.Core.UI.Theming;using Cthangover.Mods.Interface.Theming;using Godot;/// <summary> "Log" tab: what happened, newest last — scene entries, skipped scenarios with the reason, scenario starts and ends, stops and studio edits. Skips are tinted as warnings because they are the usual answer to "nothing happened". </summary>
public static class StudioLogPage{private const int TimeWidth=44;public static void Build(VBoxContainer content,StudioSession session){var list=UiKit.Column(0);content.AddChild(list);foreach(var entry in session.Log){var plate=new PanelContainer{MouseFilter=Control.MouseFilterEnum.Ignore};plate.AddThemeStyleboxOverride("panel",StudioStyles.LogRow(entry.IsWarning));var row=UiKit.Row(UiTokens.Space3);plate.AddChild(row);var time=StudioStyles.Text(entry.Time,UiTokens.FontSmall,UiTokens.Faint,mono:true);time.CustomMinimumSize=new Vector2(TimeWidth,0);row.AddChild(time);row.AddChild(UiKit.Badge(TranslationServer.Translate(CategoryKey(entry.Category)),StudioStyles.LogColor(entry.Category)));var text=UiKit.Label(entry.Text.Resolve(),UiTokens.FontBodySmall,entry.IsWarning?UiTokens.Warning:UiTokens.Body);text.AutowrapMode=TextServer.AutowrapMode.WordSmart;text.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;row.AddChild(text);list.AddChild(plate);}}private static string CategoryKey(StudioLogCategory category){switch(category){case StudioLogCategory.Scene:return"studio/log_scene";case StudioLogCategory.Skip:return"studio/log_skip";case StudioLogCategory.Stop:return"studio/log_stop";case StudioLogCategory.End:return"studio/log_end";case StudioLogCategory.Edit:return"studio/log_edit";default:return"studio/log_scenario";}}}}

===== FILE mods/scenario_studio/src/Pages/StudioMapPage.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System;using Cthangover.Core.UI.Theming;using Cthangover.Mods.Interface.Theming;using Godot;/// <summary> "Map" tab: the walk between scenes around the player, laid out as a vertical path because the dock is narrow — where the player came from, where they are, and the exits from here with the command or choice that leads there. Exits can be taken directly (a debugging teleport); new scenes are created in the studio layer, the current scenario can be linked to any scene, and the layer can be exported as a regular mod. </summary>
public static class StudioMapPage{public static void Build(VBoxContainer content,StudioSession session,Action<StudioActionButton>go,Action<StudioActionButton>newScene,Action<StudioActionButton>link,Action<StudioActionButton>export){var visited=false;foreach(var node in session.Nodes){if(node.State!=StudioNodeState.Visited)continue;if(!visited)content.AddChild(UiKit.SectionTitle(TranslationServer.Translate("studio/map_from"),UiTokens.FontSmall));visited=true;content.AddChild(BuildNode(node,go));content.AddChild(Arrow());}if(!visited)content.AddChild(UiKit.SectionTitle(TranslationServer.Translate("studio/map_here"),UiTokens.FontSmall));foreach(var node in session.Nodes){if(node.State==StudioNodeState.Here)content.AddChild(BuildNode(node,null));}content.AddChild(UiKit.SectionTitle(TranslationServer.Translate("studio/map_to"),UiTokens.FontSmall));var exits=0;foreach(var node in session.Nodes){if(node.State!=StudioNodeState.Next&&node.State!=StudioNodeState.New)continue;content.AddChild(BuildNode(node,go));exits++;}if(exits==0)content.AddChild(UiKit.Hint(TranslationServer.Translate("studio/map_no_exits"),UiTokens.FontBodySmall));var actions=new HFlowContainer{MouseFilter=Control.MouseFilterEnum.Ignore};actions.AddThemeConstantOverride("h_separation",UiTokens.Space2);actions.AddThemeConstantOverride("v_separation",UiTokens.Space2);actions.AddChild(StudioActionButton.Create("studio/map_new_scene",newScene));if(session.HasScenario)actions.AddChild(StudioActionButton.Create("studio/map_link",link));actions.AddChild(StudioActionButton.Create("studio/map_export",export));content.AddChild(actions);}private static Control BuildNode(StudioSceneNode node,Action<StudioActionButton>go){var card=new PanelContainer{MouseFilter=Control.MouseFilterEnum.Ignore};card.AddThemeStyleboxOverride("panel",StudioStyles.Node(node.State));var row=UiKit.Row(UiTokens.Space3);card.AddChild(row);var column=UiKit.Column(UiTokens.Space1);column.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;row.AddChild(column);var head=UiKit.Row(UiTokens.Space3);var nameColor=node.State==StudioNodeState.Here?UiTokens.AccentBright:UiTokens.Ink;head.AddChild(StudioStyles.Text(node.Name,UiTokens.FontBodySmall,nameColor,mono:true));head.AddChild(StudioStyles.Scope(node.Scope));column.AddChild(head);var sub=TranslationServer.Translate(StateKey(node.State));if(node.Via!=null)sub+=" · "+node.Via.Resolve();column.AddChild(StudioStyles.Text(sub,UiTokens.FontSmall,UiTokens.Faint));if(node.State!=StudioNodeState.Here){var button=StudioActionButton.Create("studio/map_go",go);button.Payload=node.Name;button.SizeFlagsVertical=Control.SizeFlags.ShrinkCenter;row.AddChild(button);}return card;}private static Control Arrow(){var arrow=StudioStyles.Text("↓",UiTokens.FontBody,UiTokens.Faint);arrow.HorizontalAlignment=HorizontalAlignment.Center;return arrow;}private static string StateKey(StudioNodeState state){switch(state){case StudioNodeState.Here:return"studio/node_here";case StudioNodeState.Visited:return"studio/node_visited";case StudioNodeState.New:return"studio/node_new";default:return"studio/node_next";}}}}

===== FILE mods/scenario_studio/src/Pages/StudioNovicePage.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System;using Cthangover.Core.UI.Theming;using Cthangover.Mods.Interface.Theming;using Godot;/// <summary> "Now" tab in the novice mode: the moment of the story rather than the script (<see cref="StudioMoment"/>) — what is on screen, the lines said right before it, every answer with where it leads, then what else the scene has queued. The only insert actions are the ones that make sense here: a line before the question (or after a plain line) and a new answer, which starts broken until its target is chosen. Requests carry 0-based indices into the scenario text, where the dock inserts. Ends with the same "this change affects" plate as the author mode. </summary>
public sealed class StudioNovicePage{/// <summary>A new line is requested at the given text index.</summary>
public event Action<int>LineRequested;/// <summary>A new answer is requested at the given text index.</summary>
public event Action<int>OptionRequested;/// <summary>"Edit" on a line, question or answer.</summary>
public event Action<StudioActionButton>EditRequested;/// <summary>"Choose where" on an answer.</summary>
public event Action<StudioActionButton>TargetRequested;private StudioMoment _moment;public void Build(VBoxContainer content,StudioSession session){_moment=StudioMoment.Build(session);if(_moment.Say==null){content.AddChild(UiKit.EmptyState(TranslationServer.Translate(session.HasScenario?"studio/moment_empty":"studio/no_scenario"),UiTokens.FontBody));return;}content.AddChild(StudioStyles.Caption(TranslationServer.Translate("studio/moment_now")));foreach(var line in _moment.Before)content.AddChild(new StudioSayView(line,true,OnEdit));content.AddChild(new StudioSayView(_moment.Say,false,OnEdit));if(_moment.IsQuestion){content.AddChild(StudioStyles.Caption(TranslationServer.Translate("studio/moment_answers")));foreach(var branch in _moment.Branches)content.AddChild(new StudioBranchView(branch,OnEdit,OnTarget));}var actions=UiKit.Row(UiTokens.Space2);actions.AddChild(InsertButton(_moment.IsQuestion?"studio/add_line_before":"studio/add_line_after",OnLinePressed));if(_moment.IsQuestion)actions.AddChild(InsertButton("studio/add_option",OnOptionPressed));content.AddChild(actions);content.AddChild(StudioStyles.Caption(TranslationServer.Translate("studio/moment_next")));content.AddChild(BuildNext(session));content.AddChild(StudioNowPage.BuildScopeBanner(session));}private static Control BuildNext(StudioSession session){foreach(var candidate in session.Candidates){if(candidate.Status==StudioCandidateStatus.Queued)return UiKit.Hint(string.Format(TranslationServer.Translate("studio/next_waiting"),candidate.Name),UiTokens.FontBodySmall);}return UiKit.Hint(TranslationServer.Translate("studio/next_none"),UiTokens.FontBodySmall);}private static Button InsertButton(string key,Action handler){var button=StudioStyles.SmallButton(new Button(),TranslationServer.Translate(key));button.Pressed+=handler;return button;}private void OnLinePressed(){var say=_moment.Say.Number-1;LineRequested?.Invoke(_moment.IsQuestion?say:say+1);}private void OnOptionPressed(){var last=_moment.Branches.Count>0?_moment.Branches[^1].Line:_moment.Say;OptionRequested?.Invoke(last.Number);}private void OnEdit(StudioActionButton button)=>EditRequested?.Invoke(button);private void OnTarget(StudioActionButton button)=>TargetRequested?.Invoke(button);}}

===== FILE mods/scenario_studio/src/Pages/StudioNowPage.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System;using Cthangover.Core.UI.Theming;using Cthangover.Mods.Interface.Theming;using Godot;/// <summary> "Now" tab in the author mode: the running scenario as script with a gutter (<see cref="StudioCodeLineView"/>) and the insert bar for a line after the current one. It ends with the "this change affects" plate naming the mod that receives the edit and the mod it overrides, so nobody changes someone else's content unknowingly; the novice mode (<see cref="StudioNovicePage"/>) shows the same plate. <para/> One instance lives as long as the dock; <see cref="Build"/> refills the body and wires the controls to this instance's handlers. </summary>
public sealed class StudioNowPage{/// <summary>A gutter click changed a breakpoint.</summary>
public event Action<StudioCodeLine>BreakpointToggled;/// <summary>One of the insert buttons was pressed.</summary>
public event Action<StudioLineKind>InsertRequested;public void Build(VBoxContainer content,StudioSession session){if(!session.HasScenario){content.AddChild(UiKit.EmptyState(TranslationServer.Translate("studio/no_scenario"),UiTokens.FontBody));return;}var well=new PanelContainer{MouseFilter=Control.MouseFilterEnum.Ignore};well.AddThemeStyleboxOverride("panel",StudioStyles.CodeWell());content.AddChild(well);var lines=UiKit.Column(0);well.AddChild(lines);foreach(var line in session.Lines){var view=new StudioCodeLineView(line);view.BreakpointToggled+=OnBreakpointToggled;lines.AddChild(view);}var at=session.InsertionTextIndexAfterCurrent();var caption=at<=0?string.Empty:string.Format(TranslationServer.Translate("studio/after_line"),at);content.AddChild(BuildInsertBar(caption));content.AddChild(BuildScopeBanner(session));}private Control BuildInsertBar(string caption){var bar=new PanelContainer{MouseFilter=Control.MouseFilterEnum.Ignore};bar.AddThemeStyleboxOverride("panel",StudioStyles.InsertBar());var row=new HFlowContainer{MouseFilter=Control.MouseFilterEnum.Ignore};row.AddThemeConstantOverride("h_separation",UiTokens.Space2);row.AddThemeConstantOverride("v_separation",UiTokens.Space2);bar.AddChild(row);if(!string.IsNullOrEmpty(caption)){var label=StudioStyles.Text(caption,UiTokens.FontSmall,UiTokens.Faint);label.VerticalAlignment=VerticalAlignment.Center;label.SizeFlagsVertical=Control.SizeFlags.ShrinkCenter;row.AddChild(label);}row.AddChild(InsertButton("studio/add_line",OnAddLine));row.AddChild(InsertButton("studio/add_choice",OnAddChoice));row.AddChild(InsertButton("studio/add_jump",OnAddJump));return bar;}private static Button InsertButton(string key,Action handler){var button=StudioStyles.SmallButton(new Button(),TranslationServer.Translate(key));button.Pressed+=handler;return button;}/// <summary> Inline editor of the whole scenario text (the footer's "Edit text"), filling the body; the dock reads the text back when editing ends. </summary>
public static CodeEdit BuildEditor(VBoxContainer content,string text){var editor=new CodeEdit{Text=text??string.Empty,GuttersDrawLineNumbers=true,HighlightCurrentLine=true,WrapMode=TextEdit.LineWrappingMode.None,SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,CustomMinimumSize=new Vector2(0,560),SyntaxHighlighter=StudioSyntax.CreateHighlighter(),};editor.AddThemeFontOverride("font",StudioStyles.Mono);editor.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);content.AddChild(editor);return editor;}/// <summary>"This change affects" plate: the edit scope and, when it differs, the mod it overrides.</summary>
public static Control BuildScopeBanner(StudioSession session){var banner=new PanelContainer{MouseFilter=Control.MouseFilterEnum.Ignore};banner.AddThemeStyleboxOverride("panel",StudioStyles.ScopeBanner());var column=UiKit.Column(UiTokens.Space2);banner.AddChild(column);var title=StudioStyles.Text(TranslationServer.Translate("studio/affects"),UiTokens.FontTiny,UiTokens.Warning);title.Uppercase=true;column.AddChild(title);var row=new HFlowContainer{MouseFilter=Control.MouseFilterEnum.Ignore};row.AddThemeConstantOverride("h_separation",UiTokens.Space3);column.AddChild(row);if(session.EditScope==null)return banner;row.AddChild(StudioStyles.Scope(session.EditScope));if(session.EditScope.ModId!=session.FileScope.ModId){row.AddChild(StudioStyles.Text(TranslationServer.Translate("studio/overrides"),UiTokens.FontSmall,UiTokens.Faint));row.AddChild(StudioStyles.Scope(session.FileScope));}return banner;}private void OnBreakpointToggled(StudioCodeLine line)=>BreakpointToggled?.Invoke(line);private void OnAddLine()=>InsertRequested?.Invoke(StudioLineKind.Line);private void OnAddChoice()=>InsertRequested?.Invoke(StudioLineKind.Choice);private void OnAddJump()=>InsertRequested?.Invoke(StudioLineKind.Jump);}}

===== FILE mods/scenario_studio/src/Pages/StudioScenePage.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System;using Cthangover.Core.UI.Theming;using Cthangover.Mods.Interface.Theming;using Godot;/// <summary> "Scene" tab: every scenario the last composition of the current scene considered, in start order, with the owning mod, priority, header condition and its result, and the status with the reason in words. This is the answer to "why did my scenario not start": a false condition, a one-run scenario already completed, or another mod winning the same scenario name. Any candidate can be started now; a completed one can be made fresh again; a new scenario for the scene goes to the studio layer. </summary>
public static class StudioScenePage{public static void Build(VBoxContainer content,StudioSession session,Action<StudioActionButton>run,Action<StudioActionButton>reset,Action<StudioActionButton>create){var hint=UiKit.Hint(TranslationServer.Translate("studio/cand_hint"));hint.AutowrapMode=TextServer.AutowrapMode.WordSmart;content.AddChild(hint);if(session.Candidates.Count==0)content.AddChild(UiKit.EmptyState(TranslationServer.Translate("studio/cand_none"),UiTokens.FontBody));foreach(var candidate in session.Candidates)content.AddChild(BuildCard(candidate,run,reset));var add=StudioActionButton.Create("studio/new_scenario",create);add.Payload=session.SceneName;add.SizeFlagsHorizontal=Control.SizeFlags.ShrinkBegin;content.AddChild(add);}private static Control BuildCard(StudioCandidate candidate,Action<StudioActionButton>run,Action<StudioActionButton>reset){var running=candidate.Status==StudioCandidateStatus.Running;var card=new PanelContainer{MouseFilter=Control.MouseFilterEnum.Ignore};card.AddThemeStyleboxOverride("panel",StudioStyles.Candidate(running));var column=UiKit.Column(UiTokens.Space2);card.AddChild(column);var head=UiKit.Row(UiTokens.Space3);head.AddChild(StudioStyles.Text(candidate.Name,UiTokens.FontBody,UiTokens.InkBright,mono:true));head.AddChild(UiKit.Spacer());head.AddChild(UiKit.Badge(TranslationServer.Translate(StatusKey(candidate.Status)),StudioStyles.StatusColor(candidate.Status)));column.AddChild(head);var meta=new HFlowContainer{MouseFilter=Control.MouseFilterEnum.Ignore};meta.AddThemeConstantOverride("h_separation",UiTokens.Space3);meta.AddChild(StudioStyles.Scope(candidate.Scope));meta.AddChild(StudioStyles.Text(string.Format(TranslationServer.Translate("studio/priority"),candidate.Priority),UiTokens.FontSmall,UiTokens.Muted,mono:true));var condition=candidate.Condition??TranslationServer.Translate("studio/no_condition");var mark=candidate.ConditionOk==null?"":candidate.ConditionOk.Value?"✓ ":"✗ ";var conditionColor=candidate.ConditionOk==false?UiTokens.DangerText:candidate.ConditionOk==true?UiTokens.SuccessText:UiTokens.Muted;meta.AddChild(StudioStyles.Text(mark+condition,UiTokens.FontSmall,conditionColor,mono:true));if(candidate.IsOneRun)meta.AddChild(StudioStyles.Text(TranslationServer.Translate("studio/one_run"),UiTokens.FontSmall,UiTokens.Muted));column.AddChild(meta);var reason=UiKit.Label(candidate.Reason?.Resolve()??string.Empty,UiTokens.FontSmall,UiTokens.Muted);reason.AutowrapMode=TextServer.AutowrapMode.WordSmart;column.AddChild(reason);if(running||candidate.Status==StudioCandidateStatus.Shadowed)return card;var actions=UiKit.Row(UiTokens.Space2);var start=StudioActionButton.Create("studio/run_now",run);start.Payload=candidate.Name;actions.AddChild(start);if(candidate.Status==StudioCandidateStatus.Completed){var fresh=StudioActionButton.Create("studio/reset_completed",reset);fresh.Payload=candidate.Name;actions.AddChild(fresh);}column.AddChild(actions);return card;}private static string StatusKey(StudioCandidateStatus status){switch(status){case StudioCandidateStatus.Running:return"studio/cand_running";case StudioCandidateStatus.Queued:return"studio/cand_queued";case StudioCandidateStatus.Skipped:return"studio/cand_skipped";case StudioCandidateStatus.Completed:return"studio/cand_completed";case StudioCandidateStatus.Failed:return"studio/cand_failed";default:return"studio/cand_shadowed";}}}}

===== FILE mods/scenario_studio/src/Pages/StudioStatePage.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System;using Cthangover.Core.UI.Theming;using Cthangover.Mods.Interface.Theming;using Godot;/// <summary> "State" tab: game state grouped by owner — time, dialog variables of the running dialog, quests the current scenario refers to, inventory and the one-run completion history. Variables, item counts and quest status are editable (Enter writes them into the game), because changing one is the quickest way to test another branch of a scenario. </summary>
public static class StudioStatePage{public static void Build(VBoxContainer content,StudioSession session,Action<StudioStateEntry,string>submitted){var hint=UiKit.Hint(TranslationServer.Translate("studio/state_hint"));hint.AutowrapMode=TextServer.AutowrapMode.WordSmart;content.AddChild(hint);string group=null;VBoxContainer rows=null;foreach(var entry in session.State){if(entry.Group.Key!=group){group=entry.Group.Key;content.AddChild(UiKit.SectionTitle(entry.Group.Resolve(),UiTokens.FontSmall));var well=new PanelContainer{MouseFilter=Control.MouseFilterEnum.Ignore};well.AddThemeStyleboxOverride("panel",StudioStyles.Well());content.AddChild(well);rows=UiKit.Column(UiTokens.Space2);well.AddChild(rows);}var row=new StudioStateRow(entry);row.Submitted+=submitted;rows.AddChild(row);}}}}

===== FILE mods/scenario_studio/src/Runtime/StudioDebugger.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System;using System.Collections.Generic;using Cthangover.Mods.Dialog;using Cthangover.Mods.Dialog.Action;using Cthangover.Mods.Dialog.Executable;/// <summary> The studio's <see cref="IDialogDebugger"/>: pause, step and breakpoints for scenario dialogs. Only dialogs shown in the <see cref="DialogBox"/> with a <see cref="ScenarioEvent"/> as locker are held — standalone runtimes (hover commands) and click dialogs cannot be resumed and always run through. Breakpoints are keyed by scenario name, the identity that survives an edit moving the file into the studio layer. <para/> "Pause" holds the dialog before the next action; "continue" and "step" let exactly one action pass the hold (<c>allowOnce</c>), step then holds again. The first action of every new scenario event raises <see cref="ScenarioStarted"/> before anything runs, which is when the rollback snapshot is taken. </summary>
public sealed class StudioDebugger:IDialogDebugger{private readonly Dictionary<string,HashSet<int>>_breakpoints=new();private readonly HashSet<int>_executed=new();private int _allowOnce;/// <summary>The scenario event the debugger last saw run.</summary>
public ScenarioEvent Event{get;private set;}public bool IsPaused{get;private set;}/// <summary>Scenario line the dialog is held at, 0 when not held.</summary>
public int HeldLine{get;private set;}/// <summary>Raised before the first action of a new scenario event runs.</summary>
public event Action<ScenarioEvent>ScenarioStarted;/// <summary>Raised when the dialog is held at a line (pause or breakpoint).</summary>
public event Action<int,bool>Held;/// <summary>Raised after any change worth redrawing (an action ran, a hold, a toggle).</summary>
public event Action Changed;public bool ShouldHold(DialogRuntime runtime,IActionCommand action){if(runtime.DialogBox==null||runtime.DialogBox.Locker is not ScenarioEvent scenario)return false;if(scenario!=Event){Event=scenario;_executed.Clear();HeldLine=0;ScenarioStarted?.Invoke(scenario);}if(_allowOnce>0){_allowOnce--;return Pass(action);}var breakpoint=action.SourceLine>0&&HasBreakpoint(scenario.ID,action.SourceLine);if(!IsPaused&& !breakpoint)return Pass(action);IsPaused=true;HeldLine=action.SourceLine;Held?.Invoke(action.SourceLine,breakpoint);Changed?.Invoke();return true;}public bool IsExecuted(int line)=>_executed.Contains(line);public bool HasBreakpoint(string scenario,int line)=>scenario!=null&&_breakpoints.TryGetValue(scenario,out var lines)&&lines.Contains(line);public void ToggleBreakpoint(string scenario,int line){if(string.IsNullOrEmpty(scenario))return;if(!_breakpoints.TryGetValue(scenario,out var lines))_breakpoints[scenario]=lines=new HashSet<int>();if(!lines.Remove(line))lines.Add(line);Changed?.Invoke();}/// <summary>Turns pause on: the next action of a scenario dialog will be held.</summary>
public void Pause(){IsPaused=true;Changed?.Invoke();}/// <summary> Turns pause off and lets the held action run (<paramref name="box"/> resumes it); without a hold it only clears the pause. </summary>
public void Continue(DialogBox box){IsPaused=false;ReleaseHold(box);Changed?.Invoke();}/// <summary> Runs exactly one action and holds before the next. When the dialog is not held but waits for a click, the click is made for the user. </summary>
public void Step(DialogBox box){IsPaused=true;if(box==null|| !box.IsRunning)return;if(box.Runtime.IsHeld){ReleaseHold(box);}else if(!box.IsWaitingAnswer){_allowOnce=1;box.NextAction();}Changed?.Invoke();}/// <summary>Forgets the current event, e.g. before restarting it.</summary>
public void Reset(){Event=null;HeldLine=0;_allowOnce=0;_executed.Clear();}private void ReleaseHold(DialogBox box){if(box==null|| !box.Runtime.IsHeld)return;HeldLine=0;_allowOnce=1;box.Resume();}private bool Pass(IActionCommand action){if(action.SourceLine>0)_executed.Add(action.SourceLine);Changed?.Invoke();return false;}}}

===== FILE mods/scenario_studio/src/Runtime/StudioJournal.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System.Collections.Generic;using Cthangover.Core.Events;using Cthangover.Core.Scenes;using Godot;/// <summary> What happened in the session, for the "Log", "Scene" and "Map" tabs: log entries (scene entries, skipped candidates with the reason, scenario starts and ends, breakpoints, studio edits), the order of visited scenes and the last composition report of every scene. Fed by the mod event bus and by <see cref="StudioLive"/>; times are session time (mm:ss since the studio started). </summary>
public sealed class StudioJournal{private const int MaxEntries=300;private readonly ulong _startMsec=Time.GetTicksMsec();private readonly Dictionary<string,SceneComposedEvent>_composed=new();public List<StudioLogEntry>Entries{get;}=new();/// <summary>Scenes in the order they were entered (repeats included).</summary>
public List<string>SceneHistory{get;}=new();public void Add(StudioLogCategory category,StudioText text,bool warning=false){Entries.Add(new StudioLogEntry{Time=Now(),Category=category,Text=text,IsWarning=warning});if(Entries.Count>MaxEntries)Entries.RemoveAt(0);}/// <summary>Last composition report of <paramref name="scene"/>, or null.</summary>
public SceneComposedEvent Composed(string scene)=>scene!=null&&_composed.TryGetValue(scene,out var report)?report:null;public void OnSceneEntered(SceneEnteredEvent e){SceneHistory.Add(e.SceneName);Add(StudioLogCategory.Scene,new StudioText("studio/log_text_enter",e.SceneName));}public void OnSceneComposed(SceneComposedEvent e){_composed[e.SceneName]=e;foreach(var candidate in e.Candidates){switch(candidate.Outcome){case SceneCandidateOutcome.ConditionFalse:Add(StudioLogCategory.Skip,new StudioText("studio/log_text_condition",candidate.Id),true);break;case SceneCandidateOutcome.Completed:Add(StudioLogCategory.Skip,new StudioText("studio/log_text_completed",candidate.Id),true);break;case SceneCandidateOutcome.Shadowed:Add(StudioLogCategory.Skip,new StudioText("studio/log_text_shadowed",candidate.Id,candidate.ModId),true);break;case SceneCandidateOutcome.Failed:Add(StudioLogCategory.Skip,new StudioText("studio/log_text_failed",candidate.Id),true);break;}}}public void OnDialogEnded(DialogEndedEvent e){if(!string.IsNullOrEmpty(e.LockerId))Add(StudioLogCategory.End,new StudioText("studio/log_text_ended",e.LockerId));}private string Now(){var seconds=(Time.GetTicksMsec()-_startMsec)/1000;return$"{seconds / 60:00}:{seconds % 60:00}";}}}

===== FILE mods/scenario_studio/src/Runtime/StudioLayer.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System.Collections.Generic;using System.IO;using System.Text;using Cthangover.Core.Bootstrap;using Cthangover.Core.Games;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Providers;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Scenes;using Cthangover.Core.Utils;using Godot;/// <summary> The studio's edit layer: a folder per game (<c>user://studio/&lt;game&gt;/</c>, or <c>--studio-layer=&lt;dir&gt;</c>) laid out like a mod (<c>scenarios/</c>, <c>scenes/</c>) and mounted as content overlay <see cref="Id"/> through <see cref="ModOverlays"/>. Scenarios written here override same-named scenarios of any mod, so a player can change content of read-only zip mods without touching them; new scenes and scenarios live here too. <para/> Edits of folder mods go into the mod itself (the author's workflow) unless <c>--studio-write=layer</c> forces the layer — tests use it to keep the repository untouched. <see cref="Export"/> turns the layer into a regular mod in <c>user://mods/</c>. </summary>
public sealed class StudioLayer{public const string Id="my_studio";private const string LayerArg="--studio-layer=";private const string WriteArg="--studio-write=layer";/// <summary>Absolute path of the layer folder.</summary>
public string RootPath{get;}/// <summary>True when every edit goes to the layer, folder mods included.</summary>
public bool ForceLayer{get;}public StudioLayer(string[]args){string dir=null;foreach(var arg in args){if(arg.StartsWith(LayerArg,System.StringComparison.Ordinal))dir=arg.Substring(LayerArg.Length);if(arg==WriteArg)ForceLayer=true;}dir??="user://studio";var game=string.IsNullOrEmpty(ActiveGame.Id)?"dev":ActiveGame.Id;RootPath=Path.Combine(ProjectSettings.GlobalizePath(dir),game);}/// <summary>Creates the folder if needed, mounts it as overlay and refreshes scenario and scene tables.</summary>
public void Mount(){Directory.CreateDirectory(Path.Combine(RootPath,"scenarios"));Directory.CreateDirectory(Path.Combine(RootPath,"scenes"));ModOverlays.Mount(Id,new FolderModFileProvider(RootPath,Id));RefreshContent();GameLogger.Log("UI",$"Scenario studio: layer '{Id}' mounted at {RootPath}");}/// <summary>Writes a layer file (mod-relative path) and returns its absolute path.</summary>
public string Write(string relativePath,string text){var path=Path.Combine(RootPath,relativePath.Replace('/',Path.DirectorySeparatorChar));Directory.CreateDirectory(Path.GetDirectoryName(path));File.WriteAllText(path,text);return path;}/// <summary> Creates scene <paramref name="name"/> with the given backgrounds and a first scenario for it; returns the scenario name. </summary>
public string CreateScene(string name,IReadOnlyList<string>backgrounds,string firstLine){var json=new StringBuilder("{\n  \"name\": \"").Append(name).Append('"');if(backgrounds!=null&&backgrounds.Count>0){json.Append(",\n  \"defaultBackground\": [");for(var i=0;i<backgrounds.Count;i++)json.Append(i==0?"":", ").Append('"').Append(backgrounds[i]).Append('"');json.Append(']');}json.Append("\n}\n");Write($"scenes/{name}.json",json.ToString());return CreateScenario(name,firstLine);}/// <summary>Creates a new scenario for scene <paramref name="scene"/> with one line; returns its name.</summary>
public string CreateScenario(string scene,string firstLine){var name=scene+"_studio";for(var n=2;File.Exists(ScenarioFile(name));n++)name=$"{scene}_studio_{n}";Write($"scenarios/{name}.scenario",$"scene: {scene}\npriority: 50\n---\n{firstLine}\nend\n");RefreshContent();return name;}/// <summary>Scene names defined by the layer itself.</summary>
public List<string>SceneNames(){var result=new List<string>();var dir=Path.Combine(RootPath,"scenes");if(!Directory.Exists(dir))return result;foreach(var file in Directory.GetFiles(dir,"*.json"))result.Add(Path.GetFileNameWithoutExtension(file));return result;}/// <summary> Copies the layer into <c>user://mods/&lt;modId&gt;/</c> with a manifest, so the edits become a regular mod that can be added to a game's collection. Returns the folder written. </summary>
public string Export(string modId){var target=Path.Combine(ProjectSettings.GlobalizePath("user://mods/"),modId);CopyTree(RootPath,target);var manifest="{\n"+$"  \"id\": \"{modId}\",\n"+$"  \"name\": \"{modId}\",\n"+"  \"version\": \"0.1.0\",\n"+"  \"author\": \"studio\",\n"+"  \"description\": \"Exported from the scenario studio layer.\",\n"+"  \"category\": \"content\",\n"+"  \"depends\": [\"dialog_base>=1\"]\n"+"}\n";File.WriteAllText(Path.Combine(target,"manifest.json"),manifest);GameLogger.Log("UI",$"Scenario studio: layer exported as mod '{modId}' to {target}");return target;}/// <summary>Makes the next scene composition and the scene table see changed scenario and scene files.</summary>
public static void RefreshContent(){ScenarioDefs.Invalidate();ModManager.Instance.GetResolver<SceneDefinitionResolver>()?.ClearCache();AppServices.Current.SceneDefs.Clear();SceneManager.RescanScenes();}private string ScenarioFile(string name)=>Path.Combine(RootPath,"scenarios",name+".scenario");private static void CopyTree(string from,string to){Directory.CreateDirectory(to);foreach(var file in Directory.GetFiles(from))File.Copy(file,Path.Combine(to,Path.GetFileName(file)),true);foreach(var dir in Directory.GetDirectories(from))CopyTree(dir,Path.Combine(to,Path.GetFileName(dir)));}}}

===== FILE mods/scenario_studio/src/Runtime/StudioLineReader.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System.Collections.Generic;using Cthangover.Mods.Dialog.Scenarios;/// <summary> Reads scenario text into <see cref="StudioCodeLine"/>s for the dock: one entry per non-empty line, numbered like the parser numbers <c>SourceLine</c> (1-based, header included), classified by command with the player-visible text (<see cref="StudioCodeLine.Say"/>: the literal, or the line's own <c>key=</c> translated when shown) and the line's target. Lines are split by <see cref="ScenarioParser.SplitLine"/> so the dock never disagrees with the engine; on top of the parser it reports what the parser silently skips — unknown commands, answers and jumps to missing labels. </summary>
public static class StudioLineReader{public static List<StudioCodeLine>Read(string text){var result=new List<StudioCodeLine>();if(string.IsNullOrEmpty(text))return result;var lines=text.Replace("\r\n","\n").Split('\n');var headerEnd=HeaderEnd(lines);var labels=new HashSet<string>();for(var i=0;i<lines.Length;i++){var trimmed=lines[i].Trim();if(trimmed.Length==0)continue;var line=new StudioCodeLine{Number=i+1,Text=lines[i].TrimEnd()};if(i<=headerEnd)line.Kind=StudioLineKind.Header;else Classify(line,trimmed);if(line.Kind==StudioLineKind.Label)labels.Add(line.Target);result.Add(line);}foreach(var line in result)CheckTarget(line,labels);return result;}private static int HeaderEnd(string[]lines){for(var i=0;i<lines.Length;i++){if(lines[i].Trim()=="---")return i;}return-1;}private static void Classify(StudioCodeLine line,string trimmed){if(trimmed.StartsWith('#')){line.Kind=StudioLineKind.Comment;return;}if(trimmed.StartsWith(':')){line.Kind=StudioLineKind.Label;line.Target=trimmed.Substring(1).Trim();return;}var positional=new List<string>();var named=new Dictionary<string,string>();var cmd=ScenarioParser.SplitLine(trimmed,positional,named,out var arrow);switch(cmd){case"text":case"ptext":line.Kind=StudioLineKind.Line;line.Say=SayOf(positional,named);return;case"select":line.Kind=StudioLineKind.Choice;line.Say=SayOf(positional,named);return;case"option":line.Kind=StudioLineKind.Option;line.Say=SayOf(positional,named);line.Target=OptionTarget(positional,named,arrow);return;case"goto":line.Kind=StudioLineKind.Goto;line.Target= !string.IsNullOrEmpty(arrow)?arrow:named.GetValueOrDefault("target");return;case"switch_scene":line.Kind=StudioLineKind.Jump;line.Target=positional.Count>0?positional[0]:null;return;case"action":line.Kind=StudioLineKind.Action;line.Target=positional.Count>0?positional[0]:null;return;case"end":line.Kind=StudioLineKind.End;return;case"light_set":case"light_use_time":line.Kind=StudioLineKind.Light;return;case"background":case"foreground":line.Kind=StudioLineKind.Background;return;}line.Kind=StudioLineKind.Command;if(cmd!=null&&ScenarioCommandStrategyFactory.Get(cmd)==null)line.Error=new StudioText("studio/error_unknown_command",cmd);}private static StudioText SayOf(List<string>positional,Dictionary<string,string>named){if(named.TryGetValue("key",out var key)&& !string.IsNullOrEmpty(key))return new StudioText(key);return positional.Count>0?StudioText.Raw(positional[0]):null;}private static string OptionTarget(List<string>positional,Dictionary<string,string>named,string arrow){if(!string.IsNullOrEmpty(arrow))return arrow;if(named.TryGetValue("goto",out var target))return target;return positional.Count>1&&positional[1].StartsWith(':')?positional[1].TrimStart(':'):null;}private static void CheckTarget(StudioCodeLine line,HashSet<string>labels){if(line.Kind!=StudioLineKind.Option&&line.Kind!=StudioLineKind.Goto)return;if(string.IsNullOrEmpty(line.Target))line.Error??=new StudioText("studio/error_no_target");else if(!labels.Contains(line.Target))line.Error??=new StudioText("studio/error_label_missing",":"+line.Target);}}}

===== FILE mods/scenario_studio/src/Runtime/StudioLive.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System;using System.Collections.Generic;using System.IO;using System.Text.RegularExpressions;using Cthangover.Core.Bootstrap;using Cthangover.Core.Events;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Quests;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Core.Utils;using Cthangover.Mods.Dialog;using Cthangover.Mods.Dialog.Executable;using Godot;/// <summary> The studio's connection to the running game, parked at <c>/root</c> for the whole session (the dock comes and goes, this stays). It installs the <see cref="StudioDebugger"/> into the dialog runtime, keeps the <see cref="StudioJournal"/> fed from the event bus, owns the edit <see cref="StudioLayer"/>, the per-scenario <see cref="StudioWorkingCopy"/>s and the rollback <see cref="StudioSnapshot"/> of the running scenario, and performs every action of the dock: apply edits, the restart modes, run a scene candidate, go to a scene, create scenes and scenarios, change state values, export the layer. <para/> <see cref="Version"/> grows on every change the dock may want to show; the dock polls it instead of subscribing, so a burst of dialog actions costs one redraw. </summary>
public partial class StudioLive:Node{private const string ChainGroup="main_event_chain";private static readonly Regex ModIdPattern=new("^[a-z][a-z0-9_]*$");private readonly Dictionary<string,StudioWorkingCopy>_copies=new();private StudioSnapshot _snapshot;private StudioSnapshot _checkpoint;public static StudioLive Instance{get;private set;}public StudioDebugger Debugger{get;}=new();public StudioJournal Journal{get;}=new();public StudioLayer Layer{get;}/// <summary>Grows on every change worth redrawing.</summary>
public int Version{get;private set;}public StudioLive(StudioLayer layer){Name="ScenarioStudioLive";Layer=layer;}public override void _EnterTree(){Instance=this;DialogRuntime.Debugger=Debugger;Debugger.ScenarioStarted+=OnScenarioStarted;Debugger.Held+=OnHeld;Debugger.Changed+=Touch;ModEventBus.Subscribe<SceneEnteredEvent>(OnSceneEntered,priority:50);ModEventBus.Subscribe<SceneComposedEvent>(OnSceneComposed);ModEventBus.Subscribe<DialogEndedEvent>(OnDialogEnded);}public override void _ExitTree(){if(DialogRuntime.Debugger==Debugger)DialogRuntime.Debugger=null;ModEventBus.Unsubscribe<SceneEnteredEvent>(OnSceneEntered);ModEventBus.Unsubscribe<SceneComposedEvent>(OnSceneComposed);ModEventBus.Unsubscribe<DialogEndedEvent>(OnDialogEnded);if(Instance==this)Instance=null;}// ── what is running ────────────────────────────────────────
public DialogBox Box=>SceneContextNode.FindNode<DialogBox>("DialogBox");public static string SceneName=>SceneManager.GetCurrentSceneName();/// <summary>The scenario in the dialog box, or the last one the debugger saw while it still exists.</summary>
public ScenarioEvent CurrentEvent{get{var box=Box;if(box!=null&&box.IsRunning&&box.Locker is ScenarioEvent running)return running;var last=Debugger.Event;return last!=null&&IsInstanceValid(last)?last:null;}}/// <summary>True while <see cref="CurrentEvent"/> is the dialog in the box.</summary>
public bool IsCurrentRunning{get{var box=Box;return box!=null&&box.IsRunning&&box.Locker==CurrentEvent;}}/// <summary>Scenario line of the action the running dialog stands on; 0 when nothing runs.</summary>
public int CurrentLine(){if(!IsCurrentRunning)return 0;var runtime=Box.Runtime;var queue=runtime.Dialog?.Queue;var index=runtime.Index;return queue!=null&&index>=0&&index<queue.Count?queue[index]?.SourceLine??0:0;}/// <summary>Current text of a scenario event's file (edits applied so far), or its built-in text.</summary>
public static string SourceText(ScenarioEvent evt){if(evt==null)return null;if(!string.IsNullOrEmpty(evt.SourceModId))return ScenarioDefs.ReadText(evt.SourceModId,evt.SourcePath)??evt.ScenarioText;return evt.ScenarioText;}/// <summary>The mod (or layer) a scenario file belongs to and how the studio may touch it.</summary>
public StudioModScope ScopeOf(string modId,string path){if(string.IsNullOrEmpty(modId))return new StudioModScope("inline",StudioScopeKind.ReadOnly);if(modId==StudioLayer.Id)return new StudioModScope(modId,StudioScopeKind.Layer);return string.IsNullOrEmpty(ModManager.Instance.GetFileSystemPath(modId,path))?new StudioModScope(modId,StudioScopeKind.Overlay):new StudioModScope(modId,StudioScopeKind.Own);}/// <summary>Where an edit of a file in <paramref name="fileScope"/> is written.</summary>
public StudioModScope EditScopeFor(StudioModScope fileScope){if(fileScope.Kind==StudioScopeKind.Own&& !Layer.ForceLayer)return fileScope;return new StudioModScope(StudioLayer.Id,StudioScopeKind.Layer);}// ── edits ────────────────────────────────────────────────── <summary>Pending edits of a scenario, or null.</summary>
public StudioWorkingCopy Copy(string scenarioName)=>scenarioName!=null&&_copies.TryGetValue(scenarioName,out var copy)?copy:null;/// <summary>Pending edits of the current scenario, created from its file on first use; null without a scenario.</summary>
public StudioWorkingCopy EnsureCopy(){var evt=CurrentEvent;if(evt==null)return null;if(!_copies.TryGetValue(evt.ID,out var copy))_copies[evt.ID]=copy=new StudioWorkingCopy(evt.ID,SourceText(evt));return copy;}/// <summary>Inserts lines before 0-based line <paramref name="index"/> of the current scenario.</summary>
public void Insert(int index,params string[]lines){EnsureCopy()?.Insert(index,lines);Touch();}public void ReplaceLine(int index,string line){EnsureCopy()?.Replace(index,line);Touch();}public void ReplaceText(string text){EnsureCopy()?.ReplaceAll(text);Touch();}/// <summary>Points the answer at 0-based line <paramref name="index"/> to a scene (a label block leading there is added).</summary>
public void TargetScene(int index,string scene){var copy=EnsureCopy();if(copy==null)return;var label=StudioScriptEdits.EnsureSceneExit(copy,scene);copy.Replace(index,StudioScriptEdits.WithTarget(copy.Line(index),label));Touch();}/// <summary>Points the answer at 0-based line <paramref name="index"/> to a label of the scenario.</summary>
public void TargetLabel(int index,string label){var copy=EnsureCopy();if(copy==null)return;copy.Replace(index,StudioScriptEdits.WithTarget(copy.Line(index),label));Touch();}/// <summary>Points the answer at 0-based line <paramref name="index"/> to the end of the scenario.</summary>
public void TargetEnd(int index){var copy=EnsureCopy();if(copy==null)return;var label=StudioScriptEdits.EnsureEnd(copy);copy.Replace(index,StudioScriptEdits.WithTarget(copy.Line(index),label));Touch();}public void Discard(){var evt=CurrentEvent;if(evt!=null&&_copies.Remove(evt.ID))Touch();}/// <summary> Writes the pending edits of the current scenario — into its folder mod, or into the studio layer for zip mods, layer files and forced layer — and replays the scenario from its start with the game state rolled back. Returns the scope written to, or null when there was nothing. </summary>
public StudioModScope Apply(){var evt=CurrentEvent;var copy=Copy(evt?.ID);if(copy==null||copy.PendingCount==0)return null;if(string.IsNullOrEmpty(evt.SourceModId)){evt.ScenarioText=copy.Text;_copies.Remove(evt.ID);Restart(StudioRestartMode.Scenario);return ScopeOf(null,null);}var target=EditScopeFor(ScopeOf(evt.SourceModId,evt.SourcePath));var pending=copy.PendingCount;if(target.Kind==StudioScopeKind.Layer)Layer.Write(evt.SourcePath,copy.Text);else File.WriteAllText(ModManager.Instance.GetFileSystemPath(evt.SourceModId,evt.SourcePath),copy.Text);_copies.Remove(evt.ID);StudioLayer.RefreshContent();Journal.Add(StudioLogCategory.Edit,new StudioText("studio/log_text_applied",evt.ID,pending,target.ModId));GameLogger.Log("UI",$"Scenario studio: applied {pending} change(s) of '{evt.ID}' to '{target.ModId}'");Restart(StudioRestartMode.Rollback);return target;}// ── restarts and navigation ────────────────────────────────
public void Restart(StudioRestartMode mode){var evt=CurrentEvent;if(evt==null)return;var name=evt.ID;switch(mode){case StudioRestartMode.Scene:GoToScene(SceneName);return;case StudioRestartMode.Rollback when _snapshot!=null&&_snapshot.ScenarioName==name:StopDialog();if(_snapshot.Restore())ForgetCompleted(name);RunScenario(name,null,evt);return;case StudioRestartMode.FromLabel:var label=LabelAbove(SourceText(evt),CurrentLine());StopDialog();ForgetCompleted(name);RunScenario(name,label,evt);return;default:StopDialog();ForgetCompleted(name);RunScenario(name,null,evt);return;}}/// <summary>Starts scenario <paramref name="name"/> of the current scene now, stopping the running dialog.</summary>
public bool RunNow(string name){StopDialog();ForgetCompleted(name);return RunScenario(name,null,null);}/// <summary>Removes <paramref name="name"/> from the one-run completion history.</summary>
public void ForgetCompleted(string name){var runtime=GameData.Instance?.Runtime;if(runtime==null)return;runtime.CompletedScenarioIds.Remove(name);runtime.LoadedCompletedScenarioIds?.Remove(name);Touch();}/// <summary>Leaves the running dialog and enters <paramref name="scene"/> (a debugging teleport).</summary>
public void GoToScene(string scene){if(string.IsNullOrEmpty(scene))return;StopDialog();Debugger.Reset();GetNodeOrNull<SceneManager>("/root/SceneManager")?.SwitchScene(scene);Touch();}private void StopDialog(){Debugger.Continue(null);Box?.Abort();}/// <summary> Builds a fresh event for scenario <paramref name="name"/> from the current definitions (edits included), puts it on the scene's chain in place of <paramref name="replaced"/> and starts it at once, so other queued scenarios do not run first. A scenario without a definition (test play, inline) is rebuilt from the replaced event's text. </summary>
private bool RunScenario(string name,string startLabel,ScenarioEvent replaced){var chain=GetTree().GetFirstNodeInGroup(ChainGroup)as ExecutableMainEventChain;var factory=AppServices.Current.SceneEvents.ScenarioEventFactory;if(chain==null||factory==null){GameLogger.Log("UI",$"Scenario studio: cannot run '{name}' — no event chain or scenario factory",LogLevel.Error);return false;}var def=FindDefinition(name);ScenarioEvent evt;if(def!=null){var text=ScenarioDefs.ReadText(def.ModId,def.FilePath);evt=factory.CreateFromDefinition(text,name,def.Condition,def.LightUseTime,def.IsOneRun,def.ModId,def.FilePath)as ScenarioEvent;}else if(replaced!=null&&IsInstanceValid(replaced)){evt=factory.CreateFromText(replaced.ScenarioText)as ScenarioEvent;}else{GameLogger.Log("UI",$"Scenario studio: scenario '{name}' not found",LogLevel.Error);return false;}if(evt==null)return false;evt.StartLabel=startLabel;if(replaced!=null&&IsInstanceValid(replaced)&&replaced.GetParent()==chain)chain.RemoveEvent(replaced);chain.AddEvent(evt);var started=evt.RunDialog();GameLogger.Log("UI",$"Scenario studio: run '{name}'"+(startLabel!=null?$" from :{startLabel}":"")+(started?"":" — dialog box busy"));Touch();return started;}public static ScenarioDefinition FindDefinition(string name){var defs=ScenarioDefs.CollectDefinitions();if(defs==null)return null;foreach(var list in defs.Values){foreach(var def in list){if(def.Name==name)return def;}}return null;}private static string LabelAbove(string text,int line){if(text==null||line<=0)return null;var lines=text.Replace("\r\n","\n").Split('\n');for(var i=Math.Min(line,lines.Length)-1;i>=0;i--){var trimmed=lines[i].Trim();if(trimmed.StartsWith(':'))return trimmed.Substring(1).Trim();}return null;}/// <summary>True when a manual checkpoint was saved in this session.</summary>
public bool HasCheckpoint=>_checkpoint!=null;/// <summary>Remembers the whole game state and the scene now, to come back to with <see cref="RestoreCheckpoint"/>.</summary>
public void SaveCheckpoint(){_checkpoint=StudioSnapshot.Capture(null,SceneName);Journal.Add(StudioLogCategory.Edit,new StudioText("studio/log_text_checkpoint_saved",SceneName));Touch();}/// <summary>Brings back the saved checkpoint: game state, then its scene entered again.</summary>
public bool RestoreCheckpoint(){if(_checkpoint==null)return false;StopDialog();Debugger.Reset();if(!_checkpoint.Restore())return false;Journal.Add(StudioLogCategory.Edit,new StudioText("studio/log_text_checkpoint_restored",_checkpoint.SceneName));GoToScene(_checkpoint.SceneName);return true;}// ── new content ──────────────────────────────────────────── <summary>Creates a scene in the layer (backgrounds of the current scene) with a first scenario; returns false on a bad name.</summary>
public bool CreateScene(string name){name=(name??string.Empty).Trim().ToLowerInvariant();if(!ModIdPattern.IsMatch(name))return false;var current=SceneName;var defs=AppServices.Current.SceneDefs.CollectAll();List<string>backgrounds=null;if(current!=null&&defs!=null&&defs.TryGetValue(current,out var list)&&list.Count>0)backgrounds=list[0].DefaultBackground;var scenario=Layer.CreateScene(name,backgrounds,StudioScriptEdits.NewLine());StudioLayer.RefreshContent();Journal.Add(StudioLogCategory.Edit,new StudioText("studio/log_text_scene_created",name,scenario));Touch();return true;}/// <summary>Creates a new scenario for the current scene in the layer; returns its name.</summary>
public string CreateScenario(){var scene=SceneName;if(string.IsNullOrEmpty(scene))return null;var name=Layer.CreateScenario(scene,StudioScriptEdits.NewLine());Journal.Add(StudioLogCategory.Edit,new StudioText("studio/log_text_scenario_created",name,scene));Touch();return name;}/// <summary>Exports the layer as mod <paramref name="modId"/>; null on a bad id.</summary>
public string Export(string modId){modId=(modId??string.Empty).Trim().ToLowerInvariant();if(!ModIdPattern.IsMatch(modId))return null;var path=Layer.Export(modId);Journal.Add(StudioLogCategory.Edit,new StudioText("studio/log_text_exported",modId));Touch();return path;}// ── state ────────────────────────────────────────────────── <summary>Writes a value edited in the "State" tab back into the game.</summary>
public void EditState(StudioStateEntry entry,string value){switch(entry.Editor){case StudioStateEditor.DialogVariable:Box?.Runtime.SetVariable(entry.Id,value);break;case StudioStateEditor.Inventory:var inventory=GameData.Instance?.Runtime.Inventory;if(inventory==null|| !int.TryParse(value,out var count)||count<0)return;var delta=count-inventory.CheckCount(entry.Id);if(delta>0)inventory.Add(entry.Id,delta);else if(delta<0)inventory.Remove(entry.Id,-delta);break;case StudioStateEditor.Quest:QuestServiceRegistry.Provider?.SetStatus(entry.Id,value);break;default:return;}Journal.Add(StudioLogCategory.Edit,new StudioText("studio/log_text_state",entry.Id,value));Touch();}// ── bus and debugger callbacks ─────────────────────────────
private void OnScenarioStarted(ScenarioEvent evt){try{_snapshot=StudioSnapshot.Capture(evt.ID,SceneName);}catch(Exception ex){_snapshot=null;GameLogger.Log("UI",$"Scenario studio: snapshot of '{evt.ID}' failed: {ex.Message}",LogLevel.Warning);}Journal.Add(StudioLogCategory.Scenario,new StudioText("studio/log_text_started",evt.ID));}private void OnHeld(int line,bool breakpoint){Journal.Add(StudioLogCategory.Stop,new StudioText(breakpoint?"studio/log_text_breakpoint":"studio/log_text_paused",line));}private void OnSceneEntered(SceneEnteredEvent e){Journal.OnSceneEntered(e);Touch();}private void OnSceneComposed(SceneComposedEvent e){Journal.OnSceneComposed(e);Touch();}private void OnDialogEnded(DialogEndedEvent e){Journal.OnDialogEnded(e);Touch();}private void Touch()=>Version++;}}

===== FILE mods/scenario_studio/src/Runtime/StudioScriptEdits.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System.Collections.Generic;using System.Text;using Cthangover.Mods.Dialog.Scenarios;using Godot;/// <summary> The DSL the dock writes. New lines are written in the current language as literals (what an author would type; the locale atlas convention for scenario text). Edits rewrite one line through <see cref="ScenarioParser.SplitLine"/> and keep its other arguments, so changing an answer's text does not lose its target and vice versa; a literal replaces a <c>key=</c>, because the new text is no longer the translated one. </summary>
public static class StudioScriptEdits{public static string NewLine()=>"text "+Quote(Tr("studio/new_line_text"));public static string NewQuestion()=>"select "+Quote(Tr("studio/new_choice_text"));public static string NewAnswer()=>"option "+Quote(Tr("studio/new_option_text"));public static string Jump(string scene)=>"switch_scene "+scene;/// <summary><paramref name="line"/> with its player-visible text replaced by <paramref name="text"/>.</summary>
public static string WithSay(string line,string text){var positional=new List<string>();var named=new Dictionary<string,string>();var cmd=ScenarioParser.SplitLine(line.Trim(),positional,named,out var arrow);if(cmd==null)return line;named.Remove("key");if(positional.Count>0)positional[0]=text;else positional.Insert(0,text);return Indent(line)+Compose(cmd,positional,named,arrow);}/// <summary><paramref name="line"/> (an answer or goto) pointing at label <paramref name="label"/>.</summary>
public static string WithTarget(string line,string label){var positional=new List<string>();var named=new Dictionary<string,string>();var cmd=ScenarioParser.SplitLine(line.Trim(),positional,named,out _);if(cmd==null)return line;named.Remove("goto");named.Remove("target");if(positional.Count>1&&positional[1].StartsWith(':'))positional.RemoveAt(1);return Indent(line)+Compose(cmd,positional,named,label);}/// <summary> Makes sure the scenario has a label block that leads to scene <paramref name="scene"/> (<c>:to_scene</c> → <c>switch_scene</c> → <c>end</c>), appending it at the end when missing; returns the label name. </summary>
public static string EnsureSceneExit(StudioWorkingCopy copy,string scene){var label="to_"+scene;if(copy.FindLabel(label)<0)copy.Append("",":"+label,Jump(scene),"end");return label;}/// <summary>Makes sure the scenario has a label that just ends it; returns the label name.</summary>
public static string EnsureEnd(StudioWorkingCopy copy){const string label="finish";if(copy.FindLabel(label)<0)copy.Append("",":"+label,"end");return label;}public static string Quote(string text)=>"\""+(text??string.Empty).Replace("\"","\\\"")+"\"";private static string Compose(string cmd,List<string>positional,Dictionary<string,string>named,string arrow){var result=new StringBuilder(cmd);var sayCommand=cmd=="text"||cmd=="ptext"||cmd=="select"||cmd=="option";for(var i=0;i<positional.Count;i++){var value=positional[i];result.Append(' ').Append((i==0&&sayCommand)||NeedsQuotes(value)?Quote(value):value);}foreach(var pair in named)result.Append(' ').Append(pair.Key).Append('=').Append(pair.Value);if(!string.IsNullOrEmpty(arrow))result.Append(" -> :").Append(arrow);return result.ToString();}private static bool NeedsQuotes(string value)=>value.Length==0||value.Contains(' ')||value.Contains('"')||value.Contains('=');private static string Indent(string line)=>line.Substring(0,line.Length-line.TrimStart().Length);private static string Tr(string key)=>TranslationServer.Translate(key).ToString();}}

===== FILE mods/scenario_studio/src/Runtime/StudioSessionBuilder.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System.Collections.Generic;using Cthangover.Core.Bootstrap;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Quests;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Mods.Dialog.Executable;using Godot;/// <summary> Builds the <see cref="StudioSession"/> the dock renders from the live game: the current scenario's lines (pending edits included) with the debugger's marks, its file and edit scopes, the scene candidates of the last composition merged with what is true now (running, completed), the game state worth seeing while debugging, the log and the scene map around the player. Read-only: nothing here changes the game. </summary>
public static class StudioSessionBuilder{private const int VisitedOnMap=4;public static StudioSession Build(StudioLive live){var session=new StudioSession{SceneName=StudioLive.SceneName};var evt=live.CurrentEvent;session.RunState=RunState(live);if(evt!=null)FillScenario(session,live,evt);FillCandidates(session,live,evt);FillState(session,live);session.Log.AddRange(live.Journal.Entries);FillMap(session,live);return session;}private static StudioRunState RunState(StudioLive live){var box=live.Box;if(box!=null&&box.IsRunning&&box.Runtime.IsHeld)return StudioRunState.Held;if(live.Debugger.IsPaused)return StudioRunState.Paused;return live.IsCurrentRunning?StudioRunState.Running:StudioRunState.Idle;}private static void FillScenario(StudioSession session,StudioLive live,ScenarioEvent evt){session.ScenarioName=evt.ID;session.FileScope=live.ScopeOf(evt.SourceModId,evt.SourcePath);session.EditScope=live.EditScopeFor(session.FileScope);var copy=live.Copy(evt.ID);session.PendingCount=copy?.PendingCount??0;var lines=StudioLineReader.Read(copy!=null?copy.Text:StudioLive.SourceText(evt));var running=live.IsCurrentRunning&&live.Debugger.Event==evt;var current=live.IsCurrentRunning?live.CurrentLine():0;string label=null;foreach(var line in lines){line.SourceLine=copy!=null?copy.OriginalNumber(line.Number):line.Number;line.IsNew=copy!=null&&copy.IsChanged(line.Number-1);line.HasBreakpoint=line.SourceLine>0&&live.Debugger.HasBreakpoint(evt.ID,line.SourceLine);line.IsCurrent=current>0&&line.SourceLine==current;line.IsDone=running&& !line.IsCurrent&&line.SourceLine>0&&live.Debugger.IsExecuted(line.SourceLine);if(line.Kind==StudioLineKind.Label&&current>0&&line.SourceLine>0&&line.SourceLine<current)label=":"+line.Target;}session.LabelName=label;session.Lines.AddRange(lines);}private static void FillCandidates(StudioSession session,StudioLive live,ScenarioEvent evt){var report=live.Journal.Composed(session.SceneName);if(report==null)return;var completed=GameData.Instance?.Runtime.CompletedScenarioIds;foreach(var candidate in report.Candidates){var item=new StudioCandidate{Name=candidate.Id,Scope=live.ScopeOf(candidate.ModId,candidate.ScenarioPath),Priority=candidate.Priority,Condition=string.IsNullOrEmpty(candidate.Condition)?null:candidate.Condition,IsOneRun=candidate.IsOneRun,};switch(candidate.Outcome){case SceneCandidateOutcome.ConditionFalse:item.ConditionOk=false;item.Status=StudioCandidateStatus.Skipped;item.Reason=new StudioText("studio/reason_condition_false");break;case SceneCandidateOutcome.Completed:item.Status=StudioCandidateStatus.Completed;item.Reason=new StudioText("studio/reason_completed");break;case SceneCandidateOutcome.Shadowed:item.Status=StudioCandidateStatus.Shadowed;item.Reason=new StudioText("studio/reason_shadowed");break;case SceneCandidateOutcome.Failed:item.Status=StudioCandidateStatus.Failed;item.Reason=new StudioText("studio/reason_failed");break;default:item.ConditionOk=true;item.Status=StudioCandidateStatus.Queued;item.Reason=new StudioText("studio/reason_queued",candidate.Priority);break;}if(evt!=null&&live.IsCurrentRunning&&evt.ID==candidate.Id){item.Status=StudioCandidateStatus.Running;item.Reason=new StudioText("studio/reason_running");}else if(completed!=null&&candidate.IsOneRun&&completed.Contains(candidate.Id)){item.Status=StudioCandidateStatus.Completed;item.Reason=new StudioText("studio/reason_completed");}session.Candidates.Add(item);}}private static void FillState(StudioSession session,StudioLive live){var runtime=GameData.Instance?.Runtime;if(runtime==null)return;var time=runtime.Time;Add(session,"studio/group_time",new StudioText("studio/state_day"),StudioText.Raw(time.Days.ToString()),StudioStateEditor.None,null);Add(session,"studio/group_time",new StudioText("studio/state_hour"),StudioText.Raw($"{time.Hours:00}:{time.Minutes:00}"),StudioStateEditor.None,null);var box=live.Box;if(box!=null&&box.IsRunning){foreach(var variable in box.Runtime.Variables)Add(session,"studio/group_dialog_vars",StudioText.Raw("${"+variable.Key+"}"),StudioText.Raw(variable.Value),StudioStateEditor.DialogVariable,variable.Key);}var quests=QuestServiceRegistry.Provider;if(quests!=null){foreach(var id in QuestIds(session.Lines)){var quest=quests.Get(id);if(quest!=null)Add(session,"studio/group_quests",StudioText.Raw(id),StudioText.Raw(quest.Status.ToString()),StudioStateEditor.Quest,id);}}var inventory=runtime.Inventory;if(inventory?.Items!=null){foreach(var slot in inventory.Items){if(slot?.Item!=null)Add(session,"studio/group_inventory",StudioText.Raw(slot.Item.ID),StudioText.Raw(slot.Count.ToString()),StudioStateEditor.Inventory,slot.Item.ID);}}foreach(var id in runtime.CompletedScenarioIds)Add(session,"studio/group_completed",StudioText.Raw(id),new StudioText("studio/value_yes"),StudioStateEditor.None,id);}private static IEnumerable<string>QuestIds(List<StudioCodeLine>lines){var seen=new HashSet<string>();foreach(var line in lines){var at=line.Text.IndexOf("quest_id=",System.StringComparison.Ordinal);if(at<0)continue;var start=at+"quest_id=".Length;var end=line.Text.IndexOf(' ',start);var id=(end<0?line.Text.Substring(start):line.Text.Substring(start,end-start)).Trim('"');if(id.Length>0&&seen.Add(id))yield return id;}}private static void Add(StudioSession session,string group,StudioText key,StudioText value,StudioStateEditor editor,string id){session.State.Add(new StudioStateEntry{Group=new StudioText(group),Key=key,Value=value,Editor=editor,Id=id});}private static void FillMap(StudioSession session,StudioLive live){var here=session.SceneName;var scenes=AppServices.Current.SceneDefs.CollectAll();var visited=new List<string>();var history=live.Journal.SceneHistory;for(var i=history.Count-1;i>=0&&visited.Count<VisitedOnMap;i--){if(history[i]!=here&& !visited.Contains(history[i]))visited.Insert(0,history[i]);}foreach(var scene in visited)session.Nodes.Add(Node(live,scenes,scene,StudioNodeState.Visited,null));if(string.IsNullOrEmpty(here))return;session.Nodes.Add(Node(live,scenes,here,StudioNodeState.Here,null));var exits=new HashSet<string>();var defs=ScenarioDefs.CollectDefinitions();if(defs!=null&&defs.TryGetValue(here,out var scenarios)){foreach(var def in scenarios){foreach(var line in StudioLineReader.Read(ScenarioDefs.ReadText(def.ModId,def.FilePath))){if(line.Kind==StudioLineKind.Jump&& !string.IsNullOrEmpty(line.Target)&&exits.Add(line.Target))session.Nodes.Add(Node(live,scenes,line.Target,StudioNodeState.Next,new StudioText("studio/via_scenario",def.Name)));}}}foreach(var scene in live.Layer.SceneNames()){if(scene!=here&&exits.Add(scene))session.Nodes.Add(Node(live,scenes,scene,StudioNodeState.New,new StudioText("studio/via_layer")));}}private static StudioSceneNode Node(StudioLive live,Dictionary<string,List<SceneDefinition>>scenes,string name,StudioNodeState state,StudioText via){string modId=null;if(scenes!=null&&scenes.TryGetValue(name,out var list)&&list.Count>0)modId=list[0].ModId;var scope=modId==null?new StudioModScope("?",StudioScopeKind.ReadOnly):live.ScopeOf(modId,$"scenes/{name}.json");return new StudioSceneNode{Name=name,State=state,Scope=scope,Via=via};}}}

===== FILE mods/scenario_studio/src/Runtime/StudioSnapshot.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System.Text.Json;using Cthangover.Core.Settings;using Cthangover.Core.Utils;/// <summary> Game state at the moment a scenario started: a full save package (<see cref="SaveService.BuildPackage"/>) kept serialized in memory, plus the scenario and scene it belongs to. Restoring it is the "restart with rollback": quests, inventory, time and completion history go back to the scenario's start. It is kept as JSON because applying a package may reuse its slots, and one snapshot is restored again and again while a scenario is being polished. </summary>
public sealed class StudioSnapshot{private readonly string _json;public string ScenarioName{get;}public string SceneName{get;}private StudioSnapshot(string scenarioName,string sceneName,string json){ScenarioName=scenarioName;SceneName=sceneName;_json=json;}public static StudioSnapshot Capture(string scenarioName,string sceneName){var package=SaveService.BuildPackage("studio_snapshot");return new StudioSnapshot(scenarioName,sceneName,JsonSerializer.Serialize(package,JsonOptionsProvider.WriteCompactOptions));}/// <summary>Brings the game state back; false when the package could not be applied.</summary>
public bool Restore(){var package=JsonSerializer.Deserialize<SavePackage>(_json,JsonOptionsProvider.ReadOptions);return package!=null&&SaveService.ApplyPackage(package,"studio snapshot of "+ScenarioName);}}}

===== FILE mods/scenario_studio/src/Runtime/StudioWorkingCopy.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System.Collections.Generic;/// <summary> Unapplied edits of one scenario: its text as lines plus which lines were added or changed since the copy was taken. Every dock edit (insert, edit a line's text, choose an answer's target, edit the whole text) goes here first and reaches the game only on Apply, so an author can prepare several changes and replay them at once. Every line remembers its number in the original text (0 for added lines): the running dialog's actions carry original numbers, so the debugger's marks and breakpoints are mapped through it while edits shift the displayed numbering. </summary>
public sealed class StudioWorkingCopy{private readonly List<string>_lines;private readonly List<bool>_changed;private readonly List<int>_origin;public string ScenarioName{get;}public StudioWorkingCopy(string scenarioName,string text){ScenarioName=scenarioName;_lines=new List<string>((text??string.Empty).Replace("\r\n","\n").Split('\n'));_changed=new List<bool>(new bool[_lines.Count]);_origin=new List<int>(_lines.Count);for(var i=0;i<_lines.Count;i++)_origin.Add(i+1);}/// <summary>Original (1-based) number of the displayed line <paramref name="number"/>; 0 for an added line.</summary>
public int OriginalNumber(int number)=>number>=1&&number<=_origin.Count?_origin[number-1]:0;public string Text=>string.Join("\n",_lines);public int LineCount=>_lines.Count;/// <summary>Number of added or changed lines.</summary>
public int PendingCount{get{var count=0;foreach(var changed in _changed){if(changed)count++;}return count;}}/// <summary>Whether the line at 0-based <paramref name="index"/> was added or changed.</summary>
public bool IsChanged(int index)=>index>=0&&index<_changed.Count&&_changed[index];public string Line(int index)=>index>=0&&index<_lines.Count?_lines[index]:string.Empty;/// <summary>Inserts lines before 0-based <paramref name="index"/> (clamped to the text).</summary>
public void Insert(int index,params string[]lines){index=System.Math.Clamp(index,0,_lines.Count);_lines.InsertRange(index,lines);_changed.InsertRange(index,NewFlags(lines.Length));_origin.InsertRange(index,new int[lines.Length]);}/// <summary>Appends lines at the end of the text.</summary>
public void Append(params string[]lines)=>Insert(_lines.Count,lines);public void Replace(int index,string line){if(index<0||index>=_lines.Count||_lines[index]==line)return;_lines[index]=line;_changed[index]=true;}/// <summary> Replaces the whole text (the inline text editor). Lines that differ from the line at the same index before the edit are marked changed; this is a display hint, not a diff, so an inserted line marks the shifted tail too. </summary>
public void ReplaceAll(string text){var lines=(text??string.Empty).Replace("\r\n","\n").Split('\n');for(var i=0;i<lines.Length;i++){var changed=i>=_lines.Count||_lines[i]!=lines[i]||_changed[i];if(i<_lines.Count){_lines[i]=lines[i];_changed[i]=changed;}else{_lines.Add(lines[i]);_changed.Add(true);_origin.Add(0);}}if(_lines.Count>lines.Length){_lines.RemoveRange(lines.Length,_lines.Count-lines.Length);_changed.RemoveRange(lines.Length,_changed.Count-lines.Length);_origin.RemoveRange(lines.Length,_origin.Count-lines.Length);}}/// <summary>0-based index of the label line <c>:name</c>, or -1.</summary>
public int FindLabel(string name){for(var i=0;i<_lines.Count;i++){if(_lines[i].Trim()==":"+name)return i;}return-1;}private static IEnumerable<bool>NewFlags(int count){for(var i=0;i<count;i++)yield return true;}}}

===== FILE mods/scenario_studio/src/StudioAutoOpenNode.cs =====
namespace Cthangover.Mods.ScenarioStudio{using Cthangover.Core.Bootstrap;using Cthangover.Core.Scenes;using Cthangover.Core.UI;using Cthangover.Core.Utils;using Godot;/// <summary> Waits until boot is done and the game shell (its <c>TopView</c> toolbar) is in the tree, toggles the <c>studio</c> UI entry exactly like the toolbar button, then frees itself. Started by <see cref="StudioLaunchInitializer"/>. </summary>
public partial class StudioAutoOpenNode:Node{private const string EntryId="studio";private const float PollSeconds=0.5f;public StudioAutoOpenNode(){var timer=new Timer{WaitTime=PollSeconds,Autostart=true};AddChild(timer);timer.Timeout+=OnPoll;}private void OnPoll(){if(!Boot.IsReady||UiHost.Instance==null||SceneContextNode.FindNode<Node>("TopView")==null)return;AppServices.Current.Ui.ToggleEntry(EntryId);GameLogger.Log("UI","Scenario studio: dock opened by --studio");QueueFree();}}}

===== FILE mods/scenario_studio/src/StudioLaunchInitializer.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;/// <summary> Lifecycle entry of the studio mod. When resources are ready it mounts the edit layer (<see cref="StudioLayer"/>) and parks <see cref="StudioLive"/> at <c>/root</c>, so the debugger, the journal and the rollback snapshots work from the first scene on, before the dock is ever opened. The launch flag <c>--studio</c> additionally opens the dock with the first scene (<c>debug_scenario_studio.bat</c>). Nodes are added deferred because resources-ready fires before the shell scene exists. </summary>
public class StudioLaunchInitializer:IModInitializer{private const string Flag="--studio";public void OnModLoaded(string modId){}public void OnModResourcesReady(){if(StudioLive.Instance!=null)return;var args=OS.GetCmdlineUserArgs();var layer=new StudioLayer(args);layer.Mount();var root=(Godot.Engine.GetMainLoop()as SceneTree)?.Root;root?.CallDeferred("add_child",new StudioLive(layer));if(!args.Contains(Flag))return;GameLogger.Log("UI","Scenario studio: --studio given, the dock opens with the first scene");root?.CallDeferred("add_child",new StudioAutoOpenNode());}}}

===== FILE mods/scenario_studio/src/StudioPanel.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System.Collections.Generic;using Cthangover.Core.Bootstrap;using Cthangover.Core.UI.Theming;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Mods.Interface.Theming;using Godot;/// <summary> The studio dock: a non-modal panel at the right edge over the running game (manifest <c>ui</c> entry <c>studio</c>, own toolbar icon <c>ui/icons/studio.png</c>). The root control spans the screen but ignores the mouse, so the game on the left keeps its input; only the <c>Dock</c> panel catches clicks. The skeleton — header with the mode switch, transport row, "where we are", tab row, scrolling body and footer — lives in <c>scenes/studio_panel.tscn</c>; this behaviour dresses it with the studio styles and refills the body for the active tab and mode from a <see cref="StudioSession"/> built off <see cref="StudioLive"/>. <para/> The dock polls <see cref="StudioLive.Version"/> and redraws only when it changed and the user is not typing (a focused field, the inline text editor or an open prompt), so a running dialog never steals an edit. Every action goes through <see cref="StudioLive"/>; the dock keeps only view state (mode, tab, footer status) and the context of an open prompt. </summary>
public partial class StudioPanel:Widget{private const string Root="Dock/Column/";private const float PollSeconds=0.4f;private const long TargetEnd=0;private const long TargetLabelBase=1000;private const long TargetSceneBase=2000;private const int CheckpointSave=100;private const int CheckpointRestore=101;private readonly StudioNowPage _nowPage=new();private readonly StudioNovicePage _novicePage=new();private StudioMode _mode=StudioMode.Author;private StudioTab _tab=StudioTab.Now;private StudioSession _session=new();private string _status;private int _shownVersion= -1;private CodeEdit _editor;private Node _prompt;private StudioCodeLine _promptLine;private List<string>_pickLabels=new();private List<string>_pickScenes=new();private PanelContainer _runState;private Label _runStateText;private Button _authorButton;private Button _noviceButton;private Button _pauseButton;private Button _stepButton;private Button _continueButton;private MenuButton _restartButton;private HFlowContainer _crumbs;private HFlowContainer _scope;private Button[]_tabs;private VBoxContainer _content;private Label _statusLabel;private Button _editButton;private Button _applyButton;/// <summary>Currently shown tab.</summary>
public StudioTab Tab=>_tab;/// <summary>Currently shown mode.</summary>
public StudioMode Mode=>_mode;/// <summary>The session the dock shows now.</summary>
public StudioSession Session=>_session;private static StudioLive Live=>StudioLive.Instance;protected override void OnceConstruct(){MouseFilter=MouseFilterEnum.Ignore;var dock=GetNode<PanelContainer>("Dock");dock.AddThemeStyleboxOverride("panel",StudioStyles.Dock());foreach(var rule in new[]{"RuleHeader","RuleTransport","RuleContext","RuleTabs","RuleFooter"})GetNode<ColorRect>(Root+rule).Color=UiTokens.Line;StudioStyles.Title(GetNode<Label>(Root+"Header/Row/Title"),Tr("studio/title"));_runState=GetNode<PanelContainer>(Root+"Header/Row/RunState");_runStateText=GetNode<Label>(Root+"Header/Row/RunState/Text");GetNode<PanelContainer>(Root+"Header/Row/ModeSwitch").AddThemeStyleboxOverride("panel",StudioStyles.SegmentWell());_authorButton=GetNode<Button>(Root+"Header/Row/ModeSwitch/Items/AuthorButton");_noviceButton=GetNode<Button>(Root+"Header/Row/ModeSwitch/Items/NoviceButton");StudioStyles.Segment(_authorButton,Tr("studio/mode_author"));StudioStyles.Segment(_noviceButton,Tr("studio/mode_novice"));_authorButton.Pressed+=OnAuthorPressed;_noviceButton.Pressed+=OnNovicePressed;var close=GetNode<Button>(Root+"Header/Row/CloseButton");CthangoverTheme.ApplyIcon(close);close.TooltipText=Tr("studio/close");close.Pressed+=OnClosePressed;_pauseButton=GetNode<Button>(Root+"Transport/Row/PauseButton");StudioStyles.Transport(_pauseButton,Tr("studio/pause"));_pauseButton.Pressed+=OnPausePressed;_stepButton=GetNode<Button>(Root+"Transport/Row/StepButton");StudioStyles.Transport(_stepButton,Tr("studio/step"));_stepButton.Pressed+=OnStepPressed;_continueButton=GetNode<Button>(Root+"Transport/Row/ContinueButton");StudioStyles.Transport(_continueButton,Tr("studio/continue"));_continueButton.Pressed+=OnContinuePressed;_restartButton=GetNode<MenuButton>(Root+"Transport/Row/RestartButton");BuildRestartMenu(_restartButton);_crumbs=GetNode<HFlowContainer>(Root+"Context/Column/Crumbs");_scope=GetNode<HFlowContainer>(Root+"Context/Column/Scope");_tabs=new[]{GetNode<Button>(Root+"Tabs/Row/NowTab"),GetNode<Button>(Root+"Tabs/Row/SceneTab"),GetNode<Button>(Root+"Tabs/Row/StateTab"),GetNode<Button>(Root+"Tabs/Row/LogTab"),GetNode<Button>(Root+"Tabs/Row/MapTab"),};StudioStyles.SmallTab(_tabs[(int)StudioTab.Now],Tr("studio/tab_now"));StudioStyles.SmallTab(_tabs[(int)StudioTab.Scene],Tr("studio/tab_scene"));StudioStyles.SmallTab(_tabs[(int)StudioTab.State],Tr("studio/tab_state"));StudioStyles.SmallTab(_tabs[(int)StudioTab.Log],Tr("studio/tab_log"));StudioStyles.SmallTab(_tabs[(int)StudioTab.Map],Tr("studio/tab_map"));_tabs[(int)StudioTab.Now].Pressed+=OnNowTabPressed;_tabs[(int)StudioTab.Scene].Pressed+=OnSceneTabPressed;_tabs[(int)StudioTab.State].Pressed+=OnStateTabPressed;_tabs[(int)StudioTab.Log].Pressed+=OnLogTabPressed;_tabs[(int)StudioTab.Map].Pressed+=OnMapTabPressed;_content=GetNode<VBoxContainer>(Root+"Body/Scroll/Content");_nowPage.BreakpointToggled+=OnBreakpointToggled;_nowPage.InsertRequested+=OnInsertRequested;_novicePage.LineRequested+=OnNoviceLineRequested;_novicePage.OptionRequested+=OnNoviceOptionRequested;_novicePage.EditRequested+=OnEditLineRequested;_novicePage.TargetRequested+=OnTargetRequested;_statusLabel=GetNode<Label>(Root+"Footer/Row/Status");_statusLabel.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);_statusLabel.AddThemeColorOverride("font_color",UiTokens.Faint);_editButton=GetNode<Button>(Root+"Footer/Row/EditButton");StudioStyles.SmallButton(_editButton,Tr("studio/edit_text"));_editButton.Pressed+=OnEditPressed;_applyButton=GetNode<Button>(Root+"Footer/Row/ApplyButton");StudioStyles.SmallButton(_applyButton,Tr("studio/apply"),primary:true);_applyButton.Pressed+=OnApplyPressed;var timer=new Timer{WaitTime=PollSeconds,Autostart=true};AddChild(timer);timer.Timeout+=OnPoll;Refresh();}/// <summary>Redraws at once whenever the dock is shown.</summary>
protected override void ShowConstruct(){_shownVersion= -1;if(_content!=null)Refresh();}private void BuildRestartMenu(MenuButton button){StudioStyles.Transport(button,Tr("studio/restart"));var popup=button.GetPopup();CthangoverTheme.Apply(popup);popup.AddItem(Tr("studio/restart_scenario"),(int)StudioRestartMode.Scenario);popup.AddItem(Tr("studio/restart_rollback"),(int)StudioRestartMode.Rollback);popup.AddItem(Tr("studio/restart_scene"),(int)StudioRestartMode.Scene);popup.AddItem(Tr("studio/restart_from_label"),(int)StudioRestartMode.FromLabel);popup.AddSeparator(Tr("studio/checkpoint"));popup.AddItem(Tr("studio/checkpoint_save"),CheckpointSave);popup.AddItem(Tr("studio/checkpoint_restore"),CheckpointRestore);popup.IdPressed+=OnRestartPicked;}// ── drawing ────────────────────────────────────────────────
private void OnPoll(){if(!Visible||Live==null||Live.Version==_shownVersion||IsBusy())return;Refresh();}/// <summary>True while the user types or answers a prompt: redrawing would drop the edit.</summary>
private bool IsBusy(){if(_editor!=null||(_prompt!=null&&IsInstanceValid(_prompt)))return true;var focus=GetViewport()?.GuiGetFocusOwner();return focus is LineEdit or TextEdit&&_content.IsAncestorOf(focus);}/// <summary>Rebuilds the session from the live game and re-renders every dynamic part of the dock.</summary>
public void Refresh(){if(Live==null)return;_shownVersion=Live.Version;_session=StudioSessionBuilder.Build(Live);RefreshHeader();RefreshContext();RefreshBody();RefreshFooter();}private void RefreshHeader(){var state=_session.RunState;var stopped=state==StudioRunState.Paused||state==StudioRunState.Held;StudioStyles.RunState(_runState,_runStateText,state,Tr(RunStateKey(state)));_pauseButton.SetPressedNoSignal(Live.Debugger.IsPaused);_stepButton.Disabled= !_session.HasScenario;_continueButton.Disabled= !stopped;var restartMenu=_restartButton.GetPopup();for(var i=0;i<restartMenu.ItemCount;i++){var id=restartMenu.GetItemId(i);if(id<CheckpointSave&& !restartMenu.IsItemSeparator(i))restartMenu.SetItemDisabled(i,!_session.HasScenario);else if(id==CheckpointRestore)restartMenu.SetItemDisabled(i,!Live.HasCheckpoint);}_authorButton.SetPressedNoSignal(_mode==StudioMode.Author);_noviceButton.SetPressedNoSignal(_mode==StudioMode.Novice);for(var i=0;i<_tabs.Length;i++)_tabs[i].SetPressedNoSignal(i==(int)_tab);_tabs[(int)StudioTab.Scene].Text=_session.Candidates.Count>0?$"{Tr("studio/tab_scene")}  {_session.Candidates.Count}":Tr("studio/tab_scene");}private static string RunStateKey(StudioRunState state){switch(state){case StudioRunState.Running:return"studio/state_running";case StudioRunState.Paused:return"studio/state_paused";case StudioRunState.Held:return"studio/state_held";default:return"studio/state_idle";}}private void RefreshContext(){Clear(_crumbs);AddCrumb(Tr("studio/crumb_scene"),_session.SceneName??"—");if(_session.HasScenario)AddCrumb(Tr("studio/crumb_scenario"),_session.ScenarioName);if(!string.IsNullOrEmpty(_session.LabelName))AddCrumb(null,_session.LabelName);Clear(_scope);if(_session.FileScope==null)return;_scope.AddChild(StudioStyles.Scope(_session.FileScope));if(_session.EditScope!=null&&_session.EditScope.ModId!=_session.FileScope.ModId){var hint=StudioStyles.Text(string.Format(Tr("studio/edits_go_to"),_session.EditScope.ModId),UiTokens.FontSmall,UiTokens.Faint);hint.VerticalAlignment=VerticalAlignment.Center;_scope.AddChild(hint);}}private void AddCrumb(string key,string value){if(_crumbs.GetChildCount()>0)_crumbs.AddChild(StudioStyles.Text("›",UiTokens.FontBodySmall,UiTokens.Faint));if(key!=null)_crumbs.AddChild(StudioStyles.Text(key,UiTokens.FontSmall,UiTokens.Faint));_crumbs.AddChild(StudioStyles.Text(value,UiTokens.FontBodySmall,UiTokens.Body,mono:true));}private void RefreshBody(){if(_editor!=null)return;Clear(_content);switch(_tab){case StudioTab.Now when _mode==StudioMode.Author:_nowPage.Build(_content,_session);break;case StudioTab.Now:_novicePage.Build(_content,_session);break;case StudioTab.Scene:StudioScenePage.Build(_content,_session,OnRunCandidate,OnResetCandidate,OnNewScenario);break;case StudioTab.State:StudioStatePage.Build(_content,_session,OnStateSubmitted);break;case StudioTab.Log:StudioLogPage.Build(_content,_session);break;case StudioTab.Map:StudioMapPage.Build(_content,_session,OnGoToScene,OnNewScene,OnLinkScene,OnExport);break;}}private void RefreshFooter(){var pending=_session.PendingCount;_statusLabel.Text=_status??(pending==0?Tr("studio/no_pending"):string.Format(Tr("studio/pending"),pending));_applyButton.Disabled=pending==0&&_editor==null;_editButton.Visible=_mode==StudioMode.Author;_editButton.Disabled= !_session.HasScenario;_editButton.Text=Tr(_editor!=null?"studio/edit_done":"studio/edit_text");}private static void Clear(Node container){foreach(var child in container.GetChildren()){container.RemoveChild(child);child.QueueFree();}}private void SetStatus(string text){_status=text;_statusLabel.Text=text;}private void Changed(){_status=null;Refresh();}// ── header, tabs, transport ────────────────────────────────
private void ShowTab(StudioTab tab){CloseEditor();_tab=tab;Changed();}private void ShowMode(StudioMode mode){CloseEditor();_mode=mode;_tab=StudioTab.Now;Changed();}private void OnAuthorPressed()=>ShowMode(StudioMode.Author);private void OnNovicePressed()=>ShowMode(StudioMode.Novice);private void OnNowTabPressed()=>ShowTab(StudioTab.Now);private void OnSceneTabPressed()=>ShowTab(StudioTab.Scene);private void OnStateTabPressed()=>ShowTab(StudioTab.State);private void OnLogTabPressed()=>ShowTab(StudioTab.Log);private void OnMapTabPressed()=>ShowTab(StudioTab.Map);private void OnClosePressed()=>Hide();private void OnPausePressed(){if(Live.Debugger.IsPaused)Live.Debugger.Continue(Live.Box);else Live.Debugger.Pause();Changed();}private void OnStepPressed(){var box=Live.Box;if(box!=null&&box.IsWaitingAnswer&& !box.Runtime.IsHeld){SetStatus(Tr("studio/step_needs_answer"));return;}Live.Debugger.Step(box);Changed();}private void OnContinuePressed(){Live.Debugger.Continue(Live.Box);Changed();}private void OnRestartPicked(long id){CloseEditor();switch(id){case CheckpointSave:Live.SaveCheckpoint();_status=Tr("studio/checkpoint_saved");Refresh();return;case CheckpointRestore:_status=Live.RestoreCheckpoint()?Tr("studio/checkpoint_restored"):Tr("studio/checkpoint_none");Refresh();return;default:Live.Restart((StudioRestartMode)id);Changed();return;}}// ── author edits ───────────────────────────────────────────
private void OnBreakpointToggled(StudioCodeLine line){if(line.SourceLine>0)Live.Debugger.ToggleBreakpoint(_session.ScenarioName,line.SourceLine);Changed();}private void OnInsertRequested(StudioLineKind kind){var at=_session.InsertionTextIndexAfterCurrent();switch(kind){case StudioLineKind.Choice:Live.Insert(at,StudioScriptEdits.NewQuestion(),StudioScriptEdits.NewAnswer());Changed();break;case StudioLineKind.Jump:_promptLine=new StudioCodeLine{Number=at};OpenScenePick(OnInsertJumpPicked);break;default:Live.Insert(at,StudioScriptEdits.NewLine());Changed();break;}}private void OnInsertJumpPicked(long id){var scene=PickedScene(id);if(scene!=null&&_promptLine!=null)Live.Insert(_promptLine.Number,StudioScriptEdits.Jump(scene));_prompt=null;Changed();}private void OnEditPressed(){if(_editor!=null){CloseEditor();Changed();return;}var copy=Live.EnsureCopy();if(copy==null)return;_tab=StudioTab.Now;Clear(_content);_editor=StudioNowPage.BuildEditor(_content,copy.Text);_editor.GrabFocus();RefreshFooter();}/// <summary>Takes the inline editor's text into the working copy and closes it.</summary>
private void CloseEditor(){if(_editor==null)return;var text=_editor.Text;_editor=null;Live.ReplaceText(text);}private void OnApplyPressed(){CloseEditor();var scope=Live.Apply();_status=scope==null?Tr("studio/no_pending"):string.Format(Tr("studio/applied_to"),scope.ModId);Refresh();}// ── novice edits ───────────────────────────────────────────
private void OnNoviceLineRequested(int at){Live.Insert(at,StudioScriptEdits.NewLine());Changed();}private void OnNoviceOptionRequested(int at){Live.Insert(at,StudioScriptEdits.NewAnswer());Changed();}private void OnEditLineRequested(StudioActionButton button){_promptLine=button.Line;_prompt=StudioPrompt.AskText(this,Tr("studio/prompt_edit_title"),button.Line.Say?.Resolve(),OnLineTextEntered);}private void OnLineTextEntered(string text){if(_promptLine!=null&& !string.IsNullOrWhiteSpace(text)){var index=_promptLine.Number-1;var copy=Live.EnsureCopy();if(copy!=null)Live.ReplaceLine(index,StudioScriptEdits.WithSay(copy.Line(index),text.Trim()));}_prompt=null;Changed();}/// <summary>"Choose where" for an answer: the end, the scenario's labels or any scene.</summary>
private void OnTargetRequested(StudioActionButton button){_promptLine=button.Line;var menu=StudioPrompt.Pick(this,OnTargetPicked);menu.AddItem(Tr("studio/target_end"),(int)TargetEnd);_pickLabels=new List<string>();foreach(var line in _session.Lines){if(line.Kind==StudioLineKind.Label)_pickLabels.Add(line.Target);}if(_pickLabels.Count>0)menu.AddSeparator(Tr("studio/target_labels"));for(var i=0;i<_pickLabels.Count;i++)menu.AddItem(":"+_pickLabels[i],(int)(TargetLabelBase+i));AddScenes(menu);_prompt=menu;StudioPrompt.Open(menu);}private void OnTargetPicked(long id){if(_promptLine!=null){var index=_promptLine.Number-1;if(id==TargetEnd)Live.TargetEnd(index);else if(id>=TargetSceneBase)Live.TargetScene(index,PickedScene(id));else if(id>=TargetLabelBase&&id-TargetLabelBase<_pickLabels.Count)Live.TargetLabel(index,_pickLabels[(int)(id-TargetLabelBase)]);}_prompt=null;Changed();}private void OpenScenePick(PopupMenu.IdPressedEventHandler picked){var menu=StudioPrompt.Pick(this,picked);AddScenes(menu);_prompt=menu;StudioPrompt.Open(menu);}private void AddScenes(PopupMenu menu){_pickScenes=new List<string>();var scenes=AppServices.Current.SceneDefs.CollectAll();if(scenes!=null)_pickScenes.AddRange(scenes.Keys);_pickScenes.Sort(System.StringComparer.Ordinal);menu.AddSeparator(Tr("studio/target_scenes"));for(var i=0;i<_pickScenes.Count;i++)menu.AddItem(string.Format(Tr("studio/target_scene"),_pickScenes[i]),(int)(TargetSceneBase+i));}private string PickedScene(long id){var index=(int)(id-TargetSceneBase);return index>=0&&index<_pickScenes.Count?_pickScenes[index]:null;}// ── scene, state, map ──────────────────────────────────────
private void OnRunCandidate(StudioActionButton button){var started=Live.RunNow(button.Payload);_tab=StudioTab.Now;_status=started?null:Tr("studio/run_failed");Refresh();}private void OnResetCandidate(StudioActionButton button){Live.ForgetCompleted(button.Payload);Changed();}private void OnNewScenario(StudioActionButton button){var name=Live.CreateScenario();_status=name==null?null:string.Format(Tr("studio/scenario_created"),name);Refresh();}private void OnStateSubmitted(StudioStateEntry entry,string value){GetViewport()?.GuiReleaseFocus();Live.EditState(entry,value);Changed();}private void OnGoToScene(StudioActionButton button){Live.GoToScene(button.Payload);Changed();}private void OnNewScene(StudioActionButton button){_prompt=StudioPrompt.AskText(this,Tr("studio/prompt_scene_title"),"",OnSceneNameEntered);}private void OnSceneNameEntered(string name){_prompt=null;_status=Live.CreateScene(name)?string.Format(Tr("studio/scene_created"),name.Trim().ToLowerInvariant()):Tr("studio/bad_name");Refresh();}private void OnLinkScene(StudioActionButton button){_promptLine=new StudioCodeLine{Number=_session.InsertionTextIndexAfterCurrent()};OpenScenePick(OnInsertJumpPicked);}private void OnExport(StudioActionButton button){_prompt=StudioPrompt.AskText(this,Tr("studio/prompt_export_title"),"my_story",OnExportEntered);}private void OnExportEntered(string modId){_prompt=null;var path=Live.Export(modId);_status=path==null?Tr("studio/bad_name"):string.Format(Tr("studio/exported"),modId.Trim().ToLowerInvariant());Refresh();}}}

===== FILE mods/scenario_studio/src/Views/StudioActionButton.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System;using Godot;/// <summary> Small studio button that carries what it acts on — a scenario line, a scenario or scene name — and reports itself when pressed, so one dock handler serves every row without a closure per button. </summary>
public partial class StudioActionButton:Button{/// <summary>Name the action applies to (scenario, scene), if any.</summary>
public string Payload{get;set;}/// <summary>Scenario line the action applies to, if any.</summary>
public StudioCodeLine Line{get;set;}/// <summary>Raised with the button itself when pressed.</summary>
public event Action<StudioActionButton>Chosen;public StudioActionButton(){Pressed+=OnPressed;}/// <summary>Small studio-styled button with a localized caption.</summary>
public static StudioActionButton Create(string key,Action<StudioActionButton>chosen,bool primary=false){var button=new StudioActionButton();StudioStyles.SmallButton(button,TranslationServer.Translate(key),primary);button.Chosen+=chosen;return button;}private void OnPressed()=>Chosen?.Invoke(this);}}

===== FILE mods/scenario_studio/src/Views/StudioBranchView.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System;using Cthangover.Core.UI.Theming;using Cthangover.Mods.Interface.Theming;using Godot;/// <summary> One answer of the current question for the novice (<c>.ct-branch</c>): the answer text and, under it, where it leads — a scene, an action, the end of the scenario — with the label hop already followed. Every answer can be redirected ("Choose where"); a broken one says so in words and offers only that, as the gold action. </summary>
public partial class StudioBranchView:PanelContainer{public bool IsBroken{get;}public StudioBranchView(StudioBranch branch,Action<StudioActionButton>edit,Action<StudioActionButton>chooseTarget){IsBroken=branch.Kind==StudioOutcomeKind.Broken;var isNew=branch.Line.IsNew;MouseFilter=MouseFilterEnum.Ignore;AddThemeStyleboxOverride("panel",StudioStyles.Branch(IsBroken,isNew));var row=UiKit.Row(StudioStyles.BarGap);AddChild(row);if(isNew)row.AddChild(StudioStyles.Bar(UiTokens.Success));var body=UiKit.Column(UiTokens.Space1);body.SizeFlagsHorizontal=SizeFlags.ExpandFill;row.AddChild(body);var text=UiKit.Label(StudioSayView.Quote(branch.Line),UiTokens.FontBodyLarge,UiTokens.Ink);text.AutowrapMode=TextServer.AutowrapMode.WordSmart;body.AddChild(text);body.AddChild(BuildOutcome(branch));var actions=UiKit.Column(UiTokens.Space1);actions.SizeFlagsVertical=SizeFlags.ShrinkCenter;row.AddChild(actions);var target=StudioActionButton.Create("studio/choose_target",chooseTarget,primary:IsBroken);target.Line=branch.Line;actions.AddChild(target);if(IsBroken)return;var editText=StudioActionButton.Create("studio/edit",edit);editText.Line=branch.Line;actions.AddChild(editText);}private static Control BuildOutcome(StudioBranch branch){var color=StudioStyles.OutcomeColor(branch.Kind);var row=UiKit.Row(UiTokens.Space2);row.AddChild(StudioStyles.Text("→",UiTokens.FontBodySmall,UiTokens.Faint));row.AddChild(StudioStyles.Text(TranslationServer.Translate(OutcomeKey(branch.Kind)),UiTokens.FontBodySmall,color));if(!string.IsNullOrEmpty(branch.Target))row.AddChild(StudioStyles.Text(branch.Target,UiTokens.FontBodySmall,color,mono:true));return row;}private static string OutcomeKey(StudioOutcomeKind kind){switch(kind){case StudioOutcomeKind.Scene:return"studio/outcome_scene";case StudioOutcomeKind.Action:return"studio/outcome_action";case StudioOutcomeKind.End:return"studio/outcome_end";case StudioOutcomeKind.Continue:return"studio/outcome_continue";default:return"studio/outcome_broken";}}}}

===== FILE mods/scenario_studio/src/Views/StudioCodeLineView.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System;using Cthangover.Core.UI.Theming;using Godot;/// <summary> One script line of the author mode (<c>.ct-code__line</c>): gutter with the breakpoint dot, line number and the ▶ marker of the current line, then the coloured source. A click on the gutter toggles the breakpoint, like in an IDE; a parse error is shown as a faint comment after the source so the line keeps its place. The source label has a fixed line height instead of fit-content: a fitted label without wrapping demands its full text width and would widen the whole dock for one long line. </summary>
public partial class StudioCodeLineView:PanelContainer{private const int GutterWidth=58;private const int LineHeight=20;private readonly StudioCodeLine _line;private readonly Control _gutter;/// <summary>The line this row shows.</summary>
public StudioCodeLine Line=>_line;/// <summary>The clickable gutter (breakpoint dot, number, marker).</summary>
public Control Gutter=>_gutter;/// <summary>Raised when the gutter is clicked.</summary>
public event Action<StudioCodeLine>BreakpointToggled;public StudioCodeLineView(StudioCodeLine line){_line=line;MouseFilter=MouseFilterEnum.Pass;AddThemeStyleboxOverride("panel",StudioStyles.CodeLine(line));var row=new HBoxContainer{MouseFilter=MouseFilterEnum.Ignore};row.AddThemeConstantOverride("separation",0);AddChild(row);_gutter=new HBoxContainer{CustomMinimumSize=new Vector2(GutterWidth,0),MouseFilter=MouseFilterEnum.Stop};_gutter.AddThemeConstantOverride("separation",0);_gutter.MouseDefaultCursorShape=CursorShape.PointingHand;_gutter.GuiInput+=OnGutterInput;row.AddChild(_gutter);var dotSlot=new CenterContainer{CustomMinimumSize=new Vector2(14,0),MouseFilter=MouseFilterEnum.Ignore};var dot=new Panel{CustomMinimumSize=new Vector2(8,8),MouseFilter=MouseFilterEnum.Ignore};dot.AddThemeStyleboxOverride("panel",StudioStyles.Breakpoint(line.HasBreakpoint));dotSlot.AddChild(dot);_gutter.AddChild(dotSlot);var current=line.IsCurrent;var number=StudioStyles.Text(line.Number.ToString(),UiTokens.FontBodySmall,current?UiTokens.AccentBright:UiTokens.Faint,mono:true);number.CustomMinimumSize=new Vector2(28,0);number.HorizontalAlignment=HorizontalAlignment.Right;_gutter.AddChild(number);var mark=StudioStyles.Text(current?" ▶":"",UiTokens.FontTiny,UiTokens.AccentBright);mark.CustomMinimumSize=new Vector2(16,0);mark.VerticalAlignment=VerticalAlignment.Center;_gutter.AddChild(mark);var source=new RichTextLabel{BbcodeEnabled=true,FitContent=false,CustomMinimumSize=new Vector2(0,LineHeight),ScrollActive=false,AutowrapMode=TextServer.AutowrapMode.Off,ClipContents=true,SizeFlagsHorizontal=SizeFlags.ExpandFill,MouseFilter=MouseFilterEnum.Ignore,Text=StudioSyntax.ToBbCode(line.Text)+ErrorSuffix(line),};source.AddThemeFontOverride("normal_font",StudioStyles.Mono);source.AddThemeFontSizeOverride("normal_font_size",UiTokens.FontBodySmall);source.AddThemeColorOverride("default_color",line.IsDone?UiTokens.Muted:UiTokens.Body);if(line.IsDone)source.Modulate=new Color(1f,1f,1f,0.6f);row.AddChild(source);}private static string ErrorSuffix(StudioCodeLine line){if(line.Error==null)return string.Empty;return$"  [color=#{UiTokens.DangerText.ToHtml(false)}]# {line.Error.Resolve().Replace("[", "[lb]")}[/color]";}private void OnGutterInput(InputEvent@event){if(@event is not InputEventMouseButton mouse|| !mouse.Pressed||mouse.ButtonIndex!=MouseButton.Left)return;_gutter.AcceptEvent();BreakpointToggled?.Invoke(_line);}}}

===== FILE mods/scenario_studio/src/Views/StudioPrompt.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System;using Cthangover.Core.UI.Theming;using Godot;/// <summary> The dock's small modal questions: a one-line text input (edit a line, name a scene or an exported mod) and a pick list (where an answer leads, which scene to link). They are plain Godot popups themed like the game, added under the dock and freed when closed. </summary>
public static class StudioPrompt{/// <summary>Asks for a line of text; <paramref name="done"/> gets it on OK.</summary>
public static ConfirmationDialog AskText(Node parent,string title,string initial,Action<string>done){var dialog=new ConfirmationDialog{Title=title,MinSize=new Vector2I(460,0)};CthangoverTheme.Apply(dialog);dialog.OkButtonText=TranslationServer.Translate("studio/ok");dialog.CancelButtonText=TranslationServer.Translate("studio/cancel");var field=new LineEdit{Text=initial??string.Empty,SelectAllOnFocus=true};dialog.AddChild(field);dialog.RegisterTextEnter(field);var relay=new StudioPromptRelay(field,done);dialog.Confirmed+=relay.OnConfirmed;dialog.Confirmed+=dialog.QueueFree;dialog.Canceled+=dialog.QueueFree;dialog.AddChild(relay);parent.AddChild(dialog);dialog.PopupCentered();field.GrabFocus();return dialog;}/// <summary>Shows a pick list under the mouse; <paramref name="picked"/> gets the item id.</summary>
public static PopupMenu Pick(Node parent,PopupMenu.IdPressedEventHandler picked){var menu=new PopupMenu();CthangoverTheme.Apply(menu);menu.IdPressed+=picked;menu.PopupHide+=menu.QueueFree;parent.AddChild(menu);return menu;}/// <summary>Opens a pick list built with <see cref="Pick"/> at the mouse position.</summary>
public static void Open(PopupMenu menu){menu.ResetSize();menu.Position=(Vector2I)menu.GetViewport().GetMousePosition();menu.Popup();}}}

===== FILE mods/scenario_studio/src/Views/StudioPromptRelay.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System;using Godot;/// <summary> Hands the text field of a <see cref="StudioPrompt.AskText"/> dialog to its callback on OK. A node child of the dialog, so it lives and dies with it and the dialog needs no closure over the field. </summary>
public partial class StudioPromptRelay:Node{private readonly LineEdit _field;private readonly Action<string>_done;public StudioPromptRelay(LineEdit field,Action<string>done){_field=field;_done=done;}public void OnConfirmed()=>_done?.Invoke(_field.Text);}}

===== FILE mods/scenario_studio/src/Views/StudioSayView.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System;using Cthangover.Core.UI.Theming;using Cthangover.Mods.Interface.Theming;using Godot;/// <summary> Novice card of a line the player reads (<c>.ct-say</c>): what kind of line it is (a question or a line, "new" while unapplied) and its text, with "Edit" on the right. The line on screen is the gold card; lines said right before it are plain cards with the green bar. </summary>
public partial class StudioSayView:PanelContainer{public StudioSayView(StudioCodeLine line,bool before,Action<StudioActionButton>edit){MouseFilter=MouseFilterEnum.Ignore;AddThemeStyleboxOverride("panel",StudioStyles.Say(before));var row=UiKit.Row(StudioStyles.BarGap);AddChild(row);row.AddChild(StudioStyles.Bar(before?UiTokens.Success:UiTokens.Accent));var body=UiKit.Column(UiTokens.Space1);body.SizeFlagsHorizontal=SizeFlags.ExpandFill;row.AddChild(body);var kind=TranslationServer.Translate(line.Kind==StudioLineKind.Choice?"studio/say_question":"studio/say_line").ToString();if(line.IsNew)kind=string.Format(TranslationServer.Translate("studio/say_new"),kind);var caption=StudioStyles.Text(kind,UiTokens.FontTiny,before?UiTokens.Faint:UiTokens.AccentDim);caption.Uppercase=true;body.AddChild(caption);var text=UiKit.Label(Quote(line),before?UiTokens.FontBodyLarge:UiTokens.FontLead,before?UiTokens.Ink:UiTokens.InkBright);text.AutowrapMode=TextServer.AutowrapMode.WordSmart;body.AddChild(text);var button=StudioActionButton.Create("studio/edit",edit);button.Line=line;button.SizeFlagsVertical=SizeFlags.ShrinkBegin;row.AddChild(button);}/// <summary>The player-visible text in quotes, or an empty placeholder for a line that has none.</summary>
public static string Quote(StudioCodeLine line){var text=line.Say?.Resolve();return string.Format(TranslationServer.Translate("studio/quote"),string.IsNullOrEmpty(text)?"…":text);}}}

===== FILE mods/scenario_studio/src/Views/StudioStateRow.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System;using Cthangover.Core.UI.Theming;using Godot;/// <summary> Key/value row of the "State" tab. Editable values (dialog variables, item counts, quest status) are written back when the field is submitted with Enter; the rest is shown read-only. </summary>
public partial class StudioStateRow:HBoxContainer{private const int KeyWidth=170;private readonly StudioStateEntry _entry;/// <summary>Raised with the entry and the submitted text.</summary>
public event Action<StudioStateEntry,string>Submitted;public StudioStateRow(StudioStateEntry entry){_entry=entry;MouseFilter=MouseFilterEnum.Ignore;AddThemeConstantOverride("separation",UiTokens.Space3);var key=StudioStyles.Text(entry.Key.Resolve(),UiTokens.FontBodySmall,UiTokens.ChipKey,mono:true);key.CustomMinimumSize=new Vector2(KeyWidth,0);key.ClipText=true;key.VerticalAlignment=VerticalAlignment.Center;AddChild(key);var value=new LineEdit{Text=entry.Value.Resolve(),Editable=entry.Editor!=StudioStateEditor.None,SizeFlagsHorizontal=SizeFlags.ExpandFill,};value.AddThemeFontOverride("font",StudioStyles.Mono);value.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);value.TextSubmitted+=OnSubmitted;AddChild(value);}private void OnSubmitted(string text)=>Submitted?.Invoke(_entry,text);}}

===== FILE mods/scenario_studio/src/Views/StudioStyles.cs =====
namespace Cthangover.Mods.ScenarioStudio{using Cthangover.Core.UI.Theming;using Cthangover.Mods.Interface.Theming;using Cthangover.Mods.Interface.Tool;using Godot;/// <summary> Godot counterparts of the studio components of the style guide (<c>mockups/style_guide</c>, section <c>#studio</c>: <c>.ct-dock</c>, <c>.ct-scope</c>, <c>.ct-code</c>, <c>.ct-block</c>, <c>.ct-cand</c>…). Every colour comes from <see cref="UiTokens"/>; mod scenes ignore theme overrides, so the dock skeleton from the scene is dressed here and the data rows are built with the same helpers. <para/> Two deliberate deviations from the HTML guide, both recorded there: <c>StyleBoxFlat</c> has no dashed border, so the insert bar and new map nodes use a solid low-alpha border instead of a dashed one. </summary>
public static class StudioStyles{/// <summary>Width of the dock; the scene offsets use the same value plus the 8px edge gap.</summary>
public const int DockWidth=480;public static Font Mono=>ToolWindow.GetMonospaceFont();/// <summary>Dock surface: window surface, line border, large radius and the window shadow, without any backdrop.</summary>
public static StyleBoxFlat Dock(){var style=UiKit.Frame(UiTokens.RadiusLg,UiTokens.Surface0,UiTokens.Line);style.ShadowColor=UiTokens.Alpha(Colors.Black,0.6f);style.ShadowSize=24;style.ShadowOffset=new Vector2(0,12);return style;}public static Label Title(Label label,string text){label.Text=text.ToUpperInvariant();label.AddThemeFontSizeOverride("font_size",UiTokens.FontLead);label.AddThemeColorOverride("font_color",UiTokens.InkBright);return label;}/// <summary>Monospace state plate: running green, paused and held warning yellow, idle muted.</summary>
public static void RunState(PanelContainer plate,Label text,StudioRunState state,string caption){var color=state switch{StudioRunState.Running=>UiTokens.Success,StudioRunState.Idle=>UiTokens.Muted,_=>UiTokens.Warning,};var style=state==StudioRunState.Idle?Chip(Colors.Transparent,UiTokens.Line):Chip(UiTokens.BadgeFill(color),UiTokens.BadgeLine(color));plate.AddThemeStyleboxOverride("panel",style);text.Text=caption.ToUpperInvariant();text.AddThemeFontOverride("font",Mono);text.AddThemeFontSizeOverride("font_size",UiTokens.FontTiny);text.AddThemeColorOverride("font_color",color);}/// <summary>Well behind the segmented mode switch.</summary>
public static StyleBoxFlat SegmentWell(){var style=UiKit.Frame(UiTokens.RadiusSm,UiTokens.Well,UiTokens.Line);Pad(style,2,2);return style;}/// <summary>Segment of the mode switch: transparent, the pressed one looks like the active tab.</summary>
public static void Segment(Button button,string text){button.Text=text;var normal=UiKit.Frame(UiTokens.RadiusXs,Colors.Transparent);Pad(normal,10,3);var hover=UiKit.Frame(UiTokens.RadiusXs,UiTokens.WhiteTrace);Pad(hover,10,3);var active=UiKit.Frame(UiTokens.RadiusXs,UiTokens.AccentSoft,UiTokens.AccentLine);Pad(active,10,3);ApplyStates(button,normal,hover,active);button.AddThemeFontSizeOverride("font_size",UiTokens.FontSmall);button.AddThemeColorOverride("font_color",UiTokens.Muted);button.AddThemeColorOverride("font_hover_color",UiTokens.Ink);button.AddThemeColorOverride("font_pressed_color",UiTokens.AccentBright);button.AddThemeColorOverride("font_hover_pressed_color",UiTokens.AccentBright);}/// <summary>Transport button (pause/step/continue/restart): a compact secondary button; pressed means "on".</summary>
public static void Transport(Button button,string text){button.Text=text;var normal=UiKit.Frame(UiTokens.RadiusSm,UiTokens.Surface3,UiTokens.Line);Pad(normal,10,5);var hover=UiKit.Frame(UiTokens.RadiusSm,UiTokens.BtnHover,UiTokens.AccentLine);Pad(hover,10,5);var on=UiKit.Frame(UiTokens.RadiusSm,UiTokens.BtnPrimary,UiTokens.AccentLine);Pad(on,10,5);ApplyStates(button,normal,hover,on);button.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);button.AddThemeColorOverride("font_color",UiTokens.Body);button.AddThemeColorOverride("font_hover_color",UiTokens.InkBright);button.AddThemeColorOverride("font_pressed_color",UiTokens.AccentBright);button.AddThemeColorOverride("font_hover_pressed_color",UiTokens.AccentBright);}/// <summary>Small tab (<c>.ct-tab--sm</c>): the tab variation with the compact paddings.</summary>
public static void SmallTab(Button button,string text){button.Text=text;var normal=UiKit.Frame(UiTokens.RadiusXs,Colors.Transparent);Pad(normal,9,4);var hover=UiKit.Frame(UiTokens.RadiusXs,UiTokens.WhiteTrace);Pad(hover,9,4);var active=UiKit.Frame(UiTokens.RadiusXs,UiTokens.AccentSoft,UiTokens.AccentLine);Pad(active,9,4);ApplyStates(button,normal,hover,active);button.AddThemeFontSizeOverride("font_size",UiTokens.FontSmall);button.AddThemeColorOverride("font_color",UiTokens.Muted);button.AddThemeColorOverride("font_hover_color",UiTokens.Ink);button.AddThemeColorOverride("font_pressed_color",UiTokens.AccentBright);button.AddThemeColorOverride("font_hover_pressed_color",UiTokens.AccentBright);}/// <summary>Small action button inside the body (<c>.ct-btn--sm</c>); primary turns it gold.</summary>
public static Button SmallButton(Button button,string text,bool primary=false){button.Text=text;var background=primary?UiTokens.BtnPrimary:UiTokens.Surface3;var border=primary?UiTokens.AccentLine:UiTokens.Line;var normal=UiKit.Frame(UiTokens.RadiusSm,background,border);Pad(normal,12,6);var hover=UiKit.Frame(UiTokens.RadiusSm,primary?UiTokens.BtnPrimaryHover:UiTokens.BtnHover,primary?UiTokens.AccentLineStrong:UiTokens.AccentLine);Pad(hover,12,6);ApplyStates(button,normal,hover,hover);button.FocusMode=Control.FocusModeEnum.None;button.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);button.AddThemeColorOverride("font_color",primary?UiTokens.AccentBright:UiTokens.Body);button.AddThemeColorOverride("font_hover_color",primary?UiTokens.AccentHover:UiTokens.InkBright);button.AddThemeColorOverride("font_pressed_color",primary?UiTokens.AccentHover:UiTokens.InkBright);return button;}public static Label Text(string text,int size,Color color,bool mono=false){var label=UiKit.Label(text,size,color);if(mono)label.AddThemeFontOverride("font",Mono);return label;}/// <summary> Mod scope badge (<c>.ct-scope</c>): caps kind caption plus the mono mod id, coloured by how the studio may touch the mod. </summary>
public static Control Scope(StudioModScope scope){var color=ScopeColor(scope.Kind);var style=scope.Kind==StudioScopeKind.ReadOnly?Chip(Colors.Transparent,UiTokens.Line):Chip(UiTokens.BadgeFill(color),UiTokens.BadgeLine(color));var badge=new PanelContainer{MouseFilter=Control.MouseFilterEnum.Ignore};badge.AddThemeStyleboxOverride("panel",style);var row=UiKit.Row(UiTokens.Space2);var kind=UiKit.Label(TranslationServer.Translate(ScopeKey(scope.Kind)),UiTokens.FontMicro,color);kind.Uppercase=true;kind.VerticalAlignment=VerticalAlignment.Center;row.AddChild(kind);row.AddChild(Text(scope.ModId,UiTokens.FontSmall,color,mono:true));badge.AddChild(row);return badge;}public static Color ScopeColor(StudioScopeKind kind){switch(kind){case StudioScopeKind.Own:return UiTokens.SuccessText;case StudioScopeKind.Overlay:return UiTokens.Info;case StudioScopeKind.Layer:return UiTokens.TypeTarget;default:return UiTokens.Faint;}}private static string ScopeKey(StudioScopeKind kind){switch(kind){case StudioScopeKind.Own:return"studio/scope_own";case StudioScopeKind.Overlay:return"studio/scope_overlay";case StudioScopeKind.Layer:return"studio/scope_layer";default:return"studio/scope_readonly";}}/// <summary>"This change affects" plate: warning-tinted, lists every mod the action writes to or overrides.</summary>
public static StyleBoxFlat ScopeBanner(){var style=UiKit.Frame(UiTokens.RadiusSm,UiTokens.BadgeFill(UiTokens.Warning),UiTokens.BadgeLine(UiTokens.Warning));Pad(style,12,8);return style;}/// <summary>Well behind the script lines (<c>.ct-code</c>).</summary>
public static StyleBoxFlat CodeWell(){var style=UiKit.Frame(UiTokens.RadiusSm,UiTokens.Well,UiTokens.Line);Pad(style,0,4);return style;}/// <summary>Row of one script line: a 2px left bar and a tint carry current / new / error.</summary>
public static StyleBoxFlat CodeLine(StudioCodeLine line){Color background=Colors.Transparent;Color bar=Colors.Transparent;if(line.IsCurrent){background=UiTokens.AccentSoft;bar=UiTokens.Accent;}else if(line.Error!=null){background=UiTokens.DangerSoft;bar=UiTokens.Danger;}else if(line.IsNew){background=UiTokens.SuccessSoft;bar=UiTokens.Success;}var style=new StyleBoxFlat{BgColor=background,BorderColor=bar,BorderWidthLeft=2};Pad(style,0,1);style.ContentMarginRight=UiTokens.Space4;return style;}/// <summary>Breakpoint dot of the gutter; transparent when the line has none.</summary>
public static StyleBoxFlat Breakpoint(bool on){var style=UiKit.Frame(4,on?UiTokens.Danger:Colors.Transparent);if(on){style.ShadowColor=UiTokens.DangerSoft;style.ShadowSize=2;}return style;}/// <summary>Section caption of the novice moment (<c>.ct-moment__caption</c>).</summary>
public static Label Caption(string text){var label=Text(text,UiTokens.FontTiny,UiTokens.Faint);label.Uppercase=true;return label;}/// <summary> Card of what is on screen (<c>.ct-say</c>): selected surface with the gold bar; a line added before it (<c>.ct-say--before</c>) is a plain card with the success bar. </summary>
public static StyleBoxFlat Say(bool before){var style=before?UiKit.Frame(UiTokens.RadiusMd,UiTokens.Surface2,UiTokens.Line):UiKit.Frame(UiTokens.RadiusMd,UiTokens.SurfaceSelected,UiTokens.AccentLine);Pad(style,12,10);style.ContentMarginLeft=0;return style;}/// <summary> Coloured 3px left bar of a card (gold for the line on screen, green for new lines). StyleBoxFlat has a single border colour, so the bar is its own strip at the start of the card row; the card style then has no left content margin and the bar is followed by <see cref="BarGap"/>. </summary>
public static ColorRect Bar(Color color)=>new(){Color=color,CustomMinimumSize=new Vector2(3,0),MouseFilter=Control.MouseFilterEnum.Ignore,};/// <summary>Space between a <see cref="Bar"/> and the card content.</summary>
public const int BarGap=9;/// <summary>Answer row (<c>.ct-branch</c>): broken answers are red-tinted, new ones get the success bar.</summary>
public static StyleBoxFlat Branch(bool broken,bool isNew){var style=broken?UiKit.Frame(UiTokens.RadiusMd,UiTokens.DangerSoft,UiTokens.BadgeLine(UiTokens.TypeEnemy)):UiKit.Frame(UiTokens.RadiusMd,UiTokens.Surface2,UiTokens.Line);Pad(style,12,8);if(isNew)style.ContentMarginLeft=0;return style;}public static Color OutcomeColor(StudioOutcomeKind kind){switch(kind){case StudioOutcomeKind.Scene:return UiTokens.TypeTarget;case StudioOutcomeKind.Action:return UiTokens.TypeUsed;case StudioOutcomeKind.Broken:return UiTokens.DangerText;default:return UiTokens.Muted;}}/// <summary>Insert bar (<c>.ct-insert</c>); solid accent 20% border because StyleBoxFlat cannot dash.</summary>
public static StyleBoxFlat InsertBar(){var style=UiKit.Frame(UiTokens.RadiusSm,Colors.Transparent,UiTokens.AccentLineWeak);Pad(style,8,6);return style;}/// <summary>Scene candidate card (<c>.ct-cand</c>); the running one gets the accent border.</summary>
public static StyleBoxFlat Candidate(bool running){var style=UiKit.Frame(UiTokens.RadiusMd,running?UiTokens.SurfaceSelected:UiTokens.Surface2,running?UiTokens.AccentLine:UiTokens.Line);Pad(style,12,8);return style;}public static Color StatusColor(StudioCandidateStatus status){switch(status){case StudioCandidateStatus.Running:return UiTokens.Success;case StudioCandidateStatus.Queued:return UiTokens.Warning;case StudioCandidateStatus.Failed:return UiTokens.Danger;case StudioCandidateStatus.Skipped:return UiTokens.TypeEnemy;case StudioCandidateStatus.Completed:return UiTokens.TypeResource;default:return UiTokens.Muted;}}public static Color LogColor(StudioLogCategory category){switch(category){case StudioLogCategory.Scene:return UiTokens.TypeTarget;case StudioLogCategory.Skip:return UiTokens.TypeUsed;case StudioLogCategory.Stop:return UiTokens.TypeEnemy;case StudioLogCategory.End:return UiTokens.Muted;case StudioLogCategory.Edit:return UiTokens.Success;default:return UiTokens.Accent;}}/// <summary>Map node card (<c>.ct-node</c>): here glows gold, visited is strong-lined, new is teal.</summary>
public static StyleBoxFlat Node(StudioNodeState state){StyleBoxFlat style;switch(state){case StudioNodeState.Here:style=UiKit.Frame(UiTokens.RadiusMd,UiTokens.SurfaceSelected,UiTokens.AccentLineStrong);style.ShadowColor=UiTokens.Alpha(UiTokens.Accent,0.35f);style.ShadowSize=8;break;case StudioNodeState.Visited:style=UiKit.Frame(UiTokens.RadiusMd,UiTokens.Surface2,UiTokens.LineStrong);break;case StudioNodeState.New:style=UiKit.Frame(UiTokens.RadiusMd,UiTokens.Surface2,UiTokens.BadgeLine(UiTokens.TypeTarget));break;default:style=UiKit.Frame(UiTokens.RadiusMd,UiTokens.Surface2,UiTokens.Line);break;}Pad(style,12,8);return style;}/// <summary>Rows of the state tab: inset well like the inventory quantity block.</summary>
public static StyleBoxFlat Well(){var style=UiKit.Frame(UiTokens.RadiusMd,UiTokens.Surface2,UiTokens.Line);Pad(style,12,10);return style;}/// <summary>Bottom hairline between log rows.</summary>
public static StyleBoxFlat LogRow(bool warning){var style=new StyleBoxFlat{BgColor=warning?UiTokens.BadgeFill(UiTokens.Warning):Colors.Transparent,BorderColor=UiTokens.LineSoft,BorderWidthBottom=1,};Pad(style,8,5);return style;}private static StyleBoxFlat Chip(Color fill,Color border){var style=UiKit.Frame(UiTokens.RadiusXs,fill,border);Pad(style,7,2);return style;}private static void ApplyStates(Button button,StyleBox normal,StyleBox hover,StyleBox pressed){button.AddThemeStyleboxOverride("normal",normal);button.AddThemeStyleboxOverride("hover",hover);button.AddThemeStyleboxOverride("pressed",pressed);button.AddThemeStyleboxOverride("hover_pressed",pressed);button.AddThemeStyleboxOverride("focus",new StyleBoxEmpty());}private static void Pad(StyleBoxFlat style,int horizontal,int vertical){style.ContentMarginLeft=horizontal;style.ContentMarginRight=horizontal;style.ContentMarginTop=vertical;style.ContentMarginBottom=vertical;}}}

===== FILE mods/scenario_studio/src/Views/StudioSyntax.cs =====
namespace Cthangover.Mods.ScenarioStudio{using System.Text;using Cthangover.Core.UI.Theming;using Cthangover.Mods.Dialog.Scenarios;using Godot;/// <summary> Colours one scenario DSL line as BBCode with the studio palette of the style guide: command word gold, quoted text ink, <c>:labels</c> teal, <c>name=</c> keys chip-key, comments faint. A light single-pass scanner is enough for display: it never fails on malformed lines, it just colours less of them, and the real parser stays the only judge of validity. </summary>
public static class StudioSyntax{/// <summary> Highlighter for the inline text editor with the same palette: every registered command word (plus <c>select</c>/<c>option</c>) gold, quoted text ink, comments faint, <c>:labels</c> teal. </summary>
public static CodeHighlighter CreateHighlighter(){var highlighter=new CodeHighlighter{NumberColor=UiTokens.Body,SymbolColor=UiTokens.Muted,FunctionColor=UiTokens.Body,MemberVariableColor=UiTokens.Body,};foreach(var command in ScenarioCommandStrategyFactory.GetAllCommandNames())highlighter.AddKeywordColor(command,UiTokens.AccentBright);highlighter.AddKeywordColor("select",UiTokens.AccentBright);highlighter.AddKeywordColor("option",UiTokens.AccentBright);highlighter.AddColorRegion("\"","\"",UiTokens.Ink);highlighter.AddColorRegion("#","",UiTokens.Faint,true);highlighter.AddColorRegion(":"," ",UiTokens.TypeTarget,true);return highlighter;}public static string ToBbCode(string line){var text=line??string.Empty;var trimmed=text.TrimStart();var indent=text.Substring(0,text.Length-trimmed.Length);var result=new StringBuilder(Escape(indent));if(trimmed.StartsWith('#'))return result.Append(Paint(trimmed,UiTokens.Faint)).ToString();if(trimmed.StartsWith(':'))return result.Append(Paint(trimmed,UiTokens.TypeTarget)).ToString();var i=0;var first=true;while(i<trimmed.Length){var c=trimmed[i];if(c==' '){result.Append(' ');i++;continue;}if(c=='#'){result.Append(Paint(trimmed.Substring(i),UiTokens.Faint));break;}var end=c=='"'?CloseQuote(trimmed,i):NextSpace(trimmed,i);var token=trimmed.Substring(i,end-i);result.Append(PaintToken(token,first));first=false;i=end;}return result.ToString();}private static string PaintToken(string token,bool first){if(first)return Paint(token,UiTokens.AccentBright);if(token.StartsWith('"'))return Paint(token,UiTokens.Ink);if(token.StartsWith(':'))return Paint(token,UiTokens.TypeTarget);if(token=="->")return Paint(token,UiTokens.Muted);var eq=token.IndexOf('=');if(eq>0)return Paint(token.Substring(0,eq+1),UiTokens.ChipKey)+Paint(token.Substring(eq+1),UiTokens.Body);return Paint(token,UiTokens.Body);}private static int CloseQuote(string text,int start){var close=text.IndexOf('"',start+1);return close<0?text.Length:close+1;}private static int NextSpace(string text,int start){var space=text.IndexOf(' ',start);return space<0?text.Length:space;}private static string Paint(string text,Color color)=>$"[color=#{color.ToHtml(false)}]{Escape(text)}[/color]";private static string Escape(string text)=>text.Replace("[","[lb]");}}

===== FILE mods/scenario_studio/test_suites/live/src/StudioLiveDriverNode.cs =====
namespace Cthangover.Mods.ScenarioStudio.Tests.Live{using System.IO;using Cthangover.Core.Bootstrap;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;using Cthangover.Core.UI;using Cthangover.Core.Utils;using Cthangover.Mods.Dialog;using Cthangover.Mods.Dialog.Action.Impls;using Cthangover.Mods.Dialog.Executable;using Godot;/// <summary> Drives the studio on the real kitchen scenario the way a player would, one step per tick, checking after each step what it must have changed: <list type="number"> <item>waits for the kitchen scenario's question, opens the dock with the <c>studio</c> UI toggle (the toolbar button) and checks the current line is the running action's line;</item> <item>pause and continue;</item> <item>novice mode: the question and its answers with outcomes; adds an answer (broken), redirects it to a scene through "Choose where", inserts a line before the question;</item> <item>applies: the scenario restarts with rollback and first shows the inserted line, the edit is in the test layer and the cooking mod's file is untouched; the question then has the new answer;</item> <item>breakpoint on the question, restart, hold before it, continue;</item> <item>the Scene, State (a checkpoint is saved there), Log and Map tabs;</item> <item>creates a scene in the layer, goes there and sees its scenario run from the layer, exports the layer as a mod, returns to the checkpoint and closes the dock.</item> </list> The test layer is deleted at the end. Ends with <c>[TEST_RESULT] name=studio_live passed=N failed=M</c>. </summary>
public partial class StudioLiveDriverNode:Node{private const string ResultName="studio_live";private const string EntryId="studio";private const string Root="Dock/Column/";private const float TickSeconds=0.5f;private const float TimeoutSeconds=150f;private const string NewScene="studio_test_yard";private const string ExportId="studio_test_export";private readonly string _shotsDir;private StudioPanel _panel;private string _scenario;private string _sourceMod;private string _sourcePath;private string _originalText;private int _questionLine;private int _answers;private int _step= -1;private int _passed;private int _failed;private double _elapsed;private bool _finished;public StudioLiveDriverNode(string shotsDir){_shotsDir=shotsDir;var timer=new Timer{WaitTime=TickSeconds,Autostart=true};AddChild(timer);timer.Timeout+=Tick;}private static StudioLive Live=>StudioLive.Instance;private DialogBox Box=>Live?.Box;private void Tick(){if(_finished)return;_elapsed+=TickSeconds;if(_elapsed>=TimeoutSeconds){Fail($"timeout ({TimeoutSeconds:0}s) at step {_step}");Finish();return;}if(_step<0&& !QuestionIsUp())return;_step++;switch(_step){case 0:var scenario=(ScenarioEvent)Box.Locker;_scenario=scenario.ID;_sourceMod=scenario.SourceModId;_sourcePath=scenario.SourcePath;_originalText=ModManager.Instance.ReadFileText(_sourceMod,_sourcePath);_questionLine=CurrentAction()?.SourceLine??0;_answers=Box.AnswerVariantCount;Check(Live!=null&&Live.Layer.ForceLayer,"studio live is up with the layer forced");Check(_sourceMod!=null&&_sourcePath!=null,$"running scenario '{_scenario}' knows its file ({_sourceMod}:{_sourcePath})");Check(_questionLine>0,$"question action carries its source line ({_questionLine})");AppServices.Current.Ui.ToggleEntry(EntryId);break;case 1:Check(UiHost.Instance.TryGetInstance(EntryId,out var control)&&control is StudioPanel,"dock opened by the toggle");_panel=control as StudioPanel;if(_panel==null){Finish();return;}var session=_panel.Session;Check(session.ScenarioName==_scenario,$"dock shows the running scenario ('{session.ScenarioName}')");Check(IsCurrentLine(session,_questionLine),"current line is the question's line");Check(session.FileScope?.ModId==_sourceMod,$"file scope is the scenario's mod ({session.FileScope?.ModId})");Check(session.EditScope?.Kind==StudioScopeKind.Layer,"edits go to the studio layer");Check(Mathf.Abs(_panel.GetNode<Control>("Dock").Size.X-StudioStyles.DockWidth)<1,"dock keeps its width");Shot("author");Press(Root+"Transport/Row/PauseButton");break;case 2:Check(Live.Debugger.IsPaused&&_panel.Session.RunState==StudioRunState.Paused,"pause is on");Press(Root+"Transport/Row/ContinueButton");Press(Root+"Header/Row/ModeSwitch/Items/NoviceButton");break;case 3:Check(!Live.Debugger.IsPaused,"continue turns pause off");Check(_panel.Mode==StudioMode.Novice,"novice mode selected");Check(CountOf<StudioSayView>()==1,"novice: the question on screen");Check(CountOf<StudioBranchView>()==_answers,$"novice: {_answers} answers");Check(Broken()==0,"novice: every answer leads somewhere");PressText("studio/add_option");break;case 4:Check(CountOf<StudioBranchView>()==_answers+1,"new answer added");Check(Broken()==1,"the new answer leads nowhere yet");PressBrokenTarget();break;case 5:PickScene("home_vestibule");break;case 6:Check(Broken()==0,"the new answer now leads to a scene");PressText("studio/add_line_before");break;case 7:Check(CountOf<StudioSayView>()==2,"new line shown before the question");Check(_panel.Session.PendingCount>0,$"pending edits counted ({_panel.Session.PendingCount})");Shot("novice");Press(Root+"Footer/Row/ApplyButton");break;case 8:var line=CurrentAction()as ActionText;Check(Box.IsRunning&&Box.Locker is ScenarioEvent{ID:var id}&&id==_scenario,"scenario restarted after apply");Check(line!=null&&line.Text==TranslationServer.Translate("studio/new_line_text"),$"restart shows the inserted line first ('{line?.Text}')");Check(File.Exists(Path.Combine(Live.Layer.RootPath,_sourcePath)),"edit written to the studio layer");Check(ModManager.Instance.ReadFileText(_sourceMod,_sourcePath)==_originalText,"the mod's own file is untouched");Shot("applied");Box.NextAction();break;case 9:Check(Box.IsWaitingAnswer&&Box.AnswerVariantCount==_answers+1,$"question now has the new answer ({Box.AnswerVariantCount})");_questionLine=CurrentAction()?.SourceLine??0;Press(Root+"Header/Row/ModeSwitch/Items/AuthorButton");break;case 10:ToggleBreakpoint(_questionLine);break;case 11:Check(Live.Debugger.HasBreakpoint(_scenario,_questionLine),$"breakpoint set on line {_questionLine}");Restart(StudioRestartMode.Scenario);break;case 12:Box.NextAction();break;case 13:Check(Box.Runtime.IsHeld&&Box.AnswerVariantCount==0,"held at the breakpoint before the question runs");Check(_panel.Session.RunState==StudioRunState.Held,"dock shows the hold");Shot("breakpoint");Press(Root+"Transport/Row/ContinueButton");break;case 14:Check(!Box.Runtime.IsHeld&&Box.AnswerVariantCount==_answers+1,"continue runs the question");Press(Root+"Tabs/Row/SceneTab");break;case 15:Check(_panel.Tab==StudioTab.Scene&&IsRunningCandidate(_panel.Session,_scenario),"scene tab: the scenario is the running candidate");Shot("scene");Press(Root+"Tabs/Row/StateTab");break;case 16:Check(_panel.Session.State.Count>0&&CountOf<StudioStateRow>()==_panel.Session.State.Count,"state tab: a row per state value");Shot("state");MenuPick(100);Check(Live.HasCheckpoint,"checkpoint remembered from the restart menu");Press(Root+"Tabs/Row/LogTab");break;case 17:Check(HasEditLog(_panel.Session),"log tab: the applied edit is logged");Shot("log");Press(Root+"Tabs/Row/MapTab");break;case 18:Check(HasNode(_panel.Session,StudioNodeState.Here,"home_kitchen"),"map tab: you are in the kitchen");Check(HasNode(_panel.Session,StudioNodeState.Next,"home_vestibule"),"map tab: the vestibule is an exit");Shot("map");PressText("studio/map_new_scene");break;case 19:AnswerPrompt(NewScene);break;case 20:Check(File.Exists(Path.Combine(Live.Layer.RootPath,"scenes",NewScene+".json")),"new scene written to the layer");Check(GetNodeOrNull<SceneManager>("/root/SceneManager")?.HasScene(NewScene)==true,"new scene is known to the scene manager");PressPayload(NewScene);break;case 21:if(SceneManager.GetCurrentSceneName()!=NewScene||Box?.Locker is not ScenarioEvent){_step--;break;}Check(((ScenarioEvent)Box.Locker).SourceModId==StudioLayer.Id,$"the new scene's scenario runs from the layer ('{Box.Locker.ID}')");Shot("new_scene");PressText("studio/map_export");break;case 22:AnswerPrompt(ExportId);break;case 23:var exported=Path.Combine(ProjectSettings.GlobalizePath("user://mods/"),ExportId);Check(File.Exists(Path.Combine(exported,"manifest.json"))&&File.Exists(Path.Combine(exported,"scenes",NewScene+".json")),"layer exported as a mod with a manifest");if(Directory.Exists(exported))Directory.Delete(exported,true);MenuPick(101);break;case 24:if(SceneManager.GetCurrentSceneName()!="home_kitchen"){_step--;break;}Check(true,"back at the checkpoint's scene");Press(Root+"Header/Row/CloseButton");break;default:Check(!_panel.Visible,"close hides the dock");Cleanup();Finish();break;}}private static bool IsCurrentLine(StudioSession session,int number){foreach(var line in session.Lines){if(line.IsCurrent&&line.Number==number)return true;}return false;}private static bool IsRunningCandidate(StudioSession session,string name){foreach(var candidate in session.Candidates){if(candidate.Status==StudioCandidateStatus.Running&&candidate.Name==name)return true;}return false;}private static bool HasEditLog(StudioSession session){foreach(var entry in session.Log){if(entry.Category==StudioLogCategory.Edit)return true;}return false;}private static bool HasNode(StudioSession session,StudioNodeState state,string name){foreach(var node in session.Nodes){if(node.State==state&&node.Name==name)return true;}return false;}private bool QuestionIsUp(){var tree=GetTree();if(!Boot.IsReady||tree.CurrentScene==null||UiHost.Instance==null||Live==null)return false;var box=Box;return box!=null&&box.IsRunning&&box.Locker is ScenarioEvent&&box.IsWaitingAnswer;}private Dialog.Action.IActionCommand CurrentAction(){var queue=Box?.Runtime.Dialog?.Queue;var index=Box?.Runtime.Index?? -1;return queue!=null&&index>=0&&index<queue.Count?queue[index]:null;}private void Press(string path){var button=_panel.GetNodeOrNull<Button>(path);Check(button!=null,$"button {path} found");button?.EmitSignal(BaseButton.SignalName.Pressed);}private void PressText(string key){var button=FindButton(_panel,TranslationServer.Translate(key));Check(button!=null,$"button '{key}' found");button?.EmitSignal(BaseButton.SignalName.Pressed);}private void PressBrokenTarget(){foreach(var node in Content().GetChildren()){if(node is StudioBranchView{IsBroken:true}branch){var button=FindButton(branch,TranslationServer.Translate("studio/choose_target"));Check(button!=null,"broken answer offers 'choose where'");button?.EmitSignal(BaseButton.SignalName.Pressed);return;}}Fail("no broken answer to redirect");}private void PickScene(string scene){PopupMenu menu=null;foreach(var child in _panel.GetChildren()){if(child is PopupMenu popup&&popup.Visible)menu=popup;}Check(menu!=null,"target menu opened");if(menu==null)return;var caption=string.Format(TranslationServer.Translate("studio/target_scene"),scene);for(var i=0;i<menu.ItemCount;i++){if(menu.GetItemText(i)!=caption)continue;menu.EmitSignal(PopupMenu.SignalName.IdPressed,(long)menu.GetItemId(i));menu.Hide();return;}Fail($"scene {scene} not offered as a target");}private void PressPayload(string payload){foreach(var button in Descendants<StudioActionButton>(Content())){if(button.Payload!=payload)continue;button.EmitSignal(BaseButton.SignalName.Pressed);return;}Fail($"no button for '{payload}'");}/// <summary>Types <paramref name="text"/> into the open studio prompt and confirms it.</summary>
private void AnswerPrompt(string text){foreach(var child in _panel.GetChildren()){if(child is not ConfirmationDialog dialog|| !dialog.Visible)continue;foreach(var field in Descendants<LineEdit>(dialog))field.Text=text;dialog.EmitSignal(AcceptDialog.SignalName.Confirmed);return;}Fail("no studio prompt open");}private void ToggleBreakpoint(int line){foreach(var node in Descendants<StudioCodeLineView>(Content())){if(node.Line.SourceLine!=line)continue;var click=new InputEventMouseButton{ButtonIndex=MouseButton.Left,Pressed=true};node.Gutter.EmitSignal(Control.SignalName.GuiInput,click);return;}Fail($"no script line {line} in the author view");}private void Restart(StudioRestartMode mode)=>MenuPick((long)mode);private void MenuPick(long id){var popup=_panel.GetNode<MenuButton>(Root+"Transport/Row/RestartButton").GetPopup();popup.EmitSignal(PopupMenu.SignalName.IdPressed,id);}private int Broken(){var broken=0;foreach(var node in Content().GetChildren()){if(node is StudioBranchView{IsBroken:true})broken++;}return broken;}private VBoxContainer Content()=>_panel.GetNode<VBoxContainer>(Root+"Body/Scroll/Content");private int CountOf<T>()where T:Node=>Descendants<T>(Content()).Count;private static System.Collections.Generic.List<T>Descendants<T>(Node node)where T:Node{var result=new System.Collections.Generic.List<T>();foreach(var child in node.GetChildren()){if(child is T typed)result.Add(typed);result.AddRange(Descendants<T>(child));}return result;}private static Button FindButton(Node node,string text){if(node is Button button&&button.Text==text)return button;foreach(var child in node.GetChildren()){var found=FindButton(child,text);if(found!=null)return found;}return null;}private void Cleanup(){var root=Live?.Layer.RootPath;if(string.IsNullOrEmpty(root)|| !Directory.Exists(root))return;ModOverlays.Unmount(StudioLayer.Id);StudioLayer.RefreshContent();Directory.Delete(root,true);var parent=Path.GetDirectoryName(root);if(parent!=null&&Directory.GetFileSystemEntries(parent).Length==0)Directory.Delete(parent);Log($"test layer removed: {root}");}private void Shot(string id){if(string.IsNullOrEmpty(_shotsDir)||DisplayServer.GetName()=="headless")return;var dir=ProjectSettings.GlobalizePath(_shotsDir);Directory.CreateDirectory(dir);var path=Path.Combine(dir,id+".png");GetViewport().GetTexture().GetImage().SavePng(path);Log($"screenshot {path}");}private void Check(bool ok,string name){if(ok){_passed++;Log($"[PASS] {name}");}else{Fail(name);}}private void Fail(string message){_failed++;Log($"[FAIL] {message}");}private void Finish(){if(_finished)return;_finished=true;GameLogger.Log("TEST",$"[TEST_RESULT] name={ResultName} passed={_passed} failed={_failed}");GetTree().Quit(_failed==0?0:1);}private static void Log(string message)=>GameLogger.Log("TEST",message);}}

===== FILE mods/scenario_studio/test_suites/live/src/StudioLiveTestInitializer.cs =====
namespace Cthangover.Mods.ScenarioStudio.Tests.Live{using System.Linq;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;/// <summary> Entry point of the studio live test, active only with <c>--itest=studio_live</c>; <c>--shots=&lt;dir&gt;</c> also saves a screenshot per checked state (needs a real display, e.g. xvfb). The test expects <c>--studio-write=layer</c> and its own <c>--studio-layer=</c> so it never writes into a mod. The driver is added deferred because resources-ready fires before the shell scene exists. </summary>
public class StudioLiveTestInitializer:IModInitializer{private static bool driverQueued;public void OnModLoaded(string modId){}public void OnModResourcesReady(){if(driverQueued)return;var args=OS.GetCmdlineUserArgs();if(!args.Any(a=>a=="--itest=studio_live"))return;var shots=args.Where(a=>a.StartsWith("--shots=",System.StringComparison.Ordinal)).Select(a=>a.Substring("--shots=".Length)).FirstOrDefault();driverQueued=true;GameLogger.Log("TEST",$"StudioLiveTestInitializer: activating studio live test, shots='{shots ?? ""}'");(Godot.Engine.GetMainLoop()as SceneTree)?.Root?.CallDeferred("add_child",new StudioLiveDriverNode(shots));}}}

===== FILE mods/skills_base/src/RareType.cs =====
namespace Cthangover.Mods.Skills{/// <summary> Represents the rarity tier of a skill card, determining its drop probability, visual frame treatment, and relative power budget. Higher tiers correspond to more potent effects and rarer acquisition chances. </summary>
public enum RareType{/// <summary>Standard skill with the highest drop rate and baseline power.</summary>
Common,/// <summary>Slightly improved stats and a lower drop rate than <c>Common</c>.</summary>
Uncommon,/// <summary>Notably stronger, appearing infrequently in loot pools.</summary>
Rare,/// <summary>Powerful skill with significant gameplay impact; very low drop chance.</summary>
Epic,/// <summary>The highest tier — game-changing abilities with extremely rare availability.</summary>
Legendary}}

===== FILE mods/skills_base/src/SkillCardBehaviour.cs =====
using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Theming;namespace Cthangover.Mods.Skills{/// <summary> Grid card of one collected skill. Pure presentation: the panel binds a <see cref="SkillInfo"/> through <see cref="Construct"/> and toggles selection through <see cref="SetSelected"/>, while every colour comes from <see cref="SkillPalette"/> so the same rarity reads identically on the card, the study plate and the rarity filter tabs. <para/> Clicking is intentionally not handled here: the panel subscribes to <c>GuiInput</c>, so the card keeps a single responsibility and can be hosted by any list without knowing its owner. Hover is handled inside because it only repaints the card itself. </summary>
public partial class SkillCardBehaviour:ListItem<SkillInfo>{[Export]private Panel bg;[Export]private Panel accent;[Export]private PanelContainer art;[Export]private TextureRect imgCard;[Export]private Label txtName;[Export]private Label txtGems;[Export]private PanelContainer badgeBox;[Export]private Label badgeLabel;[Export]private HBoxContainer effectRow;[Export]private Label effectValue;[Export]private Label effectLabel;private bool _selected;private bool _hover;/// <summary>Configures the card to accept mouse input for hover/selection feedback.</summary>
public override void _Ready(){MouseFilter=MouseFilterEnum.Stop;MouseEntered+=OnMouseEntered;MouseExited+=OnMouseExited;}/// <summary> Binds the skill definition to the card: artwork, translated name, rarity gems (one diamond per tier), the type badge and the first numeric effect as the card's short line. Skills without effects hide the effect row instead of leaving an empty band; a skill without artwork keeps the empty well, which is a valid authored state. </summary>
public override void Construct(SkillInfo skillInfo){base.Construct(skillInfo);ResolveNodes();if(art!=null)art.AddThemeStyleboxOverride("panel",UiKit.Frame(UiTokens.RadiusSm,UiTokens.Well,UiTokens.Line));if(skillInfo==null)return;if(imgCard!=null)imgCard.Texture=skillInfo.Sprite;if(txtName!=null){txtName.Text=TranslationServer.Translate(skillInfo.Name);txtName.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);txtName.AddThemeColorOverride("font_color",UiTokens.InkBright);}if(txtGems!=null){txtGems.Text=new string('◆',Mathf.Clamp((int)skillInfo.RareType+1,1,5));txtGems.AddThemeFontSizeOverride("font_size",UiTokens.FontMicro);txtGems.AddThemeColorOverride("font_color",SkillPalette.RarityColor(skillInfo.RareType));}ApplyTypeBadge(skillInfo);ApplyEffect(skillInfo);RefreshStyle();}/// <summary> Applies the panel-owned selection state: the selected card gets the golden surface, rarity border and a soft rarity glow, while hover only lifts the border. Both are repainted from scratch so the card can never end up half-styled after a filter rebuild. </summary>
public void SetSelected(bool selected){_selected=selected;RefreshStyle();}/// <summary>Frees the node; the panel owns the card's lifetime and rebuilds the grid.</summary>
public override void Destruct(){QueueFree();}private void ResolveNodes(){bg??=GetNodeOrNull<Panel>("Bg");accent??=GetNodeOrNull<Panel>("Accent");art??=GetNodeOrNull<PanelContainer>("Art");imgCard??=GetNodeOrNull<TextureRect>("Art/ImgCard");txtName??=GetNodeOrNull<Label>("TxtName");txtGems??=GetNodeOrNull<Label>("Gems");badgeBox??=GetNodeOrNull<PanelContainer>("BadgeBox");badgeLabel??=GetNodeOrNull<Label>("BadgeBox/BadgeLabel");effectRow??=GetNodeOrNull<HBoxContainer>("EffectRow");effectValue??=GetNodeOrNull<Label>("EffectRow/EffectValue");effectLabel??=GetNodeOrNull<Label>("EffectRow/EffectLabel");}private void ApplyTypeBadge(SkillInfo skillInfo){var color=SkillPalette.TypeColor(skillInfo.SkillType);if(badgeBox!=null){var style=UiKit.Frame(UiTokens.RadiusXs,UiTokens.BadgeFill(color),UiTokens.BadgeLine(color));style.ContentMarginLeft=6;style.ContentMarginRight=6;style.ContentMarginTop=1;style.ContentMarginBottom=1;badgeBox.AddThemeStyleboxOverride("panel",style);}if(badgeLabel!=null){badgeLabel.Text=TranslationServer.Translate(SkillPalette.TypeLocaleKey(skillInfo.SkillType));badgeLabel.Uppercase=true;badgeLabel.AddThemeFontSizeOverride("font_size",UiTokens.FontMicro);badgeLabel.AddThemeColorOverride("font_color",color);}}private void ApplyEffect(SkillInfo skillInfo){var effect=skillInfo.Effects!=null&&skillInfo.Effects.Count>0?skillInfo.Effects[0]:null;var hasEffect=effect!=null&& !string.IsNullOrEmpty(effect.Key);if(effectRow!=null)effectRow.Visible=hasEffect;if(!hasEffect)return;if(effectValue!=null){effectValue.Text=effect.FormatValue();effectValue.AddThemeFontSizeOverride("font_size",UiTokens.FontTiny);effectValue.AddThemeColorOverride("font_color",UiTokens.AccentBright);}if(effectLabel!=null){effectLabel.Text=TranslationServer.Translate(effect.Key);effectLabel.AddThemeFontSizeOverride("font_size",UiTokens.FontTiny);effectLabel.AddThemeColorOverride("font_color",UiTokens.Muted);}}private void RefreshStyle(){var rarity=SkillPalette.RarityColor(Model?.RareType??RareType.Common);if(accent!=null){var accentStyle=UiKit.Frame(UiTokens.RadiusXs,rarity);accentStyle.CornerRadiusBottomLeft=0;accentStyle.CornerRadiusBottomRight=0;accent.AddThemeStyleboxOverride("panel",accentStyle);}if(bg==null)return;var background=_selected?UiTokens.SurfaceSelected:_hover?UiTokens.SurfaceHover:UiTokens.Surface1;var border=_selected||_hover?rarity:UiTokens.LineCard;var style=UiKit.Frame(UiTokens.RadiusMd,background,border);if(_selected){style.ShadowColor=UiTokens.Alpha(rarity,0.35f);style.ShadowSize=4;}bg.AddThemeStyleboxOverride("panel",style);}private void OnMouseEntered(){_hover=true;RefreshStyle();}private void OnMouseExited(){_hover=false;RefreshStyle();}}}

===== FILE mods/skills_base/src/SkillEffectAggregator.cs =====
using System.Collections.Generic;namespace Cthangover.Mods.Skills{/// <summary> Sums skill effects across a set of owned skills into the totals shown by the skills panel's global "collection effect" plate. The result is what the player actually gains right now — a card collected from any mod changes it immediately, without touching the individual definitions. <para/> Aggregation is keyed by <see cref="SkillEffectInfo.Key"/> and preserves first-seen order, so the plate does not reshuffle between openings and a newly granted skill appends its unique effects at the end. Null skills, null effect lists and effects without a key are skipped: mod JSON is not trusted to be complete. </summary>
public static class SkillEffectAggregator{/// <summary> Builds the collection-wide totals from the given skills. Effects with the same key are summed; the <c>Percent</c> flag survives if any contributor set it, so a mixed authoring mistake degrades to the percent display instead of silently dropping the suffix. </summary> <param name="skills">Owned skills, usually resolved from <see cref="SkillData.Skills"/>.</param> <returns>One entry per unique effect key, in first-seen order. Empty when nothing contributes.</returns>
public static List<SkillEffectInfo>Aggregate(IEnumerable<SkillInfo>skills){var order=new List<string>();var totals=new Dictionary<string,SkillEffectInfo>();if(skills!=null){foreach(var skill in skills){if(skill?.Effects==null)continue;foreach(var effect in skill.Effects){if(effect==null||string.IsNullOrEmpty(effect.Key))continue;if(!totals.TryGetValue(effect.Key,out var total)){total=new SkillEffectInfo{Key=effect.Key,Percent=effect.Percent};totals[effect.Key]=total;order.Add(effect.Key);}total.Value+=effect.Value;total.Percent|=effect.Percent;}}}var result=new List<SkillEffectInfo>();foreach(var key in order)result.Add(totals[key]);return result;}}}

===== FILE mods/skills_base/src/SkillEffectInfo.cs =====
using System;using System.Globalization;using System.Text.Json.Serialization;namespace Cthangover.Mods.Skills{/// <summary> One numeric bonus granted by a skill card — the gameplay payload behind the collection. <see cref="Key"/> is the locale key naming the affected stat (e.g. <c>characters/skills/effects/recruit</c>), <see cref="Value"/> its magnitude and <see cref="Percent"/> the display unit. <para/> Effects sharing a key are summed by <see cref="SkillEffectAggregator"/> into the panel's global "collection effect" plate, so one key must keep the same unit across every skill: two effects of <c>+10%</c> and <c>+5%</c> become <c>+15%</c>, never <c>+15</c>. Mods author these values in the skill JSON next to the card's rarity and type. </summary>
[Serializable]public class SkillEffectInfo{/// <summary>Locale key of the affected stat, shared by all skills that touch it.</summary>
[JsonPropertyName("Key")]public string Key{get;set;}/// <summary>Magnitude in the unit encoded by <see cref="Percent"/>. Negative values are penalties.</summary>
[JsonPropertyName("Value")]public float Value{get;set;}/// <summary>When true the value is displayed with a <c>%</c> suffix; still summed as a plain number.</summary>
[JsonPropertyName("Percent")]public bool Percent{get;set;}/// <summary> Formats the magnitude for cards, chips and the collection plate. An explicit <c>+</c> marks positive values so a bonus never reads as a subtracted amount; trailing zeros are trimmed (<c>+50%</c>, not <c>+50.0%</c>); <c>%</c> is appended when <see cref="Percent"/> is set. </summary>
public string FormatValue(){var number=Value.ToString("0.##",CultureInfo.InvariantCulture);if(Value>0f)number="+"+number;return Percent?number+"%":number;}}}

===== FILE mods/skills_base/src/SkillFactory.cs =====
using System;using Cthangover.Core.Factories;namespace Cthangover.Mods.Skills{/// <summary> Thin <c>FileFactory</c> for skill definitions. Skills are collected by the shared <c>EntityResolver</c> through the DTO's <c>[EntityKind("skill")]</c> attribute, so this factory carries no directory knowledge at all — a skill file is found wherever a mod decides to put it, as long as its envelope declares the skill kind. <para/> The single instance is a self-contained lazy singleton owned by this mod assembly; consumers resolve it through <see cref="Instance"/>. </summary>
public class SkillFactory:FileFactory<SkillInfo>{private static readonly Lazy<SkillFactory>instance=new(()=>new SkillFactory());public static SkillFactory Instance=>instance.Value;}}

===== FILE mods/skills_base/src/SkillInfo.cs =====
using System;using System.Collections.Generic;using System.Text.Json.Serialization;using Cthangover.Core.Factories;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Mods.Skills{/// <summary> Immutable data model for a single skill definition, populated by <see cref="SkillFactory"/> from JSON assets. Each instance carries the metadata required to render a skill card in the UI (<see cref="Sprite"/>, <see cref="Name"/>, <see cref="Description"/>) and to resolve its gameplay behaviour through <see cref="SkillType"/> and <see cref="RareType"/>. Instances are shared across the runtime and should not be mutated after construction. </summary>
[Serializable][EntityKind("skill")]public class SkillInfo:IIdentifiable{/// <summary> The unique string key of this skill, used as the primary lookup in <see cref="SkillFactory"/> and as the identity token in <see cref="SkillData.Skills"/>. </summary>
[JsonPropertyName("Id")]public string ID{get;set;}/// <summary> The display name shown on the skill card. This value is passed through <see cref="TranslationServer.Translate"/> before rendering, allowing localisation via Godot's translation system. </summary>
public string Name{get;set;}/// <summary> A longer textual description explaining what the skill does in gameplay terms. Displayed in tooltip and detail views alongside the card artwork. </summary>
public string Description{get;set;}/// <summary> The relative path to the skill's artwork texture, resolved inside the mod-group "characters/skills" by <see cref="TextureUtils.LoadFromModGroup"/> when the <see cref="Sprite"/> property is first accessed. </summary>
public string Image{get;set;}/// <summary> Optional ID of the action card this skill gifts to the player when acquired (e.g. an active skill that hands over a battle card). Resolved against <see cref="Cthangover.Core.Factories.Impls.ActionCharacterFactory"/> when the skills panel renders the detail view, and pushed into <see cref="Cthangover.Core.Settings.ActionPoolData.ActionIds"/> by <see cref="SkillService"/> so the card becomes assignable to a character. Empty for skills that only act as passive gates and never produce a card. </summary>
public string GrantsAction{get;set;}/// <summary> Numeric bonuses this skill grants while owned. They are the card's actual payload: the skills panel shows the first effect on the grid card, lists all of them in the study plate, and sums every owned skill's effects into the global "collection effect" plate via <see cref="SkillEffectAggregator"/>. Empty for skills that only gate content (e.g. unlock an action) without touching stats. </summary>
[JsonPropertyName("Effects")]public List<SkillEffectInfo>Effects{get;set;}/// <summary> The gameplay classification of this skill (<c>Active</c>, <c>Passive</c>, or <c>ForCharacter</c>), serialised as a string in JSON and converted via <see cref="JsonStringEnumConverter"/>. </summary>
[JsonConverter(typeof(JsonStringEnumConverter))][JsonPropertyName("Type")]public SkillType SkillType{get;set;}/// <summary> The rarity tier of this skill, serialised as a string in JSON and converted via <see cref="JsonStringEnumConverter"/>. Drives the colour of the card frame, study plate and rarity badge through <see cref="SkillPalette"/>. </summary>
[JsonConverter(typeof(JsonStringEnumConverter))][JsonPropertyName("Rare")]public RareType RareType{get;set;}private Texture2D cacheSprite;/// <summary> Lazily loads and caches the <see cref="Texture2D"/> for this skill's card artwork. On first access the texture is resolved from the mod-group "characters/skills" using the <see cref="Image"/> path; subsequent accesses return the cached reference. </summary>
public Texture2D Sprite{get{if(cacheSprite==null)cacheSprite=TextureUtils.LoadFromModGroup("characters/skills",Image);return cacheSprite;}}}}

===== FILE mods/skills_base/src/SkillName.cs =====
namespace Cthangover.Mods.Skills{/// <summary> Enumerates every skill available in the game by its canonical identifier. Each member corresponds to a JSON-definition entry loaded by <see cref="SkillFactory"/> and used to construct <see cref="SkillInfo"/> instances. The underlying <c>int</c> values are not manually assigned; the enum serves purely as a compile-time-safe skill reference. </summary>
public enum SkillName:int{/// <summary> A domination-type skill that allows the player to command enslaved units, overriding enemy AI and redirecting attacks under the player's control. </summary>
SLAVE_MASTER}/// <summary> Provides conversion helpers for <see cref="SkillName"/> values, used when bridging the enum-based API with string-based lookups in <see cref="SkillData"/> and factory registries. </summary>
public static class SkillNameExtensions{/// <summary> Converts a <see cref="SkillName"/> enum value to its lowercase string identifier, matching the format expected by <see cref="SkillData.Skills"/> and serialization layers. </summary> <param name="skill">The skill enum value to convert.</param> <returns>The lowercase name of the enum constant (e.g. "slave_master").</returns>
public static string ID(this SkillName skill){return skill.ToString().ToLower();}}}

===== FILE mods/skills_base/src/SkillPalette.cs =====
using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Mods.Skills{/// <summary> Maps skill card enums to their visual identity. The five rarity colours are fixed by the UI style guide (Common→muted, Uncommon→success, Rare→info, Epic→recipe, Legendary→accent) and are shared by the grid card frame, the study plate, the rarity filter tabs and the rarity badge — a card can never appear in two different colours in one screen. The type accents reuse the action-card palette so the same "passive", "active" and "self" semantics read identically across panels, and the locale keys follow both lists for badge labels. </summary>
public static class SkillPalette{private static readonly Color[]RarityColors={UiTokens.Muted,UiTokens.Success,UiTokens.Info,UiTokens.TypeRecipe,UiTokens.Accent,};/// <summary>Rarity colour clipped to the five known tiers; unknown values degrade to Common.</summary>
public static Color RarityColor(RareType rarity){var index=Mathf.Clamp((int)rarity,0,RarityColors.Length-1);return RarityColors[index];}/// <summary>Locale key of the rarity name shown on the badge and the rarity filter tabs.</summary>
public static string RarityLocaleKey(RareType rarity)=>"ui/skills/rarity/"+rarity.ToString().ToLowerInvariant();/// <summary>Accent colour of the skill type badge, matching the action-card badges.</summary>
public static Color TypeColor(SkillType type){switch(type){case SkillType.Active:return UiTokens.TypeActive;case SkillType.ForCharacter:return UiTokens.TypeSelf;default:return UiTokens.TypePassive;}}/// <summary>Locale key of the type name; <c>ForCharacter</c> needs an explicit snake_case key.</summary>
public static string TypeLocaleKey(SkillType type){switch(type){case SkillType.Active:return"ui/skills/type/active";case SkillType.ForCharacter:return"ui/skills/type/for_character";default:return"ui/skills/type/passive";}}}}

===== FILE mods/skills_base/src/SkillService.cs =====
using Cthangover.Core.Factories.Impls;using Cthangover.Core.Settings;using Cthangover.Core.Utils;namespace Cthangover.Mods.Skills{/// <summary> Single entry point for acquiring a skill at runtime. A skill is more than a checkbox in <see cref="SkillData"/>: an active skill can hand the player an action card through <see cref="SkillInfo.GrantsAction"/>, and that card is what the player later assigns to a character in the character panel. Granting the skill and its card together keeps the two collections from drifting apart — a skill without its card (or a card without its skill) would survive save/restore inconsistently and the skills panel could not explain where the card came from. <para/> Granting is idempotent: once <see cref="SkillData"/> owns the skill ID, repeated calls are no-ops, so replayed scenario events cannot duplicate action cards in the pool. </summary>
public static class SkillService{/// <summary> Resolves the skill definition through <see cref="SkillFactory"/> and grants it to the current runtime. This is the path used by scenario actions and mod initializers. Unknown IDs are logged and rejected instead of partially mutating the runtime state. </summary> <param name="id">Skill ID as authored in the skill's JSON file (e.g. "slave_master").</param> <returns><c>true</c> when the skill was newly granted; <c>false</c> when it was already owned or could not be resolved.</returns>
public static bool GrantSkill(string id){if(string.IsNullOrEmpty(id))return false;var skill=SkillFactory.Instance.Get(id);if(skill==null){GameLogger.Log("SKILL",$"GrantSkill: unknown skill '{id}'",LogLevel.Warning);return false;}return GrantSkill(GameData.Instance.Runtime,skill);}/// <summary> Core grant routine operating on an explicit runtime, so the skill/card pairing can be exercised without the Godot autoload. Adds the skill to <see cref="RuntimeData.SkillData"/> and, when the definition names one, appends its granted action to <see cref="RuntimeData.ActionPool"/> where the character panel can pick it up. </summary> <param name="runtime">Session state that owns both the skill set and the action pool.</param> <param name="skill">Resolved skill definition to grant.</param> <returns><c>true</c> when the skill was newly granted; <c>false</c> when it was already owned or the arguments are invalid.</returns>
public static bool GrantSkill(RuntimeData runtime,SkillInfo skill){if(runtime==null||skill==null||string.IsNullOrEmpty(skill.ID))return false;if(runtime.SkillData.HasSkill(skill.ID))return false;runtime.SkillData.AddSkill(skill.ID);if(!string.IsNullOrEmpty(skill.GrantsAction))runtime.ActionPool.ActionIds.Add(skill.GrantsAction);GameLogger.Log("SKILL",string.IsNullOrEmpty(skill.GrantsAction)?$"GrantSkill: granted '{skill.ID}'":$"GrantSkill: granted '{skill.ID}', action '{skill.GrantsAction}' added to pool");return true;}}}

===== FILE mods/skills_base/src/SkillType.cs =====
namespace Cthangover.Mods.Skills{/// <summary> Classifies how a skill behaves during gameplay. This distinction drives which battle-phase the skill can be used in and how its effects are resolved by the combat engine. </summary>
public enum SkillType{/// <summary> Must be intentionally triggered during the player's turn, consuming an action resource and targeting a specific ally or enemy. </summary>
Active,/// <summary> Remains in effect continuously once acquired, providing a persistent buff or aura without requiring any player input. </summary>
Passive,/// <summary> Applies directly to the owning character as a stat modifier or intrinsic trait, rather than being a card the player selects each turn. </summary>
ForCharacter,}}

===== FILE mods/skills_base/src/SkillWidget.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Settings;using Cthangover.Mods.Interface.Base.Lists.Impls;namespace Cthangover.Mods.Skills{/// <summary> UI widget that displays all skills currently owned by the player in a column-based card grid. Extends <see cref="ColumnCellListWidget{TItem, TModel}"/> to reuse the generic list-rendering pipeline. When the widget is populated it reads the set of owned skill IDs from <see cref="SkillData.Skills"/> in the current <see cref="GameData"/> runtime, resolves each ID to a <see cref="SkillInfo"/> via <see cref="SkillFactory.Instance"/>, and hands the resulting models to the list for rendering as <see cref="SkillCardBehaviour"/> items. </summary>
public partial class SkillWidget:ColumnCellListWidget<SkillCardBehaviour,SkillInfo>{/// <summary> Produces the collection of <see cref="SkillInfo"/> models that the list widget will render. Each ownedskill ID from <see cref="GameData.Instance.Runtime.SkillData"/> is resolved through <see cref="SkillFactory.Instance.Get"/> and collected into a <see cref="List{T}"/>. </summary> <returns> A snapshot of all currently owned skills, each as a fully-populated <see cref="SkillInfo"/> instance. </returns>
public override ICollection<SkillInfo>CreateModels(){return GameData.Instance.Runtime.SkillData.Skills.Select(x=>SkillFactory.Instance.Get(x)).ToList();}}}

===== FILE mods/skills_base/src/SkillsModInitializer.cs =====
using Cthangover.Core.Actions;using Cthangover.Core.Mods;namespace Cthangover.Mods.Skills{/// <summary> Binds the skills subsystem into the kernel bridge. Kernel entry points (scenario actions, the character service) route grants through <see cref="SkillsBridge"/>; the actual grant logic — resolving the definition and pairing the skill with its granted action card — is owned by this mod's <see cref="SkillService"/>. The binding happens on <c>OnModResourcesReady</c>, when skill JSON data is guaranteed to be collectable through the factory. </summary>
public class SkillsModInitializer:IModInitializer{/// <summary>No per-mod work: registration is idempotent and resources-ready only.</summary>
public void OnModLoaded(string modId){}/// <summary>Installs the grant sink (first registration wins).</summary>
public void OnModResourcesReady(){SkillsBridge.Register(SkillService.GrantSkill);}}}

===== FILE mods/skills_base/src/SkillsPanel/SkillsPanelBehaviour.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Characters;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Scenes;using Cthangover.Core.UI;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.Settings;using Cthangover.Core.Skills;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Theming;namespace Cthangover.Mods.Skills{/// <summary> Skills collection panel: the showcase grid of collected skill cards on the left, the study plate of the selected card on the right and the global "collection effect" plate hanging in the screen corner. The window structure lives in <c>skills_panel.tscn</c>; this behaviour only resolves the nodes, paints them with <see cref="UiTokens"/> and fills them with data. <para/> The grid never reserves slots for cards the player does not own: the total card count in the world depends on the loaded mods and is unknown at runtime, so only owned skills are shown and the header counter reports the collected amount. The effect plate sums every owned skill's <see cref="SkillEffectInfo"/> through <see cref="SkillEffectAggregator"/>, which is the "what the collection gives me right now" readout. <para/> Selecting a card only repaints the study plate; filters, search and sort rebuild the grid and keep the selection on the first visible card. All rarity colours come from <see cref="SkillPalette"/>, shared with the card scene, so a card and its plate can never disagree. </summary>
public partial class SkillsPanelBehaviour:Widget{private enum SortMode{Rarity,Type,Name,}private const int Columns=6;private const float CellGap=10f;private const float SideWidth=400f;private const float CardAspect=210f/160f;private readonly List<SkillCardBehaviour>_cards=new();private ModScenePrefab _cardPrefab;private ColorRect _backdrop;private Panel _window;private Label _title;private HBoxContainer _tabsRow;private Label _progressLabel;private Label _progressValue;private Button _closeButton;private Panel _rule;private LineEdit _searchEdit;private OptionButton _sortOption;private HFlowContainer _rarityTabs;private ScrollContainer _gridScroll;private Control _gridContent;private Label _emptyLabel;private Label _hint;private Button _resetButton;private PanelContainer _side;private Label _sideLabel;private Control _plate;private Panel _plateBg;private PanelContainer _plateArt;private TextureRect _plateImage;private Label _plateGems;private PanelContainer _medal;private Label _medalGlyph;private PanelContainer _plateNameBox;private Label _plateName;private HFlowContainer _badges;private Label _desc;private HFlowContainer _chips;private PanelContainer _grant;private TextureRect _grantIcon;private Label _grantCaption;private Label _grantName;private Label _passportEmpty;private Panel _hud;private Panel _hudAccent;private Label _hudTitle;private Label _hudCount;private Panel _hudRule;private ScrollContainer _hudScroll;private GridContainer _hudList;private Label _hudEmpty;private Label _hudFoot;private SkillInfo _selected;private SkillType?_typeFilter;private RareType?_rarityFilter;private SortMode _sort=SortMode.Rarity;private string _search=string.Empty;/// <summary>Resolves nodes, applies styles and texts, loads the skill card prefab and wires search/sort/close handlers.</summary>
public override void _Ready(){base._Ready();ResolveNodes();ApplyStaticStyles();ApplyStaticTexts();_cardPrefab=new ModScenePrefab("scenes/ui/skills_panel/skill_card.tscn");if(_closeButton!=null)_closeButton.Pressed+=OnClosePressed;if(_searchEdit!=null)_searchEdit.TextChanged+=OnSearchChanged;if(_sortOption!=null)_sortOption.ItemSelected+=OnSortSelected;if(_resetButton!=null)_resetButton.Pressed+=OnResetPressed;GameLogger.Log("SKILLSPANEL",$"Ready: grid={_gridContent != null} side={_side != null} hud={_hudList != null} prefab={_cardPrefab != null}");// A scene that starts visible never goes through Widget.Show (the lifecycle only fires on the typed Show/Switch calls), so build the panel here too — ShowConstruct rebuilds from scratch, and the next open just repeats that rebuild.
if(Visible){EnsureConstructed();ShowConstruct();}}/// <summary> Rebuilds the whole panel on every open with a clean filter state, so the player always lands on the full collection. Newly collected skills appear here because the grid is rebuilt from <see cref="SkillData.Skills"/> rather than cached. </summary>
protected override void ShowConstruct(){_typeFilter=null;_rarityFilter=null;_sort=SortMode.Rarity;_search=string.Empty;if(_searchEdit!=null)_searchEdit.Text=string.Empty;if(_sortOption!=null)_sortOption.Selected=0;var owned=OwnedSkills();RebuildTypeTabs(owned);RebuildRarityTabs(owned);RebuildGrid(owned);RebuildHud(owned);GameLogger.Log("SKILLSPANEL",$"ShowConstruct: {owned.Count} skill(s) owned");}protected override void HideDestruct(){ClearCards();_selected=null;ClearChildren(_badges);ClearChildren(_chips);}private void ResolveNodes(){const string root="Window/Margin/Root";_backdrop=GetNodeOrNull<ColorRect>("Bg");_window=GetNodeOrNull<Panel>("Window");_title=GetNodeOrNull<Label>(root+"/Header/Title");_tabsRow=GetNodeOrNull<HBoxContainer>(root+"/Header/TabsRow");_progressLabel=GetNodeOrNull<Label>(root+"/Header/ProgressLabel");_progressValue=GetNodeOrNull<Label>(root+"/Header/ProgressValue");_closeButton=GetNodeOrNull<Button>(root+"/Header/CloseButton");_rule=GetNodeOrNull<Panel>(root+"/Rule");_searchEdit=GetNodeOrNull<LineEdit>(root+"/Body/Showcase/Toolbar/SearchEdit");_sortOption=GetNodeOrNull<OptionButton>(root+"/Body/Showcase/Toolbar/SortOption");_rarityTabs=GetNodeOrNull<HFlowContainer>(root+"/Body/Showcase/RarityTabs");_gridScroll=GetNodeOrNull<ScrollContainer>(root+"/Body/Showcase/GridScroll");_gridContent=GetNodeOrNull<Control>(root+"/Body/Showcase/GridScroll/GridContent");_emptyLabel=GetNodeOrNull<Label>(root+"/Body/Showcase/EmptyLabel");_hint=GetNodeOrNull<Label>(root+"/Body/Showcase/Footer/Hint");_resetButton=GetNodeOrNull<Button>(root+"/Body/Showcase/Footer/ResetButton");_side=GetNodeOrNull<PanelContainer>(root+"/Body/Side");_sideLabel=GetNodeOrNull<Label>(root+"/Body/Side/SideScroll/Passport/SideLabel");_plate=GetNodeOrNull<Control>(root+"/Body/Side/SideScroll/Passport/Plate");_plateBg=GetNodeOrNull<Panel>(root+"/Body/Side/SideScroll/Passport/Plate/PlateBg");_plateArt=GetNodeOrNull<PanelContainer>(root+"/Body/Side/SideScroll/Passport/Plate/PlateArt");_plateImage=GetNodeOrNull<TextureRect>(root+"/Body/Side/SideScroll/Passport/Plate/PlateArt/PlateImage");_plateGems=GetNodeOrNull<Label>(root+"/Body/Side/SideScroll/Passport/Plate/PlateGems");_medal=GetNodeOrNull<PanelContainer>(root+"/Body/Side/SideScroll/Passport/Plate/PlateMedal");_medalGlyph=GetNodeOrNull<Label>(root+"/Body/Side/SideScroll/Passport/Plate/PlateMedal/MedalGlyph");_plateNameBox=GetNodeOrNull<PanelContainer>(root+"/Body/Side/SideScroll/Passport/Plate/PlateNameBox");_plateName=GetNodeOrNull<Label>(root+"/Body/Side/SideScroll/Passport/Plate/PlateNameBox/PlateName");_badges=GetNodeOrNull<HFlowContainer>(root+"/Body/Side/SideScroll/Passport/Badges");_desc=GetNodeOrNull<Label>(root+"/Body/Side/SideScroll/Passport/Desc");_chips=GetNodeOrNull<HFlowContainer>(root+"/Body/Side/SideScroll/Passport/Chips");_grant=GetNodeOrNull<PanelContainer>(root+"/Body/Side/SideScroll/Passport/Grant");_grantIcon=GetNodeOrNull<TextureRect>(root+"/Body/Side/SideScroll/Passport/Grant/GrantRow/GrantIcon");_grantCaption=GetNodeOrNull<Label>(root+"/Body/Side/SideScroll/Passport/Grant/GrantRow/GrantInfo/GrantCaption");_grantName=GetNodeOrNull<Label>(root+"/Body/Side/SideScroll/Passport/Grant/GrantRow/GrantInfo/GrantName");_passportEmpty=GetNodeOrNull<Label>(root+"/Body/Side/SideScroll/Passport/Empty");_hud=GetNodeOrNull<Panel>("Hud");_hudAccent=GetNodeOrNull<Panel>("Hud/HudAccent");_hudTitle=GetNodeOrNull<Label>("Hud/HudMargin/HudColumn/HudHead/HudTitle");_hudCount=GetNodeOrNull<Label>("Hud/HudMargin/HudColumn/HudHead/HudCount");_hudRule=GetNodeOrNull<Panel>("Hud/HudMargin/HudColumn/HudRule");_hudScroll=GetNodeOrNull<ScrollContainer>("Hud/HudMargin/HudColumn/HudScroll");_hudList=GetNodeOrNull<GridContainer>("Hud/HudMargin/HudColumn/HudScroll/HudList");_hudEmpty=GetNodeOrNull<Label>("Hud/HudMargin/HudColumn/HudEmpty");_hudFoot=GetNodeOrNull<Label>("Hud/HudMargin/HudColumn/HudFoot");}private void ApplyStaticStyles(){if(_backdrop!=null)UiKit.ApplyPanelBackdrop(_backdrop);if(_window!=null)_window.AddThemeStyleboxOverride("panel",MakePanelStyle(UiTokens.Surface0,UiTokens.Line,UiTokens.RadiusLg,true));if(_rule!=null)_rule.AddThemeStyleboxOverride("panel",MakePanelStyle(UiTokens.Line,UiTokens.Line,0));if(_side!=null)_side.AddThemeStyleboxOverride("panel",MakeSideStyle());if(_plateBg!=null)_plateBg.AddThemeStyleboxOverride("panel",MakePanelStyle(UiTokens.Surface1,UiTokens.Line,UiTokens.RadiusMd));if(_plateArt!=null)_plateArt.AddThemeStyleboxOverride("panel",MakePanelStyle(UiTokens.Well,UiTokens.Line,UiTokens.RadiusSm));if(_grant!=null){var grantStyle=MakePanelStyle(UiTokens.Well,UiTokens.Line,UiTokens.RadiusMd);ApplyContentMargins(grantStyle,10,9);_grant.AddThemeStyleboxOverride("panel",grantStyle);}if(_hud!=null)_hud.AddThemeStyleboxOverride("panel",MakePanelStyle(UiTokens.Surface0,UiTokens.Line,UiTokens.RadiusLg,true));if(_hudAccent!=null)_hudAccent.AddThemeStyleboxOverride("panel",UiKit.Frame(0,UiTokens.AccentLine));if(_hudRule!=null)_hudRule.AddThemeStyleboxOverride("panel",MakePanelStyle(UiTokens.LineSoft,UiTokens.LineSoft,0));StyleLabel(_title,UiTokens.FontTitle,UiTokens.InkBright);if(_title!=null)_title.Uppercase=true;StyleLabel(_progressLabel,UiTokens.FontTiny,UiTokens.Faint);if(_progressLabel!=null)_progressLabel.Uppercase=true;StyleLabel(_progressValue,UiTokens.FontBodyLarge,UiTokens.AccentBright);StyleLabel(_hint,UiTokens.FontBodySmall,UiTokens.Faint);StyleLabel(_sideLabel,UiTokens.FontTiny,UiTokens.Faint);if(_sideLabel!=null)_sideLabel.Uppercase=true;StyleLabel(_plateGems,UiTokens.FontSmall,UiTokens.Muted);StyleLabel(_medalGlyph,UiTokens.FontBodyLarge,UiTokens.Muted);StyleLabel(_plateName,UiTokens.FontBodyLarge,UiTokens.InkBright);StyleLabel(_desc,UiTokens.FontBody,UiTokens.Body);StyleLabel(_grantCaption,UiTokens.FontTiny,UiTokens.Faint);if(_grantCaption!=null)_grantCaption.Uppercase=true;StyleLabel(_grantName,UiTokens.FontBodyLarge,UiTokens.InkBright);StyleLabel(_passportEmpty,UiTokens.FontBodyLarge,UiTokens.Faint);StyleLabel(_hudTitle,UiTokens.FontTiny,UiTokens.AccentDim);if(_hudTitle!=null)_hudTitle.Uppercase=true;StyleLabel(_hudCount,UiTokens.FontSmall,UiTokens.Muted);StyleLabel(_hudEmpty,UiTokens.FontBodySmall,UiTokens.Faint);StyleLabel(_hudFoot,UiTokens.FontTiny,UiTokens.Faint);if(_closeButton!=null){_closeButton.Text="✕";_closeButton.TooltipText=Translate("ui/journal/close");CthangoverTheme.ApplyIcon(_closeButton);}if(_resetButton!=null){_resetButton.FocusMode=FocusModeEnum.None;_resetButton.AddThemeFontSizeOverride("font_size",UiTokens.FontBody);}if(_medal!=null)_medal.AddThemeStyleboxOverride("panel",MakeCircleStyle(UiTokens.Line));if(_plateNameBox!=null)_plateNameBox.AddThemeStyleboxOverride("panel",MakeNamePlateStyle(UiTokens.Line));}private void ApplyStaticTexts(){if(_title!=null)_title.Text=Translate("ui/skills/title");if(_progressLabel!=null)_progressLabel.Text=Translate("ui/skills/collection");if(_hint!=null)_hint.Text=Translate("ui/skills/hint");if(_resetButton!=null)_resetButton.Text=Translate("ui/skills/reset");if(_sideLabel!=null)_sideLabel.Text=Translate("ui/skills/side_title");if(_hudTitle!=null)_hudTitle.Text=Translate("ui/skills/collection_effect");if(_hudFoot!=null)_hudFoot.Text=Translate("ui/skills/collection_effect_hint");if(_hudEmpty!=null)_hudEmpty.Text=Translate("ui/skills/no_effects");if(_grantCaption!=null)_grantCaption.Text=Translate("ui/skills/grants_action");if(_searchEdit!=null)_searchEdit.PlaceholderText=Translate("ui/skills/search");if(_sortOption!=null){_sortOption.Clear();_sortOption.AddItem(Translate("ui/skills/sort_rarity"),(int)SortMode.Rarity);_sortOption.AddItem(Translate("ui/skills/sort_type"),(int)SortMode.Type);_sortOption.AddItem(Translate("ui/skills/sort_name"),(int)SortMode.Name);_sortOption.Selected=0;}}private List<SkillInfo>OwnedSkills(){var result=new List<SkillInfo>();foreach(var id in GameData.Instance.Runtime.SkillData.Skills){var skill=SkillFactory.Instance.Get(id);if(skill!=null)result.Add(skill);}return result;}private void RebuildTypeTabs(List<SkillInfo>owned){ClearChildren(_tabsRow);if(_tabsRow==null)return;AddTypeTab(null,"ui/skills/filter_all",owned.Count);AddTypeTab(SkillType.Passive,SkillPalette.TypeLocaleKey(SkillType.Passive),CountType(owned,SkillType.Passive));AddTypeTab(SkillType.Active,SkillPalette.TypeLocaleKey(SkillType.Active),CountType(owned,SkillType.Active));AddTypeTab(SkillType.ForCharacter,SkillPalette.TypeLocaleKey(SkillType.ForCharacter),CountType(owned,SkillType.ForCharacter));}private void AddTypeTab(SkillType?type,string localeKey,int count){var active=_typeFilter==type;var button=new Button{Text=Translate(localeKey)+" "+count,FocusMode=FocusModeEnum.None,};button.AddThemeFontSizeOverride("font_size",UiTokens.FontBody);button.AddThemeStyleboxOverride("normal",MakeTypeTabStyle(active,false));button.AddThemeStyleboxOverride("hover",MakeTypeTabStyle(active,true));button.AddThemeStyleboxOverride("pressed",MakeTypeTabStyle(active,true));button.AddThemeStyleboxOverride("focus",new StyleBoxEmpty());button.AddThemeColorOverride("font_color",active?UiTokens.AccentBright:UiTokens.Muted);button.AddThemeColorOverride("font_hover_color",active?UiTokens.AccentBright:UiTokens.Ink);button.AddThemeColorOverride("font_pressed_color",UiTokens.AccentBright);button.Pressed+=()=>OnTypePressed(type);_tabsRow.AddChild(button);}private void RebuildRarityTabs(List<SkillInfo>owned){ClearChildren(_rarityTabs);if(_rarityTabs==null)return;AddRarityTab(null,"ui/skills/filter_rarities",owned.Count,UiTokens.Accent,false);foreach(RareType rarity in Enum.GetValues(typeof(RareType)))AddRarityTab(rarity,SkillPalette.RarityLocaleKey(rarity),CountRarity(owned,rarity),SkillPalette.RarityColor(rarity),true);}private void AddRarityTab(RareType?rarity,string localeKey,int count,Color color,bool withGlyph){var active=_rarityFilter==rarity;var button=new Button{Text=(withGlyph?"◆ ":string.Empty)+Translate(localeKey)+" "+count,FocusMode=FocusModeEnum.None,};button.AddThemeFontSizeOverride("font_size",UiTokens.FontSmall);button.AddThemeStyleboxOverride("normal",MakeRarityTabStyle(active,false,color));button.AddThemeStyleboxOverride("hover",MakeRarityTabStyle(active,true,color));button.AddThemeStyleboxOverride("pressed",MakeRarityTabStyle(active,true,color));button.AddThemeStyleboxOverride("focus",new StyleBoxEmpty());button.AddThemeColorOverride("font_color",active?color:UiTokens.Alpha(color,0.75f));button.AddThemeColorOverride("font_hover_color",color);button.AddThemeColorOverride("font_pressed_color",color);button.Pressed+=()=>OnRarityPressed(rarity);_rarityTabs.AddChild(button);}private void RebuildGrid(List<SkillInfo>owned){var previous=_selected;ClearCards();if(_gridContent==null)return;var skills=FilterAndSort(owned);if(skills.Count==0){if(_gridScroll!=null)_gridScroll.Visible=false;if(_emptyLabel!=null){_emptyLabel.Visible=true;_emptyLabel.Text=Translate(owned.Count==0?"ui/skills/empty":"ui/skills/nothing_found");}HidePassport(owned.Count==0?"ui/skills/empty":"ui/skills/nothing_found");GameLogger.Log("SKILLSPANEL",$"RebuildGrid: no cards ({owned.Count} owned)");return;}if(_gridScroll!=null)_gridScroll.Visible=true;if(_emptyLabel!=null)_emptyLabel.Visible=false;var cardSize=CalculateCardSize();for(var i=0;i<skills.Count;i++){var skill=skills[i];var card=_cardPrefab?.Instantiate<SkillCardBehaviour>();if(card==null)continue;_gridContent.AddChild(card);card.Construct(skill);PlaceCard(card,i,cardSize);card.GuiInput+=@event=>{if(@event is InputEventMouseButton mouse&&mouse.Pressed&&mouse.ButtonIndex==MouseButton.Left)SelectSkill(skill);};_cards.Add(card);}if(_cards.Count==0){HidePassport("ui/skills/nothing_found");return;}var last=_cards[_cards.Count-1];_gridContent.CustomMinimumSize=new Vector2(0,last.Position.Y+last.Size.Y+CellGap);var keep=previous==null?null:skills.Find(candidate=>candidate.ID==previous.ID);SelectSkill(keep??_cards[0].Model);GameLogger.Log("SKILLSPANEL",$"RebuildGrid: {_cards.Count} card(s) from {owned.Count} owned");}private List<SkillInfo>FilterAndSort(List<SkillInfo>owned){var result=new List<SkillInfo>();foreach(var skill in owned){if(_typeFilter.HasValue&&skill.SkillType!=_typeFilter.Value)continue;if(_rarityFilter.HasValue&&skill.RareType!=_rarityFilter.Value)continue;if(!MatchesSearch(skill))continue;result.Add(skill);}result.Sort(CompareSkills);return result;}private bool MatchesSearch(SkillInfo skill){if(string.IsNullOrWhiteSpace(_search))return true;var needle=_search.Trim().ToLowerInvariant();if(Translate(skill.Name).ToLowerInvariant().Contains(needle))return true;return skill.ID!=null&&skill.ID.ToLowerInvariant().Contains(needle);}private int CompareSkills(SkillInfo a,SkillInfo b){switch(_sort){case SortMode.Name:return string.Compare(Translate(a.Name),Translate(b.Name),StringComparison.OrdinalIgnoreCase);case SortMode.Type:{var type=((int)a.SkillType).CompareTo((int)b.SkillType);return type!=0?type:string.Compare(Translate(a.Name),Translate(b.Name),StringComparison.OrdinalIgnoreCase);}default:{var rarity=((int)b.RareType).CompareTo((int)a.RareType);return rarity!=0?rarity:string.Compare(Translate(a.Name),Translate(b.Name),StringComparison.OrdinalIgnoreCase);}}}private Vector2 CalculateCardSize(){var viewportWidth=GetViewportRect().Size.X;if(viewportWidth<=0f)viewportWidth=1920f;var panelWidth=viewportWidth-80f-48f;var showcaseWidth=Mathf.Max(320f,panelWidth-SideWidth-14f);var cardWidth=(showcaseWidth-(Columns-1)*CellGap)/Columns;cardWidth=Mathf.Clamp(cardWidth,72f,220f);return new Vector2(cardWidth,cardWidth*CardAspect);}private static void PlaceCard(SkillCardBehaviour card,int index,Vector2 size){var column=index%Columns;var row=index/Columns;card.Position=new Vector2(column*(size.X+CellGap),row*(size.Y+CellGap));card.AnchorRight=0f;card.AnchorBottom=0f;card.Size=size;}private void SelectSkill(SkillInfo skill){_selected=skill;foreach(var card in _cards)card.SetSelected(ReferenceEquals(card.Model,skill));BuildPassport(skill);}private void BuildPassport(SkillInfo skill){if(skill==null){HidePassport("ui/skills/nothing_found");return;}var rarityColor=SkillPalette.RarityColor(skill.RareType);var rarityLine=UiTokens.Alpha(rarityColor,0.5f);SetPassportBlockVisible(true);if(_passportEmpty!=null)_passportEmpty.Visible=false;if(_plateBg!=null)_plateBg.AddThemeStyleboxOverride("panel",MakePanelStyle(UiTokens.Surface1,rarityColor,UiTokens.RadiusMd,true));if(_plateImage!=null)_plateImage.Texture=skill.Sprite;if(_plateGems!=null){_plateGems.Text=new string('◆',Mathf.Clamp((int)skill.RareType+1,1,5));_plateGems.AddThemeColorOverride("font_color",rarityColor);}if(_medal!=null)_medal.AddThemeStyleboxOverride("panel",MakeCircleStyle(rarityColor));if(_medalGlyph!=null)_medalGlyph.AddThemeColorOverride("font_color",rarityColor);if(_plateName!=null)_plateName.Text=Translate(skill.Name);if(_plateNameBox!=null)_plateNameBox.AddThemeStyleboxOverride("panel",MakeNamePlateStyle(rarityLine));ClearChildren(_badges);if(_badges!=null){_badges.AddChild(UiKit.Badge(Translate(SkillPalette.RarityLocaleKey(skill.RareType)),rarityColor));_badges.AddChild(UiKit.Badge(Translate(SkillPalette.TypeLocaleKey(skill.SkillType)),SkillPalette.TypeColor(skill.SkillType)));}if(_desc!=null)_desc.Text=Translate(skill.Description);ClearChildren(_chips);if(_chips!=null&&skill.Effects!=null){foreach(var effect in skill.Effects){if(effect==null||string.IsNullOrEmpty(effect.Key))continue;_chips.AddChild(UiKit.PropertyChip(Translate(effect.Key),effect.FormatValue()));}}ApplyGrant(skill.GrantsAction);}private void HidePassport(string messageKey){SetPassportBlockVisible(false);if(_passportEmpty!=null){_passportEmpty.Visible=true;_passportEmpty.Text=Translate(messageKey);}}private void SetPassportBlockVisible(bool visible){if(_plate!=null)_plate.Visible=visible;if(_badges!=null)_badges.Visible=visible;if(_desc!=null)_desc.Visible=visible;if(_chips!=null)_chips.Visible=visible;if(_grant!=null&& !visible)_grant.Visible=false;}/// <summary> Shows the action card a skill gifts to the player. Skills without a granted action (pure passives and gates) keep the block hidden, and a broken cross-mod reference degrades to a plain skill card instead of failing the whole study plate. </summary>
private void ApplyGrant(string actionId){var action=string.IsNullOrEmpty(actionId)?null:ActionTemplateRegistry.Get(actionId);if(_grant!=null)_grant.Visible=action!=null;if(action==null)return;if(_grantIcon!=null)_grantIcon.Texture=action.Image;if(_grantName!=null)_grantName.Text=Translate(action.Name);}private void RebuildHud(List<SkillInfo>owned){if(_hudCount!=null)_hudCount.Text=owned.Count.ToString();if(_progressValue!=null)_progressValue.Text=owned.Count.ToString();var totals=SkillEffectAggregator.Aggregate(owned);ClearChildren(_hudList);var hasTotals=totals.Count>0;if(_hudScroll!=null)_hudScroll.Visible=hasTotals;if(_hudEmpty!=null)_hudEmpty.Visible= !hasTotals;if(_hudList==null)return;foreach(var total in totals){var cell=UiKit.Row(10);var value=UiKit.Label(total.FormatValue(),UiTokens.FontBodyLarge,UiTokens.AccentBright);value.CustomMinimumSize=new Vector2(54,0);value.HorizontalAlignment=HorizontalAlignment.Right;cell.AddChild(value);var key=UiKit.Label(Translate(total.Key),UiTokens.FontBodySmall,UiTokens.Body);key.SizeFlagsHorizontal=SizeFlags.ExpandFill;key.ClipText=true;cell.AddChild(key);_hudList.AddChild(cell);}}private static int CountType(List<SkillInfo>skills,SkillType type){var count=0;foreach(var skill in skills){if(skill.SkillType==type)count++;}return count;}private static int CountRarity(List<SkillInfo>skills,RareType rarity){var count=0;foreach(var skill in skills){if(skill.RareType==rarity)count++;}return count;}private void ClearCards(){foreach(var card in _cards)card.QueueFree();_cards.Clear();}private void OnTypePressed(SkillType?type){_typeFilter=type;var owned=OwnedSkills();RebuildTypeTabs(owned);RebuildGrid(owned);}private void OnRarityPressed(RareType?rarity){_rarityFilter=rarity;var owned=OwnedSkills();RebuildRarityTabs(owned);RebuildGrid(owned);}private void OnSearchChanged(string text){_search=text;RebuildGrid(OwnedSkills());}private void OnSortSelected(long index){_sort=(SortMode)index;RebuildGrid(OwnedSkills());}private void OnResetPressed(){ShowConstruct();}private void OnClosePressed(){Hide();}private static string Translate(string key)=>TranslationServer.Translate(key).ToString();private static void StyleLabel(Label label,int fontSize,Color color){if(label==null)return;label.AddThemeFontSizeOverride("font_size",fontSize);label.AddThemeColorOverride("font_color",color);}private static void ApplyContentMargins(StyleBoxFlat style,int horizontal,int vertical){style.ContentMarginLeft=horizontal;style.ContentMarginRight=horizontal;style.ContentMarginTop=vertical;style.ContentMarginBottom=vertical;}private static StyleBoxFlat MakePanelStyle(Color background,Color border,int radius,bool shadow=false){var style=UiKit.Frame(radius,background,border);if(shadow){style.ShadowColor=new Color(0f,0f,0f,0.6f);style.ShadowSize=18;style.ShadowOffset=new Vector2(0f,8f);}return style;}private static StyleBoxFlat MakeSideStyle(){var style=new StyleBoxFlat{BgColor=UiTokens.SideBg,BorderColor=UiTokens.Line,BorderWidthLeft=1,ContentMarginLeft=20,ContentMarginRight=18,ContentMarginTop=16,ContentMarginBottom=16,};return style;}private static StyleBoxFlat MakeCircleStyle(Color border){var style=UiKit.Frame(17,UiTokens.Surface0,border);ApplyContentMargins(style,5,4);return style;}private static StyleBoxFlat MakeNamePlateStyle(Color border){var style=UiKit.Frame(UiTokens.RadiusXs,UiTokens.SlotTagBg,border);ApplyContentMargins(style,10,4);return style;}private static StyleBoxFlat MakeTypeTabStyle(bool active,bool hover){var style=new StyleBoxFlat();style.BgColor=active?UiTokens.AccentSoft:hover?UiTokens.WhiteTrace:Colors.Transparent;if(active){style.BorderColor=UiTokens.AccentLine;style.BorderWidthLeft=1;style.BorderWidthRight=1;style.BorderWidthTop=1;style.BorderWidthBottom=1;}style.CornerRadiusTopLeft=UiTokens.RadiusSm;style.CornerRadiusTopRight=UiTokens.RadiusSm;style.CornerRadiusBottomLeft=UiTokens.RadiusSm;style.CornerRadiusBottomRight=UiTokens.RadiusSm;ApplyContentMargins(style,13,7);return style;}private static StyleBoxFlat MakeRarityTabStyle(bool active,bool hover,Color color){var style=UiKit.Frame(UiTokens.RadiusXs,Colors.Transparent,Colors.Transparent);if(active){style.BgColor=UiTokens.Alpha(color,0.09f);style.BorderColor=UiTokens.Alpha(color,0.5f);}else if(hover){style.BgColor=UiTokens.WhiteTrace;}ApplyContentMargins(style,9,4);return style;}private static void ClearChildren(Node container){if(container==null)return;foreach(var child in container.GetChildren()){if(child is Node node){container.RemoveChild(node);node.QueueFree();}}}}}

===== FILE mods/skills_base/src/SubsystemRegistration.cs =====
namespace Cthangover.Mods.Skills{/// <summary> Marks the skills engine mod for subsystem coverage discovery: the coverage test scans loaded assemblies for this marker and requires a declared integration test for the subsystem. </summary>
public static class SubsystemRegistration{public static string SubsystemId=>"skills";}}

===== FILE mods/test_defaults_and_patches/src/PropertyPatchesTest.cs =====
using System;using System.Collections.Generic;using System.Linq;using System.Text.Json;using Cthangover.Core.Bootstrap;using Cthangover.Core.Characters;using Cthangover.Core.Items;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;using Godot;namespace Mods.TestDefaultsAndPatches{/// <summary> Entry point of the property patches itest: active only with <c>--itest=property_patches</c>. The fixture itself is loaded only by <c>--with-mods=test_defaults_and_patches</c> (manifest <c>fixture</c>). </summary>
public class PropertyPatchesTestInitializer:IModInitializer{private static bool queued;public void OnModLoaded(string modId){}public void OnModResourcesReady(){if(queued|| !OS.GetCmdlineUserArgs().Contains("--itest=property_patches"))return;queued=true;GameLogger.Log("TEST","PropertyPatchesTest: activating");(Godot.Engine.GetMainLoop()as SceneTree)?.Root?.CallDeferred("add_child",new PropertyPatchesTestDriverNode());}}/// <summary> Checks the merge of <c>patches/character.json</c> and <c>patches/item.json</c> into the real entities of other mods, as the game resolves them: a targeted patch wins over the wildcard, the wildcard reaches every entity of the kind, and untouched typed fields survive. Prints <c>[TEST_RESULT] name=property_patches</c> and quits. </summary>
public partial class PropertyPatchesTestDriverNode:Node{private const string ResultName="property_patches";private const double TimeoutSeconds=60;private int passed;private int failed;private double elapsed;private bool done;public override void _Process(double delta){if(done)return;elapsed+=delta;if(elapsed>TimeoutSeconds){Check("boot finished in time",false);Finish();return;}if(!Boot.IsReady)return;try{Run();}catch(Exception ex){Check($"no exception ({ex.GetType().Name}: {ex.Message})",false);}Finish();}private void Run(){var characterPatches=Patches.Collect("character");Check("character patches collected (Marao + *)",characterPatches!=null&&characterPatches.ContainsKey("Marao")&&characterPatches.ContainsKey("*"));var itemPatches=Patches.Collect("item");Check("item patches collected (food/ration + *)",itemPatches!=null&&itemPatches.ContainsKey("food/ration")&&itemPatches.ContainsKey("*"));var characters=Entities.Collect<CharacterInfo>();Check("Marao resolved",characters.ContainsKey("Marao"));if(characters.TryGetValue("Marao",out var marao)){Expect(marao.ExtraProperties,"TestPatched","42","Marao: targeted value");Expect(marao.ExtraProperties,"TestDefault","100","Marao: targeted beats wildcard");Expect(marao.ExtraProperties,"TestGlobalPatch","1","Marao: wildcard-only key");}var other=characters.Values.FirstOrDefault(c=>c.ID!="Marao");Check("another character exists",other!=null);if(other!=null){Expect(other.ExtraProperties,"TestDefault","-20",$"{other.ID}: wildcard value");Check($"{other.ID}: no targeted key",other.ExtraProperties==null|| !other.ExtraProperties.ContainsKey("TestPatched"));}var items=Entities.Collect<ItemInfo>();if(items.TryGetValue("food/ration",out var ration)){Expect(ration.ExtraProperties,"Durability","99","ration: targeted beats wildcard");Expect(ration.ExtraProperties,"SpecialProp","true","ration: targeted-only key");Expect(ration.ExtraProperties,"GlobalItemProp","patched","ration: wildcard-only key");Check("ration: typed field survives the merge",!string.IsNullOrEmpty(ration.Name));}else Check("food/ration resolved",false);if(items.TryGetValue("food/wolf_meat",out var meat))Expect(meat.ExtraProperties,"Durability","50","wolf_meat: wildcard value");else Check("food/wolf_meat resolved",false);}private void Expect(Dictionary<string,object>properties,string key,string expected,string what){var actual=properties!=null&&properties.TryGetValue(key,out var value)?Text(value):null;Check($"{what}: {key}={expected}"+(actual==expected?string.Empty:$" (got {actual ?? "nothing"})"),actual==expected);}private static string Text(object value)=>value switch{JsonElement{ValueKind:JsonValueKind.String}s=>s.GetString(),JsonElement e=>e.GetRawText(),bool b=>b?"true":"false",null=>null,_=>Convert.ToString(value,System.Globalization.CultureInfo.InvariantCulture),};private void Check(string what,bool ok){if(ok)passed++;else failed++;GameLogger.Log("TEST",$"  [{(ok ? "PASS" : "FAIL")}] {what}");}private void Finish(){done=true;GameLogger.Log("TEST",$"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");GetTree().Quit(failed==0?0:1);}}}

===== FILE mods/time_base/src/Actions/LightingClearMapAction.cs =====
using Cthangover.Core.Actions;namespace Cthangover.Mods.Time{/// <summary> Resets the lighting depth and albedo maps to null via the lighting service. This effectively disables scene-specific lighting masks, reverting to default flat lighting. Used when transitioning between scenes that have different lighting setups or when entering a scene that doesn't use depth-based lighting. </summary>
public class LightingClearMapAction:IScenarioAction{/// <summary> Registered as "lighting.clear_map" — resets the depth and albedo lighting maps to null via UiLightController. Both maps are cleared simultaneously because the lighting shader requires either both present or neither — partial clearing produces visual artifacts. Use when transitioning between scenes that have different lighting setups, or when entering a scene that doesn't use depth-based lighting. </summary>
public string Name=>"lighting.clear_map";/// <summary> Delegates to ctx.Lighting.ClearDepthMap which nulls out both depth and albedo textures on the UiLightController singleton. Safe to call when the controller hasn't been initialized (null-conditional access in the implementation). </summary>
public void Run(IActionContext ctx){ctx.Lighting.ClearDepthMap();ctx.Log("SHADER","LightingClearMapAction: cleared depth and albedo maps");}}}

===== FILE mods/time_base/src/Actions/LightingServiceImpl.cs =====
using Cthangover.Core.Actions;namespace Cthangover.Mods.Time{/// <summary> Thin wrapper around UiLightController.Instance. ClearDepthMap sets both depth and albedo to null simultaneously — partial clearing would leave the shader in an inconsistent state. SetUseTime delegates to the controller's IsUseLight property. </summary>
public class LightingServiceImpl:ILightingService{/// <summary> Sets both depth and albedo maps to null on UiLightController. Both are cleared together because the lighting shader requires either both textures present or neither — partial clearing produces visual artifacts. Safe to call when the controller hasn't been initialized yet (null-conditional access). </summary>
public void ClearDepthMap(){var controller=UiLightController.Instance;controller?.SetupDepthMap(null);controller?.SetupAlbedoMap(null);}/// <summary> Toggles the IsUseLight property on UiLightController. When true, the controller applies time-of-day lighting adjustments; when false, lighting remains static. Null-safe: if the controller singleton is absent (pre-initialization), the call is silently skipped. </summary>
public void SetUseTime(bool useTime){var controller=UiLightController.Instance;if(controller!=null)controller.IsUseLight=useTime;}}}

===== FILE mods/time_base/src/LightingHooks.cs =====
using Cthangover.Core.Actions;using Cthangover.Core.Scenes;using Godot;namespace Cthangover.Mods.Time{/// <summary> Static adapters bound into the kernel <see cref="LightingBridge"/> on <c>OnModResourcesReady</c>. They reproduce the two lighting touch points the kernel SceneManager used to own directly: resetting the per-scene static lights and lighting maps when a scene switches, and pushing background depth/albedo textures onto the scene controller. Both degrade to no-ops when the lighting controller is absent, so the calls stay safe in scenes without a "Lights" node. </summary>
public static class LightingHooks{/// <summary>Clears static lights and both lighting maps on the scene's controller; no-op when the scene has none.</summary>
public static void ClearSceneLights(){var lightController=SceneContextNode.FindNode<UiLightController>("Lights");if(lightController==null)return;lightController.ClearStaticLights();lightController.SetupDepthMap(null);lightController.SetupAlbedoMap(null);}/// <summary>Pushes background depth/albedo textures onto the controller singleton; skipped when it is not in the tree.</summary>
public static void SetupBackgroundMaps(Texture2D depth,Texture2D albedo){var controller=UiLightController.Instance;controller?.SetupDepthMap(depth);controller?.SetupAlbedoMap(albedo);}}}

===== FILE mods/time_base/src/Lights/LampBehaviour.cs =====
using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Core.UI.Tool;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Tool;namespace Cthangover.Mods.Time{/// <summary> Draggable lamp UI element for the player's portable light source. Input handling exists in both _GuiInput (bubbled) and _Input (global) to catch drags that start on the lamp but continue outside its rect. When the lamp is hidden, it resets position to offscreen (-1000,-1000) to disable the light in the shader. GetLightParams() is a static helper so the light controller can query lamp parameters without coupling to the behaviour instance. Inherits from TextureRectModIconLoader for mod-provided lamp icon textures. </summary>
public partial class LampBehaviour:TextureRectModIconLoader{private UiLightController controller;private Vector2 dragPosition;private bool isPlaced;private bool pressingOnLamp;/// <summary> Queries lamp parameters (color, radius, influence) from <see cref="GameData.Instance.Runtime.LampData"/>. Static so the <see cref="UiLightController"/> can query without a direct reference. </summary>
public static(Color,float,float)GetLightParams(){var lampData=GameData.Instance.Runtime.LampData;var radius=lampData.Radius;var influence=lampData.Influence;return(Colors.Yellow,radius,influence);}/// <summary> Visibility toggle for the lamp. Setting to <c>false</c> resets the placed state and sends the light position offscreen to disable the lamp light in the shader. Uses <c>new</c> to log transitions. </summary>
public new bool Visible{get=>base.Visible;set{if(base.Visible!=value)GameLogger.Log("LIGHT",$"Lamp.Visible: {base.Visible} -> {value}, isPlaced={isPlaced}",LogLevel.Debug);base.Visible=value;if(!value){pressingOnLamp=false;if(isPlaced){isPlaced=false;controller?.UpdateLightPos(new Vector2(-1000f,-1000f));}}}}/// <summary>Finds the shared <see cref="UiLightController"/> this lamp pushes positions to.</summary>
public override void _Ready(){base._Ready();MouseFilter=MouseFilterEnum.Stop;controller=SceneContextNode.FindNode<UiLightController>("Lights");GameLogger.Log("LIGHT",$"Lamp._Ready: controller={(controller != null ? "OK" : "NULL")}",LogLevel.Debug);}/// <summary>Click to place the light at the cursor, drag to move it, and click a placed lamp to pick it back up.</summary>
public override void _GuiInput(InputEvent@event){if(@event is InputEventMouseButton mouseButton){if(mouseButton.ButtonIndex==MouseButton.Left){if(mouseButton.Pressed){pressingOnLamp=true;dragPosition=GetGlobalMousePosition();if(isPlaced){dragPosition=new Vector2(-1000f,-1000f);controller?.UpdateLightPos(dragPosition);isPlaced=false;}}else{if(pressingOnLamp&& !isPlaced){isPlaced=true;controller?.UpdateLightPos(dragPosition);}pressingOnLamp=false;}}AcceptEvent();}else if(@event is InputEventMouseMotion mouseMotion){if(Input.IsMouseButtonPressed(MouseButton.Left)){dragPosition=GetGlobalMousePosition();controller?.UpdateLightPos(dragPosition);isPlaced=false;AcceptEvent();}}}/// <summary>Keeps dragging the placed light while the pointer moves outside the lamp's own control rect.</summary>
public override void _Input(InputEvent@event){if(!Visible)return;if(@event is InputEventMouseMotion mouseMotion&&Input.IsMouseButtonPressed(MouseButton.Left)&&pressingOnLamp){dragPosition=GetGlobalMousePosition();controller?.UpdateLightPos(dragPosition);isPlaced=false;}if(@event is InputEventMouseButton mouseButton&&mouseButton.ButtonIndex==MouseButton.Left&& !mouseButton.Pressed&&pressingOnLamp){if(!isPlaced){isPlaced=true;controller?.UpdateLightPos(dragPosition);}pressingOnLamp=false;}}}}

===== FILE mods/time_base/src/Lights/LightDef.cs =====
using System.Text.Json.Serialization;using Godot;namespace Cthangover.Mods.Time{/// <summary> Serializable light definition for JSON scene lighting. Coordinates are viewport-relative (0.0–1.0) so scenes adapt to different resolutions. ToPixelPos/FromPixelPos convert between normalized space and pixel space using a reference viewport size. Color is stored as a hex string for human-readable JSON editing. </summary>
public class LightDef{/// <summary> Normalized horizontal position (0.0–1.0) relative to viewport width. </summary>
[JsonPropertyName("x")]public float X{get;set;}/// <summary> Normalized vertical position (0.0–1.0) relative to viewport height. </summary>
[JsonPropertyName("y")]public float Y{get;set;}/// <summary> Light radius in pixel units. Determines the falloff distance in the lighting shader. </summary>
[JsonPropertyName("radius")]public float Radius{get;set;}/// <summary> Light intensity (0.0–1.0). At <c>1.0</c>, the light fully brightens pixels within its radius. Lower values create subtler effects. </summary>
[JsonPropertyName("influence")]public float Influence{get;set;}=1f;/// <summary> Light color as a hex string (e.g. "#ff8800"). Parsed by <see cref="ToColor"/> for shader upload. </summary>
[JsonPropertyName("color")]public string ColorHex{get;set;}="#ffff00";/// <summary> Converts <see cref="ColorHex"/> to a Godot <c>Color</c> struct. </summary>
public Color ToColor(){return new Color(ColorHex);}/// <summary> Converts normalized coordinates (<see cref="X"/>, <see cref="Y"/>) to pixel coordinates based on <paramref name="viewportSize"/>. </summary> <param name="viewportSize">Current viewport dimensions in pixels.</param>
public Vector2 ToPixelPos(Vector2 viewportSize){return new Vector2(X*viewportSize.X,Y*viewportSize.Y);}/// <summary> Computes normalized coordinates from pixel coordinates by dividing by <paramref name="referenceSize"/>. Enables saving light positions across different resolutions. </summary> <param name="pixelPos">Light position in pixel space.</param> <param name="referenceSize">Viewport dimensions used as reference.</param>
public void FromPixelPos(Vector2 pixelPos,Vector2 referenceSize){X=pixelPos.X/referenceSize.X;Y=pixelPos.Y/referenceSize.Y;}}}

===== FILE mods/time_base/src/Lights/UiLightController.cs =====
using System.Collections.Generic;using System.Text.Json;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Core.UI.Event;using Cthangover.Mods.Interface.View;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Mods.Time{/// <summary> Singleton 2D lighting controller that drives a multi-light shader system. Maintains up to 11 lights: index 0 is the player's lamp, indices 1-10 are static scene lights. Pushes uniform arrays (positions, colors, radii, influence) to all registered ShaderMaterials — the ViewBox background/ foreground plus any externally registered materials. The time-of-day system blends four phases (morning/day/evening/night) via a Vector4 weights uniform, with smooth transitions between phases. DarkMode overrides time with full-night weights. Materials are discovered lazily via SceneContextNode and cached, with a registration system for extra materials (e.g. mod-added sprites that need lighting). _EnterTree manages singleton enforcement with duplicate warnings, not assertions, to avoid blocking scene reloads. </summary>
public partial class UiLightController:Control,IOnTimeEvent{/// <summary> Singleton accessor for the active controller. Set in <see cref="_EnterTree"/>, cleared in <see cref="_ExitTree"/>. Duplicate instances log an error but overwrite the reference. </summary>
public static UiLightController Instance{get;private set;}/// <summary> Default depth map texture applied on startup for parallax lighting effects. </summary>
[Export]public Texture2D DefaultDepthMap{get;set;}/// <summary> Default albedo (color) texture applied on startup for lighting blending. </summary>
[Export]public Texture2D DefaultAlbedoMap{get;set;}/// <summary> When <c>true</c>, forces full-night time-of-day weights regardless of in-game time. Set from game settings or scenario commands. </summary>
[Export]public bool DarkMode{get;set;}/// <summary> Master toggle for time-of-day lighting. When <c>false</c>, time blending is disabled on all shader materials. </summary>
[Export]public bool UseLight{get;set;}=true;private int staticLightCount;/// <summary> The currently active depth map texture, pushed to all shader materials. </summary>
public Texture2D CurrentDepthMap{get;private set;}/// <summary> The currently active albedo texture, pushed to all shader materials. </summary>
public Texture2D CurrentAlbedoMap{get;private set;}private SceneEventController eventController;private ShaderMaterial viewBgMaterial;private ShaderMaterial viewFgMaterial;private readonly List<ShaderMaterial>extraMaterials=new();private readonly Vector2[]lightPositions=new Vector2[11];private readonly Color[]lightColors=new Color[11];private readonly float[]lightRadii=new float[11];private readonly float[]lightInfluence=new float[11];private LampBehaviour lampBehaviour;/// <summary> Priority <c>0</c> — the lighting controller runs before most other timer-tick subscribers to ensure shader params are updated first. </summary>
public int Priority=>0;/// <summary> Runtime dark mode toggle. Updates <see cref="DarkMode"/> and immediately recalculates time-of-day weights. </summary>
public bool IsDarkMode{get=>DarkMode;set{DarkMode=value;SetTime(GameData.Instance?.Runtime?.Time?.Normalized??0f);}}/// <summary> Runtime toggle for time-of-day blending. Pushes the new value to all registered shader materials. </summary>
public bool IsUseLight{get=>UseLight;set{UseLight=value;ChangeUseTime(UseLight);}}/// <summary>Sets the singleton with a diagnostic log when a duplicate controller enters the tree first.</summary>
public override void _EnterTree(){if(Instance!=null&&GodotObject.IsInstanceValid(Instance)){var scene=GetTree()?.CurrentScene?.Name??"?";var existingPath=Instance.GetPath().ToString();var myPath=GetPath().ToString();GameLogger.Log("DUPLICATE",$"UiLightController._EnterTree: Instance ALREADY SET by '{existingPath}', overwriting with duplicate at '{myPath}' on scene '{scene}'",LogLevel.Error);}Instance=this;}/// <summary>Clears the singleton and detaches from the timer ticks when the controller leaves the tree.</summary>
public override void _ExitTree(){if(Instance==this)Instance=null;eventController?.RemoveTimerTickEventListener(this);}/// <summary>Initializes shader maps, parks the lamp offscreen and subscribes to timer ticks for time-of-day blending.</summary>
public override void _Ready(){MouseFilter=MouseFilterEnum.Ignore;UpdateLampLightParams();lightPositions[0]=new Vector2(-1000f,-1000f);SetupDepthMap(DefaultDepthMap);SetupAlbedoMap(DefaultAlbedoMap);eventController=SceneContextNode.FindNode<SceneEventController>("EventController");eventController?.AddTimerTickEventListener(this);GameLogger.Log("LIGHT",$"LightsCtrl._Ready: eventCtrl={(eventController != null ? "OK" : "NULL")}, DarkMode={DarkMode}, UseLight={UseLight}",LogLevel.Debug);CallDeferred(nameof(SetupTime));}/// <summary> Reads lamp parameters from <see cref="LampBehaviour.GetLightParams"/> and updates the player light slot (index 0) in the uniform arrays. </summary>
public void UpdateLampLightParams(){var lightParams=LampBehaviour.GetLightParams();lightColors[0]=lightParams.Item1;lightRadii[0]=lightParams.Item2;lightInfluence[0]=lightParams.Item3;}/// <summary> Resets all static light slots (indices 1–10) to off-screen positions with zero radius and influence, then pushes to shader materials. </summary>
public void ClearStaticLights(){for(int i=1;i<11;i++){lightPositions[i]=new Vector2(-1000f,-1000f);lightRadii[i]=0f;lightInfluence[i]=0f;lightColors[i]=Colors.White;}staticLightCount=0;UpdateShaders();GameLogger.Log("LIGHT","LightsCtrl.ClearStaticLights: all static lights cleared");}/// <summary> Deserializes a JSON array of <see cref="LightDef"/> objects and populates static light slots 1–10. Supports up to 10 static lights. </summary> <param name="json">JSON string containing an array of <see cref="LightDef"/> objects.</param>
public void SetStaticLights(string json){ClearStaticLights();if(string.IsNullOrEmpty(json))return;try{var lights=JsonSerializer.Deserialize<List<LightDef>>(json,JsonOptionsProvider.ReadOptions);if(lights==null||lights.Count==0)return;int count=Mathf.Min(lights.Count,10);var vpSize=GetViewport()?.GetVisibleRect().Size??new Vector2(1920,1024);for(int i=0;i<count;i++){lightPositions[i+1]=lights[i].ToPixelPos(vpSize);lightRadii[i+1]=lights[i].Radius;lightInfluence[i+1]=lights[i].Influence;lightColors[i+1]=lights[i].ToColor();}staticLightCount=count+1;UpdateShaders();GameLogger.Log("LIGHT",$"LightsCtrl.SetStaticLights: parsed {lights.Count} light(s), applied {count} static lights");}catch(System.Exception ex){GameLogger.Log("LIGHT",$"LightsCtrl.SetStaticLights: JSON parse error: {ex.Message}",LogLevel.Error);}}private void SetupTime(){SetTime(GameData.Instance?.Runtime?.Time?.Normalized??0f);}/// <summary> Timer-tick callback (~1/sec). Updates lamp visibility based on time of day and recalculates time-of-day weights for shaders. </summary>
public void OnTimerTick(){lampBehaviour??=FindLampChild();if(lampBehaviour!=null){var time=GameData.Instance?.Runtime?.Time;var hours=time?.Hours??0;var newVis=DarkMode|| !(hours>=6&&hours<22);lampBehaviour.Visible=newVis;}SetTime(GameData.Instance?.Runtime?.Time?.Normalized??0f);}/// <summary> Pushes the <c>use_time</c> shader parameter to all registered materials. </summary> <param name="value">The new <c>use_time</c> uniform value.</param>
public void ChangeUseTime(bool value){ForEachMaterial(m=>m.SetShaderParameter("use_time",value));}/// <summary> Adds a <see cref="ShaderMaterial"/> to receive lighting uniform updates alongside the ViewBox background/foreground. Duplicate registrations are silently ignored. </summary> <param name="material">The material to register for lighting updates.</param>
public void RegisterMaterial(ShaderMaterial material){if(material==null||extraMaterials.Contains(material))return;extraMaterials.Add(material);PushAllParams(material);}/// <summary> Removes a previously registered material from the lighting update list. No-op if the material is not currently registered. </summary> <param name="material">The material to unregister.</param>
public void UnregisterMaterial(ShaderMaterial material){extraMaterials.Remove(material);}private void PushAllParams(ShaderMaterial material){var weights=DarkMode?new Vector4(0,0,0,1):CalculateTimeWeights(GameData.Instance?.Runtime?.Time?.Normalized??0f);material.SetShaderParameter("time_weights",weights);material.SetShaderParameter("use_time",UseLight);var posArray=new Godot.Collections.Array<Vector2>();var colArray=new Godot.Collections.Array<Color>();var radArray=new Godot.Collections.Array<float>();var infArray=new Godot.Collections.Array<float>();for(int i=0;i<11;i++){posArray.Add(lightPositions[i]);colArray.Add(lightColors[i]);radArray.Add(lightRadii[i]);infArray.Add(lightInfluence[i]);}int count=0;if(lightPositions[0].X> -100)count++;count+=staticLightCount;material.SetShaderParameter("light_count",count);material.SetShaderParameter("light_positions",posArray);material.SetShaderParameter("light_colors",colArray);material.SetShaderParameter("light_radii",radArray);material.SetShaderParameter("light_influence",infArray);material.SetShaderParameter("light_radius_scale",1.0f);}/// <summary> Sets the depth map texture and pushes it to all shader materials via the <c>depth_mask</c> uniform. </summary> <param name="depthMap">The new depth map texture.</param>
public void SetupDepthMap(Texture2D depthMap){CurrentDepthMap=depthMap;ForEachMaterial(m=>m.SetShaderParameter("depth_mask",CurrentDepthMap));}/// <summary> Sets the albedo map texture and pushes it to all shader materials via the <c>albedo_map</c> uniform. </summary> <param name="albedoMap">The new albedo texture.</param>
public void SetupAlbedoMap(Texture2D albedoMap){CurrentAlbedoMap=albedoMap;ForEachMaterial(m=>m.SetShaderParameter("albedo_map",CurrentAlbedoMap));}/// <summary> Rebuilds and pushes all lighting uniform arrays to every registered shader material. </summary>
public void UpdateShaders(){int count=0;if(lightPositions[0].X> -100)count++;count+=staticLightCount;SetShaderArrays(count);ChangeUseTime(UseLight);}/// <summary> Updates the player lamp position (slot 0) and pushes to all shaders. If the position is off-screen (X &lt; -100), the lamp is excluded from the active light count. </summary> <param name="position">New lamp position in screen-space pixels.</param>
public void UpdateLightPos(Vector2 position){lightPositions[0]=position;int count=0;if(lightPositions[0].X> -100)count++;count+=staticLightCount;SetShaderArrays(count);ChangeUseTime(UseLight);}/// <summary> Directly sets the player light position, radius, and influence without triggering a shader push. Call <see cref="UpdateShaders"/> afterward. </summary> <param name="position">Screen-space pixel position.</param> <param name="radius">Light radius in pixels.</param> <param name="influence">Light influence factor (0.0–1.0).</param>
public void SetPlayerLight(Vector2 position,float radius,float influence){lightPositions[0]=position;lightRadii[0]=radius;lightInfluence[0]=influence;}/// <summary> Calculates time-of-day blend weights and pushes to all shader materials. Respects <see cref="DarkMode"/> — when active, full-night weights are used regardless of <paramref name="normalizedHour"/>. </summary> <param name="normalizedHour">Hour in 0–24 range (may be fractional).</param>
public void SetTime(float normalizedHour){if(DarkMode){var night=new Vector4(0,0,0,1);ForEachMaterial(m=>m.SetShaderParameter("time_weights",night));}else{var weights=CalculateTimeWeights(normalizedHour);ForEachMaterial(m=>m.SetShaderParameter("time_weights",weights));}UpdateShaders();}private void SetShaderArrays(int count){var posArray=new Godot.Collections.Array<Vector2>();var colArray=new Godot.Collections.Array<Color>();var radArray=new Godot.Collections.Array<float>();var infArray=new Godot.Collections.Array<float>();for(int i=0;i<11;i++){posArray.Add(lightPositions[i]);colArray.Add(lightColors[i]);radArray.Add(lightRadii[i]);infArray.Add(lightInfluence[i]);}ForEachMaterial(m=>{m.SetShaderParameter("light_count",count);m.SetShaderParameter("light_positions",posArray);m.SetShaderParameter("light_colors",colArray);m.SetShaderParameter("light_radii",radArray);m.SetShaderParameter("light_influence",infArray);m.SetShaderParameter("light_radius_scale",1.0f);});}private void ForEachMaterial(System.Action<ShaderMaterial>action){if(viewBgMaterial==null||viewFgMaterial==null){var viewBox=SceneContextNode.FindNode<ViewBox>("ViewBox");if(viewBox!=null){viewBgMaterial??=viewBox.Background?.Material as ShaderMaterial;viewFgMaterial??=viewBox.Foreground?.Material as ShaderMaterial;}}if(viewBgMaterial!=null)action(viewBgMaterial);if(viewFgMaterial!=null)action(viewFgMaterial);for(int i=extraMaterials.Count-1;i>=0;i--){if(extraMaterials[i]==null)extraMaterials.RemoveAt(i);else action(extraMaterials[i]);}}private LampBehaviour FindLampChild(){return SceneContextNode.FindNode<LampBehaviour>("Lamp");}// 06:00-10:00 - Morning 10:00-18:00 - Day 18:00-22:00 - Evening 22:00-06:00 - Night
private static Vector4 CalculateTimeWeights(float hour){float night=0.0f;float morning=0.0f;float day=0.0f;float evening=0.0f;if(hour>=22.0f||hour<4.0f){night=1.0f;}else if(hour>=4.0f&&hour<6.0f){float t=(hour-4.0f)/2.0f;night=1.0f-t;morning=t;}else if(hour>=6.0f&&hour<9.0f){morning=1.0f;}else if(hour>=9.0f&&hour<11.0f){float t=(hour-9.0f)/2.0f;morning=1.0f-t;day=t;}else if(hour>=11.0f&&hour<17.0f){day=1.0f;}else if(hour>=17.0f&&hour<19.0f){float t=(hour-17.0f)/2.0f;day=1.0f-t;evening=t;}else if(hour>=19.0f&&hour<20.0f){evening=1.0f;}else if(hour>=20.0f&&hour<22.0f){float t=(hour-20.0f)/2.0f;evening=1.0f-t;night=t;}return new Vector4(morning,day,evening,night);}}}

===== FILE mods/time_base/src/SubsystemRegistration.cs =====
namespace Cthangover.Mods.Time{/// <summary> Marks the time engine mod for subsystem coverage discovery: the coverage test scans loaded assemblies for this marker and requires a declared integration test for the subsystem. </summary>
public static class SubsystemRegistration{public static string SubsystemId=>"time";}}

===== FILE mods/time_base/src/TimeController.cs =====
using Cthangover.Core.Settings;using Cthangover.Core.UI.Event;using Godot;namespace Cthangover.Mods.Time{/// <summary> In-game clock: subscribes to SceneEventController timer ticks and advances GameData.Runtime.Time. Renders the time text on a Label and applies Godot.Engine.TimeScale from the exported timeScale field for global time acceleration. The #if TOOLS block updates the rendered time in the editor inspector when properties change, giving designers live time preview. </summary>
public partial class TimeController:Control,IOnTimeEvent{[Export]private float timeScale=1f;private Label timerField;private SceneEventController eventController;private PhaseType lastPhase;/// <summary>Finds the clock label, subscribes to timer ticks and renders the initial time.</summary>
public override void _Ready(){timerField=GetNodeOrNull<Label>("TimeLabel");eventController=GetTree()?.Root?.GetNodeOrNull<SceneEventController>("EventController")??GetNodeOrNull<SceneEventController>("/root/EventController");eventController?.AddTimerTickEventListener(this);OnTimerTick();}/// <summary>Detaches from the timer ticks when the clock leaves the scene.</summary>
public override void _ExitTree(){eventController?.RemoveTimerTickEventListener(this);}/// <summary> Priority in the <see cref="SceneEventController"/> listener chain. Returns 0 (default). </summary>
public int Priority=>0;/// <summary> Called by <see cref="SceneEventController"/> on each timer tick. Delegates to <see cref="UpdateRenderedTime"/> to refresh the on-screen clock label. </summary>
public void OnTimerTick(){UpdateRenderedTime();}/// <summary> Applies <c>timeScale</c> to <c>Engine.TimeScale</c> (global time speed), reads the current in-game time from <see cref="GameData.Runtime.Time"/>, and writes its text representation to the <c>TimeLabel</c> node. </summary>
public void UpdateRenderedTime(){Godot.Engine.TimeScale=timeScale;var time=GameData.Instance.Runtime.Time;var text=time?.Text??string.Empty;if(timerField!=null)timerField.Text=text;}
#if TOOLS
/// <summary>Refreshes the live time preview whenever an exported property changes in the editor inspector.</summary>
public override void _ValidateProperty(Godot.Collections.Dictionary property){base._ValidateProperty(property);OnTimerTick();}
#endif
}}

===== FILE mods/time_base/src/TimeModInitializer.cs =====
using Cthangover.Core.Actions;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Mods.Time{/// <summary> Wires the time subsystem into the kernel. On <c>OnModResourcesReady</c> it installs the lighting adapters into <see cref="LightingBridge"/> (first registration wins) and registers the <see cref="TimeTickController"/> with the scene event system, so in-game time advances each timer pulse. The controller lives in a static field: <c>EnsureRegistered</c> is idempotent per instance, and a process-wide singleton guarantees a mod reload cannot stack a second timer-tick subscription onto <c>EventController</c>. </summary>
public class TimeModInitializer:IModInitializer{private static readonly TimeTickController TickController=new();/// <summary>No per-mod work: all wiring happens once the whole mod set is ready.</summary>
public void OnModLoaded(string modId){}/// <summary>Installs the lighting bridge adapters and subscribes the clock tick.</summary>
public void OnModResourcesReady(){LightingBridge.Register(LightingHooks.ClearSceneLights,LightingHooks.SetupBackgroundMaps);var root=(Godot.Engine.GetMainLoop()as SceneTree)?.Root;if(root==null){GameLogger.Log("TIME","TimeModInitializer: no scene tree available; time tick registration skipped",LogLevel.Warning);return;}TickController.EnsureRegistered(root);}}}

===== FILE mods/time_base/src/TimeTickController.cs =====
using Cthangover.Core.Bootstrap;using Cthangover.Core.Settings;using Cthangover.Core.Events;using Cthangover.Core.Mods;using Cthangover.Core.UI.Event;using Godot;namespace Cthangover.Mods.Time{/// <summary> Bridges the scene-event timer system to the in-game clock. Implements <see cref="Cthangover.Core.UI.Event.IOnTimeEvent"/> with priority 0 so that the tick is processed before most other listeners. The controller must be explicitly registered via <see cref="EnsureRegistered"/>, which finds the <see cref="Cthangover.Core.UI.Event.SceneEventController"/> in the scene tree and subscribes <see cref="OnTimerTick"/> to it. Registration is idempotent — subsequent calls are no-ops. </summary>
public class TimeTickController:IOnTimeEvent{/// <summary>Executes before default-priority listeners so time updates are visible to all other tick handlers.</summary>
public int Priority=>0;private bool _registered;/// <summary> Locates the <see cref="Cthangover.Core.UI.Event.SceneEventController"/> in the scene tree and registers this controller as a timer-tick listener. Safe to call multiple times — only the first call does work. </summary>
public void EnsureRegistered(Node node){if(_registered)return;var eventController=node.GetTree()?.Root?.FindChild("EventController",true,false)as SceneEventController;if(eventController==null)return;eventController.AddTimerTickEventListener(this);_registered=true;}/// <summary> Called by the event system on each timer pulse. Advances the in-game clock by one minute via <see cref="TimeData.AddTick"/>. </summary>
public void OnTimerTick(){// The SceneEventController keeps ticking (1s interval, ProcessMode Always) while the launcher boot compiles mods in the background — registration in GameData._Ready succeeds because the engine puts every autoload into the tree before any _Ready fires. Touching Runtime here would create it before the starter inventory can be seeded through loaded mod factories: the seed then runs against an empty registry, logs "Initialize refused" spam and permanently skips the starter items. Skip boot-window ticks instead — the same readiness contract GameData.Runtime enforces.
if(!Boot.ModsReady)return;GameData.Instance.Runtime.Time.AddTick();ModEventBus.Publish(new TimeTickEvent{TimeTick=GameData.Instance.Runtime.Time.Tick,NormalizedTime=GameData.Instance.Runtime.Time.Normalized,});}}}

===== FILE mods/turn_battle_base/src/Actions/ActionExecutorHub.cs =====
using System.Collections.Generic;using Cthangover.Core.Characters;namespace Cthangover.Core.Battle.Actions{/// <summary> Central action-execution dispatcher. Resolves an action ID to an IActionExecutor in two tiers: first the active provider (set by the current IBattleCore, allowing per-battle-engine overrides), then a global fallback registry. This two-level lookup lets mods supply custom executors for specific battle cores without replacing the global ones. Execute returns ChangedAttributes to allow the caller to inspect the result (success/failure, stat deltas). </summary>
public class ActionExecutorHub{/// <summary> Singleton hub instance. There is one global dispatcher shared across all battle cores; cores swap providers via <see cref="SetActiveProvider"/> when they activate. </summary>
public static readonly ActionExecutorHub Instance=new();private IActionExecutorProvider _activeProvider;private readonly Dictionary<string,IActionExecutor>_globalExecutors=new();/// <summary> Installs a per-core executor provider. The active provider is consulted before the global registry, allowing the current battle core to override specific action handlers. </summary>
public void SetActiveProvider(IActionExecutorProvider provider){_activeProvider=provider;}/// <summary> Registers a fallback executor in the global pool, keyed by <see cref="IActionExecutor.ActionId"/>. Global executors are used when no active provider supplies a match. </summary>
public void RegisterGlobal(IActionExecutor executor){if(executor!=null&& !string.IsNullOrEmpty(executor.ActionId))_globalExecutors[executor.ActionId]=executor;}/// <summary> Dispatches an action to the appropriate executor. Resolution order: active provider first, then global registry. Returns <c>Result = false</c> if no executor is found or if <paramref name="action"/> is null. </summary>
public ChangedAttributes Execute(ActionCharacter action,Character user,Character target){if(action==null)return new ChangedAttributes{Result=false};var executor=Resolve(action.ID);if(executor==null)return new ChangedAttributes{Result=false};return executor.Execute(action,user,target);}private IActionExecutor Resolve(string actionId){if(_activeProvider!=null){var executor=_activeProvider.GetExecutor(actionId);if(executor!=null)return executor;}_globalExecutors.TryGetValue(actionId,out var fallback);return fallback;}}}

===== FILE mods/turn_battle_base/src/Actions/BattleActionMachine.cs =====
using System.Collections.Generic;using Cthangover.Core.UI;using Godot;using Cthangover.Mods.Interface.Base;namespace Cthangover.Core.Battle.Actions{/// <summary> Sequential action queue that processes one IBattleAction per frame until it signals completion (DoAction returns true), then moves to the next. Fires OnStopMachine when the queue drains naturally or is explicitly stopped — used by battle cores to detect when a turn's animation sequence is finished. Actions can be added before or during processing; the machine starts automatically once at least one action is queued and no current action is running. <para/> Derives from <see cref="ModNode"/> so the engine lifecycle callbacks reach this runtime-compiled script through the core base class (mod assemblies have no Godot source generation of their own). </summary>
public partial class BattleActionMachine:ModNode{/// <summary> Pending action queue. Actions are dequeued one-by-one in the process hook, starting the next as soon as the current signals completion. Can be extended while the machine is running — new actions will be processed after the current queue drains. </summary>
public List<IBattleAction>Actions{get;set;}=new();private IBattleAction CurrentAction{get;set;}private bool _hadActions;/// <summary> Fired when the machine drains naturally or is explicitly stopped. Battle cores subscribe to advance the turn phase without polling. </summary>
public event StopMachineDelegate OnStopMachine;/// <summary>Stops the machine and fires <see cref="OnStopMachine"/> when the node leaves the tree, so subscribers never wait on a dead node.</summary>
protected override void OnExitTree(){base.OnExitTree();StopMachine();}/// <summary>Drives the queued actions once per frame; fires <see cref="OnStopMachine"/> when the queue drains.</summary>
protected override void OnProcess(double delta){if(Actions.Count==0&&CurrentAction==null){if(_hadActions){_hadActions=false;StopMachine();}return;}var action=CurrentAction;if(action==null){CurrentAction=Actions[0];Actions.RemoveAt(0);action=CurrentAction;_hadActions=true;action?.DoStart();}if(action==null){_hadActions=false;StopMachine();return;}if(action.DoAction()){action.DoEnd();CurrentAction=null;}}/// <summary> Enqueues a single action. If the machine is idle (no current action), processing starts on the next _Process tick. </summary>
public void AddAction(IBattleAction action){if(action==null)return;Actions.Add(action);}/// <summary> Batch-enqueues multiple actions. All are appended to the existing queue and processed in FIFO order on subsequent frames. </summary>
public void AddActions(List<IBattleAction>actions){if(actions==null||actions.Count==0)return;Actions.AddRange(actions);}/// <summary> Immediately clears the queue, aborts the current action, and fires <see cref="OnStopMachine"/>. Called on tree exit and when the queue drains naturally. </summary>
public void StopMachine(){CurrentAction=null;Actions.Clear();_hadActions=false;OnStopMachine?.Invoke();}}}

===== FILE mods/turn_battle_base/src/Actions/GDBattleActionExecutor.cs =====
using Cthangover.Core.Characters;using Godot;namespace Cthangover.Core.Battle.Actions{/// <summary> Adapter that lets a GDScript mod act as a battle action executor. The script is detected by <see cref="TurnBattleGDScriptHook"/> when it exposes <c>get_action_id()</c> and <c>execute(action, user, target)</c>; this adapter flattens the C# arguments into Godot dictionaries via <see cref="BattleGDScriptBridge"/> and maps the returned dictionary back to <see cref="ChangedAttributes"/>. Dictionaries are used instead of custom RefCounted DTOs because runtime-compiled mod classes get no Godot source generation, so their members would not be visible to GDScript. </summary>
public class GDBattleActionExecutor:IActionExecutor{private readonly GodotObject _instance;/// <summary>Creates an adapter around a live GDScript object. The object is retained by reference.</summary>
public GDBattleActionExecutor(string actionId,GodotObject instance){ActionId=actionId;_instance=instance;}/// <summary>Action id reported by the script through <c>get_action_id()</c>.</summary>
public string ActionId{get;}/// <summary> Calls the script and converts its result. A script that returns anything other than a dictionary (or throws) yields a failed result so one broken mod cannot abort the action pipeline. </summary>
public ChangedAttributes Execute(ActionCharacter action,Character user,Character target){var gdAction=BattleGDScriptBridge.ActionToDictionary(action);var gdUser=BattleGDScriptBridge.CharacterToDictionary(user);var gdTarget=BattleGDScriptBridge.CharacterToDictionary(target);var variant=_instance.Call("execute",gdAction,gdUser,gdTarget);if(variant.VariantType!=Variant.Type.Dictionary)return new ChangedAttributes{Result=false};return BattleGDScriptBridge.ResultFromDictionary(variant.AsGodotDictionary());}}}

===== FILE mods/turn_battle_base/src/Actions/IActionExecutor.cs =====
using Cthangover.Core.Characters;namespace Cthangover.Core.Battle.Actions{/// <summary> Executes a specific battle action. The executor is keyed by ActionId (matching the action's ID) so the hub can look it up without knowing the concrete type. Returns ChangedAttributes so callers can apply stat deltas and check whether the action succeeded. </summary>
public interface IActionExecutor{/// <summary> String key that matches <see cref="ActionCharacter.ID"/>. The hub uses this to resolve executors without knowing concrete types. </summary>
string ActionId{get;}/// <summary> Applies the action's effects. Receives the action definition and the user/target <see cref="Character"/> instances; returns <see cref="ChangedAttributes"/> with deltas and success/failure. </summary>
ChangedAttributes Execute(ActionCharacter action,Character user,Character target);}}

===== FILE mods/turn_battle_base/src/Actions/IActionExecutorProvider.cs =====
namespace Cthangover.Core.Battle.Actions{/// <summary> Typed executor source. Each IBattleCore returns its own provider, giving cores the ability to supply custom executor sets without polluting the global registry. </summary>
public interface IActionExecutorProvider{/// <summary> Returns the executor registered for <paramref name="actionId"/>, or null if no override is configured. Called by <see cref="ActionExecutorHub"/> before falling back to the global registry. </summary>
IActionExecutor GetExecutor(string actionId);}}

===== FILE mods/turn_battle_base/src/Actions/IBattleAction.cs =====
namespace Cthangover.Core.Battle.Actions{/// <summary> A single step in the battle action queue (animation, damage, effect). DoStart is called once when the action begins, DoAction is polled every frame — it returns true when the action is done — and DoEnd is the cleanup callback. This polling model lets actions drive their own duration (e.g. a tween runs until finished) without the machine needing timers. </summary>
public interface IBattleAction{/// <summary> Called once when the action is dequeued and begins execution. Use for setup: spawning nodes, caching references, starting tweens or coroutines. </summary>
void DoStart();/// <summary> Polled every frame. Returns <c>true</c> when the action has finished its work; returns <c>false</c> to remain active. This model eliminates the need for the machine to manage timers. </summary>
bool DoAction();/// <summary> Cleanup callback fired after <see cref="DoAction"/> returns <c>true</c>. Use for teardown: freeing nodes, resetting state, applying final stat deltas. </summary>
void DoEnd();}}

===== FILE mods/turn_battle_base/src/Actions/StopMachineDelegate.cs =====
namespace Cthangover.Core.Battle.Actions{/// <summary> Signal emitted when the action machine drains or is stopped. Battle cores subscribe to this to advance to the next phase (e.g. end turn, start enemy turn) without polling. </summary>
public delegate void StopMachineDelegate();}

===== FILE mods/turn_battle_base/src/Adapters/AnimationRegistry.cs =====
using System.Collections.Generic;namespace Cthangover.Core.Battle{/// <summary> Global animation-player index keyed by AnimationType string. Mods or cores register players; the action machine looks up the player for a given animation type. Singleton — there is one set of players for the entire runtime, but each player can make per-core decisions internally. </summary>
public class AnimationRegistry{/// <summary> Singleton registry. One set of animation players serves the entire runtime; individual players dispatch per-core via internal logic. </summary>
public static readonly AnimationRegistry Instance=new();private readonly Dictionary<string,IBattleAnimationPlayer>_players=new();/// <summary> Registers a player under <paramref name="animType"/>. Replaces any existing player for the same key — last write wins. </summary>
public void Register(string animType,IBattleAnimationPlayer player){_players[animType]=player;}/// <summary> Retrieves the player registered for <paramref name="animType"/>, or null if no match exists. </summary>
public IBattleAnimationPlayer Get(string animType){_players.TryGetValue(animType,out var player);return player;}}}

===== FILE mods/turn_battle_base/src/Adapters/BattleContextImpl.cs =====
using Cthangover.Core.Characters;using Godot;namespace Cthangover.Core.Battle{/// <summary> Default IBattleContext wired to the current BattleSceneContext. ApplyDamage clamps at 0 (no negative health from defence overflow), EndBattle delegates to ShowWinground/ShowDeadground on the scene context. ScheduleAnimation and EndTurn are stubs — specific battle cores are expected to override or supplement this behaviour. </summary>
public class BattleContextImpl:IBattleContext{private readonly BattleSceneContext _sceneContext;/// <summary> Root Godot <see cref="Node"/> of the battle scene, used by animation actions to parent spawned visual effects. </summary>
public Node RootNode=>_sceneContext;/// <summary> The owning <see cref="BattleSceneContext"/> that manages the battle lifecycle. </summary>
public BattleSceneContext SceneContext=>_sceneContext;/// <summary> Creates a context adapter around the given scene context. </summary>
public BattleContextImpl(BattleSceneContext sceneContext){_sceneContext=sceneContext;}/// <summary> Subtracts <paramref name="defence"/> from <paramref name="rawDamage"/>, clamping at 0 to prevent negative health from defence overflow, and applies the result to <paramref name="target"/>'s health. </summary>
public void ApplyDamage(Character source,Character target,int rawDamage,int defence){var finalDamage=Mathf.Max(0,rawDamage-defence);target.Attributes.Health.Value-=finalDamage;}/// <summary> Stub — specific battle cores override this to enqueue animation actions into the <see cref="BattleActionMachine"/>. </summary>
public void ScheduleAnimation(IBattleAnimation animation){}/// <summary> Stub — cores implement this to advance the turn sequence when a side finishes its action phase. </summary>
public void EndTurn(BattleSide side){}/// <summary> Resolves the battle outcome by delegating to <see cref="BattleSceneContext.ShowWinground"/> for player victory or <see cref="BattleSceneContext.ShowDeadground"/> for enemy victory. </summary>
public void EndBattle(BattleSide winner){if(winner==BattleSide.Player)_sceneContext.ShowWinground();else _sceneContext.ShowDeadground();}}}

===== FILE mods/turn_battle_base/src/Adapters/IBattleAnimation.cs =====
using System.Collections.Generic;using Cthangover.Core.Characters;namespace Cthangover.Core.Battle{/// <summary> Describes a battle animation without binding to a concrete player. Carries Source/Target characters, an AnimationType key for dispatch, a Speed multiplier, and an open-ended Parameters dictionary so custom animations can pass arbitrary data (damage value, element type, etc.) without interface changes. </summary>
public interface IBattleAnimation{/// <summary>The <see cref="Character"/> performing the action.</summary>
Character Source{get;}/// <summary>The <see cref="Character"/> receiving the action.</summary>
Character Target{get;}/// <summary> Key used by <see cref="AnimationRegistry"/> to dispatch to the correct <see cref="IBattleAnimationPlayer"/>. </summary>
string AnimationType{get;}/// <summary>Multiplier for the playback speed (1.0 = normal).</summary>
float Speed{get;}/// <summary> Open-ended data bag for animation-specific values (e.g. damage amount, elemental flag) — no interface changes needed when adding new animation types. </summary>
Dictionary<string,object>Parameters{get;}}}

===== FILE mods/turn_battle_base/src/Adapters/IBattleAnimationPlayer.cs =====
using System;namespace Cthangover.Core.Battle{/// <summary> Plays a specific animation type. Each player is keyed by AnimationType so the registry can dispatch animations without knowing concrete player types. The onComplete callback chains into the action machine's polling loop. </summary>
public interface IBattleAnimationPlayer{/// <summary> String key used by <see cref="AnimationRegistry"/> to select this player for a given <see cref="IBattleAnimation.AnimationType"/>. </summary>
string AnimationType{get;}/// <summary> Starts playing the animation described by <paramref name="anim"/>. Must invoke <paramref name="onComplete"/> once the animation finishes, so the action machine can advance to the next step. </summary>
void Play(IBattleAnimation anim,Action onComplete);}}

===== FILE mods/turn_battle_base/src/Adapters/IBattleContext.cs =====
using Cthangover.Core.Characters;using Godot;namespace Cthangover.Core.Battle{/// <summary> Bridge from the battle core back to the Godot scene. Cores work against this interface so they remain engine-agnostic. ApplyDamage computes final-damage-minus-defence and mutates the target's Health directly (no event — the UI layer polls). ScheduleAnimation is the hook for visual feedback; EndTurn/EndBattle are phase signals. RootNode gives cores a Godot anchor for instantiating nodes (e.g. damage numbers). </summary>
public interface IBattleContext{void ApplyDamage(Character source,Character target,int rawDamage,int defence);void ScheduleAnimation(IBattleAnimation animation);void EndTurn(BattleSide side);void EndBattle(BattleSide winner);Node RootNode{get;}}}

===== FILE mods/turn_battle_base/src/Adapters/IBattleCore.cs =====
using Cthangover.Core.Battle.Actions;using Cthangover.Core.Characters;namespace Cthangover.Core.Battle{/// <summary> Pluggable battle engine contract. Each implementation is a self-contained battle rule set (turn order, AI, phases). Init receives the two character arrays and a context adapter; Start begins the loop. The ActionProvider property lets the core inject its own executor overrides via the hub. Cores are instantiated fresh per battle — they carry no persistent state between encounters. </summary>
public interface IBattleCore{/// <summary> Unique identifier for this battle engine (e.g. "card_battle", "ff_battle"). Used by <see cref="BattleCoreRegistry"/> and <see cref="BattleData.ActiveBattleCore"/> for lookup. </summary>
string Id{get;}/// <summary> Provider that returns core-specific <see cref="IActionExecutor"/> overrides. Set on the <see cref="ActionExecutorHub"/> when this core activates, so actions route through custom logic before falling back to the global registry. </summary>
IActionExecutorProvider ActionProvider{get;}/// <summary> Initialises the core with the two character arrays and a <see cref="IBattleContext"/> for callbacks. Called once per battle before <see cref="Start"/>. </summary>
void Init(Character[]playerChars,Character[]enemyChars,IBattleContext ctx);/// <summary> Begins the turn loop. Cores may use coroutines, polling, or event-driven state machines — the core owns its own lifecycle after this call. </summary>
void Start();}}

===== FILE mods/turn_battle_base/src/Adapters/IBattleSide.cs =====
namespace Cthangover.Core.Battle{/// <summary> Identifies which side of the battle a character or turn belongs to. Used by IBattleContext.EndTurn and EndBattle to route results (victory vs defeat screen). </summary>
public enum BattleSide{/// <summary>The player's party.</summary>
Player,/// <summary>The opposing force (monsters, NPCs).</summary>
Enemy,}}

===== FILE mods/turn_battle_base/src/Base/ActionBase.cs =====
using Cthangover.Core.Battle.Actions;using Cthangover.Core.Utils;namespace Cthangover.Core.Characters{/// <summary> Convenience base for IActionExecutor implementations. Provides CheckRequiredAndUsePoint which validates that the user has enough "Point" attribute (a generic action-point / mana pool) before deducting it — returning false lets the action fail gracefully. ID is abstract so each action declares its own identity. </summary>
public abstract class ActionBase:IActionExecutor{/// <summary> Maps to the abstract <see cref="ID"/> so the hub can resolve this executor by action name. </summary>
public string ActionId=>ID;/// <summary> Unique action identifier that matches <see cref="ActionCharacter.ID"/> in data. </summary>
public abstract string ID{get;}/// <summary> Applies the action's effects to <paramref name="target"/> using <paramref name="user"/>'s stats and the <paramref name="action"/> definition. Returns stat deltas and a success/failure flag. </summary>
public abstract ChangedAttributes Execute(ActionCharacter action,Character user,Character target);/// <summary> Validates that <paramref name="user"/> has at least <c>"RequiredPoint"</c> remaining, deducts it, and returns <c>true</c>. Returns <c>false</c> with a warning log if the user lacks sufficient points, allowing the action to fail gracefully. </summary>
protected bool CheckRequiredAndUsePoint(ActionCharacter action,Character user){var requiredPoint=action.GetInt("RequiredPoint");if(user.Attributes.Point.Value<requiredPoint){GameLogger.Log("BATTLE",user.ID+" not checked point required ["+user.Attributes.Point.Value+"] for action "+action.ID+" ["+requiredPoint+"]",LogLevel.Warning);return false;}GameLogger.Log("BATTLE",user.ID+" use ["+requiredPoint+"] points for action "+action.ID,LogLevel.Debug);user.Attributes.Point.Value-=requiredPoint;return true;}}}

===== FILE mods/turn_battle_base/src/BattleCoreConsumer.cs =====
namespace Cthangover.Core.Battle{using Cthangover.Core.Mods.Consumers;/// <summary> Feeds <see cref="BattleCoreRegistry"/> from the kernel's assembly fan-out: every mod's <see cref="IBattleCore"/> implementations are registered as the mod loads and removed when it unloads, so a game switch or hot reload never leaves a core of a released mod behind. </summary>
public class BattleCoreConsumer:ModTypeConsumer<IBattleCore>{/// <inheritdoc />
public override string Id=>"turn_battle_base.battle_cores";/// <inheritdoc />
protected override void Add(IBattleCore instance,string modId)=>BattleCoreRegistry.Instance.Register(instance);/// <inheritdoc />
protected override void Remove(IBattleCore instance,string modId)=>BattleCoreRegistry.Instance.Unregister(instance);}}

===== FILE mods/turn_battle_base/src/BattleCoreRegistry.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Games;using Cthangover.Core.Utils;namespace Cthangover.Core.Battle{/// <summary> Pluggable battle-core registry. The kernel hands every mod's IBattleCore implementations to <see cref="BattleCoreConsumer"/>, which registers them by ID (each core self-identifies) and removes them again when their mod unloads; one core is activated by ID. GetActive constructs a fresh instance every call — battle cores are stateless between battles. Falls back to the first registered core if no active ID was set, enabling mods to ship drop-in battle engines. </summary>
public class BattleCoreRegistry{/// <summary>Game collection setting naming the battle core of that game.</summary>
public const string GameSettingKey="battle.core";/// <summary>Singleton registry instance.</summary>
public static readonly BattleCoreRegistry Instance=new();private readonly Dictionary<string,Type>_cores=new();private string _activeCoreId;/// <summary> Registers a core by its self-reported <see cref="IBattleCore.Id"/>; a later core with the same id replaces the earlier one. Fed by <see cref="BattleCoreConsumer"/> for every mod assembly. </summary>
public void Register(IBattleCore core){if(core==null)return;_cores[core.Id]=core.GetType();GameLogger.Log("BATTLE",$"BattleCoreRegistry: registered core '{core.Id}'");}/// <summary> Removes a core whose mod is being unloaded (only when the id still maps to that core's type); an active selection of it is dropped. </summary>
public void Unregister(IBattleCore core){if(core==null|| !_cores.TryGetValue(core.Id,out var type)||type!=core.GetType())return;_cores.Remove(core.Id);if(_activeCoreId==core.Id)_activeCoreId=null;}/// <summary> Selects the active core by ID. Throws if the core is not registered. The selected core will be instantiated fresh on the next call to <see cref="GetActive"/>. </summary>
public void SetActive(string id){if(!_cores.ContainsKey(id))throw new Exception($"Battle core '{id}' not found");_activeCoreId=id;}/// <summary> Constructs a fresh instance of the active core type. Without an explicit <c>battle.set_core</c> the active game's <c>settings["battle.core"]</c> decides, then the first registered core. Throws if no cores are registered at all. </summary>
public IBattleCore GetActive(){if(_activeCoreId!=null&&_cores.ContainsKey(_activeCoreId))return(IBattleCore)Activator.CreateInstance(_cores[_activeCoreId]);var gameCore=ActiveGame.GetSetting(GameSettingKey);if(gameCore!=null&&_cores.ContainsKey(gameCore)){_activeCoreId=gameCore;return(IBattleCore)Activator.CreateInstance(_cores[gameCore]);}var firstId=GetFirstCoreId();if(firstId!=null){_activeCoreId=firstId;return(IBattleCore)Activator.CreateInstance(_cores[firstId]);}throw new Exception("No active battle core set. Use battle.set_core first.");}/// <summary> Returns the ID of the first registered core, or null if the registry is empty. Used as a fallback when no active core has been explicitly set. </summary>
public string GetFirstCoreId(){return _cores.Keys.FirstOrDefault();}/// <summary> Checks whether a core with the given <paramref name="id"/> has been registered. </summary>
public bool HasCore(string id){return _cores.ContainsKey(id);}/// <summary> Registered core IDs, sorted for stable output. Used by the battle coverage tool as the single source of truth for how many battle cores ship with the current mod set. </summary>
public IReadOnlyList<string>CoreIds{get{var ids=new List<string>(_cores.Keys);ids.Sort(System.StringComparer.Ordinal);return ids;}}/// <summary> Returns the registered <see cref="Type"/> for a core, or <c>null</c>. Lets tooling inspect a core's implemented interfaces (e.g. <see cref="IBattleAutoPlayable"/>) without instantiating it. </summary>
public Type GetCoreType(string id){return _cores.TryGetValue(id,out var type)?type:null;}}}

===== FILE mods/turn_battle_base/src/BattleData.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Battle;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Quests;using Cthangover.Core.Characters;using Godot;namespace Cthangover.Core.Settings{/// <summary> Immutable descriptor for a battle encounter, constructed via the static factory <see cref="InitBattle"/> overloads. Holds the background texture, enemy roster, quest reference, card order side, and the scene to return to when the battle ends. The private constructor forces creation through the factories so that all required data is supplied upfront. </summary>
public class BattleData{/// <summary>The quest that triggered this battle; may be <c>null</c> for non-quest skirmishes.</summary>
public QuestBase Quest{get;set;}/// <summary>Auxiliary tag set on new characters acquired via recruitment after this battle.</summary>
public string NewTag{get;set;}/// <summary>List of enemy <see cref="Character"/> instances spawned from the provided IDs or default templates.</summary>
public List<Character>EnemiesCards{get;private set;}/// <summary>Battle background texture (full-resolution).</summary>
public Texture2D Background{get;private set;}/// <summary>Depth map texture for the parallax post-process effect.</summary>
public Texture2D DepthMap{get;set;}/// <summary>Albedo/colour map texture for the parallax post-process effect.</summary>
public Texture2D AlbedoMap{get;set;}/// <summary>Which side of the screen the player's cards are drawn on.</summary>
public BattleSide BattleSide{get;private set;}=BattleSide.Player;/// <summary>Identifier for the active battle core logic module.</summary>
public string ActiveBattleCore{get;set;}/// <summary>Scene name to transition to when the battle finishes or is fled. Stored as a string to avoid type coupling.</summary>
public string ReturnScene{get;private set;}private BattleData(){}/// <summary> Convenience overload that defaults <see cref="BattleSide"/> to <c>BattleSide.Player</c>. Constructs enemies from their string IDs via <see cref="Cthangover.Core.Factories.Impls.CharacterFactory"/>, falling back to a level-1 template when the ID is unknown. </summary>
public static BattleData InitBattle(Texture2D background,string returnScene,params string[]enemies){return InitBattle(background,returnScene,BattleSide.Player,enemies);}/// <summary> Creates a battle with a specified <see cref="BattleSide"/> and enemy IDs. Each ID is resolved through <see cref="Cthangover.Core.Factories.Impls.CharacterFactory.Get"/>. Unknown IDs produce a fallback <see cref="Character"/> with 20 HP and 3 action points. </summary>
public static BattleData InitBattle(Texture2D background,string returnScene,BattleSide cardOrder=BattleSide.Player,params string[]enemies){var list=enemies.Select(o=>CharacterFactory.Instance.Get(o)??new Character{ID=o,Attributes=new CharacterAttributes{Health=new Attribute{Value=20,BaseValue=20},Point=new Attribute{Value=3,BaseValue=3},},Level=1,Exp=10,}).ToList();return InitBattle(background,returnScene,list,cardOrder);}/// <summary> Core factory that directly accepts a pre-built <see cref="List{Character}"/> for the enemy roster. This is the overload that all other <c>InitBattle</c> variants delegate to. </summary>
public static BattleData InitBattle(Texture2D background,string returnScene,List<Character>enemiesCards,BattleSide cardOrder=BattleSide.Player){return new BattleData{Background=background,ReturnScene=returnScene,EnemiesCards=enemiesCards,BattleSide=cardOrder,};}}}

===== FILE mods/turn_battle_base/src/BattleEndedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Published when the battle concludes (victory or defeat), after the victory/defeat screen resolves. Mods can use it for post-battle cleanup or story state. Note: the group defeat with loot and recruitment payload is the separate <see cref="CharactersDefeatedEvent"/>, which fires from the victory screen. </summary>
[LifecycleEvent][EventKey("battle_ended")]public class BattleEndedEvent:ModEvent{/// <summary>Outcome reported by the battle core (e.g. <c>"victory"</c>, <c>"defeat"</c>).</summary>
public string Result{get;init;}/// <summary>Number of turns the battle lasted.</summary>
public int Turns{get;init;}}}

===== FILE mods/turn_battle_base/src/BattleGDScriptBridge.cs =====
using Cthangover.Core.Characters;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Items;using Godot;namespace Cthangover.Core.Battle{/// <summary> Converts battle data to and from plain Godot collections so GDScript mods can work with it. The previous bridge exposed <c>[GlobalClass]</c> DTOs (GDCharacter, GDBattleResult, GDWingroundContext), but classes compiled at runtime inside mods never go through Godot's C# source generator, so their properties and methods are invisible to GDScript. Dictionaries and arrays are native variants and work regardless of which assembly produced them. <para/> Keys are snake_case to match GDScript conventions. The same helpers are used by <see cref="GDBattleActionExecutor"/> (action/character snapshots in, result deltas out) and <see cref="GDWingroundListener"/> (victory context in, exp/loot mutations out). </summary>
public static class BattleGDScriptBridge{/// <summary>Flattens a character into a dictionary of primitive stats and identity fields.</summary>
public static Godot.Collections.Dictionary CharacterToDictionary(Character character){var data=new Godot.Collections.Dictionary();if(character==null)return data;data["id"]=character.ID;data["name"]=character.Name;data["level"]=character.Level;data["exp"]=character.Exp;data["recruitment_chance"]=character.RecruitmentChance;var attrs=character.Attributes;if(attrs!=null){data["health_value"]=attrs.Health?.Value??0;data["health_max"]=attrs.Health?.BaseValue??0;data["defence_value"]=attrs.Defence?.Value??0;data["defence_max"]=attrs.Defence?.BaseValue??0;data["attack_value"]=attrs.Attack?.Value??0;data["attack_max"]=attrs.Attack?.BaseValue??0;data["point_value"]=attrs.Point?.Value??0;data["point_max"]=attrs.Point?.BaseValue??0;var properties=new Godot.Collections.Dictionary<string,string>();if(attrs.Properties?.Values!=null){foreach(var kvp in attrs.Properties.Values)properties[kvp.Key]=kvp.Value;}data["properties"]=properties;}return data;}/// <summary>Flattens an action card into a dictionary of identity fields and raw properties.</summary>
public static Godot.Collections.Dictionary ActionToDictionary(ActionCharacter action){var data=new Godot.Collections.Dictionary();if(action==null)return data;data["id"]=action.ID;data["name"]=action.Name;data["description"]=action.Description;data["type"]=(int)action.Type;var properties=new Godot.Collections.Dictionary<string,string>();if(action.Properties?.Values!=null){foreach(var kvp in action.Properties.Values)properties[kvp.Key]=kvp.Value;}data["properties"]=properties;return data;}/// <summary> Builds <see cref="ChangedAttributes"/> from the dictionary a GDScript executor returns. Missing keys default to zero/false; <c>result</c> defaults to <c>true</c> so a script that only reports deltas still counts as a successful action. </summary>
public static ChangedAttributes ResultFromDictionary(Godot.Collections.Dictionary data){if(data==null)return new ChangedAttributes{Result=false};var result=new ChangedAttributes();if(data.TryGetValue("source_damage",out var sourceDamage))result.Source.Damage=sourceDamage.AsInt32();if(data.TryGetValue("source_defence",out var sourceDefence))result.Source.Defence=sourceDefence.AsInt32();if(data.TryGetValue("target_damage",out var targetDamage))result.Target.Damage=targetDamage.AsInt32();if(data.TryGetValue("target_defence",out var targetDefence))result.Target.Defence=targetDefence.AsInt32();result.Result= !data.TryGetValue("result",out var success)||success.AsBool();return result;}/// <summary>Builds the mutable victory context dictionary passed to GDScript winground hooks.</summary>
public static Godot.Collections.Dictionary WingroundToDictionary(WingroundContext ctx){var data=new Godot.Collections.Dictionary();if(ctx==null)return data;var expGained=new Godot.Collections.Dictionary<string,int>();if(ctx.PlayerExpGained!=null){foreach(var kvp in ctx.PlayerExpGained)expGained[kvp.Key]=kvp.Value;}data["exp_gained"]=expGained;var defeated=new Godot.Collections.Array<Godot.Collections.Dictionary>();if(ctx.DefeatedEnemies!=null){foreach(var enemy in ctx.DefeatedEnemies)defeated.Add(CharacterToDictionary(enemy));}data["defeated_enemies"]=defeated;var loot=new Godot.Collections.Array<Godot.Collections.Dictionary>();if(ctx.GeneratedLoot!=null){foreach(var item in ctx.GeneratedLoot){loot.Add(new Godot.Collections.Dictionary{["item_id"]=item.Item?.ID??string.Empty,["count"]=item.Count,});}}data["generated_loot"]=loot;data["return_scene"]=ctx.BattleData?.ReturnScene??string.Empty;return data;}/// <summary> Writes GDScript mutations from a winground context dictionary back to the C# pipeline. Only <c>exp_gained</c> and <c>generated_loot</c> are synced — defeated enemies are read-only snapshots, matching the documented contract of <see cref="IWingroundListener"/>. </summary>
public static void SyncWingroundBack(Godot.Collections.Dictionary data,WingroundContext ctx){if(data==null||ctx==null)return;if(data.TryGetValue("exp_gained",out var expVariant)&&expVariant.AsGodotDictionary()is Godot.Collections.Dictionary expDict&&ctx.PlayerExpGained!=null){ctx.PlayerExpGained.Clear();foreach(var key in expDict.Keys)ctx.PlayerExpGained[key.AsString()]=expDict[key].AsInt32();}if(data.TryGetValue("generated_loot",out var lootVariant)&&lootVariant.AsGodotArray()is Godot.Collections.Array lootArray&&ctx.GeneratedLoot!=null){ctx.GeneratedLoot.Clear();foreach(var entry in lootArray){if(entry.AsGodotDictionary()is not Godot.Collections.Dictionary itemDict)continue;var itemId=itemDict.TryGetValue("item_id",out var idVariant)?idVariant.AsString():null;var count=itemDict.TryGetValue("count",out var countVariant)?countVariant.AsInt32():0;ctx.GeneratedLoot.Add(new ItemContainer{Item=string.IsNullOrEmpty(itemId)?null:ItemFactory.Instance.Get(itemId),Count=count,});}}}}}

===== FILE mods/turn_battle_base/src/BattleInitAction.cs =====
using Cthangover.Core.Actions;namespace Cthangover.Core.Battle{/// <summary> Dispatched by the scenario DSL to initiate battle. Reads "scene", "enemies", "quest_id" and "new_tag" from dialog variables and calls <see cref="BattleService.Init"/>. The enemies string is a comma-separated list embedded in the scenario script. Battle initialization must happen *after* the background has been set by a prior background action, otherwise the encounter gets a null backdrop. <para/> Registered by the battle mod's assembly scan, so the core scenario action registry stays free of battle commands. </summary>
public class BattleInitAction:IScenarioAction{/// <summary>Registered as "battle.init" — the scenario DSL's primary battle initiation command.</summary>
public string Name=>"battle.init";/// <summary> Reads battle parameters from dialog variables and initiates battle construction. Requires both "scene" and "enemies" to be non-empty — returns early with a log if either is missing. </summary>
public void Run(IActionContext ctx){var sceneRaw=ctx.GetParam("scene");var enemies=ctx.GetParam("enemies");var questId=ctx.GetParam("quest_id");var newTag=ctx.GetParam("new_tag");if(string.IsNullOrEmpty(sceneRaw)||string.IsNullOrEmpty(enemies)){ctx.Log("EVENT","BattleInitAction: missing 'scene' or 'enemies' variable");return;}BattleService.Init(sceneRaw,enemies,questId,newTag);ctx.Log("EVENT",$"BattleInitAction: battle started at '{sceneRaw}' with enemies '{enemies}'");}}}

===== FILE mods/turn_battle_base/src/BattleRequest.cs =====
using Cthangover.Core.Settings;namespace Cthangover.Core.Battle{/// <summary> Static hand-off slot between the scenario side (which builds an encounter through <see cref="BattleService"/>) and the battle scene (which consumes it in <see cref="BattleSceneContext"/>). Lives in the battle mod instead of the core runtime state so the core does not need to know about battle data; <see cref="Current"/> is transient — it is never serialized and is cleared by the victory/defeat screens when the player leaves the encounter. </summary>
public static class BattleRequest{/// <summary>Pending encounter, or <c>null</c> when no battle was requested.</summary>
public static BattleData Current{get;set;}}}

===== FILE mods/turn_battle_base/src/BattleSceneContext.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Events;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Mods;using Cthangover.Core.Settings;using Cthangover.Core.UI;using Cthangover.Mods.Time;using Cthangover.Core.Characters;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Base;using Cthangover.Mods.Interface.Base.Lists;namespace Cthangover.Core.Battle{/// <summary> Singleton scene-root for battle, wired as a self-registering Node (sets Instance on enter-tree, clears on exit-tree). On ready it assembles player characters from CharacterData.BattleSet, enemy cards from the pending <see cref="BattleRequest"/>, resolves the active <see cref="IBattleCore"/> via <see cref="BattleCoreRegistry"/>, and defers Init+Start to the next frame so the scene tree is fully built before core logic runs. Tracks PlayerExpGained (per-character, split evenly among survivors) and DefeatedEnemies for the victory/loot screen. Exposes OnCharacterDied and OnBattleCleared events so external systems (e.g. quests, the action machine) can react without coupling. ShowWinground / ShowDeadground are mutually exclusive end-of-battle paths — both set IsDestroyed to prevent double-triggering and reset battle speed before clearing state. <para/> The context derives from <see cref="ModNode"/> because mod assemblies get no Godot source generation: the core base receives the engine lifecycle callbacks and forwards them to the <c>OnXxx</c> overrides. It also implements <see cref="IActiveCharacterProvider"/> so core UI (the inventory panel's action-point row) can read the acting character without knowing anything about battle. </summary>
public partial class BattleSceneContext:ModNode,IActiveCharacterProvider{/// <summary> Singleton reference to the active battle scene context. Set on enter-tree, cleared on exit-tree. External systems read this to access battle state. </summary>
public static BattleSceneContext Instance{get;private set;}private TextureRect battleBg;/// <summary> The <see cref="Actions.BattleActionMachine"/> node that processes the animation queue for this battle. Cores push animation actions through the context and the machine drives playback frame-by-frame. </summary>
public Actions.BattleActionMachine BattleActionMachine{get;private set;}private IBattleSpeedControl _battleSpeedControl;private Widget deadground;private Widget winground;/// <summary> Per-character EXP earned during this battle, keyed by <see cref="Character.ID"/>. Populated by <see cref="RecordEnemyDefeated"/>; consumed by the victory screen provided by the turn-battle GUI mod. </summary>
public Dictionary<string,int>PlayerExpGained{get;private set;}=new();/// <summary> Enemies defeated during this battle (for loot generation and recruitment rolls). </summary>
public List<Character>DefeatedEnemies{get;private set;}=new();/// <summary> Guards against double-triggering end-of-battle paths. Set to <c>true</c> by <see cref="ShowWinground"/> or <see cref="ShowDeadground"/>. </summary>
public bool IsDestroyed{get;private set;}/// <summary> When <c>true</c>, the battle core pauses processing (e.g. while a UI overlay is open). Set externally by pause widgets. </summary>
public bool IsWait{get;set;}/// <summary> The <see cref="Character"/> whose turn is currently active in battle. Set by the battle core when a character is selected and cleared when the turn ends or selection is cancelled. <c>null</c> means no character is active (out of battle, between turns, or idle state). External panels such as the inventory panel read this to check whether the active character has enough action points before performing item-related actions. </summary>
public Character ActiveCharacter{get;set;}/// <summary>Fired when a character's health reaches 0.</summary>
public event System.Action<Character>OnCharacterDied;/// <summary> Fired after battle state is cleared (both victory and defeat). External systems can hook this to release battle resources. </summary>
public event System.Action OnBattleCleared;/// <summary> Invokes <see cref="OnCharacterDied"/> to notify listeners (quests, achievements, the battle core) that a character has been defeated. </summary>
public void NotifyCharacterDied(Character character){OnCharacterDied?.Invoke(character);ModEventBus.Publish(new CharacterDiedEvent{CharacterId=character?.ID,CharacterName=character?.Name,Source="battle",});}/// <summary>Claims the scene singleton and registers as the <see cref="ActiveCharacterRegistry"/> provider.</summary>
protected override void OnEnterTree(){if(Instance!=null&&GodotObject.IsInstanceValid(Instance)){var scene=GetTree()?.CurrentScene?.Name??"?";var existingPath=Instance.GetPath().ToString();var myPath=GetPath().ToString();GameLogger.Log("DUPLICATE",$"BattleSceneContext.OnEnterTree: Instance ALREADY SET by '{existingPath}', overwriting with duplicate at '{myPath}' on scene '{scene}'",LogLevel.Error);}Instance=this;ActiveCharacterRegistry.Provider=this;}/// <summary>Releases the singleton slot and the active-character provider so a later scene can claim them.</summary>
protected override void OnExitTree(){base.OnExitTree();if(Instance==this)Instance=null;if(ReferenceEquals(ActiveCharacterRegistry.Provider,this))ActiveCharacterRegistry.Provider=null;}private List<Character>pendingPlayerCards;private List<Character>pendingEnemyCards;private IBattleCore activeBattleCore;/// <summary> The battle core driving this scene, resolved from <see cref="BattleCoreRegistry"/> when the scene becomes ready. Exposed so tooling and tests can query the active core (for example for <see cref="IBattleAutoPlayable"/>) without reaching into UI nodes. </summary>
public IBattleCore ActiveBattleCore=>activeBattleCore;/// <summary>Caches the battle backdrop node, resolves the active <see cref="IBattleCore"/> and starts the battle.</summary>
protected override void OnReady(){battleBg=GetNodeOrNull<TextureRect>("BattleBg");if(battleBg==null){GameLogger.Log("BATTLE","battleBg not found!",LogLevel.Error);return;}BattleActionMachine=GetNodeOrNull<Actions.BattleActionMachine>("ToolPanel/BattleActionMachine");_battleSpeedControl=GetNodeOrNull<Control>("BattleSpeedControl")as IBattleSpeedControl;deadground=GetNodeOrNull<Widget>("Deadground");winground=GetNodeOrNull<Widget>("Winground");var data=BattleRequest.Current;if(data==null){GameLogger.Log("BATTLE","BattleRequest.Current is null!",LogLevel.Error);return;}GameLogger.Log("BATTLE",$"set battle background: battleBg={(battleBg != null ? "found" : "NULL")}, texture={(data.Background != null ? "has" : "NULL")}",battleBg==null||data.Background==null?LogLevel.Error:LogLevel.Debug);if(data.Background!=null){battleBg.Texture=data.Background;GameLogger.Log("BATTLE",$"  battleBg.Texture assigned: width={battleBg.Texture.GetWidth()}, height={battleBg.Texture.GetHeight()}",LogLevel.Debug);}else{GameLogger.Log("BATTLE",$"  data.Background is NULL — no background texture set",LogLevel.Error);}var timedShader=Shaders.Resolve("timed_sprite");if(timedShader!=null){var bgMaterial=new ShaderMaterial{Shader=timedShader};battleBg.Material=bgMaterial;if(data.DepthMap!=null)bgMaterial.SetShaderParameter("depth_mask",data.DepthMap);if(data.AlbedoMap!=null)bgMaterial.SetShaderParameter("albedo_map",data.AlbedoMap);var lights=GetNodeOrNull<UiLightController>("Lights");if(lights!=null){var viewportSize=GetViewport().GetVisibleRect().Size;lights.SetPlayerLight(viewportSize/2,500f,1f);lights.RegisterMaterial(bgMaterial);}GameLogger.Log("BATTLE",$"battleBg shader: timed_sprite applied, depthMap={(data.DepthMap != null ? "has" : "NULL")}, albedoMap={(data.AlbedoMap != null ? "has" : "NULL")}, lightsCtrl={(lights != null ? "found" : "NULL")}",lights==null?LogLevel.Error:LogLevel.Debug);}else{GameLogger.Log("BATTLE","battleBg shader: timed_sprite NOT FOUND",LogLevel.Error);}pendingPlayerCards=new List<Character>();foreach(var characterType in GameData.Instance.Runtime.CharacterData.BattleSet){var characterInfo=GameData.Instance.Runtime.CharacterData.Characters[characterType];var template=CharacterFactory.Instance.Get(characterInfo.ID);var character=template!=null?template.Copy():new Character{ID=characterInfo.ID};character.Attributes=characterInfo.Attributes??new CharacterAttributes();character.Level=characterInfo.Level;character.Exp=characterInfo.Exp;if(characterInfo.ActionSlots!=null&&characterInfo.ActionSlots.Any(id=> !string.IsNullOrEmpty(id))){character.Actions=characterInfo.ActionSlots.Where(id=> !string.IsNullOrEmpty(id)).Select(id=>ActionCharacterFactory.Instance.Get(id)).Where(a=>a!=null).ToList();}pendingPlayerCards.Add(character);}pendingEnemyCards=data.EnemiesCards?.Where(c=>c!=null).ToList()??new List<Character>();if(!string.IsNullOrEmpty(data.ActiveBattleCore)&&BattleCoreRegistry.Instance.HasCore(data.ActiveBattleCore))BattleCoreRegistry.Instance.SetActive(data.ActiveBattleCore);try{activeBattleCore=BattleCoreRegistry.Instance.GetActive();}catch{activeBattleCore=null;}if(activeBattleCore==null){GameLogger.Log("BATTLE","No battle core available",LogLevel.Error);return;}Callable.From(DeferredInit).CallDeferred();}private void DeferredInit(){if(IsDestroyed||activeBattleCore==null)return;var ctx=new BattleContextImpl(this);var playerArray=pendingPlayerCards?.ToArray()??new Character[0];var enemyArray=pendingEnemyCards?.ToArray()??new Character[0];activeBattleCore.Init(playerArray,enemyArray,ctx);activeBattleCore.Start();ModEventBus.Publish(new BattleStartedEvent{BattleType=activeBattleCore.GetType().Name,Enemies=enemyArray.Select(c=>c.Name).ToArray(),});}/// <summary> Triggers the defeat screen: sets <see cref="IsDestroyed"/>, resets battle speed, clears battle state, and shows the deadground widget. </summary>
public void ShowDeadground(){if(IsDestroyed)return;IsDestroyed=true;_battleSpeedControl?.ResetToNormal();ClearBattle();deadground?.Show();}/// <summary> Triggers the victory screen: sets <see cref="IsDestroyed"/>, resets battle speed, clears battle state, and shows the winground widget. </summary>
public void ShowWinground(){if(IsDestroyed)return;IsDestroyed=true;_battleSpeedControl?.ResetToNormal();ClearBattle();winground?.Show();}/// <summary> Records a defeated enemy: adds it to <see cref="DefeatedEnemies"/> and distributes its EXP (<c>Level * Exp</c>) equally among all surviving player characters in <see cref="PlayerExpGained"/>. </summary>
public void RecordEnemyDefeated(Character enemy){if(enemy==null)return;DefeatedEnemies.Add(enemy);var enemyExp=enemy.Level*enemy.Exp;var alivePlayers=0;foreach(var c in pendingPlayerCards){if(c?.Attributes?.Health?.Value>0)alivePlayers++;}if(alivePlayers==0)return;var expPerPlayer=enemyExp/alivePlayers;foreach(var c in pendingPlayerCards){if(c?.Attributes?.Health?.Value<=0)continue;var id=c.ID;if(id==null)continue;PlayerExpGained.TryGetValue(id,out var current);PlayerExpGained[id]=current+expPerPlayer;}}private void ClearBattle(){BattleActionMachine?.StopMachine();ActiveCharacter=null;pendingPlayerCards?.Clear();pendingEnemyCards?.Clear();OnBattleCleared?.Invoke();ModEventBus.Publish(new BattleEndedEvent{Result=IsDestroyed?"defeat":"victory",});}}}

===== FILE mods/turn_battle_base/src/BattleService.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Quests;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Mods.Time;using Cthangover.Core.Utils;namespace Cthangover.Core.Battle{/// <summary> Entry point for starting a battle encounter. Constructs <see cref="BattleData"/> from scenario parameters and stores it in <see cref="BattleRequest"/> for the battle scene to pick up. Captures the current background texture (via <see cref="BackgroundFactory"/> using <see cref="SceneContextNode.LastBackgroundID"/>) so the battle scene has the correct visual context, and snapshots the depth/albedo lighting maps from <see cref="UiLightController"/> for visual consistency. If a quest id is provided, the quest is bound to the encounter and an optional new tag triggers a quest notification. The active battle core is resolved from <see cref="BattleCoreRegistry"/> — wrapped in try/catch because the registry may not be initialized when called from non-battle contexts (e.g. preloading). </summary>
public static class BattleService{/// <summary> Constructs and stores <see cref="BattleRequest.Current"/> for the upcoming encounter. Must be called after a background has been set by a prior scenario background action, otherwise the backdrop is null. The enemies string is a comma-separated list resolved by <see cref="BattleData.InitBattle"/>. </summary>
public static void Init(string sceneType,string enemies,string questId=null,string newTag=null){var enemyList=enemies.Split(',');var background=BackgroundFactory.Instance.Get(SceneContextNode.LastBackgroundID);GameLogger.Log("BATTLE",$"BattleService.Init: lastBgId='{SceneContextNode.LastBackgroundID}', bgTexture={(background != null ? "loaded" : "NULL")}",background==null?LogLevel.Error:LogLevel.Debug);var data=BattleData.InitBattle(background,sceneType,enemyList);data.DepthMap=UiLightController.Instance?.CurrentDepthMap;data.AlbedoMap=UiLightController.Instance?.CurrentAlbedoMap;GameLogger.Log("BATTLE",$"BattleService.Init: depthMap from LightsCtrl = {(data.DepthMap != null ? "captured" : "NULL")}, albedoMap = {(data.AlbedoMap != null ? "captured" : "NULL")}",data.DepthMap==null&&data.AlbedoMap==null?LogLevel.Error:LogLevel.Debug);try{data.ActiveBattleCore=BattleCoreRegistry.Instance.GetActive()?.Id;}catch(Exception ex){GameLogger.Log("BATTLE",$"BattleService.Init: {ex.Message}\n{ex.StackTrace}",LogLevel.Error);}if(!string.IsNullOrEmpty(questId)){try{var quest=QuestFactory.Instance.Get(questId);data.Quest=quest;if(!string.IsNullOrEmpty(newTag)){data.NewTag=newTag;quest.SendNotification();}}catch(KeyNotFoundException){GameLogger.Log("BATTLE",$"BattleService.Init: quest '{questId}' not found, battle will proceed without quest",LogLevel.Error);}}BattleRequest.Current=data;}}}

===== FILE mods/turn_battle_base/src/BattleSetCoreAction.cs =====
using Cthangover.Core.Actions;namespace Cthangover.Core.Battle{/// <summary> Selects which battle core (combat ruleset) the next battle will use. The "core" variable maps to a <see cref="BattleCoreRegistry"/> entry by string ID. Must be called before a battle starts, as <see cref="BattleService.Init"/> reads the active core at initialization time. </summary>
public class BattleSetCoreAction:IScenarioAction{/// <summary> Registered as "battle.set_core" — selects the combat ruleset for the next battle encounter. If "core" is empty or missing, logs a warning and returns without modifying the registry. </summary>
public string Name=>"battle.set_core";/// <summary> Reads the "core" variable and sets it as the active battle core via <see cref="BattleCoreRegistry.SetActive"/>. The core determines combat mechanics (turn order, ability system, etc.) for the next initiated battle. </summary>
public void Run(IActionContext ctx){var core=ctx.GetParam("core");if(string.IsNullOrEmpty(core)){ctx.Log("EVENT","BattleSetCoreAction: missing 'core' variable");return;}BattleCoreRegistry.Instance.SetActive(core);ctx.Log("EVENT",$"BattleSetCoreAction: battle core set to '{core}'");}}}

===== FILE mods/turn_battle_base/src/BattleSpeedControl.cs =====
using System.Collections.Generic;using Cthangover.Core.Battle;using Cthangover.Core.Settings;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.TurnBattle{/// <summary> In-battle speed toggle bar (x1/x4/x8). Directly sets Godot.Engine.TimeScale to globally accelerate the entire game loop — not just animations. Persists the choice to GameData.Settings so it survives scene reloads. <see cref="ResetToNormal"/> restores 1x without saving and is the part the core scene consumes through <see cref="IBattleSpeedControl"/> when battle ends. </summary>
public partial class BattleSpeedControl:Control,IBattleSpeedControl{private readonly Dictionary<int,Button>_buttons=new();private int _activeSpeed;/// <summary> Builds the speed bar in the constructor. Godot never runs a <c>_Ready</c> override declared in a mod assembly, so the buttons and the saved-speed restore must happen here. Anchors are used instead of a viewport-size lookup because the viewport is unavailable before the node enters the tree. </summary>
public BattleSpeedControl(){CthangoverTheme.Apply(this);var speeds=new[]{1,4,8};var hbox=new HBoxContainer{Name="SpeedButtons"};hbox.Alignment=BoxContainer.AlignmentMode.Begin;foreach(var speed in speeds){var btn=new Button{Text=$"x{speed}",CustomMinimumSize=new Vector2(48,32)};btn.Pressed+=()=>OnSpeedSelected(speed);hbox.AddChild(btn);_buttons[speed]=btn;}AddChild(hbox);AnchorLeft=1f;AnchorRight=1f;AnchorTop=0f;AnchorBottom=0f;OffsetLeft= -200f;OffsetRight= -20f;OffsetTop=20f;OffsetBottom=52f;ApplySavedSpeed();}/// <summary> Restores the speed from <see cref="GameData.Settings.BattleSpeed"/>. Called on <c>_Ready</c> to reapply the saved preference. </summary>
public void ApplySavedSpeed(){var speed=GameData.Instance.Settings.BattleSpeed;if(speed<1||speed>8)speed=1;_activeSpeed=speed;Godot.Engine.TimeScale=speed;UpdateHighlight();}/// <summary> Resets <see cref="Godot.Engine.TimeScale"/> to 1.0x without persisting to settings. Used when battle ends so the lobby and menus run at normal speed regardless of the player's in-battle preference. </summary>
public void ResetToNormal(){Godot.Engine.TimeScale=1.0f;}private void OnSpeedSelected(int speed){_activeSpeed=speed;Godot.Engine.TimeScale=speed;var settings=GameData.Instance.Settings;settings.BattleSpeed=speed;settings.Save();UpdateHighlight();}private void UpdateHighlight(){foreach(var kv in _buttons){var isActive=kv.Key==_activeSpeed;kv.Value.Modulate=isActive?UiTokens.Success:UiTokens.White;}}}}

===== FILE mods/turn_battle_base/src/BattleStartedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Published when a battle encounter is initialised (from <see cref="Cthangover.Core.Battle.BattleService.Init"/>, before the battle scene loads). Lets mods observe or prepare for the fight — e.g. pre-buff party members or record the encounter for analytics. The <c>[LifecycleEvent]</c> marker keeps it out of condition-cached flows. </summary>
[LifecycleEvent][EventKey("battle_started")]public class BattleStartedEvent:ModEvent{/// <summary>Logical name of the battle scene route being entered (e.g. <c>"Battle"</c>).</summary>
public string BattleType{get;init;}/// <summary>Template ids of the enemies participating in the encounter.</summary>
public string[]Enemies{get;init;}}}

===== FILE mods/turn_battle_base/src/ChangedAttributes.cs =====
namespace Cthangover.Core.Characters{/// <summary> Result struct for battle action resolution. Source and Target carry Damage/Defence snapshots for both sides of the exchange. Result indicates whether the action succeeded (e.g. hit landed vs. missed/blocked). A struct rather than a class because these are short-lived stack values created in tight battle loops — no heap allocation. </summary>
public struct ChangedAttributes{/// <summary> Snapshot of the action source's damage and defence after the exchange. </summary>
public CharacterSet Source;/// <summary> Snapshot of the action target's damage and defence after the exchange. </summary>
public CharacterSet Target;/// <summary> Whether the action succeeded (e.g. hit landed vs. missed or blocked). </summary>
public bool Result;}}

===== FILE mods/turn_battle_base/src/CharacterDiedEvent.cs =====
namespace Cthangover.Core.Events{/// <summary> Published when a character dies in combat. Payload copies data as strings — does not pass a live Node (may be freed by the time deferred subscribers are called). </summary>
[EventKey("character_died")]public class CharacterDiedEvent:ModEvent{/// <summary>Template id of the character that died.</summary>
public string CharacterId{get;init;}/// <summary>Display name at time of death (resolved locale string).</summary>
public string CharacterName{get;init;}/// <summary>Free-form id of the reporting battle system.</summary>
public string Source{get;init;}}}

===== FILE mods/turn_battle_base/src/CharacterSet.cs =====
namespace Cthangover.Core.Characters{/// <summary> Lightweight snapshot of damage/defence values for battle action results. A struct to avoid heap allocation in the battle loop where these are created for every action exchange. Used by ChangedAttributes to carry both source and target state. </summary>
public struct CharacterSet{/// <summary> Damage dealt or received in this exchange. </summary>
public int Damage;/// <summary> Defence value at the time of the exchange. </summary>
public int Defence;}}

===== FILE mods/turn_battle_base/src/DeadgroundBehaviour.cs =====
using Cthangover.Core.Audio;using Cthangover.Core.Battle;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Godot;namespace Cthangover.TurnBattle{/// <summary> Defeat screen widget. Minimal compared to WingroundBehaviour — only sets the deadground background texture (resolved from mods) and offers a Main Menu button that clears BattleData and reloads the menu scene. No loot, no EXP. </summary>
public partial class DeadgroundBehaviour:TransitionWidget{/// <summary> Clears battle data and switches to the main menu scene. Bound to the "Main Menu" button on the defeat screen. </summary>
public void ToMainMenuClick(){var sceneService=GetNode<GodotSceneService>("/root/GodotSceneService");sceneService?.SwitchToMenu();BattleRequest.Current=null;}/// <summary> Plays the defeat sound, resolves the deadground background texture from mods, and shows the transition widget. </summary>
public override void Show(){CthangoverTheme.Apply(this);CthangoverTheme.ApplyPrimary(GetNodeOrNull<Button>("Content/CenterPanel/MainMenuButton"));GameLogger.Log("BATTLE",$"Deadground.Show() ENTER: Visible={Visible}, GodotVisible={base.Visible}",LogLevel.Debug);var audioService=GetNodeOrNull<Node>("/root/AudioService")as IAudioService;audioService?.PlaySound("battle/deadground",SoundType.UI);var bg=GetNodeOrNull<TextureRect>("TransitionBg");GameLogger.Log("BATTLE",$"Deadground.Show: TransitionBg={(bg != null ? "found" : "NULL")}, texture={(bg?.Texture != null ? "has" : "null")}",LogLevel.Error);if(bg!=null&&bg.Texture==null){var tex=Textures.Resolve("deadground");GameLogger.Log("BATTLE",$"Deadground.Show: ResolveTexture('deadground') = {(tex != null ? "loaded" : "NULL")}",LogLevel.Error);bg.Texture=tex;}base.Show();GameLogger.Log("BATTLE",$"Deadground.Show() EXIT: Visible={Visible}, GodotVisible={base.Visible}",LogLevel.Debug);}}}

===== FILE mods/turn_battle_base/src/ExpReportCard.cs =====
using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.TurnBattle{/// <summary> Single character EXP report in the victory screen. Displays the character icon, localized name, and "+N" EXP value. Uses the same ResolveMissingNodes pattern as LootReportItem for robustness against missing scene links. </summary>
public partial class ExpReportCard:Control{[Export]private TextureRect icon;[Export]private Label nameLabel;[Export]private Label expLabel;private void ResolveMissingNodes(){icon??=GetNodeOrNull<TextureRect>("Panel/Icon");nameLabel??=GetNodeOrNull<Label>("Panel/NameLabel");expLabel??=GetNodeOrNull<Label>("Panel/ExpLabel");}/// <summary> Populates the card with a character icon, localised name, and "+N" EXP value. Calls <c>ResolveMissingNodes</c> to tolerate incomplete scene wiring in the editor. </summary>
public void Setup(Texture2D characterIcon,string characterName,int exp){CthangoverTheme.Apply(this);ResolveMissingNodes();if(icon!=null&&characterIcon!=null)icon.Texture=characterIcon;if(nameLabel!=null)nameLabel.Text=characterName;if(expLabel!=null)expLabel.Text="+"+exp;}}}

===== FILE mods/turn_battle_base/src/GDWingroundListener.cs =====
using Godot;namespace Cthangover.Core.Battle{/// <summary> GDScript bridge for <see cref="IWingroundListener"/>. Holds a reference to a GDScript object and delegates each hook through <see cref="GodotObject.HasMethod"/> / <see cref="GodotObject.Call"/>. <para/> All three hooks are optional — the GDScript mod implements only the methods it needs. The context is marshalled into a plain Godot dictionary (<c>exp_gained</c>, <c>defeated_enemies</c>, <c>generated_loot</c>, <c>return_scene</c>) before each call and synced back afterward, so GDScript mutations to exp and loot propagate to the C# victory pipeline. Dictionaries replace the old GDWingroundContext RefCounted DTO because mod classes compiled at runtime are not visible to GDScript. </summary>
public class GDWingroundListener:IWingroundListener{private readonly GodotObject _instance;/// <summary>Registry key reported by the script's <c>get_winground_listener_id()</c>; deduplicates on rescan.</summary>
public string Id{get;}/// <summary> Creates a bridge around a live GDScript object. The caller must ensure the object outlives the registry. </summary>
public GDWingroundListener(string id,GodotObject instance){Id=id;_instance=instance;}/// <summary>Calls the script's <c>on_winground_before_exp(ctx)</c> if implemented; no-op otherwise.</summary>
public void OnBeforeExpAward(WingroundContext ctx){if(!_instance.HasMethod("on_winground_before_exp"))return;var gdCtx=BattleGDScriptBridge.WingroundToDictionary(ctx);_instance.Call("on_winground_before_exp",gdCtx);BattleGDScriptBridge.SyncWingroundBack(gdCtx,ctx);}/// <summary>Calls the script's <c>on_winground_after_loot(ctx)</c> if implemented; no-op otherwise.</summary>
public void OnAfterLootGenerated(WingroundContext ctx){if(!_instance.HasMethod("on_winground_after_loot"))return;var gdCtx=BattleGDScriptBridge.WingroundToDictionary(ctx);_instance.Call("on_winground_after_loot",gdCtx);BattleGDScriptBridge.SyncWingroundBack(gdCtx,ctx);}/// <summary>Calls the script's <c>on_winground_before_show(ctx)</c> if implemented; no-op otherwise.</summary>
public void OnBeforeShow(WingroundContext ctx){if(!_instance.HasMethod("on_winground_before_show"))return;var gdCtx=BattleGDScriptBridge.WingroundToDictionary(ctx);_instance.Call("on_winground_before_show",gdCtx);BattleGDScriptBridge.SyncWingroundBack(gdCtx,ctx);}}}

===== FILE mods/turn_battle_base/src/IBattleAutoPlayable.cs =====
namespace Cthangover.Core.Battle{/// <summary> Optional automation contract for battle cores. The base <see cref="IBattleCore"/> contract describes how a battle starts but not how it advances: every core owns its own UI and waits for player input in its own way. Integration tests and AI tooling therefore ask the core itself when it waits for the player and how to advance the turn, instead of hunting for core-specific buttons by node path. Cores that don't implement this interface are still runnable but are reported as not universally testable by the battle coverage tool. </summary>
public interface IBattleAutoPlayable{/// <summary> <c>true</c> while the core accepts a player turn advance. Must become <c>false</c> immediately when the turn ends, so a polling driver cannot trigger two turns in a row. </summary>
bool IsWaitingForPlayerInput{get;}/// <summary> Ends the current player turn through the same path the core's UI uses (skipping the player's actions is a valid strategy for smoke tests). Must be a no-op when <see cref="IsWaitingForPlayerInput"/> is <c>false</c>. </summary>
void AdvancePlayerTurn();}}

===== FILE mods/turn_battle_base/src/IBattleSpeedControl.cs =====
namespace Cthangover.Core.Battle{/// <summary> Minimal contract between <see cref="BattleSceneContext"/> and the battle view's speed control. The concrete x1/x4/x8 widget lives in the turn-battle GUI mod, so the core scene only needs the one call it makes when a battle ends — resetting the global time scale without persisting it. </summary>
public interface IBattleSpeedControl{/// <summary> Restores <c>Godot.Engine.TimeScale</c> to 1x without saving the preference. Called when the battle ends so menus and the lobby run at normal speed regardless of the player's in-battle speed choice. </summary>
void ResetToNormal();}}

===== FILE mods/turn_battle_base/src/IWingroundListener.cs =====
namespace Cthangover.Core.Battle{/// <summary> Extension point for mods to inspect and mutate victory-screen data. Implementations are auto-discovered via <see cref="WingroundListenerRegistry.RegisterAssembly"/> (C#) or detected by method signature in <see cref="Mods.ModGDScriptLoader"/> (GDScript). The three hooks fire in order during <see cref="WingroundBehaviour.Show"/>: <list type="number"> <item><see cref="OnBeforeExpAward"/> — EXP data is available but has not yet been applied to characters.</item> <item><see cref="OnAfterLootGenerated"/> — loot has been rolled from enemies but has not yet been added to inventory.</item> <item><see cref="OnBeforeShow"/> — all processing is done; final chance to mutate data before the UI appears.</item> </list> The same <see cref="WingroundContext"/> instance is passed to all listeners across all three phases, so mutations from earlier hooks are visible to later hooks and to subsequent listeners. </summary>
public interface IWingroundListener{/// <summary> Unique identifier for this listener. Used in logs and for cross-mod dependency ordering. Convention: <c>"mod_id.listener_name"</c>. </summary>
string Id{get;}/// <summary> Called before EXP is awarded to surviving characters. <see cref="WingroundContext.PlayerExpGained"/> and <see cref="WingroundContext.DefeatedEnemies"/> are populated; <see cref="WingroundContext.GeneratedLoot"/> is empty at this stage. </summary>
void OnBeforeExpAward(WingroundContext ctx);/// <summary> Called after loot has been generated from <see cref="WingroundContext.DefeatedEnemies"/> but before the items are added to the player's inventory. <see cref="WingroundContext.GeneratedLoot"/> is populated and mutable — adding, removing, or modifying entries here directly affects the inventory outcome. </summary>
void OnAfterLootGenerated(WingroundContext ctx);/// <summary> Called after EXP, loot, inventory, and recruitment have all been processed, immediately before the victory UI is shown (<c>base.Show()</c>). Use for post-processing or cleanup that must happen before the player sees the screen. </summary>
void OnBeforeShow(WingroundContext ctx);}}

===== FILE mods/turn_battle_base/src/LootReportItem.cs =====
using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.TurnBattle{/// <summary> A single loot cell in the victory grid. Shows icon with an optional "xN" count label (hidden when count is 1). ResolveMissingNodes supports both editor-assigned exports and runtime lookups so the prefab works with incomplete scene wiring. TooltipText is set to the localized item name for hover. </summary>
public partial class LootReportItem:Control{[Export]private TextureRect icon;[Export]private Label countLabel;private void ResolveMissingNodes(){icon??=GetNodeOrNull<TextureRect>("Panel/Icon");countLabel??=GetNodeOrNull<Label>("Panel/CountLabel");}/// <summary> Configures the loot cell: sets the item icon, a "xN" count label (hidden when <paramref name="count"/> is 1), and the localised name as the tooltip. Calls <c>ResolveMissingNodes</c> to handle missing editor assignments gracefully. </summary>
public void Setup(Texture2D itemIcon,string localizedName,int count){CthangoverTheme.Apply(this);ResolveMissingNodes();if(icon!=null&&itemIcon!=null)icon.Texture=itemIcon;if(countLabel!=null)countLabel.Text=count>1?"x"+count:"";TooltipText=localizedName;}}}

===== FILE mods/turn_battle_base/src/ShowDamageBehaviour.cs =====
using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.TurnBattle{/// <summary> Floating damage-number overlay. Self-contained: creates its own Label in _Ready, floats upward with random horizontal drift, fades alpha, then QueueFrees itself. Static factory methods (SpawnDamage, SpawnDefence) instantiate and parent it in one call — damage is red, heal is green, defence is blue. MouseFilter is Ignore so clicks pass through to cards underneath. </summary>
public partial class ShowDamageBehaviour:Control{private Label _label;private float duration=1.0f;private float floatDistance=80f;private float driftRange=30f;private Vector2 startPosition;/// <summary> Builds the label on first use. Godot does not dispatch <c>_Ready</c> overrides declared in mod assemblies (only methods generated in the core project run), so the label cannot be created in <c>_Ready</c> — it is created here, right before the first animation starts. </summary>
private void EnsureLabel(){if(_label!=null)return;MouseFilter=MouseFilterEnum.Ignore;_label=new Label();_label.SetAnchorsAndOffsetsPreset(LayoutPreset.Center);_label.HorizontalAlignment=HorizontalAlignment.Center;_label.VerticalAlignment=VerticalAlignment.Center;_label.AddThemeFontSizeOverride("font_size",28);_label.AddThemeColorOverride("font_outline_color",UiTokens.Alpha(Colors.Black,1f));_label.AddThemeConstantOverride("outline_size",6);AddChild(_label);}/// <summary> Creates and parents a floating damage (or heal) number. Damage is red, heal is green. The label floats upward with a random horizontal drift and auto-destructs after the tween completes. Skips values &lt;= 0. </summary>
public static void SpawnDamage(int amount,Node parent,Vector2 position,bool isHeal=false){if(amount<=0)return;var color=isHeal?UiTokens.SuccessText:UiTokens.DangerText;var hint=new ShowDamageBehaviour();parent.AddChild(hint);hint.Init(amount,position,color);}/// <summary> Creates and parents a floating defence number (blue). Same floating behaviour as <see cref="SpawnDamage"/>. Skips values &lt;= 0. </summary>
public static void SpawnDefence(int amount,Node parent,Vector2 position){if(amount<=0)return;var hint=new ShowDamageBehaviour();parent.AddChild(hint);hint.Init(amount,position,UiTokens.Info);}/// <summary> Sets the label text, colour, and world position, then starts the floating animation tween. Called by the static factory methods after instantiation. </summary>
public void Init(int amount,Vector2 worldPosition,Color?color=null){EnsureLabel();_label.Text=amount.ToString();if(color.HasValue)_label.Modulate=color.Value;GlobalPosition=worldPosition;startPosition=Position;RunAnimation();}private async void RunAnimation(){float driftX=(float)GD.RandRange(-driftRange,driftRange);Vector2 targetPos=startPosition+new Vector2(driftX,-floatDistance);var tween=CreateTween();tween.SetParallel(true);tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);tween.TweenProperty(this,"position",targetPos,duration);var fadeTween=CreateTween();fadeTween.TweenProperty(_label,"modulate:a",0f,duration*0.8f);await ToSignal(tween,"finished");QueueFree();}}}

===== FILE mods/turn_battle_base/src/StatusActionsConsumer.cs =====
namespace Cthangover.Core.Factories.Impls{using Cthangover.Core.Cards.StatusEffect;using Cthangover.Core.Mods.Consumers;/// <summary> Feeds <see cref="StatusEffectActionFactory"/> with every mod's <see cref="IStatusActions"/> implementations as the mods load, and removes them when their mod unloads. Replaces the one-time AppDomain scan, which saw only the mods loaded before the first lookup and kept a released mod's behaviours after a game switch. </summary>
public class StatusActionsConsumer:ModTypeConsumer<IStatusActions>{/// <inheritdoc />
public override string Id=>"turn_battle_base.status_actions";/// <inheritdoc />
protected override void Add(IStatusActions instance,string modId)=>StatusEffectActionFactory.Instance.Register(instance);/// <inheritdoc />
protected override void Remove(IStatusActions instance,string modId)=>StatusEffectActionFactory.Instance.Unregister(instance);}}

===== FILE mods/turn_battle_base/src/StatusEffect/IStatusActions.cs =====
using Cthangover.Core.Characters;namespace Cthangover.Core.Cards.StatusEffect{/// <summary> Behaviour hooks for a status effect type. Each hook receives the owning character so a single IStatusActions instance can be shared across multiple characters. OnDealDamage / OnTakeDamage use ref int to let effects amplify or reduce damage before it is applied. ModifyAttributes is a passive stat modifier called independently of turn phases. OnFinalAction fires once when the effect expires. All hooks have empty default semantics — effects override only the hooks they care about. </summary>
public interface IStatusActions{/// <summary>Factory key matching the <c>Actions</c> field in <see cref="StatusEffectInfo"/>, e.g. <c>"basic/stun"</c>.</summary>
string ID{get;}/// <summary>Fires when the owning character's turn begins. Use for start-of-turn damage-over-time or regeneration effects.</summary>
void OnTurnStart(Character target);/// <summary>Fires when the owning character's turn ends. Mirrors <see cref="OnTurnStart"/> for symmetry; useful for effects that activate after the character acts.</summary>
void OnTurnEnd(Character target);/// <summary>Called before the owning character deals damage to <paramref name="target"/>. Modify <paramref name="damage"/> via <see langword="ref"/> to amplify or reduce outgoing damage — all active effects stack multiplicatively through the queue's iteration.</summary>
void OnDealDamage(Character source,Character target,ref int damage);/// <summary>Called before the owning character receives damage from <paramref name="source"/>. Modify <paramref name="damage"/> via <see langword="ref"/> to add damage reduction or vulnerability. Fires after <see cref="OnDealDamage"/> in the damage pipeline.</summary>
void OnTakeDamage(Character target,Character source,ref int damage);/// <summary>Fires immediately when the effect is first placed on a character. Use for one-shot stat boosts, visual feedback, or initialisation logic.</summary>
void OnApply(Character target);/// <summary>Fires when the effect is manually removed (e.g. via a cleanse card). Not called on natural expiration — use <see cref="OnFinalAction"/> for that path.</summary>
void OnRemove(Character target);/// <summary>Fires once when the effect expires naturally (timer reaches zero). Use for end-of-effect triggers like explosion damage or stat restoration. Not called on manual removal.</summary>
void OnFinalAction(Character target);/// <summary>Passive stat modifier invoked independently of turn phases. Receives a reference to the character's attribute container; implementors can add flat or percentage bonuses that other effects may also contribute to.</summary>
void ModifyAttributes(CharacterAttributes attributes);}}

===== FILE mods/turn_battle_base/src/StatusEffect/IStatusEffect.cs =====
using Godot;namespace Cthangover.Core.Cards.StatusEffect{/// <summary> Status effect data contract. Separates identity (ID, Name, Icon) from mutable state (Duration, RemainingTurns) and behaviour (Actions — an IStatusActions hook set). Copy() is required so the StatusEffectQueue can clone effects when characters are duplicated. </summary>
public interface IStatusEffect{/// <summary>Unique mod identifier, e.g. <c>"basic/stun"</c>. Used by factories and the queue to look up and remove effects by key.</summary>
string ID{get;}/// <summary>Display name shown in the battle UI.</summary>
string Name{get;}/// <summary>Flavour text explaining the effect to the player.</summary>
string Description{get;}/// <summary>High-level category (<c>Buff</c>, <c>Debuff</c>, <c>Stun</c>) used for UI tinting and quick type checks like <see cref="StatusEffectQueue.HasStun"/>.</summary>
StatusEffectType EffectType{get;}/// <summary>Base duration in turns. Set during effect creation; may be overridden by the caller via <see cref="StatusEffectQueue.Add"/>. A value of 0 or negative means the effect never expires naturally.</summary>
int Duration{get;set;}/// <summary>Turns remaining before the effect expires. Decremented each turn boundary by the queue. Independent of <c>Duration</c> so that the original value is preserved for display or re-apply logic.</summary>
int RemainingTurns{get;set;}/// <summary>Icon texture loaded from the <c>"characters"</c> mod group, displayed beside the effect name in the HUD.</summary>
Texture2D Icon{get;}/// <summary>Behaviour hooks that fire during turn phases and on damage events. Resolved by <c>StatusEffectActionFactory</c> from the <see cref="StatusEffectInfo.Actions"/> string ID.</summary>
IStatusActions Actions{get;}/// <summary>Creates a shallow clone. <c>Actions</c> is a shared reference (stateless singleton) so it is not deep-copied. Used by <see cref="StatusEffectQueue.Copy"/> when duplicating a character.</summary>
IStatusEffect Copy();}}

===== FILE mods/turn_battle_base/src/StatusEffect/Impls/StunStatusEffect.cs =====
using Cthangover.Core.Characters;namespace Cthangover.Core.Cards.StatusEffect.Impls{/// <summary> Stun behaviour set — intentionally all no-ops. The stun condition is checked separately via StatusEffectQueue.HasStun() rather than through action hooks, so the Actions implementation is empty. Exists as a concrete type so the factory can resolve "basic/stun" to a valid instance. </summary>
public class StunStatusEffect:IStatusActions{/// <inheritdoc cref="IStatusActions.ID"/>
public string ID=>"basic/stun";/// <inheritdoc cref="IStatusActions.OnTurnStart"/>
public void OnTurnStart(Character target){}/// <inheritdoc cref="IStatusActions.OnTurnEnd"/>
public void OnTurnEnd(Character target){}/// <inheritdoc cref="IStatusActions.OnDealDamage"/>
public void OnDealDamage(Character source,Character target,ref int damage){}/// <inheritdoc cref="IStatusActions.OnTakeDamage"/>
public void OnTakeDamage(Character target,Character source,ref int damage){}/// <inheritdoc cref="IStatusActions.OnApply"/>
public void OnApply(Character target){}/// <inheritdoc cref="IStatusActions.OnRemove"/>
public void OnRemove(Character target){}/// <inheritdoc cref="IStatusActions.OnFinalAction"/>
public void OnFinalAction(Character target){}/// <inheritdoc cref="IStatusActions.ModifyAttributes"/>
public void ModifyAttributes(CharacterAttributes attributes){}}}

===== FILE mods/turn_battle_base/src/StatusEffect/StatusEffectInfo.cs =====
using System.Text.Json.Serialization;using Cthangover.Core.Factories;namespace Cthangover.Core.Cards.StatusEffect{/// <summary> JSON-serialisable status effect definition loaded from mod files. The Actions field is an ID string resolved by StatusEffectActionFactory into an IStatusActions instance at runtime. Type uses JsonStringEnumConverter for human-readable config files. Implements IIdentifiable so it works with PrefabFactory caching. </summary>
[System.Serializable][EntityKind("status_effect")]public class StatusEffectInfo:IIdentifiable{/// <summary>Unique effect identifier used as the factory key. Serialised as <c>"Id"</c> in JSON for consistency with the modding schema.</summary>
[JsonPropertyName("Id")]public string ID{get;set;}/// <summary>Display name for the effect, shown in tooltips and the character status panel.</summary>
public string Name{get;set;}/// <summary>Human-readable description explaining what the effect does.</summary>
public string Description{get;set;}/// <summary>Category of the effect. Serialised as a string (<c>"Buff"</c>, <c>"Debuff"</c>, <c>"Stun"</c>) via <c>JsonStringEnumConverter</c> for readable mod configs.</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]public StatusEffectType Type{get;set;}/// <summary>Base duration in turns before the effect naturally expires.</summary>
public int Duration{get;set;}/// <summary>Factory ID string for the behaviour set, resolved by <c>StatusEffectActionFactory</c> into an <see cref="IStatusActions"/> instance at runtime.</summary>
public string Actions{get;set;}/// <summary>Filename of the icon texture within the <c>"characters"</c> mod group, loaded by <see cref="StatusEffectItem"/> during construction.</summary>
public string Icon{get;set;}}}

===== FILE mods/turn_battle_base/src/StatusEffect/StatusEffectItem.cs =====
using Cthangover.Core.Factories.Impls;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Cards.StatusEffect{/// <summary> Runtime instance of a status effect. Constructed from StatusEffectInfo with the Actions behaviour resolved via StatusEffectActionFactory. Icon is loaded from the "characters" mod group via TextureUtils — effects share the same icon pool as characters. Copy() is a shallow clone of primitives and references (Actions is a shared singleton, not cloned) suitable for StatusEffectQueue duplication. </summary>
public class StatusEffectItem:IStatusEffect{/// <inheritdoc cref="IStatusEffect.ID"/>
public string ID{get;private set;}/// <inheritdoc cref="IStatusEffect.Name"/>
public string Name{get;private set;}/// <inheritdoc cref="IStatusEffect.Description"/>
public string Description{get;private set;}/// <inheritdoc cref="IStatusEffect.Duration"/>
public int Duration{get;set;}/// <inheritdoc cref="IStatusEffect.RemainingTurns"/>
public int RemainingTurns{get;set;}/// <inheritdoc cref="IStatusEffect.EffectType"/>
public StatusEffectType EffectType{get;private set;}/// <inheritdoc cref="IStatusEffect.Icon"/>
public Texture2D Icon{get;private set;}/// <inheritdoc cref="IStatusEffect.Actions"/>
public IStatusActions Actions{get;private set;}/// <summary>Constructs a runtime effect from its JSON-serialisable definition. Resolves the icon via <c>TextureUtils.LoadFromModGroup</c> and the action set via <c>StatusEffectActionFactory</c>.</summary> <param name="info">Deserialised effect data from a mod config.</param>
public StatusEffectItem(StatusEffectInfo info){ID=info.ID;Name=info.Name;Description=info.Description;Duration=info.Duration;EffectType=info.Type;Icon=TextureUtils.LoadFromModGroup("characters",info.Icon);Actions=StatusEffectActionFactory.Instance.Get(info.Actions);}/// <summary>Parameterless constructor for cases where properties are set manually (e.g. by <see cref="Copy"/> or deserialisation paths).</summary>
public StatusEffectItem(){}/// <summary>Shallow clone that copies primitives and reuses the shared <c>Actions</c> reference. Used when the queue duplicates effects during character cloning.</summary> <returns>A new <see cref="StatusEffectItem"/> with identical property values.</returns>
public IStatusEffect Copy(){return new StatusEffectItem{ID=ID,Name=Name,Description=Description,Duration=Duration,RemainingTurns=RemainingTurns,EffectType=EffectType,Icon=Icon,Actions=Actions,};}}}

===== FILE mods/turn_battle_base/src/StatusEffect/StatusEffectQueue.cs =====
using System.Collections.Generic;using System.Linq;using Cthangover.Core.Characters;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Core.Cards.StatusEffect{/// <summary> Per-character status-effect pipeline. Manages a list of active effects, advancing their timers on turn boundaries and removing expired ones after firing OnFinalAction. Add resolves the effect info via the factory and calls OnApply immediately. OnTurnStart iterates backwards to safely remove expired effects during traversal. OnDealDamage / OnTakeDamage pipe through every active effect via ref parameters, enabling multiplicative stacking. ActiveStatusEffects returns a defensive copy so external code cannot mutate the internal list. Copy creates a deep clone for character duplication, re-firing OnApply on the new owner. </summary>
public class StatusEffectQueue{private readonly List<IStatusEffect>activeStatusEffectData;private readonly Character owner;/// <summary>Defensive copy of the internal effect list. Returns a new <c>List</c> each access so external code cannot mutate the queue's backing store. Safe for enumeration.</summary>
public List<IStatusEffect>ActiveStatusEffects=>activeStatusEffectData.ToList();/// <summary>Initialises an empty queue bound to <paramref name="owner"/>. The owner reference is used by all action-hook calls to pass the affected character.</summary> <param name="owner">The character who carries these effects.</param>
public StatusEffectQueue(Character owner){this.activeStatusEffectData=new List<IStatusEffect>();this.owner=owner;}/// <summary>Checks whether any active effect has <see cref="StatusEffectType.Stun"/>. Called by the battle engine to determine if the owner should skip their turn.</summary> <returns><see langword="true"/> if at least one Stun-type effect is active.</returns>
public bool HasStun(){foreach(var statusEffect in activeStatusEffectData)if(statusEffect.EffectType==StatusEffectType.Stun)return true;return false;}/// <summary>Looks up <paramref name="statusEffectId"/> in the factory, constructs a <see cref="StatusEffectItem"/>, optionally overrides its duration, fires <c>OnApply</c>, and appends it to the active list.</summary> <param name="statusEffectId">Factory key matching a <see cref="StatusEffectInfo"/> entry.</param> <param name="duration">If positive, overwrites the default duration from the effect definition.</param> <returns><see langword="false"/> if the factory lookup fails (key not found); otherwise <see langword="true"/>.</returns>
public bool Add(string statusEffectId,int duration= -1){var statusEffectInfo=Factories.Impls.StatusEffectInfoFactory.Instance.Get(statusEffectId);if(statusEffectInfo==null){GameLogger.Log("STATUS",$"statusEffect with ID '{statusEffectId}' not found!",LogLevel.Error);return false;}var statusEffect=new StatusEffectItem(statusEffectInfo);if(duration>0){statusEffect.Duration=duration;statusEffect.RemainingTurns=duration;}statusEffect.Actions?.OnApply(owner);activeStatusEffectData.Add(statusEffect);return true;}/// <summary>Removes the first effect matching <paramref name="statusEffectId"/> from the active list. Fires <c>OnRemove</c> so the effect can clean up before disappearing (e.g. revert stat modifications).</summary> <param name="statusEffectId">Factory key of the effect to remove.</param> <returns><see langword="false"/> if no matching effect was found; otherwise <see langword="true"/>.</returns>
public bool Remove(string statusEffectId){var statusEffect=activeStatusEffectData.FirstOrDefault(b=>b.ID==statusEffectId);if(statusEffect==null)return false;statusEffect.Actions?.OnRemove(owner);activeStatusEffectData.Remove(statusEffect);return true;}/// <summary>Advances all active effects at the start of the owner's turn. Fires <c>OnTurnStart</c> on each effect, decrements <see cref="IStatusEffect.RemainingTurns"/> for timed effects, and removes expired ones after firing <c>OnFinalAction</c>. Iterates backwards so removals do not corrupt the index.</summary>
public void OnTurnStart(){for(int i=activeStatusEffectData.Count-1;i>=0;i--){var statusEffect=activeStatusEffectData[i];statusEffect.Actions?.OnTurnStart(owner);if(statusEffect.RemainingTurns>0&&statusEffect.Duration>0){statusEffect.RemainingTurns--;if(statusEffect.RemainingTurns<=0){statusEffect.Actions?.OnFinalAction(owner);activeStatusEffectData.RemoveAt(i);}}}}/// <summary>Fires end-of-turn hooks on all active effects. Operates on a defensive copy so that <c>OnTurnEnd</c> callbacks that add or remove effects do not invalidate the enumerator. Decrements remaining turns and removes expired effects via <see cref="Remove"/> (which also fires <c>OnRemove</c>).</summary>
public void OnTurnEnd(){foreach(var statusEffect in ActiveStatusEffects){statusEffect.Actions?.OnTurnEnd(owner);statusEffect.RemainingTurns--;if(statusEffect.RemainingTurns<=0){statusEffect.Actions?.OnFinalAction(owner);Remove(statusEffect.ID);}}}/// <summary>Pipes outgoing damage through every active effect's <c>OnDealDamage</c> hook, allowing each to modify <paramref name="damage"/> via <see langword="ref"/>. Effects iterate in insertion order; multiplicative stacking means order-agnostic results for flat modifiers but order-sensitive for percentage modifiers.</summary> <param name="target">The character receiving the damage.</param> <param name="damage">The damage value, modifiable by effects through the <see langword="ref"/> parameter.</param>
public void OnDealDamage(Character target,ref int damage){foreach(var statusEffect in activeStatusEffectData){statusEffect.Actions?.OnDealDamage(owner,target,ref damage);}}/// <summary>Pipes incoming damage through every active effect's <c>OnTakeDamage</c> hook. Called after <see cref="OnDealDamage"/> in the damage pipeline, so defensive modifiers stack on top of the attacker's amplified value.</summary> <param name="source">The character dealing the damage.</param> <param name="damage">The damage value, modifiable by effects through the <see langword="ref"/> parameter.</param>
public void OnTakeDamage(Character source,ref int damage){foreach(var statusEffect in activeStatusEffectData){statusEffect.Actions?.OnTakeDamage(owner,source,ref damage);}}/// <summary>Removes all active effects, firing <c>OnRemove</c> on each before clearing the list. Used when a character dies or an encounter ends so effects do not leak between battles.</summary>
public void ClearAll(){foreach(var statusEffect in activeStatusEffectData){statusEffect.Actions?.OnRemove(owner);}activeStatusEffectData.Clear();}/// <summary>Deep-clones the entire queue for a new character owner. Each effect is copied via <see cref="IStatusEffect.Copy"/>, and <c>OnApply</c> is re-fired on <paramref name="newOwner"/> so initialisation hooks run in the target's context. Used when a character is duplicated mid-encounter (e.g. a summon or mirror effect).</summary> <param name="newOwner">The character who will own the clone.</param> <returns>A new <see cref="StatusEffectQueue"/> bound to <paramref name="newOwner"/> with copies of all active effects.</returns>
public StatusEffectQueue Copy(Character newOwner){var newSystem=new StatusEffectQueue(newOwner);foreach(var statusEffect in activeStatusEffectData){var copiedstatusEffect=statusEffect.Copy();copiedstatusEffect.Actions?.OnApply(newOwner);newSystem.activeStatusEffectData.Add(copiedstatusEffect);}return newSystem;}}}

===== FILE mods/turn_battle_base/src/StatusEffect/StatusEffectService.cs =====
using System.Runtime.CompilerServices;using Cthangover.Core.Characters;namespace Cthangover.Core.Cards.StatusEffect{/// <summary> Side-table that owns the <see cref="StatusEffectQueue"/> of every character participating in battle. The queue used to live on <see cref="Character"/> itself, but the core character model must stay free of battle subsystems — so the battle mod keeps queues keyed by character instance instead. <para/> Uses <see cref="ConditionalWeakTable{TKey, TValue}"/>: queues die together with their character, which matters because each battle constructs fresh copies via <see cref="Character.Copy"/> and discards them afterwards. All access happens on the main thread during battle, so no extra locking is needed beyond what the weak table provides. </summary>
public static class StatusEffectService{private static readonly ConditionalWeakTable<Character,StatusEffectQueue>queues=new();/// <summary> Returns the queue of <paramref name="character"/>, creating an empty one on first access. Returns <c>null</c> for a null character so call sites can keep their existing null-conditional patterns. </summary>
public static StatusEffectQueue Of(Character character){if(character==null)return null;return queues.GetValue(character,CreateQueue);}private static StatusEffectQueue CreateQueue(Character owner){return new StatusEffectQueue(owner);}}}

===== FILE mods/turn_battle_base/src/StatusEffect/StatusEffectType.cs =====
namespace Cthangover.Core.Cards.StatusEffect{/// <summary> High-level classification for status effects. Buff/Debuff are informational (UI colouring, stacking rules); Stun is singled out because the queue has a dedicated HasStun() check that the battle engine queries to skip a stunned character's turn. </summary>
public enum StatusEffectType{/// <summary>Positive effect that benefits the bearer (e.g. stat boost, regeneration). Rendered with a friendly tint in the UI.</summary>
Buff,/// <summary>Negative effect that harms the bearer (e.g. damage over time, stat reduction). Rendered with a hostile tint in the UI.</summary>
Debuff,/// <summary>Crowd-control effect that causes the bearer to skip their turn. Checked by <see cref="StatusEffectQueue.HasStun"/>; the action hooks are deliberately no-ops — the skip is enforced by the battle engine reading this type flag, not by the <see cref="IStatusActions"/> callbacks.</summary>
Stun,}}

===== FILE mods/turn_battle_base/src/StatusEffectActionFactory.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Cards.StatusEffect;using Cthangover.Core.Utils;namespace Cthangover.Core.Factories.Impls{/// <summary> Reflection-based plugin registry for status effect behaviour classes, mirroring the pattern used by <c>ItemActionFactory</c>. Each <c>IStatusActions</c> implementation corresponds to a status effect ID referenced in battle data JSON. The kernel hands every mod's implementations to <see cref="StatusActionsConsumer"/>, so mod authors add new status effects (e.g. poison, burn, freeze) by implementing the interface in their assembly; they leave again when their mod unloads. </summary>
public class StatusEffectActionFactory{private readonly Dictionary<string,IStatusActions>actions;
#region Singleton
private static readonly Lazy<StatusEffectActionFactory>instance=new(()=>new StatusEffectActionFactory());/// <summary>Lazy singleton, filled by <see cref="StatusActionsConsumer"/>.</summary>
public static StatusEffectActionFactory Instance=>instance.Value;public StatusEffectActionFactory(){actions=new Dictionary<string,IStatusActions>();}
#endregion
/// <summary>Registers a behaviour set under its <c>ID</c>; the first registration of an id wins.</summary>
public void Register(IStatusActions action){if(action!=null&& !string.IsNullOrEmpty(action.ID)&& !actions.ContainsKey(action.ID))actions[action.ID]=action;}/// <summary>Removes exactly <paramref name="action"/> (its mod is being unloaded).</summary>
public void Unregister(IStatusActions action){if(action!=null&& !string.IsNullOrEmpty(action.ID)&&actions.TryGetValue(action.ID,out var current)&&ReferenceEquals(current,action))actions.Remove(action.ID);}/// <summary>Resolves the behaviour set registered under the effect's <c>Actions</c> id; unknown ids are logged and return <c>null</c>.</summary>
public IStatusActions Get(string id){if(actions.TryGetValue(id,out var result))return result;GameLogger.Log("STATUS","actions '"+id+"' not found!",LogLevel.Error);return null;}}}

===== FILE mods/turn_battle_base/src/StatusEffectInfoFactory.cs =====
using System;using Cthangover.Core.Cards.StatusEffect;namespace Cthangover.Core.Factories.Impls{/// <summary> Thin <c>FileFactory</c> for status effect metadata — duration, stack behaviour, visual icon, etc. Works in tandem with <c>StatusEffectActionFactory</c>: this factory provides the <b>data</b> (what the effect is), while the action factory provides the <b>behaviour</b> (what the effect does each turn). Files are discovered by the shared entity resolver through the DTO's <c>[EntityKind("status_effect")]</c> attribute. </summary>
public class StatusEffectInfoFactory:FileFactory<StatusEffectInfo>{private static readonly Lazy<StatusEffectInfoFactory>lazy=new(()=>new StatusEffectInfoFactory());/// <summary>Lazy singleton facade over the AppServices-owned factory.</summary>
public static StatusEffectInfoFactory Instance=>lazy.Value;}}

===== FILE mods/turn_battle_base/src/TurnBattleGDScriptHook.cs =====
using Cthangover.Core.Battle;using Cthangover.Core.Battle.Actions;using Cthangover.Core.Mods;using Cthangover.Core.Utils;using Godot;namespace Cthangover.TurnBattle{/// <summary> GDScript hook of the turn-battle mod. The core's ModGDScriptLoader walks every <c>.gd</c> file generically and hands objects to registered hooks; this hook recognizes the two battle-side contracts: <list type="bullet"> <item><c>get_action_id()</c> + <c>execute(action, user, target)</c> — wraps the script in a <see cref="GDBattleActionExecutor"/> and registers it in the global <see cref="ActionExecutorHub"/> pool.</item> <item><c>get_winground_listener_id()</c> plus any of the three <c>on_winground_*</c> methods — wraps the script in a <see cref="GDWingroundListener"/>.</item> </list> Keeping the recognition here means the core loader no longer mentions any battle method name. </summary>
public class TurnBattleGDScriptHook:IGDScriptHook{/// <summary>Stable id used by <see cref="GDScriptHookRegistry"/> for replacement on reload.</summary>
public string Id=>"turn_battle_base.gd_hook";/// <summary>Inspects one compiled script and registers an adapter when a contract matches.</summary>
public void TryRegister(GodotObject obj,string modId,string filePath){if(obj==null)return;if(obj.HasMethod("get_action_id")&&obj.HasMethod("execute")){var actionId=obj.Call("get_action_id").AsString();if(!string.IsNullOrEmpty(actionId)){ActionExecutorHub.Instance.RegisterGlobal(new GDBattleActionExecutor(actionId,obj));GameLogger.Log("GDS_MOD",$"Registered GDScript battle executor '{actionId}' from mod '{modId}' ({filePath})");}}var hasWingroundHook=obj.HasMethod("on_winground_before_exp")||obj.HasMethod("on_winground_after_loot")||obj.HasMethod("on_winground_before_show");if(hasWingroundHook&&obj.HasMethod("get_winground_listener_id")){var listenerId=obj.Call("get_winground_listener_id").AsString();if(!string.IsNullOrEmpty(listenerId)){WingroundListenerRegistry.Instance.RegisterListener(new GDWingroundListener(listenerId,obj));GameLogger.Log("GDS_MOD",$"Registered GDScript winground listener '{listenerId}' from mod '{modId}' ({filePath})");}}}}}

===== FILE mods/turn_battle_base/src/TurnBattleSceneInitializer.cs =====
using Cthangover.Core.Audio;using Cthangover.Core.Battle;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;namespace Cthangover.TurnBattle{/// <summary> Lifecycle entry point of the turn-battle mod. Two responsibilities: <list type="number"> <item>Publishes the logical "Battle" scene route with fade transition and combat music, so the core can route <c>switch_scene Battle</c> without hardcoding a battle path.</item> <item>Registers <see cref="TurnBattleGDScriptHook"/> so GDScript mods can still supply action executors and winground listeners after the battle bridge moved out of the core loader.</item> </list> Battle cores, winground listeners and status effect behaviours of all mods reach their registries through the kernel's assembly consumers (<see cref="BattleCoreConsumer"/>, <see cref="WingroundListenerConsumer"/>, <see cref="StatusActionsConsumer"/>), not from here. </summary>
public class TurnBattleSceneInitializer:IModInitializer{private const string BattleScenePath="scenes/battle/battle.tscn";/// <summary> Installs the GDScript hook. It must happen here, not at resources-ready: ModGDScriptLoader processes <c>.gd</c> sources during assembly registration finalization, before resources become ready. </summary>
public void OnModLoaded(string modId){GDScriptHookRegistry.Register(new TurnBattleGDScriptHook());}/// <summary>Publishes the scene route.</summary>
public void OnModResourcesReady(){SceneRouteRegistry.Register("Battle",BattleScenePath,fade:true,musicScene:"Battle",music:MusicType.Combat);}}}

===== FILE mods/turn_battle_base/src/WingroundBehaviour.cs =====
using System;using System.Collections.Generic;using System.Linq;using Cthangover.Core.Audio;using Cthangover.Core.Battle;using Cthangover.Core.Events;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Items;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;using Cthangover.Core.Settings;using Cthangover.Mods.Interface.Base.Lists;using Cthangover.Core.UI.Theming;using Cthangover.Core.Characters;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Utils;using Godot;namespace Cthangover.TurnBattle{/// <summary> Victory screen widget (extends TransitionWidget for fade-in). On Show it: awards tracked EXP to each surviving character in the battle set, builds a 4-slot EXP report card row (with placeholders for missing slots to keep layout stable), rolls loot from defeated enemies using per-item probability/range, lays out loot in a grid whose cell size is computed from the container width and lootAspectHeight, and adds items to inventory. When enemies were defeated it also publishes <see cref="CharactersDefeatedEvent"/> on the core event bus — so systems like the recruiting mod can react without this screen knowing about them (and without a mod dependency). A "return" button sets the pending scene and reloads BaseScene — the actual destination scene (town_entry or the quest's ReturnScene) is injected by the scene manager. </summary>
public partial class WingroundBehaviour:TransitionWidget{private static readonly ModScenePrefab ExpReportCardPrefab=new("scenes/battle/exp_report_card.tscn");private static readonly ModScenePrefab LootReportItemPrefab=new("scenes/battle/loot_report_item.tscn");[Export]private Label txtInfo;[Export]private Label lootTitle;[Export]private Container charExpContainer;[Export]private Control lootContent;[Export]private int lootColumnCount=5;[Export]private float lootAspectHeight=1.2f;[Export]private Vector2 lootCellPadding=new(4f,4f);[Export]private float lootBorderLeft=8f;[Export]private float lootBorderRight=8f;[Export]private float lootBorderTop=8f;[Export]private float lootBorderBottom=8f;private BattleData data;/// <summary> Victory screen entry point. Builds a <see cref="WingroundContext"/> from <see cref="BattleSceneContext"/> data, notifies <see cref="WingroundListenerRegistry"/> listeners at three phases (before EXP, after loot, before show), awards EXP from the context (which listeners may have mutated), and commits loot from the context to inventory. Finally calls <c>base.Show()</c> for the fade-in animation. </summary>
public override void Show(){CthangoverTheme.Apply(this);CthangoverTheme.ApplyPrimary(GetNodeOrNull<Button>("Content/CenterPanel/ContinueButton"));lootTitle.Text=TranslationServer.Translate("ui/battle/victory/loot_title");GameLogger.Log("BATTLE",$"Winground.Show() ENTER: Visible={Visible}, GodotVisible={base.Visible}, txtInfo={(txtInfo != null ? "found" : "NULL")}",LogLevel.Debug);var audioService=GetNodeOrNull<Node>("/root/AudioService")as IAudioService;audioService?.PlaySound("battle/winground",SoundType.UI);var bg=GetNodeOrNull<TextureRect>("TransitionBg");GameLogger.Log("BATTLE",$"Winground.Show: TransitionBg={(bg != null ? "found" : "NULL")}, texture={(bg?.Texture != null ? "has" : "null")}",LogLevel.Error);if(bg!=null&&bg.Texture==null){var tex=Textures.Resolve("winground");GameLogger.Log("BATTLE",$"Winground.Show: ResolveTexture('winground') = {(tex != null ? "loaded" : "NULL")}",LogLevel.Error);bg.Texture=tex;}data=BattleRequest.Current;GameLogger.Log("BATTLE_UI",$"Winground.Show: BattleData={(data != null ? "found" : "NULL")}",LogLevel.Error);var expTracking=BuildExpTracking();var defeatedEnemies=BuildDefeatedEnemies();var ctx=new WingroundContext{PlayerExpGained=expTracking,DefeatedEnemies=defeatedEnemies,BattleData=data};WingroundListenerRegistry.Instance.NotifyBeforeExpAward(ctx);foreach(var characterType in GameData.Instance.Runtime.CharacterData.BattleSet){if(!GameData.Instance.Runtime.CharacterData.Characters.TryGetValue(characterType,out var info)){GameLogger.Log("BATTLE_UI",$"Winground.Show: characterType='{characterType}' NOT FOUND in Characters dict",LogLevel.Error);continue;}var earnedExp=0;if(ctx.PlayerExpGained!=null&&info?.ID!=null)ctx.PlayerExpGained.TryGetValue(info.ID,out earnedExp);GameLogger.Log("BATTLE_UI",$"Winground.Show: char='{characterType}' id='{info?.ID}' oldExp={info.Exp} earnedExp={earnedExp} newExp={info.Exp + earnedExp}",LogLevel.Debug);info.Exp+=earnedExp;}BuildCharExpDisplay(ctx.PlayerExpGained);if(ctx.DefeatedEnemies!=null&&ctx.DefeatedEnemies.Count>0){var loot=GenerateLoot(ctx.DefeatedEnemies);ctx.GeneratedLoot=loot;WingroundListenerRegistry.Instance.NotifyAfterLootGenerated(ctx);BuildLootDisplay(ctx.GeneratedLoot);AddLootToInventory(ctx.GeneratedLoot);PublishDefeatedCharacters(ctx.DefeatedEnemies);}WingroundListenerRegistry.Instance.NotifyBeforeShow(ctx);base.Show();GameLogger.Log("BATTLE",$"Winground.Show() EXIT: Visible={Visible}, GodotVisible={base.Visible}",LogLevel.Debug);}private static Dictionary<string,int>BuildExpTracking(){var source=BattleSceneContext.Instance?.PlayerExpGained;if(source==null||source.Count==0)return new Dictionary<string,int>();var copy=new Dictionary<string,int>();foreach(var kvp in source)copy[kvp.Key]=kvp.Value;GameLogger.Log("BATTLE_UI",$"Winground.BuildExpTracking: copied {copy.Count} entries",LogLevel.Debug);return copy;}private static List<Character>BuildDefeatedEnemies(){var source=BattleSceneContext.Instance?.DefeatedEnemies;if(source==null||source.Count==0)return new List<Character>();var copy=new List<Character>(source);GameLogger.Log("BATTLE_UI",$"Winground.BuildDefeatedEnemies: copied {copy.Count} entries",LogLevel.Debug);return copy;}private const int REPORT_SLOT_COUNT=4;private void BuildCharExpDisplay(Dictionary<string,int>expTracking){var container=charExpContainer??GetNodeOrNull<Container>("Content/CharExpContainer");GameLogger.Log("BATTLE_UI",$"BuildCharExpDisplay: charExpContainer export={(charExpContainer != null ? "found" : "NULL")}, GetNodeOrNull fallback={(container != null ? "found" : "NULL")}",LogLevel.Error);if(container==null){GameLogger.Log("BATTLE_UI","BuildCharExpDisplay: EARLY RETURN — container is null",LogLevel.Error);return;}foreach(var child in container.GetChildren())child.QueueFree();var charData=GameData.Instance.Runtime.CharacterData;GameLogger.Log("BATTLE_UI",$"BuildCharExpDisplay: expTracking={(expTracking != null ? $"found ({expTracking.Count} entries)" : "NULL")}, BattleSet count={charData.BattleSet.Count}",LogLevel.Error);var battleSetList=charData.BattleSet.ToList();for(var i=0;i<REPORT_SLOT_COUNT;i++){if(i<battleSetList.Count){var characterType=battleSetList[i];if(!charData.Characters.TryGetValue(characterType,out var info)){GameLogger.Log("BATTLE_UI",$"BuildCharExpDisplay: characterType='{characterType}' NOT FOUND in Characters dict",LogLevel.Error);AddPlaceholderSlot(container,i);continue;}var earnedExp=0;expTracking?.TryGetValue(info.ID,out earnedExp);var cardTemplate=CharacterFactory.Instance.Get(info.ID);var name=cardTemplate?.Name!=null?TranslationServer.Translate(cardTemplate.Name).ToString():info.ID;var reportCard=ExpReportCardPrefab.Instantiate<ExpReportCard>();reportCard.Setup(cardTemplate?.Image,name,earnedExp);container.AddChild(reportCard);GameLogger.Log("BATTLE_UI",$"BuildCharExpDisplay: added reportCard for '{characterType}', id='{info.ID}', name='{name}', earnedExp={earnedExp}, cardTemplate={(cardTemplate != null ? "found" : "NULL")}, icon={(cardTemplate?.Image != null ? "has" : "null")}",LogLevel.Error);}else{AddPlaceholderSlot(container,i);}}GameLogger.Log("BATTLE_UI",$"BuildCharExpDisplay: finished, container now has {container.GetChildCount()} children",LogLevel.Debug);}private static void AddPlaceholderSlot(Container container,int slotIndex){var placeholder=new Control();placeholder.CustomMinimumSize=new Vector2(130,130);placeholder.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;placeholder.SizeFlagsVertical=Control.SizeFlags.ExpandFill;placeholder.MouseFilter=MouseFilterEnum.Ignore;container.AddChild(placeholder);GameLogger.Log("BATTLE_UI",$"BuildCharExpDisplay: added placeholder slot {slotIndex}",LogLevel.Debug);}private static readonly Random random=new();private List<ItemContainer>GenerateLoot(List<Character>enemies){var loot=new List<ItemContainer>();foreach(var enemy in enemies){if(enemy?.Loot==null||enemy.Loot.Count==0)continue;foreach(var entry in enemy.Loot){if(string.IsNullOrWhiteSpace(entry.ItemId))continue;var probability=Math.Clamp(entry.Probability,0,100);if(probability<=0)continue;if(probability<100&&random.Next(1,101)>probability)continue;var count=entry.MinCount==entry.MaxCount?entry.MinCount:random.Next(entry.MinCount,entry.MaxCount+1);if(count>0)TryAddLootItem(loot,entry.ItemId,count);}}return loot;}private static void TryAddLootItem(List<ItemContainer>loot,string itemId,int count){var existing=loot.Find(c=>c.Item?.ID==itemId);if(existing!=null){existing.Count+=count;}else{var item=ItemFactory.Instance.Get(itemId);if(item!=null)loot.Add(new ItemContainer{Item=item,Count=count});}}private void BuildLootDisplay(List<ItemContainer>loot){var content=lootContent??GetNodeOrNull<Control>("Content/LootScrollContainer/LootContent");if(content==null)return;foreach(var child in content.GetChildren())child.QueueFree();if(loot.Count==0)return;var scrollContainer=content.GetParent()as ScrollContainer;if(scrollContainer!=null){content.CustomMinimumSize=new Vector2(scrollContainer.Size.X,0);content.Size=new Vector2(scrollContainer.Size.X,content.Size.Y);}var effectiveWidth=content.Size.X-lootBorderLeft-lootBorderRight;var cellWidth=(effectiveWidth-(lootColumnCount-1)*lootCellPadding.X)/lootColumnCount;var cellHeight=cellWidth*lootAspectHeight;var rows=Mathf.CeilToInt((float)loot.Count/lootColumnCount);var totalHeight=lootBorderTop+lootBorderBottom+rows*cellHeight+(rows-1)*lootCellPadding.Y;content.CustomMinimumSize=new Vector2(content.CustomMinimumSize.X,totalHeight);for(int i=0;i<loot.Count;i++){var itemContainer=loot[i];var item=itemContainer.Item;if(item==null)continue;var localizedName=TranslationServer.Translate(item.Name).ToString();var reportItem=LootReportItemPrefab.Instantiate<LootReportItem>();reportItem.Setup(item.Sprite,localizedName,itemContainer.Count);var col=i%lootColumnCount;var row=i/lootColumnCount;reportItem.Position=new Vector2(lootBorderLeft+col*(cellWidth+lootCellPadding.X),lootBorderTop+row*(cellHeight+lootCellPadding.Y));reportItem.Size=new Vector2(cellWidth,cellHeight);content.AddChild(reportItem);}}private static void AddLootToInventory(List<ItemContainer>loot){var inventory=GameData.Instance.Runtime.Inventory;foreach(var itemContainer in loot){if(itemContainer.Item!=null)inventory.Add(itemContainer.Item,itemContainer.Count);}}private static void PublishDefeatedCharacters(List<Character>enemies){ModEventBus.Publish(new CharactersDefeatedEvent{Source="battle",CharacterIds=enemies.Where(enemy=>enemy!=null&& !string.IsNullOrEmpty(enemy.ID)).Select(enemy=>enemy.ID).ToArray(),});}private void ToReturnScene(){if(data==null){GameLogger.Log("BATTLE","battle data is null!",LogLevel.Error);}if(data?.Quest!=null&&data?.NewTag!=null)data.Quest.AddTag(data.NewTag);var returnScene=data?.ReturnScene??"town_entry";BattleRequest.Current=null;var sceneService=GetNode<GodotSceneService>("/root/GodotSceneService");var sceneManager=GetNode<SceneManager>("/root/SceneManager");if(sceneManager!=null)sceneManager.PendingSceneName=returnScene;sceneService?.LoadShell();}}}

===== FILE mods/turn_battle_base/src/WingroundContext.cs =====
using System.Collections.Generic;using Cthangover.Core.Characters;using Cthangover.Core.Items;using Cthangover.Core.Settings;namespace Cthangover.Core.Battle{/// <summary> Mutable context passed to <see cref="IWingroundListener"/> hooks during the victory-screen pipeline. Listeners receive the same instance across all three phases (before EXP, after loot, before show) and can mutate any field — including removing enemies, adjusting earned EXP per character, or modifying the generated loot list. The <see cref="WingroundBehaviour"/> reads these fields after each hook, so changes take effect immediately. </summary>
public class WingroundContext{/// <summary> Per-character EXP earned during this battle, keyed by <see cref="Character.ID"/>. Populated from <see cref="BattleSceneContext.PlayerExpGained"/> before the first hook and consumed by <see cref="WingroundBehaviour"/> to award EXP to <see cref="CharacterData"/>. </summary>
public Dictionary<string,int>PlayerExpGained{get;set;}=new();/// <summary> Enemies defeated during this battle. Used by the victory screen for loot generation and recruitment rolls. Null entries are skipped by <see cref="WingroundBehaviour"/>. </summary>
public List<Character>DefeatedEnemies{get;set;}=new();/// <summary> Loot items generated from <see cref="DefeatedEnemies"/>. Filled by <see cref="WingroundBehaviour"/> between the "before EXP" and "after loot" hooks. Listeners can add, remove, or modify entries before the items are committed to inventory. </summary>
public List<ItemContainer>GeneratedLoot{get;set;}=new();/// <summary> The <see cref="BattleData"/> that started this battle. Read-only reference — do not mutate the object itself (it is shared with the quest system) but its fields such as <c>ReturnScene</c> are writable. </summary>
public BattleData BattleData{get;set;}}}

===== FILE mods/turn_battle_base/src/WingroundListenerConsumer.cs =====
namespace Cthangover.Core.Battle{using Cthangover.Core.Mods.Consumers;/// <summary> Feeds <see cref="WingroundListenerRegistry"/> with every mod's C# <see cref="IWingroundListener"/> implementations, and removes them when their mod unloads. GDScript listeners still register through the hook. </summary>
public class WingroundListenerConsumer:ModTypeConsumer<IWingroundListener>{/// <inheritdoc />
public override string Id=>"turn_battle_base.winground_listeners";/// <inheritdoc />
protected override void Add(IWingroundListener instance,string modId)=>WingroundListenerRegistry.Instance.RegisterListener(instance);/// <inheritdoc />
protected override void Remove(IWingroundListener instance,string modId)=>WingroundListenerRegistry.Instance.UnregisterListener(instance);}}

===== FILE mods/turn_battle_base/src/WingroundListenerRegistry.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Utils;namespace Cthangover.Core.Battle{/// <summary> Singleton registry that collects <see cref="IWingroundListener"/> implementations (C# from every mod through <see cref="WingroundListenerConsumer"/>, GDScript via <see cref="RegisterListener"/>) and broadcasts winground lifecycle hooks to all of them in registration order. </summary>
public class WingroundListenerRegistry{public static readonly WingroundListenerRegistry Instance=new();private readonly List<IWingroundListener>_listeners=new();/// <summary> Registers a listener, replacing any earlier one with the same <see cref="IWingroundListener.Id"/> (used by the GDScript bridge <see cref="GDWingroundListener"/> and by <see cref="WingroundListenerConsumer"/>). Safe to call from any thread — the underlying list is not thread-safe but all registrations happen during mod initialisation before battle scenes are loaded. </summary>
public void RegisterListener(IWingroundListener listener){if(listener==null)return;_listeners.RemoveAll(existing=>existing.Id==listener.Id);_listeners.Add(listener);GameLogger.Log("BATTLE",$"WingroundListenerRegistry: registered listener '{listener.Id}'");}/// <summary>Removes exactly <paramref name="listener"/> (its mod is being unloaded).</summary>
public void UnregisterListener(IWingroundListener listener){_listeners.Remove(listener);}/// <summary> Broadcasts <see cref="IWingroundListener.OnBeforeExpAward"/> to every registered listener. Exceptions from individual listeners are logged and swallowed so one broken mod cannot break the victory screen for others. </summary>
public void NotifyBeforeExpAward(WingroundContext ctx){foreach(var listener in _listeners){try{listener.OnBeforeExpAward(ctx);}catch(Exception ex){GameLogger.Log("BATTLE",$"WingroundListenerRegistry: {listener.Id}.OnBeforeExpAward threw: {ex.Message}",LogLevel.Error);}}}/// <summary> Broadcasts <see cref="IWingroundListener.OnAfterLootGenerated"/> to every registered listener. </summary>
public void NotifyAfterLootGenerated(WingroundContext ctx){foreach(var listener in _listeners){try{listener.OnAfterLootGenerated(ctx);}catch(Exception ex){GameLogger.Log("BATTLE",$"WingroundListenerRegistry: {listener.Id}.OnAfterLootGenerated threw: {ex.Message}",LogLevel.Error);}}}/// <summary> Broadcasts <see cref="IWingroundListener.OnBeforeShow"/> to every registered listener. </summary>
public void NotifyBeforeShow(WingroundContext ctx){foreach(var listener in _listeners){try{listener.OnBeforeShow(ctx);}catch(Exception ex){GameLogger.Log("BATTLE",$"WingroundListenerRegistry: {listener.Id}.OnBeforeShow threw: {ex.Message}",LogLevel.Error);}}}}}

===== FILE mods/ui_base/src/Base/InputHandlerNode.cs =====
using Godot;namespace Cthangover.Mods.Interface.Base{/// <summary> Forwarder pattern for Godot input: proxies _Input to a virtual OnInput so derived classes can handle input events without needing to know about Godot's _Input override mechanics. </summary>
public partial class InputHandlerNode:Node{/// <summary>Engine entry point: forwards the input event to <see cref="OnInput"/>.</summary>
public override void _Input(InputEvent@event){OnInput(@event);}/// <summary>Override in derived classes to receive Godot input events. Called by <see cref="_Input"/> which proxies from the engine.</summary>
protected virtual void OnInput(InputEvent@event){}}}

===== FILE mods/ui_base/src/Base/Lists/Border.cs =====
namespace Cthangover.Mods.Interface.Base.Lists{/// <summary> Serializable margin/padding struct for list layout calculations. Marked [System.Serializable] for potential JSON serialization in mod configs. </summary>
[System.Serializable]public struct Border{/// <summary>Left margin or padding, in pixels.</summary>
public float left;/// <summary>Right margin or padding, in pixels.</summary>
public float right;/// <summary>Top margin or padding, in pixels.</summary>
public float top;/// <summary>Bottom margin or padding, in pixels.</summary>
public float bottom;}}

===== FILE mods/ui_base/src/Base/Lists/ICellListWidget.cs =====
using Godot;namespace Cthangover.Mods.Interface.Base.Lists{/// <summary> Extends IListWidget with grid/cell dimensions. CellSize drives item sizing; ViewCellsCount determines how many cells fit in the visible area (used for scroll/viewport calculations). </summary>
public interface ICellListWidget<TItem,TModel>:IListWidget<TItem,TModel>where TItem:class,IListItem<TModel>{/// <summary>Pixel dimensions of each cell in the grid. Drives item sizing; derived from content width and aspect ratio.</summary>
Vector2 CellSize{get;}/// <summary>Number of cells visible in the viewport (columns × rows). Used for scroll range and visibility calculations.</summary>
Vector2I ViewCellsCount{get;}}}

===== FILE mods/ui_base/src/Base/Lists/IListItem.cs =====
using Godot;namespace Cthangover.Mods.Interface.Base.Lists{/// <summary> Contract for a single item in a list widget. Construct binds a model; Destruct tears down (typically QueueFree). The Rect getter exposes the item's Control for layout positioning by the parent list. </summary>
public interface IListItem<TModel>{/// <summary>The Control that this item occupies. Used by the parent list to position the item in layout.</summary>
Control Rect{get;}/// <summary>The bound data model. Set via <see cref="Construct"/>.</summary>
TModel Model{get;}/// <summary>Binds the item to a data model. Called by the list widget after instantiation, before layout positioning.</summary>
void Construct(TModel model);/// <summary>Cleans up the item — typically disconnects signals and calls QueueFree. Called when the list is destroyed or refreshed.</summary>
void Destruct();}}

===== FILE mods/ui_base/src/Base/Lists/IListWidget.cs =====
using System.Collections.Generic;using Godot;using Cthangover.Core.UI;namespace Cthangover.Mods.Interface.Base.Lists{/// <summary> Generic list widget contract: binds a collection of models to Item widgets. The two-step CreateModels() → CreateContent() pattern separates data sourcing from instantiation, so subclasses can pull models from anywhere (runtime state, config, save data) while the framework handles layout. ConstructUI/DestructUI manage the full lifecycle of child items. </summary>
public interface IListWidget<TItem,TModel>:IWidget where TItem:class,IListItem<TModel>{/// <summary>Padding between items in pixels, stored as Vector2(X, Y). The X component is horizontal gap, Y is vertical gap.</summary>
Vector2 ItemsCellPadding{get;}/// <summary>The container Control that holds all child item nodes. Items are added as children of this node.</summary>
Control Content{get;}/// <summary>The collection of instantiated item widgets. Populated by <see cref="ConstructUI"/> and cleared by <see cref="DestructUI"/>.</summary>
List<TItem>Items{get;set;}/// <summary>Implementations return the collection of data models to display. Called once per <see cref="ConstructUI"/> cycle.</summary>
ICollection<TModel>CreateModels();/// <summary>Instantiates item widgets from the model collection. Separates model sourcing from instantiation so subclasses control both steps.</summary>
List<TItem>CreateContent(ICollection<TModel>models);/// <summary>Calculates the total content size in pixels for <paramref name="count"/> items, used for scroll container sizing.</summary>
Vector2 GetContentSize(int count);/// <summary>Builds the full item tree: creates models, instantiates items, and positions them via layout.</summary>
void ConstructUI();/// <summary>Tears down all child items by calling each item's <see cref="IListItem{TModel}.Destruct"/> and clearing the Items list.</summary>
void DestructUI();}}

===== FILE mods/ui_base/src/Base/Lists/Impls/ColumnCellListWidget.cs =====
using Godot;namespace Cthangover.Mods.Interface.Base.Lists.Impls{/// <summary> Grid-based list layout with configurable column count, aspect-ratio cells, and border/padding offsets. Cell dimensions are computed dynamically from the content area width, so they scale with the parent. GetContentSize calculates the total scrollable height from item count and cell geometry, enabling vertical scrolling through a grid. PutToLayout positions items by flat-index-to-grid conversion. The isScaledCanvas flag supports resolution-independent layouts. </summary>
public abstract partial class ColumnCellListWidget<TItem,TModel>:ListWidget<TItem,TModel>,ICellListWidget<TItem,TModel>where TItem:Control,IListItem<TModel>{[Export]private int columnCount;[Export]private float aspectHeight=1f;[Export]private float borderLeft;[Export]private float borderRight;[Export]private float borderTop;[Export]private float borderBottom;[Export]private bool isScaledCanvas=true;private Border contentBorder=>new(){left=borderLeft,right=borderRight,top=borderTop,bottom=borderBottom};/// <summary>Dynamically computed cell dimensions in pixels. Width = (content width - borders - padding * (columns - 1)) / columns; height = width * aspectHeight.</summary>
public Vector2 CellSize=>GetCellSize(Content.Size);private Vector2 GetCellSize(Vector2 contentSize){var size=contentSize;var effectiveWidth=size.X-contentBorder.left-contentBorder.right;var cellWidth=(effectiveWidth-(columnCount-1)*ItemsCellPadding.X)/columnCount;var cellHeight=cellWidth*aspectHeight;return new Vector2(cellWidth,cellHeight);}/// <summary>Number of cells visible in the viewport: X = columnCount, Y = floor((content height - borders) / (cell height + padding)).</summary>
public Vector2I ViewCellsCount{get{var size=Content.Size;return new Vector2I(columnCount,Mathf.FloorToInt((size.Y-contentBorder.top-contentBorder.bottom)/(CellSize.Y+ItemsCellPadding.Y)));}}/// <summary>Calculates total scrollable content height: top border + bottom border + rows * cellHeight + (rows - 1) * padding.</summary>
public override Vector2 GetContentSize(int count){if(count==0)return Vector2.Zero;var rect=Content.Size;var effectiveWidth=rect.X-contentBorder.left-contentBorder.right;var cellWidth=(effectiveWidth-(columnCount-1)*ItemsCellPadding.X)/columnCount;var cellHeight=cellWidth*aspectHeight;var rows=Mathf.CeilToInt((float)count/columnCount);var contentHeight=contentBorder.top+contentBorder.bottom+rows*cellHeight+(rows-1)*ItemsCellPadding.Y;var contentWidth=rect.X;return new Vector2(contentWidth,contentHeight);}protected override void PutToLayout(TItem item,int index,Control container,Vector2 contentSize){var rectTransform=item;var cellIndex=GetIndex(index);var cellSize=GetCellSize(contentSize);if(rectTransform.GetParent()!=container)rectTransform.Reparent(container,false);rectTransform.Position=new Vector2(contentBorder.left+cellIndex.X*(cellSize.X+ItemsCellPadding.X),contentBorder.top+cellIndex.Y*(cellSize.Y+ItemsCellPadding.Y));rectTransform.Size=cellSize;}private Vector2I GetIndex(int flatIndex){var count=ViewCellsCount;return new Vector2I(flatIndex%count.X,flatIndex/count.X);}}}

===== FILE mods/ui_base/src/Base/Lists/Impls/VerticalListWidget.cs =====
using Godot;namespace Cthangover.Mods.Interface.Base.Lists.Impls{/// <summary> Simple vertical stack layout. Items are placed top-to-bottom with padding between them, offset so the list grows upward from the content center. Content height is the sum of all item heights plus padding — no pooling or virtualization, so it's intended for short lists. </summary>
public abstract partial class VerticalListWidget<TItem,TModel>:ListWidget<TItem,TModel>where TItem:Control,IListItem<TModel>{protected override void PutToLayout(TItem item,int index,Control container,Vector2 contentSize){var posY=index*(PrefabRect.Size.Y+ItemsCellPadding.Y);var itemRect=item;var pos=new Vector2(itemRect.Position.X,-posY+contentSize.Y*0.5f);itemRect.Position=pos;}/// <summary>Content height = count * (prefab height + Y padding). Width is inherited from the Content container.</summary>
public override Vector2 GetContentSize(int count){var boxSize=Content.Size;boxSize.Y=count*(PrefabRect.Size.Y+ItemsCellPadding.Y);return boxSize;}}}

===== FILE mods/ui_base/src/Base/Lists/ListItem.cs =====
using Godot;namespace Cthangover.Mods.Interface.Base.Lists{/// <summary> Base list item: stores the bound model and provides a Rect for layout. The virtual Construct saves the model reference; Destruct is abstract so subclasses must explicitly implement cleanup (signal disconnection, QueueFree). </summary>
public abstract partial class ListItem<TModel>:Control,IListItem<TModel>{/// <summary>The data model bound to this list item. Set by <see cref="Construct"/>.</summary>
public TModel Model{get;private set;}/// <summary>The Control that this item occupies for layout. Returns itself — ListItem is its own layout root.</summary>
public Control Rect=>this;/// <summary>Saves the model reference. Override in subclasses to apply model data to UI elements.</summary>
public virtual void Construct(TModel model){Model=model;}/// <summary>Required cleanup method. Subclasses must implement — typically disconnects signals and calls QueueFree.</summary>
public abstract void Destruct();/// <summary>Engine callback; forwards to <see cref="OnReady"/> so mod subclasses receive the ready signal without their own engine bindings.</summary>
public override void _Ready()=>OnReady();/// <summary>Forwarded from <see cref="_Ready"/>. Override for mod item initialization.</summary>
protected virtual void OnReady(){}}}

===== FILE mods/ui_base/src/Base/Lists/ListWidget.cs =====
using System.Collections.Generic;using Godot;namespace Cthangover.Mods.Interface.Base.Lists{/// <summary> Abstract model-driven list: instantiates items from a PackedScene prefab for each model in CreateModels(), then delegates positioning to the abstract PutToLayout (defined by layout subclasses like ColumnCellListWidget or VerticalListWidget). Refresh() tears down and rebuilds the list in place for reactive updates. The virtual Create/Init hooks let subclasses inject extra setup between instantiation and model binding without overriding CreateContent. </summary>
public abstract partial class ListWidget<T,M>:Widget,IListWidget<T,M>where T:Control,IListItem<M>{[Export]private Control content;[Export]private PackedScene itemScene;[Export]private Vector2 itemsCellPadding=new(0f,1f);/// <summary>Editor-configured padding between items; exposed for layout size math.</summary>
public Vector2 ItemsCellPadding=>itemsCellPadding;/// <summary>Prefab instance for layout measurement. Instantiated on each access — not cached. Returns null if itemScene is not set.</summary>
protected T Prefab=>itemScene?.Instantiate<T>();/// <summary>The Control rect of the item prefab, used for layout size calculations.</summary>
protected Control PrefabRect=>Prefab;/// <summary>The container Control that holds child items. Set via [Export] in the Godot editor.</summary>
public Control Content=>content;/// <summary>Calculates the total content size for a given item count. Subclass-specific — different layouts have different formulas.</summary>
public abstract Vector2 GetContentSize(int count);/// <summary>The instantiated item collection. Set by <see cref="ConstructUI"/> and cleared by <see cref="DestructUI"/>.</summary>
public List<T>Items{get;set;}/// <summary>Shows the widget and rebuilds the UI tree via <see cref="ConstructUI"/>.</summary>
public override void Show(){base.Show();ConstructUI();}/// <summary>Tears down child items via <see cref="DestructUI"/> then hides via base <see cref="Widget.Hide"/>.</summary>
public override void Hide(){DestructUI();base.Hide();}/// <summary>Destructs and reconstructs the item tree in place. Use for reactive updates when the model collection changes.</summary>
public void Refresh(){if(!Visible)return;DestructUI();ConstructUI();}/// <summary>Iterates <paramref name="models"/> and calls <see cref="Create"/> for each, building the item widget collection.</summary>
public List<T>CreateContent(ICollection<M>models){var items=new List<T>();foreach(var model in models){var item=Create(model);item.Construct(model);items.Add(item);}return items;}/// <summary> Full build pipeline: gets models via <see cref="CreateModels"/>, instantiates items via <see cref="CreateContent"/>, sets the content container size via <see cref="CallDeferred"/> to size, then positions each item via <see cref="PutToLayout"/>. </summary>
public void ConstructUI(){var rect=Content;var models=CreateModels();var items=CreateContent(models);var size=GetContentSize(items.Count);rect.CallDeferred("set_size",size);for(int i=0;i<items.Count;i++)PutToLayout(items[i],i,rect,size);Items=items;}/// <summary>Subclasses implement positioning logic. Called for each item with its flat index, the content container, and total content size.</summary>
protected abstract void PutToLayout(T item,int index,Control container,Vector2 contentSize);/// <summary>Subclasses implement model sourcing. Called by <see cref="ConstructUI"/> before item instantiation.</summary>
public abstract ICollection<M>CreateModels();/// <summary>Hook for subclasses to inject setup between instantiation and model binding. Called by <see cref="Create"/>.</summary>
protected virtual void Init(T item,M model){}/// <summary>Instantiates the PackedScene prefab, adds it to <see cref="Content"/>, and calls <see cref="Init"/> for subclass setup.</summary>
protected virtual T Create(M model){var instance=itemScene.Instantiate<T>();Content.AddChild(instance);Init(instance,model);return instance;}/// <summary>Calls <see cref="IListItem{TModel}.Destruct"/> on each child item, clears the list, and sets Items to null.</summary>
public void DestructUI(){if(Items==null||Items.Count==0)return;foreach(var item in Items)item.Destruct();Items.Clear();Items=null;}}}

===== FILE mods/ui_base/src/Base/Lists/ModWidget.cs =====
using Godot;using Cthangover.Core.UI;namespace Cthangover.Mods.Interface.Base.Lists{/// <summary> Lightweight alternative to Widget for mod-authored controls. Has a simpler lifecycle — just Construct() on first access and Destruct() on tree exit — without the visibility interception and Show/Hide pipeline that Widget carries. Use when the heavy Widget state machine would be overkill or when the control's visibility is managed by Godot's native system. </summary>
public abstract partial class ModWidget:Control,IUiLazyConstruct{private bool _constructed;/// <summary>Idempotent construction gate: calls <see cref="Construct"/> exactly once, on the first invocation. Thread-safe for repeated external calls.</summary>
public void EnsureConstructed(){if(_constructed)return;_constructed=true;Construct();}/// <summary>Runs <see cref="Destruct"/> once when the constructed control leaves the tree.</summary>
public override void _ExitTree(){if(_constructed)Destruct();}protected abstract void Construct();protected virtual void Destruct(){}}}

===== FILE mods/ui_base/src/Base/Lists/TransitionWidget.cs =====
using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Mods.Interface.Base.Lists{/// <summary> Shader-based scene transition: animates a "progress" uniform on the background's ShaderMaterial via a Godot Tween, then reveals content after a configurable delay. The shader is resolved through the mod system (ModManager.ResolveShader), allowing mods to replace the transition effect. Uses an easeInOut curve for the progress ramp. Material is duplicated on first use so each instance gets its own shader parameters. On tree exit, the duplicated material is disposed to avoid leaks. </summary>
public abstract partial class TransitionWidget:Widget{private static readonly StringName ProgressParam="progress";[Export]private TextureRect imgBackground;[Export]private Control contentGroup;[Export]private float transitionDuration=0.8f;[Export]private float contentDelay=0.05f;private ShaderMaterial materialInstance;private Tween transitionTween;/// <summary> Shows the widget base, then creates or reuses a shader material for the background and starts the transition tween. Material is duplicated from <c>imgBackground.Material</c> on first call so each instance gets independent shader parameters. </summary>
public override void Show(){base.Show();if(materialInstance==null&&imgBackground!=null){materialInstance=imgBackground.Material?.Duplicate()as ShaderMaterial;if(materialInstance==null){materialInstance=new ShaderMaterial();var shader=Shaders.Resolve("scene_transition");if(shader!=null)materialInstance.Shader=shader;}imgBackground.Material=materialInstance;materialInstance.SetShaderParameter(ProgressParam,0f);}if(transitionTween!=null){transitionTween.Kill();transitionTween=null;}RunShowAnimation();}/// <summary>Kills the active transition tween if one is running, then calls base <see cref="Widget.Hide"/>.</summary>
public override void Hide(){if(transitionTween!=null){transitionTween.Kill();transitionTween=null;}base.Hide();}private void RunShowAnimation(){if(contentGroup!=null){contentGroup.Modulate=UiTokens.Alpha(UiTokens.White,0f);contentGroup.MouseFilter=MouseFilterEnum.Ignore;}transitionTween=CreateTween();transitionTween.SetProcessMode(Tween.TweenProcessMode.Physics);transitionTween.TweenMethod(Callable.From<float>(OnProgressUpdate),0f,1f,transitionDuration);transitionTween.Finished+=OnTweenFinished;}private void OnProgressUpdate(float t){float curvedT=EaseInOut(t);if(imgBackground?.Material is ShaderMaterial mat)mat.SetShaderParameter(ProgressParam,curvedT);}private void OnTweenFinished(){if(imgBackground?.Material is ShaderMaterial mat)mat.SetShaderParameter(ProgressParam,1f);var timer=GetTree().CreateTimer(contentDelay);timer.Timeout+=()=>{if(contentGroup!=null){contentGroup.Modulate=UiTokens.White;contentGroup.MouseFilter=MouseFilterEnum.Stop;}};transitionTween=null;}private static float EaseInOut(float t){return t<0.5f?2f*t*t:1f-Mathf.Pow(-2f*t+2f,2f)/2f;}/// <summary>Disposes the per-instance shader material created for the transition.</summary>
public override void _ExitTree(){if(materialInstance!=null){materialInstance.Dispose();materialInstance=null;}base._ExitTree();}}}

===== FILE mods/ui_base/src/Base/Lists/Widget.cs =====
using Cthangover.Core.Utils;using Godot;using Cthangover.Core.UI;namespace Cthangover.Mods.Interface.Base.Lists{/// <summary> Base widget with a lazy construct/destruct lifecycle around Show/Hide. On first Show(), OnceConstruct() fires once via EnsureConstructed — subsequent Show/Hide calls trigger ShowConstruct/HideDestruct instead, allowing repeated state reset without full reconstruction. Overrides Godot's built-in Visible property to intercept visibility changes and route them through the lifecycle. Maintains an independent isVisible flag because Godot's visibility has quirks (e.g. parent visibility propagation) that would break the state machine. The #if TOOLS _ValidateProperty syncs the editor inspector with the override. </summary>
public abstract partial class Widget:Control,IWidget{private bool isConstructed;/// <summary>Syncs the internal visibility flag with the scene-file state and hides properly when the widget starts hidden.</summary>
public override void _Ready(){GameLogger.Log("WIDGET",$"_Ready: name={Name}, isVisible={isVisible}, GodotVisible={base.Visible}",LogLevel.Debug);isVisible=base.Visible;if(!isVisible)Hide();OnReady();}/// <summary>Tears down a constructed widget when it leaves the tree, so hidden-while-freed widgets still release their resources.</summary>
public override void _ExitTree(){if(isConstructed)OnceDestruct();OnExitTree();}/// <summary>Engine callback; forwards to <see cref="OnInput"/> so mod subclasses receive input without their own engine bindings.</summary>
public override void _Input(InputEvent@event)=>OnInput(@event);/// <summary>Forwarded from <see cref="_Ready"/>. Override for mod widget initialization.</summary>
protected virtual void OnReady(){}/// <summary>Forwarded from <see cref="_ExitTree"/>. Override for mod widget teardown.</summary>
protected virtual void OnExitTree(){}/// <summary>Forwarded from <see cref="_Input"/>. Override to handle input in mod widgets.</summary>
protected virtual void OnInput(InputEvent@event){}/// <summary>Toggles visibility: calls <see cref="Hide"/> if visible, <see cref="Show"/> otherwise.</summary>
public void Switch(){if(Visible)Hide();else Show();}/// <summary> Makes visible and runs lifecycle. On first call, <see cref="EnsureConstructed"/> fires <see cref="OnceConstruct"/>. Subsequent calls trigger <see cref="ShowConstruct"/> without reconstruction. Sets both the internal isVisible flag and Godot's native visibility, then explicitly shows the Body canvas item. </summary>
public new virtual void Show(){GameLogger.Log("WIDGET",$"Show ENTER: name={Name}, isVisible={isVisible}, GodotVisible={base.Visible}",LogLevel.Debug);var wasConstructed=isConstructed;EnsureConstructed();if(Visible){if(!wasConstructed)ShowConstruct();GameLogger.Log("WIDGET",$"Show SKIP: already visible",LogLevel.Debug);return;}ShowConstruct();isVisible=true;base.Visible=true;var canvasBody=Body as CanvasItem;if(canvasBody!=null)canvasBody.Visible=true;(Body as Control)?.QueueRedraw();GameLogger.Log("WIDGET",$"Show EXIT: name={Name}, isVisible={isVisible}, GodotVisible={base.Visible}",LogLevel.Debug);}/// <summary>Hides the widget, fires <see cref="HideDestruct"/> if constructed, sets internal and Godot visibility to false, and hides the Body canvas item.</summary>
public new virtual void Hide(){if(!Visible)return;if(isConstructed)HideDestruct();isVisible=false;base.Visible=false;var canvasBody=Body as CanvasItem;if(canvasBody!=null)canvasBody.Visible=false;}/// <summary>Idempotent construction gate: calls <see cref="OnceConstruct"/> exactly once, on the first invocation.</summary>
public void EnsureConstructed(){if(!isConstructed){isConstructed=true;OnceConstruct();}}/// <summary>Called exactly once on the first <see cref="Show"/>. Override to perform one-time initialization.</summary>
protected virtual void OnceConstruct(){}/// <summary>Called once on tree exit if constructed. Override for one-time cleanup of resources allocated in <see cref="OnceConstruct"/>.</summary>
protected virtual void OnceDestruct(){}[Export]private bool isVisible=true;[Export]private Node body;/// <summary> Shadows Godot's built-in visibility. Setting to true calls <see cref="Show"/>, setting to false calls <see cref="Hide"/>. Maintains an independent <c>isVisible</c> flag because Godot's native visibility propagates through the parent chain, which would break the explicit lifecycle state machine. </summary>
public new bool Visible{get=>isVisible;set{if(isVisible==value)return;if(value)Show();else Hide();}}/// <summary>The rendered content subtree. May be a child node separate from the Rect layout root.</summary>
public Node Body=>body;/// <summary>The Control used for layout. For Widget this is the node itself, enabling it to serve as both layout root and widget.</summary>
public Control Rect=>this;/// <summary>Called each time <see cref="Show"/> runs after initial construction. Override to refresh state (e.g. reload data, re-enable input).</summary>
protected virtual void ShowConstruct(){}/// <summary>Called each time <see cref="Hide"/> runs when constructed. Override to clean up display-only state without destroying permanent resources.</summary>
protected virtual void HideDestruct(){}
#if TOOLS
/// <summary>Keeps the inspector in sync with the overridden visibility: touching <c>visible</c> or <c>isVisible</c> in the editor routes through the lifecycle.</summary>
public override void _ValidateProperty(Godot.Collections.Dictionary property){base._ValidateProperty(property);if(property.TryGetValue("visible",out var _)){isVisible=base.Visible;if(isVisible)Show();else Hide();}else if(property.TryGetValue("isVisible",out var _)){if(isVisible)Show();else Hide();}}
#endif
}}

===== FILE mods/ui_base/src/Base/ModControl.cs =====
using Godot;namespace Cthangover.Mods.Interface.Base{/// <summary> Lifecycle forwarder for Control scripts compiled from mods — the Control counterpart of <see cref="ModNode"/>. Godot's C# source generator only runs in the main game assembly, so engine callbacks overridden inside a runtime-compiled mod are never registered with the engine. Mod controls derive from this base (which has bindings) and override the OnXxx methods. </summary>
public partial class ModControl:Control{/// <summary>Engine callback; forwards to <see cref="OnEnterTree"/>.</summary>
public override void _EnterTree()=>OnEnterTree();/// <summary>Engine callback; forwards to <see cref="OnExitTree"/>.</summary>
public override void _ExitTree()=>OnExitTree();/// <summary>Engine callback; forwards to <see cref="OnReady"/>.</summary>
public override void _Ready()=>OnReady();/// <summary>Engine callback; forwards to <see cref="OnProcess"/>.</summary>
public override void _Process(double delta)=>OnProcess(delta);/// <summary>Forwarded from <see cref="Node._EnterTree"/>. Override in mod controls.</summary>
protected virtual void OnEnterTree(){}/// <summary>Forwarded from <see cref="Node._ExitTree"/>. Override in mod controls.</summary>
protected virtual void OnExitTree(){}/// <summary>Forwarded from <see cref="Node._Ready"/>. Override in mod controls.</summary>
protected virtual void OnReady(){}/// <summary>Forwarded from <see cref="Node._Process"/>. Override in mod controls.</summary>
protected virtual void OnProcess(double delta){}}}

===== FILE mods/ui_base/src/Base/ModNode.cs =====
using Godot;namespace Cthangover.Mods.Interface.Base{/// <summary> Lifecycle forwarder for Node scripts compiled from mods. Godot's C# source generator only runs in the main game assembly, so virtual methods overridden inside a runtime-compiled mod (e.g. <c>_Ready</c>, <c>_Process</c>) are never registered with the engine and therefore never called. A mod node must instead derive from a core base class — the engine drives the base (which has bindings) and the base forwards every lifecycle callback to virtual <c>OnXxx</c> methods the mod can override. <para/> The same trick is used by <see cref="InputHandlerNode"/> for input events and by <see cref="ModWidget"/> for Controls. Use this wrapper for plain <see cref="Node"/> scripts that need enter/exit/ready/process hooks. </summary>
public partial class ModNode:Node{/// <summary>Engine callback; forwards to <see cref="OnEnterTree"/>.</summary>
public override void _EnterTree()=>OnEnterTree();/// <summary>Engine callback; forwards to <see cref="OnExitTree"/>.</summary>
public override void _ExitTree()=>OnExitTree();/// <summary>Engine callback; forwards to <see cref="OnReady"/>.</summary>
public override void _Ready()=>OnReady();/// <summary>Engine callback; forwards to <see cref="OnProcess"/>.</summary>
public override void _Process(double delta)=>OnProcess(delta);/// <summary>Forwarded from <see cref="Node._EnterTree"/>. Override in mod nodes.</summary>
protected virtual void OnEnterTree(){}/// <summary>Forwarded from <see cref="Node._ExitTree"/>. Override in mod nodes.</summary>
protected virtual void OnExitTree(){}/// <summary>Forwarded from <see cref="Node._Ready"/>. Override in mod nodes.</summary>
protected virtual void OnReady(){}/// <summary>Forwarded from <see cref="Node._Process"/>. Override in mod nodes.</summary>
protected virtual void OnProcess(double delta){}}}

===== FILE mods/ui_base/src/Event/DeviceInput.cs =====
using Godot;namespace Cthangover.Mods.Interface.Event{/// <summary> Stateless input abstraction that maps mouse to a virtual single-touch source. Tracks mouse-down state between frames to derive touch phases (Began/Moved/ Ended/Stationary), enabling touch-aware code to work with mouse-only devices. Provides both UI-space (UITouchPosition) and screen-space (TouchPosition) coordinate access. Update() must be called each frame to refresh the wasMouseDownLastFrame flag. </summary>
public static class DeviceInput{/// <summary> Index of the active touch (always 0 for the mouse-backed single-touch model). Exposed for API compatibility with potential multi-touch support. </summary>
public static int CurrentTouchIndex{get;set;}/// <summary> Derives a <see cref="TouchPhase"/> from mouse button state across frames. Compares the current left-button state against <c>wasMouseDownLastFrame</c>: Began on fresh press, Moved on hold, Ended on release. </summary>
public static TouchPhase GetTouchPhase{get{if(Input.IsMouseButtonPressed(MouseButton.Left)){if(Input.IsMouseButtonPressed(MouseButton.Left)&& !wasMouseDownLastFrame)return TouchPhase.Began;if(Input.IsMouseButtonPressed(MouseButton.Left)&&wasMouseDownLastFrame)return TouchPhase.Moved;}else{if(wasMouseDownLastFrame)return TouchPhase.Ended;}return TouchPhase.Stationary;}}private static bool wasMouseDownLastFrame;/// <summary> Must be called each frame to snapshot the mouse button state. The saved state drives <see cref="GetTouchPhase"/> for press/release transitions. </summary>
public static void Update(){wasMouseDownLastFrame=Input.IsMouseButtonPressed(MouseButton.Left);}/// <summary> Returns 1 when the left mouse button is pressed, 0 otherwise. Maps the mouse to a single-touch model. </summary>
public static int TouchCount{get{return Input.IsMouseButtonPressed(MouseButton.Left)?1:0;}}/// <summary> Mouse position in UI space (top-left origin, Control coordinate system). Used for hit-testing against Control nodes in the UI layer. </summary>
public static Vector2 UITouchPosition{get{var mp=GetViewport()?.GetMousePosition()??Vector2.Zero;// In Godot, UI coordinates match mouse coordinates (top-left origin in Control nodes)
return mp;}}/// <summary> Mouse position in screen space. Used for raycasting against 2D/3D world objects. </summary>
public static Vector2 TouchPosition{get{return GetViewport()?.GetMousePosition()??Vector2.Zero;}}/// <summary> Converts <see cref="TouchPosition"/> to a 3D vector (z=0) for ray-origin calculations in the 3D input pipeline. </summary>
public static Vector3 TouchPosition3D{get{var pos=TouchPosition;return new Vector3(pos.X,pos.Y,0);}}/// <summary> Last-frame mouse velocity from Godot's <c>Input.GetLastMouseVelocity()</c>. Used by drag handlers to compute movement deltas. </summary>
public static Vector2 TouchDelta{get{return Input.GetLastMouseVelocity();}}/// <summary> Returns the current touch position. The <paramref name="index"/> parameter is ignored in the single-touch model — always returns <see cref="TouchPosition"/>. Exists for multi-touch API compatibility. </summary> <param name="index">Touch index (ignored in current implementation).</param>
public static Vector2 GetTouchPosition(int index){return TouchPosition;}private static Viewport GetViewport(){var tree=Godot.Engine.GetMainLoop()as SceneTree;return tree?.Root?.GetViewport();}}}

===== FILE mods/ui_base/src/Event/TouchPhase.cs =====
namespace Cthangover.Mods.Interface.Event{/// <summary> iOS-style touch lifecycle phases. Used by DeviceInput to derive phase from mouse button state between frames. </summary>
public enum TouchPhase{Began,Moved,Stationary,Ended,Canceled}}

===== FILE mods/ui_base/src/Menu/EmptyBlackBehaviour.cs =====
using Godot;using Cthangover.Core.UI.Menu;namespace Cthangover.Mods.Interface.Menu{/// <summary> Full-screen black overlay Control. Acts as a blocking backdrop — placed behind menus to darken the game view and absorb input. No custom logic beyond what Control provides; scene configuration handles sizing/color. </summary>
public partial class EmptyBlackBehaviour:Control{}}

===== FILE mods/ui_base/src/Menu/GameMenu.cs =====
using System.Collections.Generic;using Cthangover.Core.Scenes;using Godot;using Cthangover.Core.UI.Menu;namespace Cthangover.Mods.Interface.Menu{/// <summary> In-game pause menu: save/load/settings/quit. The settings and save/load panels are not referenced by type: they are registered in the <see cref="MenuRegistry"/> (by the <c>menu_base</c> mod or any other mod that wants to provide them) and instantiated here by id, so the panel implementations can live entirely inside mods. Both panels stay hidden children created on _Ready to avoid loading delays when the player opens the menu. RefreshSaveButton dynamically disables save if the current scene doesn't allow saves (checked via SceneManager.IsSaveAllowedForCurrentScene). Button text is translated with a refresh-on-language-change subscription. </summary>
public partial class GameMenu:Control{private ISettingsPanel _settingsPanel;private ISaveLoadPanel _saveLoadPanel;private Button _saveBtn;private readonly Dictionary<Button,string>_btnKeys=new();/// <summary>Wires menu buttons, instantiates the hidden settings and save/load panels from the <see cref="MenuRegistry"/>, and subscribes to language changes.</summary>
public override void _Ready(){_saveBtn=GetNode<Button>("Panel/Margin/VBox/SaveBtn");_saveBtn.Pressed+=OnSaveClick;GetNode<Button>("Panel/Margin/VBox/ReturnBtn").Pressed+=OnReturnClick;GetNode<Button>("Panel/Margin/VBox/LoadBtn").Pressed+=OnLoadClick;GetNode<Button>("Panel/Margin/VBox/SettingsBtn").Pressed+=OnSettingsClick;GetNode<Button>("Panel/Margin/VBox/MainMenuBtn").Pressed+=OnMainMenuClick;GetNode<Button>("Panel/Margin/VBox/ExitBtn").Pressed+=OnExitClick;_btnKeys[GetNode<Button>("Panel/Margin/VBox/ReturnBtn")]="menu/resume";_btnKeys[_saveBtn]="menu/save";_btnKeys[GetNode<Button>("Panel/Margin/VBox/LoadBtn")]="menu/load";_btnKeys[GetNode<Button>("Panel/Margin/VBox/SettingsBtn")]="menu/settings";_btnKeys[GetNode<Button>("Panel/Margin/VBox/MainMenuBtn")]="menu/main_menu";_btnKeys[GetNode<Button>("Panel/Margin/VBox/ExitBtn")]="menu/exit";AttachPanel(MenuRegistry.OpenPanel("settings"));AttachPanel(MenuRegistry.OpenPanel("save_load"));Visible=false;RefreshButtons();VisibilityChanged+=OnVisibilityChanged;}/// <summary> Adopts a registry-built panel as a hidden child and forwards its typed behaviour to the menu handlers. Panels registered without a matching interface still work as plain overlays. </summary>
private void AttachPanel(Node panel){if(panel==null)return;if(panel is Control control)control.Visible=false;AddChild(panel);switch(panel){case ISettingsPanel settings:settings.LanguageChanged+=RefreshButtons;_settingsPanel=settings;break;case ISaveLoadPanel saveLoad:_saveLoadPanel=saveLoad;break;}}/// <summary> Refreshes the save button whenever the menu becomes visible. The panel is opened through <see cref="UI.UiRegistry.Toggle"/>, so this signal replaces the old toolbar callback that used to do the refresh before showing. </summary>
private void OnVisibilityChanged(){if(Visible)RefreshSaveButton();}private void RefreshButtons(){foreach(var kv in _btnKeys)kv.Key.Text=TranslationServer.Translate(kv.Value);}private void OnReturnClick(){Visible=false;}private void OnSaveClick(){_saveLoadPanel?.OpenForSave();}private bool IsSaveAllowed(){return SceneManager.IsSaveAllowedForCurrentScene();}/// <summary> Enables or disables the save button based on whether the current scene permits saving. Called by <see cref="ToolBox.OnSettingsClick"/> before showing the game menu, so the button state reflects the current scene's save policy. </summary>
public void RefreshSaveButton(){_saveBtn.Disabled= !IsSaveAllowed();}private void OnLoadClick(){_saveLoadPanel?.OpenForLoad();}private void OnSettingsClick(){_settingsPanel?.Open();}private void OnMainMenuClick(){var sceneService=SceneContextNode.FindNode<GodotSceneService>("GodotSceneService");sceneService?.SwitchToMenu();}private void OnExitClick(){GetTree().Quit();}}}

===== FILE mods/ui_base/src/Menu/MenuBuilder.cs =====
using System.Collections.Generic;using Cthangover.Core.Bootstrap;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Scenes;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Godot;using Cthangover.Core.UI.Menu;using Cthangover.Mods.Interface.Base.Lists;namespace Cthangover.Mods.Interface.Menu{/// <summary> Materializes the buttons collected in the <see cref="MenuRegistry"/> into the containers of a host menu. The host supplies a section → container map (""game"", ""mods"", ""exit"", ""tools"", …); every registry button is added to the container of its section, in registry order, and carries its translation key in node metadata (<see cref="LocaleMetaKey"/>) so the host can retranslate it on language change. <para/> A button is bound either to a scene path (pressed → the scene opens as a fullscreen child of the menu root and owns its lifetime) or to a handler delegate (pressed → the delegate runs). Unknown sections fall back to the ""mods"" container; sections without a container are skipped loudly. </summary>
public static class MenuBuilder{/// <summary>Node metadata key holding the button's translation key.</summary>
public const string LocaleMetaKey="main_menu_locale";/// <summary> Creates one button per registry entry inside the section containers. The host menu is passed in because pressed handlers instantiate mod scenes as its children. </summary>
public static void Build(Control menuRoot,IReadOnlyDictionary<string,Container>sections){if(menuRoot==null||sections==null)return;foreach(var entry in AppServices.Current.Menus.Buttons){if(entry.Visible!=null&& !SafeVisible(entry))continue;if(!sections.TryGetValue(entry.Section??MenuButtonEntry.SectionMods,out var container)&& !sections.TryGetValue(MenuButtonEntry.SectionMods,out container)){GameLogger.Log("MENU_BUILDER",$"No container for section '{entry.Section}' (button '{entry.Id}')");continue;}if(!GodotObject.IsInstanceValid(container)){GameLogger.Log("MENU_BUILDER",$"Container for section '{entry.Section}' is gone (button '{entry.Id}')");continue;}container.AddChild(CreateButton(menuRoot,entry));}}/// <summary> Collects buttons created by <see cref="Build"/> (or by tools) that carry a locale meta key into the host's translation map, so the host keeps them localized across language switches. </summary>
private static bool SafeVisible(MenuButtonEntry entry){try{return entry.Visible();}catch(System.Exception ex){GameLogger.Log("MENU_BUILDER",$"Visibility check of button '{entry.Id}' failed: {ex.Message}",LogLevel.Warning);return true;}}public static void CollectLocaleKeys(Container container,Dictionary<Button,string>keys){if(container==null||keys==null)return;foreach(var child in container.GetChildren()){if(child is Button button&&button.HasMeta(LocaleMetaKey))keys[button]=button.GetMeta(LocaleMetaKey).AsString();}}private static Button CreateButton(Control menuRoot,MenuButtonEntry entry){var title=TranslationServer.Translate(entry.Locale);var button=new Button{Name=entry.Id,Text=title,TooltipText=title,};if(!string.IsNullOrEmpty(entry.Icon)){var icon=UIIconFactory.Instance.Get(entry.Icon);if(icon!=null)button.Icon=icon;}if(!string.IsNullOrEmpty(entry.Variation))button.ThemeTypeVariation="Cthangover"+char.ToUpper(entry.Variation[0])+entry.Variation[1..];button.SetMeta(LocaleMetaKey,entry.Locale);button.Pressed+=()=>Invoke(menuRoot,entry);return button;}private static void Invoke(Control menuRoot,MenuButtonEntry entry){if(entry.Handler!=null){GameLogger.Log("MENU_BUILDER",$"Pressed '{entry.Id}' (handler)");entry.Handler(menuRoot);return;}OpenScene(menuRoot,entry.ScenePath,entry.Id);}/// <summary> Opens a mod scene as a fullscreen child of the menu root: the scene owns its lifetime and closes itself, or is replaced by another menu scene opening over it. </summary> <summary>Opens a mod scene as a full-rect overlay of the menu and returns it (null when it fails to build).</summary>
public static Node OpenScene(Control menuRoot,string scenePath,string label=null){var node=new ModScenePrefab(scenePath).Instantiate();if(node==null){GameLogger.Log("MENU_BUILDER",$"Open '{label ?? scenePath}': scene '{scenePath}' failed to build",LogLevel.Error);return null;}menuRoot.AddChild(node);CthangoverTheme.Apply(node);if(node is Control control)control.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);if(node is ModWidget modWidget)modWidget.EnsureConstructed();GameLogger.Log("MENU_BUILDER",$"Opened '{label ?? scenePath}' → {scenePath}");return node;}}}

===== FILE mods/ui_base/src/Menu/SaveIcon.cs =====
using Cthangover.Core.Factories.Impls;using Godot;using Cthangover.Core.UI.Menu;namespace Cthangover.Mods.Interface.Menu{/// <summary> Auto-loading save icon: on _Ready, resolves the "save" texture through UIIconFactory if no Texture is already assigned. Allows both editor-set textures and mod-overridden icons without scene modification. </summary>
public partial class SaveIcon:TextureRect{public override void _Ready(){if(Texture==null)Texture=UIIconFactory.Instance.Get("save");}}}

===== FILE mods/ui_base/src/Messages/MessagesHelper.cs =====
using Cthangover.Core.Events;using Cthangover.Core.Scenes;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Godot;using Cthangover.Core.UI.Messages;namespace Cthangover.Mods.Interface.Messages{/// <summary> Static factory for floating UI messages (like "Quest updated" or item pickup notifications). Manages a cycling position index of 0-5 so consecutive messages stack vertically without overlapping. Each message is instantiated from message.tscn and parented to the Background node (found via scene tree search). Messages auto-fade and self-destruct via UiMessage's OnUpdate. </summary>
public static class MessagesHelper{private const int maxMessageIndex=5;private static int lastMessageIndex=0;private static ModScenePrefab prefab;private static int GetNextIndex(int index){if(index++ >=maxMessageIndex)index=0;return index;}private static float GetPositionByLastIndex(int index){return(index-1)*60;}/// <summary> Resets the cycling position index to 0. Call when UI state resets to prevent messages from accumulating at high Y positions. </summary>
public static void ClearLastIndex(){lastMessageIndex=0;}/// <summary> Shows a message in the default warning colour at the next vertical slot. </summary> <param name="text">The message text to display.</param> <param name="speed">Fade speed multiplier (higher = faster fade, default 1).</param>
public static void AddMessage(string text,float speed=1f){AddMessage(text,UiTokens.Warning,speed);}/// <summary> Shows a message with a custom color at the next vertical slot. Messages auto-fade and self-destruct after ~3 seconds of display plus a fade duration determined by <paramref name="speed"/>. </summary> <param name="text">The message text to display.</param> <param name="color">The text color.</param> <param name="speed">Fade speed multiplier (higher = faster fade, default 1).</param>
public static void AddMessage(string text,Color color,float speed=1f){var nextIndex=GetNextIndex(lastMessageIndex);AddMessage(GetPositionByLastIndex(nextIndex),text,color,speed);lastMessageIndex=nextIndex;}/// <summary> Shows a message in the default warning colour at a specific vertical position, bypassing the cycling slot system. </summary> <param name="position">Y-offset in pixels for this message.</param> <param name="text">The message text to display.</param> <param name="speed">Fade speed multiplier (default 1).</param>
public static void AddMessage(float position,string text,float speed=1f){AddMessage(position,text,UiTokens.Warning,speed);}/// <summary> Instantiates a <see cref="UiMessage"/> from <c>message.tscn</c>, parents it to the Background node, and configures it for display and auto-fade. </summary> <param name="position">Y-offset in pixels for this message.</param> <param name="text">The message text to display.</param> <param name="color">The text color.</param> <param name="speed">Fade speed multiplier (default 1).</param>
public static void AddMessage(float position,string text,Color color,float speed=1f){if(prefab==null)prefab=new ModScenePrefab("scenes/ui/message.tscn");var instance=prefab.Instantiate<UiMessage>();var background=SceneContextNode.FindNode<Control>("Background");if(background==null){background=SceneContextNode.Instance?.GetTree()?.GetFirstNodeInGroup("Background")as Control;if(background==null){GameLogger.Log("UI","Background node not found for messages",LogLevel.Error);return;}}background.AddChild(instance);instance.Setup(position,text,speed,color);ModEventBus.Publish(new NotificationRequestedEvent{Text=text,Category="message",});}}}

===== FILE mods/ui_base/src/Messages/NotificationPanel.cs =====
using System.Collections.Generic;using Cthangover.Core.Events;using Cthangover.Core.Scenes;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Mods.Interface.Theming;using Cthangover.Core.UI.Messages;namespace Cthangover.Mods.Interface.Messages{/// <summary> Manages the visual notification list in the top-left corner of the scene. Displays up to <c>MaxVisibleSlots</c> notification banners simultaneously. Pending items wait in <see cref="NotificationBuffer"/> and are pulled into visible slots as they become free. Each visible notification has a 10-second lifetime; when it expires, it fades out and the remaining notifications shift upward with a smooth animation. </summary>
public partial class NotificationPanel:Control,IOnUpdateEvent{private const int MaxVisibleSlots=4;private const float SlotHeight=40f;private const float SlotSpacing=6f;private const float DisplayDuration=10f;private const float FadeInDuration=0.3f;private const float FadeOutDuration=0.5f;private readonly List<NotificationSlot>_slots=new();private readonly List<int>_dyingIndices=new();/// <summary>Update priority <c>1</c> — runs after the highest-priority listeners.</summary>
public int Priority=>1;/// <summary>Subscribes to notification requests; text is queued in <see cref="NotificationBuffer"/>, not shown immediately.</summary>
public override void _EnterTree(){ModEventBus.Subscribe<NotificationRequestedEvent>(OnNotificationRequested);}/// <summary>Registers with the event controller for per-frame slot updates.</summary>
public override void _Ready(){MouseFilter=MouseFilterEnum.Ignore;if(SceneContextNode.Instance!=null){var eventController=SceneContextNode.FindNode<SceneEventController>("EventController");eventController?.AddUpdateEventListener(this);}}/// <summary>Unsubscribes from notification requests and detaches from the update controller.</summary>
public override void _ExitTree(){ModEventBus.Unsubscribe<NotificationRequestedEvent>(OnNotificationRequested);if(SceneContextNode.Instance!=null){var eventController=SceneContextNode.FindNode<SceneEventController>("EventController");eventController?.RemoveUpdateEventListener(this);}}/// <summary> Per-frame update: expires old slots, collects dead ones, pulls new notifications from the buffer, and repositions all visible slots with a smooth slide animation. </summary>
public void OnUpdate(){var currentTime=(float)Godot.Time.GetTicksMsec()/1000f;for(int i=_slots.Count-1;i>=0;i--){var slot=_slots[i];var elapsed=currentTime-slot.DisplayedAt;if(elapsed>=DisplayDuration&& !slot.IsFadingOut){FadeOutSlot(i);}}_dyingIndices.Clear();for(int i=0;i<_slots.Count;i++){if(_slots[i].IsDead)_dyingIndices.Add(i);}for(int j=_dyingIndices.Count-1;j>=0;j--){var idx=_dyingIndices[j];_slots[idx].Container.QueueFree();_slots.RemoveAt(idx);}while(_slots.Count<MaxVisibleSlots&&NotificationBuffer.TryDequeue(out var text)){CreateSlot(text,currentTime);}RepositionSlots();}private void CreateSlot(string text,float time){var container=new Panel{SizeFlagsHorizontal=SizeFlags.ShrinkBegin,MouseFilter=MouseFilterEnum.Ignore,};var style=UiKit.Frame(UiTokens.RadiusMd,UiTokens.ToastBg,UiTokens.Line);style.ContentMarginLeft=UiTokens.Space4;style.ContentMarginRight=UiTokens.Space4;style.ContentMarginTop=UiTokens.Space2;style.ContentMarginBottom=UiTokens.Space2;container.AddThemeStyleboxOverride("panel",style);var label=new Label{Text=text,Modulate=UiTokens.Alpha(Colors.White,0f),HorizontalAlignment=HorizontalAlignment.Left,VerticalAlignment=VerticalAlignment.Center,};label.AddThemeFontSizeOverride("font_size",UiTokens.FontLead);label.AddThemeColorOverride("font_color",UiTokens.Ink);container.AddChild(label);AddChild(container);var slot=new NotificationSlot{Container=container,Text=text,DisplayedAt=time,};_slots.Add(slot);var tween=CreateTween();tween.TweenProperty(label,"modulate",Colors.White,FadeInDuration);}private void FadeOutSlot(int index){var slot=_slots[index];slot.IsFadingOut=true;var label=slot.Container.GetChildOrNull<Label>(0);if(label!=null){var tween=CreateTween();tween.TweenProperty(label,"modulate",UiTokens.Alpha(Colors.White,0f),FadeOutDuration);tween.TweenCallback(Callable.From(()=>{slot.IsDead=true;}));}else{slot.IsDead=true;}}private void RepositionSlots(){for(int i=0;i<_slots.Count;i++){var targetY=i*(SlotHeight+SlotSpacing);var container=_slots[i].Container;if(Mathf.Abs(container.Position.Y-targetY)>0.5f){var tween=CreateTween();tween.TweenProperty(container,"position",new Vector2(container.Position.X,targetY),0.2f).SetTrans(Tween.TransitionType.Quad).SetEase(Tween.EaseType.Out);}}}private void OnNotificationRequested(NotificationRequestedEvent evt){NotificationBuffer.Add(evt.Text);}private sealed class NotificationSlot{/// <summary>Visual container of the banner.</summary>
public Panel Container;/// <summary>Message text kept for the banner label and re-use.</summary>
public string Text;/// <summary>Timestamp of the moment the banner became visible; drives expiry.</summary>
public float DisplayedAt;/// <summary>True while the exit tween is playing.</summary>
public bool IsFadingOut;/// <summary>Set when the fade-out finished and the slot can be recycled.</summary>
public bool IsDead;}}}

===== FILE mods/ui_base/src/Messages/UiMessage.cs =====
using Cthangover.Core.Scenes;using Cthangover.Core.UI.Event;using Cthangover.Core.UI.Theming;using Godot;using Cthangover.Core.UI.Messages;namespace Cthangover.Mods.Interface.Messages{/// <summary> Floating text message with auto-fade. Appears at a vertical position, stays visible for 3 seconds, then fades over 2/Speed seconds. Self- destructs (QueueFree) when fully transparent. Registers with SceneEventController for per-frame updates; unregisters on tree exit to prevent leaks. Speed affects only the fade duration, not the display duration, so faster speed = shorter fade = message disappears more quickly. </summary>
public partial class UiMessage:Control,IOnUpdateEvent{[Export]private Label textField;/// <summary>Tint of the message text; defaults to the warning colour.</summary>
[Export]public Color Color{get;set;}=UiTokens.Warning;/// <summary>Fade speed multiplier — 1 is normal; higher values shorten the fade-out.</summary>
[Export]public float Speed{get;set;}=1f;private float timestamp;/// <summary> The text content displayed by this message. Reads/writes the child <c>textField</c> label directly. </summary>
public string Text{get=>textField.Text;set=>textField.Text=value;}/// <summary> Initializes the message: positions it, sets text/speed/color, records the start timestamp, and registers with the <see cref="SceneEventController"/> for per-frame updates. Called by <see cref="MessagesHelper"/>. </summary> <param name="pos">Vertical Y-offset in pixels.</param> <param name="text">The message text.</param> <param name="speed">Fade speed multiplier.</param> <param name="color">The text color.</param>
public void Setup(float pos,string text,float speed,Color color){OffsetLeft=50;OffsetRight= -10;Size=new Vector2(Size.X,50);Position=new Vector2(0,pos+200);Text=text;Speed=speed;Color=color;timestamp=(float)Godot.Time.GetTicksMsec()/1000f;if(SceneContextNode.Instance!=null){var eventController=SceneContextNode.FindNode<SceneEventController>("EventController");eventController?.AddUpdateEventListener(this);}}/// <summary>Detaches from the event controller so a freed message stops receiving updates.</summary>
public override void _ExitTree(){if(SceneContextNode.Instance!=null){var eventController=SceneContextNode.FindNode<SceneEventController>("EventController");eventController?.RemoveUpdateEventListener(this);}}/// <summary> Priority <c>1</c> — lightweight enough for many concurrent messages. </summary>
public int Priority=>1;/// <summary> Per-frame update: displays for 3 seconds, then fades out over <c>2 / <see cref="Speed"/></c> seconds. Self-destructs via <c>QueueFree</c> when fully transparent. </summary>
public void OnUpdate(){var currentTime=(float)Godot.Time.GetTicksMsec()/1000f;var elapsed=currentTime-timestamp;if(elapsed<3f)return;var fadeDuration=2f/Speed;var fadeElapsed=elapsed-3f;var progress=Mathf.Clamp(fadeElapsed/fadeDuration,0f,1f);textField.SelfModulate=UiTokens.Alpha(Colors.White,1f-progress);if(progress>=1f)QueueFree();}}}

===== FILE mods/ui_base/src/Theming/UiKit.cs =====
using Cthangover.Core.Mods.Resolvers;using Godot;using Cthangover.Core.UI.Theming;namespace Cthangover.Mods.Interface.Theming{/// <summary> Visual variants of <see cref="UiKit.Button"/>, each mapped to a <see cref="CthangoverTheme"/> type variation. Secondary is the unstyled default. </summary>
public enum ButtonKind{Secondary,Primary,Danger,Icon,Tab,}/// <summary> Runtime factories for composed UI pieces that are not standard Godot controls (badges, chips, stat chips, empty states) plus token-aware shortcuts for common controls. Everything built here uses <see cref="UiTokens"/> and inherits the shared <see cref="CthangoverTheme"/> once it is applied to a root. </summary>
public static class UiKit{/// <summary>Rounded frame from tokens; pass transparent border to omit it.</summary>
public static StyleBoxFlat Frame(int radius,Color background,Color?border=null,int borderWidth=1){var style=new StyleBoxFlat{BgColor=background,CornerRadiusTopLeft=radius,CornerRadiusTopRight=radius,CornerRadiusBottomLeft=radius,CornerRadiusBottomRight=radius,};if(border.HasValue){style.BorderColor=border.Value;style.BorderWidthLeft=borderWidth;style.BorderWidthRight=borderWidth;style.BorderWidthTop=borderWidth;style.BorderWidthBottom=borderWidth;}return style;}/// <summary>Creates a non-interactive label with the given size and optional colour.</summary>
public static Label Label(string text,int fontSize=UiTokens.FontBodyLarge,Color?color=null){var label=new Label{Text=text??string.Empty,MouseFilter=Control.MouseFilterEnum.Ignore,};label.AddThemeFontSizeOverride("font_size",fontSize);if(color.HasValue)label.AddThemeColorOverride("font_color",color.Value);return label;}/// <summary>Creates an uppercase muted caption used as a section heading.</summary>
public static Label SectionTitle(string text,int fontSize=UiTokens.FontBodyLarge){var label=Label(text.ToUpperInvariant(),fontSize,UiTokens.Muted);label.AddThemeConstantOverride("outline_size",0);return label;}/// <summary>Creates a centered, wrapped faint label for "nothing here yet" placeholders.</summary>
public static Label EmptyState(string text,int fontSize=UiTokens.FontBodyLarge){var label=Label(text,fontSize,UiTokens.Faint);label.HorizontalAlignment=HorizontalAlignment.Center;label.VerticalAlignment=VerticalAlignment.Center;label.AutowrapMode=TextServer.AutowrapMode.WordSmart;label.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;label.SizeFlagsVertical=Control.SizeFlags.ExpandFill;return label;}/// <summary>Creates a faint small label for helper text.</summary>
public static Label Hint(string text,int fontSize=UiTokens.FontBodySmall)=>Label(text,fontSize,UiTokens.Faint);/// <summary>Small bordered caption chip: fill 9%, border 50%, text in the accent colour.</summary>
public static Control Badge(string text,Color accent,int fontSize=UiTokens.FontTiny){var style=Frame(UiTokens.RadiusXs,UiTokens.BadgeFill(accent),UiTokens.BadgeLine(accent));style.ContentMarginLeft=7;style.ContentMarginRight=7;style.ContentMarginTop=2;style.ContentMarginBottom=2;var badge=new PanelContainer{MouseFilter=Control.MouseFilterEnum.Ignore};badge.AddThemeStyleboxOverride("panel",style);var label=Label(text,fontSize,accent);label.Uppercase=true;badge.AddChild(label);return badge;}/// <summary>Property chip ("Attack 1.0") with a muted key and gold value.</summary>
public static Control PropertyChip(string key,string value){var style=Frame(UiTokens.RadiusXs,UiTokens.AccentFaint,UiTokens.AccentLineWeak);style.ContentMarginLeft=6;style.ContentMarginRight=6;style.ContentMarginTop=1;style.ContentMarginBottom=1;var chip=new PanelContainer{MouseFilter=Control.MouseFilterEnum.Ignore};chip.AddThemeStyleboxOverride("panel",style);var row=Row(UiTokens.Space1);row.AddChild(Label(key,UiTokens.FontTiny,UiTokens.ChipKey));row.AddChild(Label(value,UiTokens.FontTiny,UiTokens.ChipValue));chip.AddChild(row);return chip;}/// <summary>Character stat chip ("HP 100/100") on its own inset surface.</summary>
public static Control StatChip(string key,string value,Color?valueColor=null){var style=Frame(UiTokens.RadiusXs,UiTokens.StatBg,UiTokens.StatLine);style.ContentMarginLeft=7;style.ContentMarginRight=7;style.ContentMarginTop=2;style.ContentMarginBottom=2;var chip=new PanelContainer{MouseFilter=Control.MouseFilterEnum.Ignore};chip.AddThemeStyleboxOverride("panel",style);var row=Row(UiTokens.Space1);var keyLabel=Label(key.ToUpperInvariant(),UiTokens.FontTiny,UiTokens.Muted);keyLabel.AddThemeConstantOverride("outline_size",0);row.AddChild(keyLabel);row.AddChild(Label(value,UiTokens.FontSmall,valueColor??UiTokens.Ink));chip.AddChild(row);return chip;}/// <summary> Creates a styled button. Secondary uses the theme default; every other kind opts the button into the matching <see cref="CthangoverTheme"/> variation. </summary>
public static Button Button(string text,ButtonKind kind=ButtonKind.Secondary){var button=new Button{Text=text??string.Empty};switch(kind){case ButtonKind.Primary:CthangoverTheme.ApplyPrimary(button);break;case ButtonKind.Danger:CthangoverTheme.ApplyDanger(button);break;case ButtonKind.Icon:CthangoverTheme.ApplyIcon(button);break;case ButtonKind.Tab:CthangoverTheme.ApplyTab(button);break;}return button;}/// <summary>Creates an icon-glyph button (icon variation) with an optional tooltip.</summary>
public static Button IconButton(string glyph,string tooltip=null){var button=Button(glyph,ButtonKind.Icon);if(!string.IsNullOrEmpty(tooltip))button.TooltipText=tooltip;return button;}/// <summary>Creates a horizontal container with token spacing and input passthrough.</summary>
public static HBoxContainer Row(int separation=UiTokens.Space4){var row=new HBoxContainer{MouseFilter=Control.MouseFilterEnum.Ignore};row.AddThemeConstantOverride("separation",separation);return row;}/// <summary>Creates a vertical container with token spacing and input passthrough.</summary>
public static VBoxContainer Column(int separation=UiTokens.Space4){var column=new VBoxContainer{MouseFilter=Control.MouseFilterEnum.Ignore};column.AddThemeConstantOverride("separation",separation);return column;}/// <summary>Invisible expanding filler between controls.</summary>
public static Control Spacer(){return new Control{MouseFilter=Control.MouseFilterEnum.Ignore,SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,};}/// <summary>Creates a square, aspect-preserving, non-interactive texture view of the given pixel size.</summary>
public static TextureRect Icon(Texture2D texture,int size){return new TextureRect{Texture=texture,CustomMinimumSize=new Vector2(size,size),ExpandMode=TextureRect.ExpandModeEnum.IgnoreSize,StretchMode=TextureRect.StretchModeEnum.KeepAspectCentered,MouseFilter=Control.MouseFilterEnum.Ignore,};}/// <summary> Styles a layered ("pie") health bar out of two stacked ProgressBars occupying the same rect: <paramref name="shieldBar"/> is the bottom layer and owns the pill track plus the blue defence fill (<see cref="UiTokens.HpShield"/>); <paramref name="healthBar"/> sits on top and paints only its own fill, so its background becomes transparent and the blue defence segment stays visible past the health edge. Battle widgets refresh the values through <see cref="UpdateShieldedHealthBar"/>; the health layer keeps its threshold colours (green → warning → red). </summary>
public static void StyleShieldedHealthBar(ProgressBar shieldBar,ProgressBar healthBar){if(shieldBar!=null){shieldBar.AddThemeStyleboxOverride("background",Frame(UiTokens.RadiusSm,UiTokens.LineDisabled));shieldBar.AddThemeStyleboxOverride("fill",Frame(UiTokens.RadiusSm,UiTokens.HpShield));}if(healthBar!=null)healthBar.AddThemeStyleboxOverride("background",new StyleBoxEmpty());}/// <summary> Refreshes the defence segment of a layered bar built by <see cref="StyleShieldedHealthBar"/>. The blue fill spans from the health edge over <paramref name="defence"/> points scaled against max health, clamped to the track, so incoming damage visually eats the blue layer before the health layer — the same order the battle executors subtract in. A defence of zero collapses the segment back onto the health edge, leaving the plain health bar. </summary>
public static void UpdateShieldedHealthBar(ProgressBar shieldBar,float healthPercent,float defence,float maxHealth){if(shieldBar==null)return;var shieldPercent=maxHealth>0?defence/maxHealth:0f;shieldBar.Value=Mathf.Clamp(healthPercent+shieldPercent,0f,1f)*100f;}/// <summary> Dresses the full-screen backdrop of a gameplay panel: the panel keeps its own <see cref="ColorRect"/> but it becomes an opaque dark surface, and the shared <c>panels_background</c> texture is stretched over the whole rect. Panels sit above live scenes, and the previous 0.9-alpha backdrop let scene buttons bleed through, which made the panel close buttons look noisy; an opaque backing removes them. The colour stays underneath as a fallback because the texture ships in the optional "ui_base" mod and may not resolve, in which case the panel still gets a solid surface. </summary>
public static void ApplyPanelBackdrop(ColorRect backdrop){if(backdrop==null)return;backdrop.Color=UiTokens.Page;var texture=new TextureRect{Texture=Textures.Resolve("panels_background"),ExpandMode=TextureRect.ExpandModeEnum.IgnoreSize,StretchMode=TextureRect.StretchModeEnum.Scale,MouseFilter=Control.MouseFilterEnum.Ignore,};texture.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);backdrop.AddChild(texture);}}}

===== FILE mods/ui_base/src/Tool/ButtonModIconLoader.cs =====
using Cthangover.Core.Factories.Impls;using Godot;using Cthangover.Core.UI.Tool;namespace Cthangover.Mods.Interface.Tool{/// <summary> Mod-aware TextureButton: resolves IconId through UIIconFactory at _Ready and applies the result to TextureNormal. Allows tool buttons to reference icons by logical ID that mods can override. </summary>
public partial class ButtonModIconLoader:TextureButton{/// <summary> Logical icon ID resolved through <see cref="UIIconFactory"/>. Set in the editor or via mod to reference a mod-overridable icon texture. </summary>
[Export]public string IconId{get;set;}public override void _Ready(){if(string.IsNullOrEmpty(IconId))return;var texture=UIIconFactory.Instance.Get(IconId);if(texture!=null)TextureNormal=texture;}}}

===== FILE mods/ui_base/src/Tool/TextureRectModIconLoader.cs =====
using Cthangover.Core.Factories.Impls;using Godot;using Cthangover.Core.UI.Tool;namespace Cthangover.Mods.Interface.Tool{/// <summary> Mod-aware TextureRect: resolves IconId through UIIconFactory at _Ready and applies the result. Used as a base class for any TextureRect that needs mod-overridable textures without scene modification. </summary>
public partial class TextureRectModIconLoader:TextureRect{/// <summary> Logical icon ID resolved through <see cref="UIIconFactory"/>. Set in the editor or via mod to reference a mod-overridable icon texture. </summary>
[Export]public string IconId{get;set;}public override void _Ready(){if(string.IsNullOrEmpty(IconId))return;var texture=UIIconFactory.Instance.Get(IconId);if(texture!=null)Texture=texture;}}}

===== FILE mods/ui_base/src/Tool/ToolBox.cs =====
using System.Collections.Generic;using Cthangover.Core.Bootstrap;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Mods;using Cthangover.Core.Scenes;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Godot;using Cthangover.Mods.Interface.Theming;using Cthangover.Core.UI.Tool;namespace Cthangover.Mods.Interface.Tool{/// <summary> Main toolbar/HUD container (the <c>TopView</c> shell node). It no longer knows any concrete panel: buttons for mod-provided panels are handed to it by <see cref="UiBuilder"/> from <see cref="UiRegistry.PanelEntries"/>, and pressing one calls <see cref="UiRegistry.Toggle"/>. Tool buttons (<see cref="IToolBoxButton"/>/<see cref="ToolFactory"/>) remain a separate, self-registering mechanism and are added alongside the panel buttons. </summary>
public partial class ToolBox:Control,IToolbarHost,ISaveIconHost{private HBoxContainer _buttons;private TextureRect _saveIcon;/// <summary>Wires the save icon visibility and appends the dev-tool buttons registered in <see cref="ToolBoxButtonFactory"/>.</summary>
public override void _Ready(){_buttons=GetNodeOrNull<HBoxContainer>("Buttons");_saveIcon=GetNodeOrNull<TextureRect>("CenterTools/Save");UpdateSaveIconVisibility();AddToolButtons(_buttons);}/// <summary> Materializes a toolbar button for every entry. Called once per shell by <see cref="UiBuilder"/>; entries arrive pre-sorted by the registry (button index, then mod load order, then id). </summary>
public void ApplyPanelButtons(IReadOnlyList<UiEntry>entries){if(_buttons==null||entries==null)return;foreach(var entry in entries)_buttons.AddChild(CreatePanelButton(entry));}private static BaseButton CreatePanelButton(UiEntry entry){var title=TranslationServer.Translate(entry.Button.Locale);var icon=UIIconFactory.Instance.Get(entry.Button.Icon);var button=CreateToolbarButton(title,icon);var id=entry.Id;button.Pressed+=()=>AppServices.Current.Ui.ToggleEntry(id);return button;}private static BaseButton CreateToolbarButton(string title,Texture2D icon){var button=new Button{CustomMinimumSize=new Vector2(60,60),SizeFlagsVertical=SizeFlags.ShrinkCenter,TooltipText=title,};if(icon!=null){var iconRect=UiKit.Icon(icon,60);button.AddChild(iconRect);iconRect.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);}else{button.Text=title;}button.AddThemeStyleboxOverride("normal",UiKit.Frame(UiTokens.RadiusSm,UiTokens.Surface0,UiTokens.Line));button.AddThemeStyleboxOverride("hover",UiKit.Frame(UiTokens.RadiusSm,UiTokens.Surface1,UiTokens.AccentLine));button.AddThemeStyleboxOverride("pressed",UiKit.Frame(UiTokens.RadiusSm,UiTokens.Surface1,UiTokens.AccentLineStrong));button.AddThemeStyleboxOverride("disabled",UiKit.Frame(UiTokens.RadiusSm,UiTokens.Surface0,UiTokens.LineDisabled));button.AddThemeStyleboxOverride("focus",new StyleBoxEmpty());return button;}/// <summary> Adds one "tools" toolbar button whose popup lists every visible <see cref="IToolBoxButton"/>. Tools used to get a text button each, which did not fit the 60×60 icon row and overlapped its icons; a single icon keeps the HUD row uniform no matter how many tools are installed. Nothing is added when no tool button is visible. </summary>
private void AddToolButtons(HBoxContainer container){if(container==null)return;var visible=new List<IToolBoxButton>(ToolBoxButtonFactory.Instance.GetVisible());if(visible.Count==0)return;visible.Sort((a,b)=>string.Compare(TranslationServer.Translate(a.LocaleKey),TranslationServer.Translate(b.LocaleKey),System.StringComparison.CurrentCulture));var btn=CreateToolbarButton(TranslationServer.Translate("tools/menu_title"),UIIconFactory.Instance.Get("tools"));var menu=new PopupMenu{Theme=ToolTheme.Get()};foreach(var buttonDef in visible)menu.AddItem(TranslationServer.Translate(buttonDef.LocaleKey));menu.IndexPressed+=index=>OpenTool(visible[(int)index].ToolId);btn.AddChild(menu);btn.Pressed+=()=>{var rect=btn.GetGlobalRect();menu.Position=new Vector2I((int)rect.Position.X,(int)rect.End.Y+UiTokens.Space1);menu.Popup();};container.AddChild(btn);}private static void OpenTool(string toolId){var tool=ToolFactory.Instance.Get(toolId);if(tool!=null)ToolWindow.ShowWindow(tool.CreateWindow());}/// <summary> Shows or hides the save icon based on <see cref="SceneManager.IsSaveAllowedForCurrentScene"/>. Call after scene transitions to reflect the new scene's save policy. </summary>
public void UpdateSaveIconVisibility(){if(_saveIcon==null)return;_saveIcon.Visible=SceneManager.IsSaveAllowedForCurrentScene();}}}

===== FILE mods/ui_base/src/Tool/ToolMenuButton.cs =====
using Godot;using Cthangover.Core.UI.Tool;namespace Cthangover.Mods.Interface.Tool{/// <summary> Shared factory for the launcher buttons that tools contribute to the main menu's tool block. Keeps the standard presentation (localized caption), the translation-key metadata used for live language switching, and the window-opening action in one place, so <see cref="IToolProvider.AddMenuButton"/> implementations stay focused while remaining free to append extra controls. </summary>
public static class ToolMenuButton{/// <summary> Creates a button labelled with the tool's <see cref="IToolProvider.LocaleKey"/>, wires it to open a fresh window via <see cref="ToolWindow.ShowWindow"/>, and adds it to <paramref name="container"/>. Returns the created button so a tool can decorate it further (icon, tooltip, extra handlers). </summary>
public static Button Attach(Container container,IToolProvider tool){var button=new Button{Text=TranslationServer.Translate(tool.LocaleKey),};button.SetMeta(Menu.MenuBuilder.LocaleMetaKey,tool.LocaleKey);button.Pressed+=()=>ToolWindow.ShowWindow(tool.CreateWindow());container.AddChild(button);return button;}}}

===== FILE mods/ui_base/src/Tool/ToolStatusKind.cs =====
namespace Cthangover.Mods.Interface.Tool{/// <summary> Tone of a message in the <see cref="ToolWindow"/> status bar, mapped to the style guide's inline messages: neutral info, ok (<c>.ct-message--ok</c>) and error (<c>.ct-message--err</c>). </summary>
public enum ToolStatusKind{Info,Ok,Error,}}

===== FILE mods/ui_base/src/Tool/ToolTheme.cs =====
using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Mods.Interface.Tool{/// <summary> Dense variant of <see cref="CthangoverTheme"/> for developer tool windows. The game theme is tuned for player panels (buttons padded 18×11, inputs 12×9), which makes an editor toolbar of ten controls overflow even a full-HD window. This theme keeps every colour, radius and type variation of the shared theme — the style guide still holds — and only tightens the content margins of the boxes, so tools fit more controls without a second palette. Built once by copying the shared theme and replacing its flat boxes with tightened duplicates; the shared theme is never mutated. </summary>
public static class ToolTheme{private const int MaxVerticalMargin=5;private const int MaxHorizontalMargin=10;private static Theme instance;/// <summary>Shared dense theme instance, built lazily from the game theme.</summary>
public static Theme Get()=>instance??=Build();private static Theme Build(){var theme=(Theme)CthangoverTheme.Get().Duplicate();theme.DefaultFontSize=UiTokens.FontBody;foreach(var type in theme.GetTypeList()){foreach(var name in theme.GetStyleboxList(type)){if(theme.GetStylebox(name,type)is not StyleBoxFlat flat)continue;var dense=(StyleBoxFlat)flat.Duplicate();dense.ContentMarginTop=Mathf.Min(dense.ContentMarginTop,MaxVerticalMargin);dense.ContentMarginBottom=Mathf.Min(dense.ContentMarginBottom,MaxVerticalMargin);dense.ContentMarginLeft=Mathf.Min(dense.ContentMarginLeft,MaxHorizontalMargin);dense.ContentMarginRight=Mathf.Min(dense.ContentMarginRight,MaxHorizontalMargin);theme.SetStylebox(name,type,dense);}if(theme.HasFontSize("font_size",type)&&theme.GetFontSize("font_size",type)>UiTokens.FontBody)theme.SetFontSize("font_size",type,UiTokens.FontBody);}return theme;}}}

===== FILE mods/ui_base/src/Tool/ToolWindow.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.UI.Theming;using Cthangover.Core.Utils;using Godot;using Cthangover.Core.UI.Tool;namespace Cthangover.Mods.Interface.Tool{/// <summary> Base for developer tool windows: borderless chrome that follows the UI style guide (title left in the header, ✕ on the right, drag-to-move, double-click/□ to maximize, edge resize), a status bar, dirty-state tracking and keyboard shortcuts. <para/> Subclasses build their UI in the constructor and add it as direct children; <see cref="SetupChrome"/> (run by <see cref="Open{T}"/> and <see cref="ShowWindow"/>) later moves those children into the content area between the header and the status bar. Status and hint text may be set before the chrome exists — it is buffered and shown once built. <para/> Tool windows use <see cref="ToolTheme"/>, a denser copy of the game theme: same tokens, smaller paddings, so editor toolbars fit. <para/> Shortcuts: Ctrl+S calls <see cref="OnSaveShortcut"/> even while a text field has focus (checked in <c>_Input</c>, before the GUI); every other key reaches <see cref="HandleKey"/> only if no focused control consumed it (<c>_UnhandledInput</c>), so Delete in a text field never deletes a selected object. </summary>
public abstract partial class ToolWindow:Window{/// <summary> Whether the window has unsaved changes. When <c>true</c>, the title bar shows a "*" prefix and closing asks for confirmation first. </summary>
protected bool _dirty;private readonly string _baseTitle;private bool _chromeBuilt;private bool _isMaximized;private Vector2I _restorePosition;private Vector2I _restoreSize;private Label _titleLabel;private Button _maximizeBtn;private HBoxContainer _titleBar;private Label _statusLabel;private Label _hintLabel;private string _pendingStatus=string.Empty;private ToolStatusKind _pendingStatusKind;private string _pendingHint=string.Empty;private bool _draggingTitle;private Vector2 _titleDragMouseStart;private Vector2I _titleDragWindowStart;private enum ResizeEdge{None,Left,Right,Top,Bottom,TopLeft,TopRight,BottomLeft,BottomRight}private bool _resizing;private ResizeEdge _resizeEdge;private Vector2 _resizeMouseStart;private Vector2I _resizePosStart;private Vector2I _resizeSizeStart;private const int EDGE_ZONE=6;private const int CORNER_ZONE=14;private const int TITLE_BAR_HEIGHT=40;private const int TITLE_BUTTON_SIZE=38;private const float DefaultSizeRatio=0.9f;/// <summary> Height of the in-game HUD icon row. HUD canvas layers draw above embedded windows, so a window placed under it would have its title bar covered; windows open below this line. </summary>
private const int HudTopInset=84;private static Font _monospaceFont;private static Vector2 GetGameViewportSize(){var tree=Godot.Engine.GetMainLoop()as SceneTree;var vpSize=tree?.Root?.GetVisibleRect().Size;if(vpSize.HasValue&&vpSize.Value.X>0&&vpSize.Value.Y>0)return vpSize.Value;return DisplayServer.WindowGetSize();}// ── public API ────────────────────────────────────────────── <summary> Creates a new instance of <typeparamref name="T"/> and displays it as a centered popup on the scene tree root. Returns the window so the caller can inspect it after closing. </summary>
public static T Open<T>()where T:ToolWindow,new(){var window=new T();ShowWindow(window);return window;}/// <summary> Displays an already-constructed <see cref="Window"/> as a centered popup on the scene tree root. Used when the window was created via <see cref="IToolProvider.CreateWindow"/>. </summary>
public static Window ShowWindow(Window window){if(window is ToolWindow tw)tw.SetupChrome();else CthangoverTheme.Apply(window);var tree=Godot.Engine.GetMainLoop()as SceneTree;tree?.Root.AddChild(window);window.PopupCentered();if(window is ToolWindow&&window.Position.Y<HudTopInset)window.Position=new Vector2I(window.Position.X,HudTopInset);GameLogger.Log("TOOL_WINDOW",$"ShowWindow '{window.GetType().Name}' size=({window.Size.X},{window.Size.Y})");return window;}// ── constructor & lifecycle ─────────────────────────────────
protected ToolWindow(string titleKey){_baseTitle=TranslationServer.Translate(titleKey);Title=_baseTitle;Unresizable=false;CloseRequested+=RequestClose;}/// <summary>Runs the <see cref="Cleanup"/> teardown hook when the window leaves the tree.</summary>
public override void _ExitTree(){Cleanup();}/// <summary>Ctrl+S → <see cref="OnSaveShortcut"/>, checked before the GUI so it works inside text fields.</summary>
public override void _Input(InputEvent@event){if(@event is not InputEventKey key|| !key.Pressed||key.Echo)return;if(key.CtrlPressed&&key.Keycode==Key.S){OnSaveShortcut();SetInputAsHandled();}}/// <summary>Forwards keys no control consumed to <see cref="HandleKey"/>.</summary>
public override void _UnhandledInput(InputEvent@event){if(@event is InputEventKey key&&key.Pressed&&HandleKey(key))SetInputAsHandled();}/// <summary> Override to release resources when the window is closed. Called from <c>_ExitTree</c>. </summary>
protected virtual void Cleanup(){}/// <summary>Ctrl+S handler; tools with a save action override it.</summary>
protected virtual void OnSaveShortcut(){}/// <summary> Tool-specific shortcut for a key no focused control consumed. Return <c>true</c> when handled. </summary>
protected virtual bool HandleKey(InputEventKey key)=>false;// ── chrome setup ──────────────────────────────────────────── <summary> Wraps all currently-added children inside the chrome layout (title bar, content area, status bar, resize zones). Called automatically by <see cref="Open{T}"/> and <see cref="ShowWindow"/>; safe to call twice. </summary>
internal void SetupChrome(){if(_chromeBuilt)return;_chromeBuilt=true;Theme=ToolTheme.Get();// Read the requested size first: assigning MinSize clamps Size at once.
var requested=Size;var viewport=GetGameViewportSize();Borderless=true;MinSize=new Vector2I(800,400);MaxSize=(Vector2I)viewport;if(requested.X<MinSize.X||requested.Y<MinSize.Y)requested=(Vector2I)(viewport*DefaultSizeRatio);var maxHeight=MaxSize.Y-HudTopInset-UiTokens.Space3;Size=new Vector2I(Mathf.Min(requested.X,MaxSize.X),Mathf.Min(requested.Y,maxHeight));var existingChildren=new List<Node>();foreach(Node child in GetChildren())existingChildren.Add(child);foreach(var child in existingChildren)RemoveChild(child);var rootPanel=BuildRootPanel();var rootVBox=new VBoxContainer();rootVBox.AddThemeConstantOverride("separation",0);rootPanel.AddChild(rootVBox);rootVBox.AddChild(BuildTitleBar());var contentMargin=new MarginContainer{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};contentMargin.AddThemeConstantOverride("margin_left",UiTokens.Space3);contentMargin.AddThemeConstantOverride("margin_right",UiTokens.Space3);contentMargin.AddThemeConstantOverride("margin_top",UiTokens.Space3);contentMargin.AddThemeConstantOverride("margin_bottom",UiTokens.Space1);rootVBox.AddChild(contentMargin);var contentArea=new Control{Name="ContentArea",SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,SizeFlagsVertical=Control.SizeFlags.ExpandFill};contentMargin.AddChild(contentArea);foreach(var child in existingChildren)contentArea.AddChild(child);rootVBox.AddChild(BuildStatusBar());AddResizeZones();}// ── dirty tracking ────────────────────────────────────────── <summary> Marks the window as having unsaved changes. Prepends "* " to the title bar text. </summary>
protected void SetDirty(){if(_dirty)return;_dirty=true;UpdateTitleText();}/// <summary> Clears the dirty flag and restores the original title bar text. </summary>
protected void MarkClean(){_dirty=false;UpdateTitleText();}private void UpdateTitleText(){var text=_dirty?"* "+_baseTitle:_baseTitle;Title=text;if(_titleLabel!=null)_titleLabel.Text=text;}// ── status bar ────────────────────────────────────────────── <summary>Shows a message in the status bar, coloured by <paramref name="kind"/>.</summary>
protected void SetStatus(string text,ToolStatusKind kind=ToolStatusKind.Info){_pendingStatus=text??string.Empty;_pendingStatusKind=kind;if(_statusLabel==null)return;_statusLabel.Text=_pendingStatus;_statusLabel.AddThemeColorOverride("font_color",kind switch{ToolStatusKind.Ok=>UiTokens.Ok,ToolStatusKind.Error=>UiTokens.DangerText,_=>UiTokens.Body,});}/// <summary>Sets the faint right-aligned hint of the status bar (usually the shortcut list).</summary>
protected void SetHint(string text){_pendingHint=text??string.Empty;if(_hintLabel!=null)_hintLabel.Text=_pendingHint;}// ── unsaved-changes dialogs ───────────────────────────────── <summary>Asks "close without saving?" and runs <paramref name="onDiscard"/> on confirmation.</summary>
protected void ShowUnsavedDialog(Action onDiscard){ShowConfirm("tools/unsaved_title","tools/unsaved_text",onDiscard);}/// <summary> Runs <paramref name="action"/> right away when there are no unsaved changes, otherwise only after the author agrees to discard them; on refusal <paramref name="onCancel"/> runs (e.g. to roll a file picker back). Use before replacing the edited document. </summary>
protected void RunDiscardingChanges(Action action,Action onCancel=null){if(!_dirty){action();return;}ShowConfirm("tools/unsaved_title","tools/unsaved_discard_text",()=>{MarkClean();action();},onCancel);}/// <summary>Shows a themed OK/Cancel dialog; <paramref name="onConfirm"/> runs on OK, <paramref name="onCancel"/> otherwise.</summary>
protected void ShowConfirm(string titleKey,string textKey,Action onConfirm,Action onCancel=null){var dialog=new ConfirmationDialog{Title=TranslationServer.Translate(titleKey),DialogText=TranslationServer.Translate(textKey),Theme=ToolTheme.Get()};dialog.Confirmed+=()=>{dialog.QueueFree();onConfirm();};dialog.Canceled+=()=>{dialog.QueueFree();onCancel?.Invoke();};AddChild(dialog);dialog.PopupCentered();}/// <summary>Closes the window, asking first when there are unsaved changes.</summary>
protected void RequestClose(){if(_dirty)ShowUnsavedDialog(QueueFree);else QueueFree();}// ── layout helpers ──────────────────────────────────────────
protected static VBoxContainer CreateFillContainer(){var c=new VBoxContainer();c.AnchorRight=1f;c.AnchorBottom=1f;c.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;c.SizeFlagsVertical=Control.SizeFlags.ExpandFill;c.AddThemeConstantOverride("separation",UiTokens.Space3);return c;}protected static HBoxContainer CreateToolbar(){var t=new HBoxContainer();t.AddThemeConstantOverride("separation",UiTokens.Space2);return t;}protected static PanelContainer CreateSidebar(VBoxContainer content,int width=280){var panel=new PanelContainer();panel.CustomMinimumSize=new Vector2(width,100);panel.SizeFlagsVertical=Control.SizeFlags.ExpandFill;panel.AddThemeStyleboxOverride("panel",SidebarBox());var scroll=new ScrollContainer();scroll.HorizontalScrollMode=ScrollContainer.ScrollMode.Disabled;scroll.SizeFlagsVertical=Control.SizeFlags.ExpandFill;scroll.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;panel.AddChild(scroll);content.SizeFlagsHorizontal=Control.SizeFlags.ExpandFill;content.AddThemeConstantOverride("separation",UiTokens.Space2);scroll.AddChild(content);return panel;}/// <summary>Inset sidebar surface: <c>--ct-surface-2</c>, line border, card radius.</summary>
protected static StyleBoxFlat SidebarBox(){var style=new StyleBoxFlat{BgColor=UiTokens.Surface2,BorderColor=UiTokens.Line,BorderWidthLeft=1,BorderWidthRight=1,BorderWidthTop=1,BorderWidthBottom=1,CornerRadiusTopLeft=UiTokens.RadiusMd,CornerRadiusTopRight=UiTokens.RadiusMd,CornerRadiusBottomLeft=UiTokens.RadiusMd,CornerRadiusBottomRight=UiTokens.RadiusMd,ContentMarginLeft=UiTokens.Space4,ContentMarginRight=UiTokens.Space4,ContentMarginTop=UiTokens.Space4,ContentMarginBottom=UiTokens.Space4};return style;}/// <summary> Monospace font for code, IDs and numbers (style guide: <c>--ct-mono</c>). The game ships no mono font, so the OS one is requested by name with the guide's fallbacks; Godot falls back to its default font if none exist. </summary>
public static Font GetMonospaceFont(){return _monospaceFont??=new SystemFont{FontNames=new[]{"Consolas","Cascadia Mono","DejaVu Sans Mono","Liberation Mono","Courier New","monospace"}};}// ── chrome building ─────────────────────────────────────────
private PanelContainer BuildRootPanel(){var panel=new PanelContainer{Name="RootPanel",AnchorRight=1f,AnchorBottom=1f};var style=new StyleBoxFlat{BgColor=UiTokens.Surface0,BorderWidthLeft=1,BorderWidthRight=1,BorderWidthBottom=1,BorderWidthTop=1,BorderColor=UiTokens.Line,CornerRadiusTopLeft=UiTokens.RadiusLg,CornerRadiusTopRight=UiTokens.RadiusLg,CornerRadiusBottomLeft=UiTokens.RadiusLg,CornerRadiusBottomRight=UiTokens.RadiusLg,ContentMarginLeft=1,ContentMarginRight=1,ContentMarginBottom=1,ContentMarginTop=1};panel.AddThemeStyleboxOverride("panel",style);AddChild(panel);return panel;}private PanelContainer BuildTitleBar(){_titleBar=new HBoxContainer{Name="TitleBar",CustomMinimumSize=new Vector2(0,TITLE_BAR_HEIGHT)};_titleBar.AddThemeConstantOverride("separation",UiTokens.Space1);_titleLabel=new Label{Text=_dirty?"* "+_baseTitle:_baseTitle,Uppercase=true,SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,VerticalAlignment=VerticalAlignment.Center,MouseFilter=Control.MouseFilterEnum.Ignore};_titleLabel.AddThemeColorOverride("font_color",UiTokens.InkBright);_titleLabel.AddThemeFontSizeOverride("font_size",UiTokens.FontLead);_titleBar.AddChild(_titleLabel);_maximizeBtn=CreateTitleButton("□",OnMaximizeToggle,UiTokens.BtnHover,UiTokens.InkBright);_maximizeBtn.TooltipText=TranslationServer.Translate("tools/window_maximize");_titleBar.AddChild(_maximizeBtn);var closeBtn=CreateTitleButton("✕",RequestClose,UiTokens.DangerSoft,UiTokens.DangerText);closeBtn.TooltipText=TranslationServer.Translate("tools/window_close");_titleBar.AddChild(closeBtn);_titleBar.GuiInput+=OnTitleBarInput;var titlePanel=new PanelContainer();titlePanel.AddChild(_titleBar);var titleBg=new StyleBoxFlat{BgColor=UiTokens.Surface1,BorderColor=UiTokens.Line,BorderWidthBottom=1,CornerRadiusTopLeft=UiTokens.RadiusLg,CornerRadiusTopRight=UiTokens.RadiusLg,ContentMarginLeft=UiTokens.Space7,ContentMarginRight=UiTokens.Space1,};titlePanel.AddThemeStyleboxOverride("panel",titleBg);return titlePanel;}private PanelContainer BuildStatusBar(){var bar=new HBoxContainer{Name="StatusBar",CustomMinimumSize=new Vector2(0,26)};bar.AddThemeConstantOverride("separation",UiTokens.Space5);_statusLabel=new Label{SizeFlagsHorizontal=Control.SizeFlags.ExpandFill,VerticalAlignment=VerticalAlignment.Center,TextOverrunBehavior=TextServer.OverrunBehavior.TrimEllipsis};_statusLabel.AddThemeFontSizeOverride("font_size",UiTokens.FontBodySmall);bar.AddChild(_statusLabel);_hintLabel=new Label{HorizontalAlignment=HorizontalAlignment.Right,VerticalAlignment=VerticalAlignment.Center};_hintLabel.AddThemeFontSizeOverride("font_size",UiTokens.FontSmall);_hintLabel.AddThemeColorOverride("font_color",UiTokens.Faint);bar.AddChild(_hintLabel);SetStatus(_pendingStatus,_pendingStatusKind);SetHint(_pendingHint);var panel=new PanelContainer();panel.AddChild(bar);var style=new StyleBoxFlat{BgColor=UiTokens.Surface2,BorderColor=UiTokens.Line,BorderWidthTop=1,CornerRadiusBottomLeft=UiTokens.RadiusLg,CornerRadiusBottomRight=UiTokens.RadiusLg,ContentMarginLeft=UiTokens.Space5,ContentMarginRight=UiTokens.Space5,};panel.AddThemeStyleboxOverride("panel",style);return panel;}private static Button CreateTitleButton(string text,Action pressed,Color hoverBg,Color hoverText){var btn=new Button{Text=text,CustomMinimumSize=new Vector2(TITLE_BUTTON_SIZE,TITLE_BUTTON_SIZE),SizeFlagsHorizontal=Control.SizeFlags.ShrinkEnd,SizeFlagsVertical=Control.SizeFlags.ShrinkCenter,Flat=true,FocusMode=Control.FocusModeEnum.None};btn.AddThemeColorOverride("font_color",UiTokens.Muted);btn.AddThemeColorOverride("font_hover_color",hoverText);btn.AddThemeColorOverride("font_pressed_color",hoverText);btn.AddThemeColorOverride("font_hover_pressed_color",hoverText);btn.AddThemeFontSizeOverride("font_size",UiTokens.FontLead);var normalStyle=new StyleBoxFlat{BgColor=Colors.Transparent};btn.AddThemeStyleboxOverride("normal",normalStyle);var hoverStyle=new StyleBoxFlat{BgColor=hoverBg,CornerRadiusTopLeft=UiTokens.RadiusSm,CornerRadiusTopRight=UiTokens.RadiusSm,CornerRadiusBottomLeft=UiTokens.RadiusSm,CornerRadiusBottomRight=UiTokens.RadiusSm};btn.AddThemeStyleboxOverride("hover",hoverStyle);btn.AddThemeStyleboxOverride("pressed",hoverStyle);btn.AddThemeStyleboxOverride("focus",new StyleBoxEmpty());btn.Pressed+=pressed;return btn;}// ── button actions ──────────────────────────────────────────
private void OnMaximizeToggle(){if(_isMaximized)RestoreWindow();else MaximizeWindow();}private void MaximizeWindow(){_restorePosition=Position;_restoreSize=Size;_isMaximized=true;_maximizeBtn.Text="❐";Position=Vector2I.Zero;Size=(Vector2I)GetGameViewportSize();}private void RestoreWindow(){_isMaximized=false;_maximizeBtn.Text="□";Position=_restorePosition;Size=_restoreSize;}// ── title bar drag ──────────────────────────────────────────
private void OnTitleBarInput(InputEvent evt){if(evt is InputEventMouseButton mb){if(mb.ButtonIndex==MouseButton.Left){if(mb.Pressed){if(mb.DoubleClick){OnMaximizeToggle();return;}if(_isMaximized){_isMaximized=false;_maximizeBtn.Text="□";var pos=DisplayServer.MouseGetPosition();var viewportSize=GetGameViewportSize();var ratio=pos.X/viewportSize.X;_titleDragWindowStart=new Vector2I(Mathf.RoundToInt(pos.X-_restoreSize.X*ratio),Mathf.RoundToInt(pos.Y-4));_titleDragMouseStart=new Vector2(pos.X,pos.Y);Size=_restoreSize;}else{_titleDragWindowStart=Position;var mp=DisplayServer.MouseGetPosition();_titleDragMouseStart=new Vector2(mp.X,mp.Y);}_draggingTitle=true;}else{_draggingTitle=false;}}}else if(evt is InputEventMouseMotion&&_draggingTitle){var mp=DisplayServer.MouseGetPosition();var delta=new Vector2(mp.X,mp.Y)-_titleDragMouseStart;Position=new Vector2I(_titleDragWindowStart.X+Mathf.RoundToInt(delta.X),_titleDragWindowStart.Y+Mathf.RoundToInt(delta.Y));}}// ── resize zones ────────────────────────────────────────────
private void AddResizeZones(){AddEdgeH("ResizeLeft",ResizeEdge.Left,0f,0f,0f,EDGE_ZONE);AddEdgeH("ResizeRight",ResizeEdge.Right,1f,1f,-EDGE_ZONE,0f);AddEdgeV("ResizeTop",ResizeEdge.Top,0f,0f,0f,EDGE_ZONE);AddEdgeV("ResizeBottom",ResizeEdge.Bottom,1f,1f,-EDGE_ZONE,0f);AddCornerZone("ResizeTL",ResizeEdge.TopLeft,0f,0f,CORNER_ZONE,CORNER_ZONE,Control.CursorShape.Fdiagsize);AddCornerZone("ResizeTR",ResizeEdge.TopRight,1f,0f,-CORNER_ZONE,CORNER_ZONE,Control.CursorShape.Bdiagsize);AddCornerZone("ResizeBL",ResizeEdge.BottomLeft,0f,1f,CORNER_ZONE,-CORNER_ZONE,Control.CursorShape.Bdiagsize);AddCornerZone("ResizeBR",ResizeEdge.BottomRight,1f,1f,-CORNER_ZONE,-CORNER_ZONE,Control.CursorShape.Fdiagsize);}private void AddEdgeH(string name,ResizeEdge edge,float anchorLeft,float anchorRight,float offsetLeft,float offsetRight){var zone=MakeResizeZone(name,edge,Control.CursorShape.Hsize);zone.AnchorLeft=anchorLeft;zone.AnchorRight=anchorRight;zone.OffsetLeft=offsetLeft;zone.OffsetRight=offsetRight;zone.AnchorTop=0f;zone.AnchorBottom=1f;zone.OffsetTop=CORNER_ZONE;zone.OffsetBottom= -CORNER_ZONE;AddChild(zone);}private void AddEdgeV(string name,ResizeEdge edge,float anchorTop,float anchorBottom,float offsetTop,float offsetBottom){var zone=MakeResizeZone(name,edge,Control.CursorShape.Vsize);zone.AnchorLeft=0f;zone.AnchorRight=1f;zone.OffsetLeft=CORNER_ZONE;zone.OffsetRight= -CORNER_ZONE;zone.AnchorTop=anchorTop;zone.AnchorBottom=anchorBottom;zone.OffsetTop=offsetTop;zone.OffsetBottom=offsetBottom;AddChild(zone);}private void AddCornerZone(string name,ResizeEdge edge,float anchorH,float anchorV,int offsetW,int offsetH,Control.CursorShape cursor){var zone=MakeResizeZone(name,edge,cursor);zone.AnchorLeft=anchorH;zone.AnchorRight=anchorH;zone.AnchorTop=anchorV;zone.AnchorBottom=anchorV;if(offsetW>=0){zone.OffsetLeft=0;zone.OffsetRight=offsetW;}else{zone.OffsetLeft=offsetW;zone.OffsetRight=0;}if(offsetH>=0){zone.OffsetTop=0;zone.OffsetBottom=offsetH;}else{zone.OffsetTop=offsetH;zone.OffsetBottom=0;}AddChild(zone);}private Control MakeResizeZone(string name,ResizeEdge edge,Control.CursorShape cursor){var zone=new Control{Name=name,MouseFilter=Control.MouseFilterEnum.Stop,MouseDefaultCursorShape=cursor};zone.GuiInput+=(InputEvent evt)=>HandleResizeInput(evt,edge);return zone;}private void HandleResizeInput(InputEvent evt,ResizeEdge edge){if(evt is InputEventMouseButton mb&&mb.ButtonIndex==MouseButton.Left){if(mb.Pressed){_resizing=true;_resizeEdge=edge;var mp=DisplayServer.MouseGetPosition();_resizeMouseStart=new Vector2(mp.X,mp.Y);_resizePosStart=Position;_resizeSizeStart=Size;}else{_resizing=false;_resizeEdge=ResizeEdge.None;}}else if(evt is InputEventMouseMotion&&_resizing){var mp=DisplayServer.MouseGetPosition();ApplyResize(new Vector2(mp.X,mp.Y));}}private void ApplyResize(Vector2 globalMouse){var delta=globalMouse-_resizeMouseStart;var dx=Mathf.RoundToInt(delta.X);var dy=Mathf.RoundToInt(delta.Y);int newX=_resizePosStart.X;int newY=_resizePosStart.Y;int newW=_resizeSizeStart.X;int newH=_resizeSizeStart.Y;var left=_resizeEdge is ResizeEdge.Left or ResizeEdge.TopLeft or ResizeEdge.BottomLeft;var right=_resizeEdge is ResizeEdge.Right or ResizeEdge.TopRight or ResizeEdge.BottomRight;var top=_resizeEdge is ResizeEdge.Top or ResizeEdge.TopLeft or ResizeEdge.TopRight;var bottom=_resizeEdge is ResizeEdge.Bottom or ResizeEdge.BottomLeft or ResizeEdge.BottomRight;if(left){newX+=dx;newW-=dx;}if(right)newW+=dx;if(top){newY+=dy;newH-=dy;}if(bottom)newH+=dy;newX=Mathf.Clamp(newX,0,MaxSize.X-100);newY=Mathf.Clamp(newY,0,MaxSize.Y-100);newW=Mathf.Clamp(newW,MinSize.X,MaxSize.X);newH=Mathf.Clamp(newH,MinSize.Y,MaxSize.Y);if(newX+newW>MaxSize.X)newX=MaxSize.X-newW;if(newY+newH>MaxSize.Y)newY=MaxSize.Y-newH;Position=new Vector2I(newX,newY);Size=new Vector2I(newW,newH);}}}

===== FILE mods/ui_base/src/View/AlignType.cs =====
namespace Cthangover.Mods.Interface.View{/// <summary> 9-point alignment grid for viewport content positioning. The naming uses horizontal-first, vertical-second convention (LeftTop, CenterCenter, RightBottom) — matching the intuitive order for CSS-like layout. </summary>
public enum AlignType{/// <summary>Content aligned to top-left corner of the viewport.</summary>
LeftTop,/// <summary>Content horizontally left, vertically centered in the viewport.</summary>
LeftCenter,/// <summary>Content horizontally left, vertically at the bottom of the viewport.</summary>
LeftBottom,/// <summary>Content horizontally centered, vertically at the top of the viewport.</summary>
CenterTop,/// <summary>Content centered both horizontally and vertically in the viewport.</summary>
CenterCenter,/// <summary>Content horizontally centered, vertically at the bottom of the viewport.</summary>
CenterBottom,/// <summary>Content horizontally right, vertically at the top of the viewport.</summary>
RightTop,/// <summary>Content horizontally right, vertically centered in the viewport.</summary>
RightCenter,/// <summary>Content aligned to the bottom-right corner of the viewport.</summary>
RightBottom,}}

===== FILE mods/ui_base/src/View/ViewBox.cs =====
using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;using Cthangover.Core.Scenes;using Cthangover.Core.Utils;using Cthangover.Core.UI;using Cthangover.Core.UI.Theming;using Godot;namespace Cthangover.Mods.Interface.View{/// <summary> Core viewport container: manages background/foreground TextureRects with a shared shader material (timed_sprite from mods) for time-of-day lighting. Supports smooth background transitions via a black ColorRect overlay that fades in, swaps the texture mid-transition, then fades out — a classic crossfade approach that doesn't require a second TextureRect. The zoom system scales the content Control between minZoom and maxZoom based on a 0–1 input value. Alignment positions content within the viewport using ViewBoxNavigator's 9-point grid. The #if TOOLS block forces content size to defaultResolution for editor preview. Interactive layers are Control containers placed between Background and Foreground (and above Foreground for UI) that host interactive scene objects. </summary>
public partial class ViewBox:Control,IBackgroundView{[Export]private Vector2I defaultResolution=new(1920,1024);[Export]private Control content;[Export]private float minZoom;[Export]private float maxZoom;/// <summary> The content node that holds all scene visuals. Scaling and alignment are applied directly to this node. </summary>
public Control Content=>content;/// <summary>Logical resolution of the content area (default 1920x1024). Used for normalised coordinate calculations.</summary>
public Vector2I LogicalSize=>defaultResolution;/// <summary> The background <see cref="TextureRect"/> rendered behind all interactive layers. Receives a <c>timed_sprite</c> shader material for time-of-day lighting when no material is already assigned. </summary>
public TextureRect Background{get;private set;}/// <summary> The foreground <see cref="TextureRect"/> rendered above interactive layers. Receives the same <c>timed_sprite</c> shader as the background. </summary>
public TextureRect Foreground{get;private set;}/// <summary>Container for interactive objects rendered behind the foreground.</summary>
public Control InteractiveLayerBackground{get;private set;}/// <summary>Container for interactive objects rendered above the foreground.</summary>
public Control InteractiveLayerForeground{get;private set;}/// <summary>Container for interactive objects rendered above all scene visuals (UI layer).</summary>
public Control InteractiveLayerUI{get;private set;}private ColorRect transitionOverlay;private Tween transitionTween;/// <summary>Resolves the background and foreground nodes and assigns the time-of-day shader when no material is set.</summary>
public override void _Ready(){Background??=GetNodeOrNull<TextureRect>("Background");Foreground??=GetNodeOrNull<TextureRect>("Foreground");var timedShader=Shaders.Resolve("timed_sprite");if(timedShader!=null){if(Background!=null&&Background.Material==null){Background.Material=new ShaderMaterial{Shader=timedShader};GameLogger.Log("LIGHT","ViewBox._Ready: created ShaderMaterial for Background",LogLevel.Debug);}if(Foreground!=null&&Foreground.Material==null){Foreground.Material=new ShaderMaterial{Shader=timedShader};GameLogger.Log("LIGHT","ViewBox._Ready: created ShaderMaterial for Foreground",LogLevel.Debug);}}SetZoom(0f);SetAlign(AlignType.CenterCenter);}/// <summary> Returns the interactive layer container for the given logical layer name. Creates the layer lazily as a direct child of ViewBox if it doesn't exist yet. </summary> <param name="layerName">"background", "foreground" or "ui".</param> <returns>The matching Control container.</returns>
public Control GetInteractiveLayer(string layerName){var result=layerName?.ToLowerInvariant()switch{"background"=>InteractiveLayerBackground??=CreateLayerDirect("InteractiveLayer_Background",1),"foreground"=>InteractiveLayerForeground??=CreateLayerDirect("InteractiveLayer_Foreground",3),"ui"=>InteractiveLayerUI??=CreateLayerDirect("InteractiveLayer_UI",5),_=>InteractiveLayerForeground??=CreateLayerDirect("InteractiveLayer_Foreground",3)};return result;}private Control CreateLayerDirect(string name,int zIndex){var layer=new Control{Name=name,MouseFilter=MouseFilterEnum.Ignore,ZIndex=zIndex,Size=new Vector2(defaultResolution.X,defaultResolution.Y)};AddChild(layer);return layer;}/// <summary> Assigns a texture to the background <see cref="TextureRect"/>. This is the primary mechanism for scenes to set the backdrop visual. Logs the new background ID from <see cref="SceneContextNode.LastBackgroundID"/> for debugging. </summary>
public void SetBackgroundTexture(Texture2D texture){if(Background!=null){Background.Texture=texture;GameLogger.Log("VIEW",$"set background '"+SceneContextNode.LastBackgroundID+"'");}}/// <summary> Assigns a texture to the foreground <see cref="TextureRect"/>. Typically used for scene overlays rendered above interactive objects but below UI. </summary>
public void SetForegroundTexture(Texture2D texture){if(Foreground!=null){Foreground.Texture=texture;GameLogger.Log("VIEW",$"set foreground");}}/// <summary> Returns the currently assigned background texture, or <c>null</c> if none is set or the <see cref="Background"/> node is missing. </summary>
public Texture2D GetBackgroundTexture(){return Background?.Texture;}/// <summary> Smoothly transitions the background to a new texture using a black overlay crossfade: fades a <see cref="ColorRect"/> to full opacity over the first half of <paramref name="duration"/>, swaps the background texture mid-transition, then fades back out over the second half. If the target texture is already set or the background node is missing, no-op. <see cref="KillTransitionTween"/> cancels any in-progress transition first. </summary> <param name="newTexture">The replacement background texture.</param> <param name="duration">Total crossfade duration in seconds (default 0.5).</param>
public void TransitionBackground(Texture2D newTexture){TransitionBackground(newTexture,0.5f);}public void TransitionBackground(Texture2D newTexture,float duration=0.5f){if(Background==null){if(newTexture!=null)SetBackgroundTexture(newTexture);return;}if(Background.Texture==newTexture)return;KillTransitionTween();var overlay=GetOrCreateTransitionOverlay();overlay.Visible=true;overlay.Modulate=UiTokens.Alpha(Colors.Black,0f);overlay.MouseFilter=MouseFilterEnum.Ignore;var halfDuration=duration/2f;transitionTween=CreateTween();transitionTween.TweenProperty(overlay,"modulate:a",1f,halfDuration);transitionTween.TweenCallback(Callable.From(()=>{SetBackgroundTexture(newTexture);}));transitionTween.TweenProperty(overlay,"modulate:a",0f,halfDuration);transitionTween.TweenCallback(Callable.From(()=>{overlay.Visible=false;transitionTween=null;}));}/// <summary> Aborts any in-progress background transition by killing the tween and resetting the overlay to invisible. Safe to call when no transition is active. </summary>
public void KillTransitionTween(){if(transitionTween!=null){transitionTween.Kill();transitionTween=null;}if(transitionOverlay!=null){transitionOverlay.Visible=false;transitionOverlay.Modulate=UiTokens.Alpha(Colors.Black,0f);}}private ColorRect GetOrCreateTransitionOverlay(){if(transitionOverlay==null){transitionOverlay=new ColorRect{Name="TransitionOverlay",Color=UiTokens.Alpha(Colors.Black,1f),MouseFilter=MouseFilterEnum.Ignore,Visible=false};transitionOverlay.AnchorRight=1f;transitionOverlay.AnchorBottom=1f;transitionOverlay.GrowHorizontal=GrowDirection.Both;transitionOverlay.GrowVertical=GrowDirection.Both;AddChild(transitionOverlay);}return transitionOverlay;}/// <summary> Applies a zoom level to the content node. The <paramref name="value"/> is a 0–1 lerp factor between <c>minZoom</c> and <c>maxZoom</c>. Scale is uniform (X and Y are equal). </summary> <param name="value">Normalized zoom factor: 0 = minZoom, 1 = maxZoom.</param>
public void SetZoom(float value){var zoom=minZoom+(maxZoom-minZoom)*value;if(content!=null)content.Scale=Vector2.One*zoom;}/// <summary> Positions the content node within the viewport using the 9-point alignment grid from <see cref="ViewBoxNavigator.GetPositionByAlign"/>. </summary> <param name="align">Which corner/edge/center to align to.</param>
public void SetAlign(AlignType align){if(content!=null)content.Position=ViewBoxNavigator.GetPositionByAlign(align,content.Size);}
#if TOOLS && DEBUG
/// <summary>Editor-only: forces the content back to the default resolution rect while inspecting properties.</summary>
public override void _ValidateProperty(Godot.Collections.Dictionary property){base._ValidateProperty(property);if(content!=null)content.Size=defaultResolution;}
#endif
}}

===== FILE mods/ui_base/src/View/ViewBoxNavigator.cs =====
using Godot;namespace Cthangover.Mods.Interface.View{/// <summary> Static utility that calculates content positioning offsets for the 9-point AlignType grid. Uses the viewport's visible rect as the camera/view area and computes offsets so the content aligns correctly within it. The CenterCenter case negates X because the content position is treated as a negative offset from top-left in Godot's coordinate space. </summary>
public static class ViewBoxNavigator{/// <summary> Calculates the content node position offset for the given 9-point <see cref="AlignType"/>. Uses the visible viewport rect size as the camera area and computes offsets relative to the top-left corner. Negative X coordinates are intentional: Godot positions content relative to the container origin, so aligning center or right requires negative offsets when content is larger than the viewport. </summary> <param name="align">The target alignment from the 9-point grid.</param> <param name="contentSize">The size of the content node being positioned.</param> <returns>A position offset that places the content correctly for the chosen alignment.</returns>
public static Vector2 GetPositionByAlign(AlignType align,Vector2 contentSize){var viewport=Godot.Engine.GetMainLoop()as SceneTree;var screenSize=viewport?.Root?.GetVisibleRect().Size??new Vector2(1920,1024);var cameraSize=screenSize;var alignPos=Vector2.Zero;switch(align){case AlignType.LeftTop:break;case AlignType.LeftCenter:alignPos.Y=(contentSize.Y-cameraSize.Y)*0.5f;break;case AlignType.LeftBottom:alignPos.Y=contentSize.Y-cameraSize.Y;break;case AlignType.CenterTop:alignPos.X= -(contentSize.X-cameraSize.X)*0.5f;break;case AlignType.CenterCenter:alignPos=(contentSize-cameraSize)*0.5f;alignPos.X= -alignPos.X;break;case AlignType.CenterBottom:alignPos.X= -(contentSize.X-cameraSize.X)*0.5f;alignPos.Y=contentSize.Y-cameraSize.Y;break;case AlignType.RightTop:alignPos.X= -(contentSize.X-cameraSize.X);break;case AlignType.RightCenter:alignPos.Y=(contentSize.Y-cameraSize.Y)*0.5f;alignPos.X= -(contentSize.X-cameraSize.X);break;case AlignType.RightBottom:alignPos.X= -(contentSize.X-cameraSize.X);alignPos.Y=contentSize.Y-cameraSize.Y;break;}return alignPos;}}}

===== FILE mods/ui_base/src/View/ZoomViewBoxController.cs =====
using Godot;namespace Cthangover.Mods.Interface.View{/// <summary> Thin adapter that exposes ViewBox.SetZoom to external callers (e.g. UI sliders or scroll handlers) through a single method. Exists as a Node so it can be placed in the scene tree and receive signals. </summary>
public partial class ZoomViewBoxController:Node{[Export]private ViewBox viewBox;/// <summary> Delegates to <see cref="ViewBox.SetZoom"/> on the linked <see cref="ViewBox"/>. Intended to be connected to a slider or scroll input signal. No-op if <c>viewBox</c> is not assigned. </summary> <param name="value">Normalized zoom factor (0–1).</param>
public void OnChangeZoom(float value){if(viewBox==null)return;viewBox.SetZoom(value);}}}

===== FILE mods/validator/src/ActionArgumentReferences.cs =====
using System.Collections.Generic;namespace Cthangover.Validator{/// <summary> Declarative map "scenario action → named argument → <see cref="ValidationKind"/>" for actions whose arguments reference declared content. The runtime <c>IScenarioAction</c> contract carries no parameter metadata, so the validator keeps the knowledge here instead: each entry names one action argument and the entity kind it must resolve to. Scenario validation adds a graph reference per literal value (see <see cref="ScenarioCommandValidationStrategy"/>), and <see cref="ReferenceStrategy"/> reports a missing declaration with the exact scenario file and line — the static answer to the typo class the runtime factories can only log. <para/> The table intentionally covers only actions with literal content ids today (character.add_to_party / character.send_notification / character.add_skill / battle.init). Extend it when a new action starts accepting content references; the mechanics need no code changes. Comma-separated lists (battle.init enemies=) and runtime-computed values (the ${name} marker) are supported by the caller, not here. </summary>
public static class ActionArgumentReferences{private static readonly Dictionary<string,string>CharacterType=new Dictionary<string,string>{["type"]=ValidationKind.Character,};private static readonly Dictionary<string,string>BattleInit=new Dictionary<string,string>{["scene"]=ValidationKind.Scene,["enemies"]=ValidationKind.Character,["quest_id"]=ValidationKind.Quest,};private static readonly Dictionary<string,string>AddSkill=new Dictionary<string,string>{["id"]=ValidationKind.Skill,};private static readonly Dictionary<string,IReadOnlyDictionary<string,string>>Table=new Dictionary<string,IReadOnlyDictionary<string,string>>(System.StringComparer.OrdinalIgnoreCase){["character.add_to_party"]=CharacterType,["character.send_notification"]=CharacterType,["character.add_skill"]=AddSkill,["battle.init"]=BattleInit,};/// <summary> Returns the argument→kind map for an action, or false when the action carries no known content references. Lookup is case-insensitive to match the reference check for the action name itself. </summary>
public static bool TryGetKinds(string actionName,out IReadOnlyDictionary<string,string>kinds){return Table.TryGetValue(actionName,out kinds);}}}

===== FILE mods/validator/src/ActionEntityStrategy.cs =====
using System.Text.Json;using Cthangover.Core.Mods;namespace Cthangover.Validator{/// <summary> Phase: Contracts. Collects action declarations, their declared object keys and the property-bag keys from their <c>Values</c> list. Actions used to be distinguished from characters by a heuristic on the shared <c>characters/</c> directory; the kind makes that explicit. </summary>
public class ActionEntityStrategy:EntityKindStrategy{public override string Kind=>ValidationKind.Action;protected override void CollectEntity(ValidationContext context,IModInfo mod,string file,string id,JsonElement item){base.CollectEntity(context,mod,file,id,item);EntityFactCollector.CollectObjectKeys(context.Graph,item);var values=JsonReadHelper.GetProperty(item,"Values");if(JsonReadHelper.IsArray(values))EntityFactCollector.CollectActionKeys(context.Graph,values.Value);}}}

===== FILE mods/validator/src/CSharpPropertyStrategy.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Mods;using Microsoft.CodeAnalysis;using Microsoft.CodeAnalysis.CSharp;using Microsoft.CodeAnalysis.CSharp.Syntax;namespace Cthangover.Validator{/// <summary> Phase: Contracts. Parses every mod's declared C# sources with Roslyn and extracts typed property accesses (<c>GetInt("Turn")</c>, <c>SetFloat("Attack", ...)</c>) from <c>DataBlob</c>, <c>PropertyData</c> and <c>ActionCharacter</c>, which share the same method names. The analysis is syntactic on purpose: it needs no metadata references, so it runs on the background thread and never triggers assembly loading. Findings: non-literal keys (<c>DYNAMIC_KEY</c>, info), the same key used with different value types (<c>TYPE_CONFLICT</c>, error), reads of keys that are neither declared in JSON nor written anywhere (<c>READ_UNDECLARED</c> / <c>READ_UNDECLARED_WITH_DEFAULT</c>), and writes that no code ever reads (<c>WRITE_UNUSED</c>, info). </summary>
public class CSharpPropertyStrategy:IValidationStrategy{private static readonly Dictionary<string,string>TypeByMethod=new(StringComparer.Ordinal){{"GetInt","int"},{"SetInt","int"},{"GetFloat","float"},{"SetFloat","float"},{"GetString","string"},{"SetString","string"},{"GetStr","string"},{"SetStr","string"},{"GetBool","bool"},{"SetBool","bool"},{"GetLong","long"},{"SetLong","long"},};public string Id=>"csharp_properties";public ValidationPhase Phase=>ValidationPhase.Contracts;public int Order=>200;public ValidationFeature Feature=>ValidationFeature.CSharp;/// <summary>Scans mod C# sources for DataBlob key usages and checks the keys against the persisted blobs, reporting unknown or mistyped keys.</summary>
public void Validate(ValidationContext context){var usages=new List<CodePropertyUsage>();var literalCounts=new Dictionary<string,int>(StringComparer.Ordinal);foreach(var mod in context.Mods){context.ThrowIfCancellationRequested();foreach(var file in CollectSources(mod)){context.ThrowIfCancellationRequested();ParseSource(mod.Id,file.Path,file.Source,usages,literalCounts);}}ReportDynamicKeys(context,usages);ReportTypeConflicts(context,usages);ReportUndeclaredReads(context,usages);ReportUnusedWrites(context,usages,literalCounts);}private static List<(string Path,string Source)>CollectSources(IModInfo mod){var result=new List<(string Path,string Source)>();var provider=mod.FileProvider;if(provider==null)return result;var patterns=(mod as ModInfo)?.Manifest?.Sources;if(patterns!=null&&patterns.Count>0){foreach(var pattern in patterns){var prefix=pattern;var suffix="*.cs";var separator=pattern.LastIndexOf('/');if(separator>=0){prefix=pattern.Substring(0,separator);suffix=pattern.Substring(separator+1);}foreach(var file in ModFileReader.ListFiles(provider,prefix,true)){if(!MatchesSource(file,suffix))continue;var source=provider.ReadFileText(file);if(source!=null)result.Add((file,source));}}}if(result.Count==0){foreach(var file in ModFileReader.ListFiles(provider,string.Empty,true)){if(!file.EndsWith(".cs",StringComparison.OrdinalIgnoreCase))continue;var source=provider.ReadFileText(file);if(source!=null)result.Add((file,source));}}return result;}private static bool MatchesSource(string file,string suffix){if(!file.EndsWith(".cs",StringComparison.OrdinalIgnoreCase))return false;if(suffix=="*"||suffix=="*.cs")return true;if(suffix.StartsWith("*.",StringComparison.Ordinal))return file.EndsWith(suffix.Substring(1),StringComparison.OrdinalIgnoreCase);return file.EndsWith(suffix,StringComparison.OrdinalIgnoreCase);}private static void ParseSource(string modId,string path,string source,List<CodePropertyUsage>usages,Dictionary<string,int>literalCounts){var tree=CSharpSyntaxTree.ParseText(source,new CSharpParseOptions(),path);var root=tree.GetRoot();foreach(var token in root.DescendantTokens()){if(!token.IsKind(SyntaxKind.StringLiteralToken))continue;var text=token.ValueText;literalCounts.TryGetValue(text,out var count);literalCounts[text]=count+1;}foreach(var node in root.DescendantNodes()){if(node is not InvocationExpressionSyntax invocation)continue;if(invocation.Expression is not MemberAccessExpressionSyntax member)continue;var method=member.Name.Identifier.ValueText;if(!TypeByMethod.TryGetValue(method,out var type))continue;var arguments=invocation.ArgumentList.Arguments;if(arguments.Count==0)continue;var key=TryGetLiteralKey(arguments[0].Expression);// A helper of the shape GetString(value, "Name", "ID", ...) has nothing to do with property bags, so it must not surface as a dynamic-key finding.
if(key==null&&HasOnlyStringLiteralTail(arguments))continue;usages.Add(new CodePropertyUsage{ModId=modId,File=path,Line=invocation.GetLocation().GetLineSpan().StartLinePosition.Line+1,Key=key,Type=type,IsWrite=method.StartsWith("Set",StringComparison.Ordinal),HasDefault=arguments.Count>1,Method=method,});}}private static string TryGetLiteralKey(ExpressionSyntax expression){if(expression is LiteralExpressionSyntax literal&&literal.IsKind(SyntaxKind.StringLiteralExpression))return literal.Token.ValueText;return null;}private static bool HasOnlyStringLiteralTail(SeparatedSyntaxList<ArgumentSyntax>arguments){if(arguments.Count<2)return false;for(var i=1;i<arguments.Count;i++){if(arguments[i].Expression is not LiteralExpressionSyntax literal|| !literal.IsKind(SyntaxKind.StringLiteralExpression))return false;}return true;}private static void ReportDynamicKeys(ValidationContext context,List<CodePropertyUsage>usages){foreach(var usage in usages){if(usage.Key!=null)continue;context.AddInfo("DYNAMIC_KEY",$"{usage.Method}(...) uses a non-literal key; static analysis skipped",usage.ModId,usage.File,usage.Line);}}private static void ReportTypeConflicts(ValidationContext context,List<CodePropertyUsage>usages){var byKey=new Dictionary<string,List<CodePropertyUsage>>(StringComparer.OrdinalIgnoreCase);foreach(var usage in usages){if(usage.Key==null)continue;if(!byKey.TryGetValue(usage.Key,out var list)){list=new List<CodePropertyUsage>();byKey[usage.Key]=list;}list.Add(usage);}foreach(var entry in byKey){var types=new List<string>();foreach(var usage in entry.Value){if(!types.Contains(usage.Type))types.Add(usage.Type);}if(types.Count<2)continue;var first=entry.Value[0];var related=new List<string>();for(var i=1;i<entry.Value.Count;i++){var other=entry.Value[i];related.Add($"{other.ModId}:{other.File}:{other.Line} ({other.Type})");}context.AddError("TYPE_CONFLICT",$"property '{entry.Key}' is accessed as {string.Join(", ", types)}",first.ModId,first.File,first.Line,"use one type for all readers and writers of this property",related);}}private static void ReportUndeclaredReads(ValidationContext context,List<CodePropertyUsage>usages){var writeKeys=new HashSet<string>(StringComparer.OrdinalIgnoreCase);foreach(var usage in usages){if(usage.IsWrite&&usage.Key!=null)writeKeys.Add(usage.Key);}var reported=new HashSet<string>(StringComparer.OrdinalIgnoreCase);foreach(var usage in usages){if(usage.IsWrite||usage.Key==null)continue;if(context.Graph.IsKeyDeclared(usage.Key)||writeKeys.Contains(usage.Key))continue;if(!reported.Add(usage.ModId+"|"+usage.File+"|"+usage.Key))continue;var message=$"property '{usage.Key}' is read as {usage.Type} but is never declared or written";var suggestion="declare the property in JSON (Values/extra fields) or write it before reading";if(usage.HasDefault)context.AddWarning("READ_UNDECLARED_WITH_DEFAULT",message,usage.ModId,usage.File,usage.Line,suggestion);else context.AddError("READ_UNDECLARED",message,usage.ModId,usage.File,usage.Line,suggestion);}}private static void ReportUnusedWrites(ValidationContext context,List<CodePropertyUsage>usages,Dictionary<string,int>literalCounts){var readKeys=new HashSet<string>(StringComparer.OrdinalIgnoreCase);var writeCounts=new Dictionary<string,int>(StringComparer.OrdinalIgnoreCase);foreach(var usage in usages){if(usage.Key==null)continue;if(usage.IsWrite){writeCounts.TryGetValue(usage.Key,out var count);writeCounts[usage.Key]=count+1;}else{readKeys.Add(usage.Key);}}var reported=new HashSet<string>(StringComparer.OrdinalIgnoreCase);foreach(var usage in usages){if(!usage.IsWrite||usage.Key==null)continue;if(readKeys.Contains(usage.Key))continue;if(!reported.Add(usage.ModId+"|"+usage.Key))continue;// A key that appears as a string literal more often than it is written is probably read through a helper the syntactic pass cannot follow (GetString(key, ...) with a literal argument).
literalCounts.TryGetValue(usage.Key,out var occurrences);writeCounts.TryGetValue(usage.Key,out var writes);if(occurrences>writes)continue;context.AddInfo("WRITE_UNUSED",$"property '{usage.Key}' is written but never read",usage.ModId,usage.File,usage.Line);}}}}

===== FILE mods/validator/src/CharacterEntityStrategy.cs =====
using System.Text.Json;using Cthangover.Core.Mods;namespace Cthangover.Validator{/// <summary> Phase: Contracts. Collects character declarations (player characters and enemies alike), their declared keys, loot item references and combat action references. This is the richest entity kind; the other kinds only look at reference subsets. </summary>
public class CharacterEntityStrategy:EntityKindStrategy{public override string Kind=>ValidationKind.Character;protected override void CollectEntity(ValidationContext context,IModInfo mod,string file,string id,JsonElement item){base.CollectEntity(context,mod,file,id,item);EntityFactCollector.CollectObjectKeys(context.Graph,item);EntityFactCollector.CollectLootReferences(context,mod,file,item);EntityFactCollector.CollectActionReferences(context,mod,file,item);}}}

===== FILE mods/validator/src/CodePropertyUsage.cs =====
namespace Cthangover.Validator{/// <summary> A single typed property access extracted from a mod's C# source: <c>action.GetInt("Turn", 3)</c> becomes (key=Turn, type=int, read, hasDefault=true). <c>DataBlob</c> and <c>PropertyData</c> share the same method names, so the collector intentionally does not distinguish receivers — only the key, type and direction matter for cross-mod conflict detection. </summary>
public class CodePropertyUsage{public string ModId{get;set;}public string File{get;set;}public int Line{get;set;}public string Key{get;set;}public string Type{get;set;}public bool IsWrite{get;set;}public bool HasDefault{get;set;}public string Method{get;set;}}}

===== FILE mods/validator/src/DependsStrategy.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Mods;namespace Cthangover.Validator{/// <summary> Phase: Reconciliation. Checks manifest <c>depends</c> entries: malformed strings, required dependencies missing from the loaded set, and circular dependency chains. The registry already filters mods with broken dependencies at load time and the compiler only logs a warning for cycles, so this strategy is what turns those silent conditions into reportable findings. </summary>
public class DependsStrategy:IValidationStrategy{public string Id=>"depends";public ValidationPhase Phase=>ValidationPhase.Reconciliation;public int Order=>100;public ValidationFeature Feature=>ValidationFeature.Json;/// <summary>Re-checks every manifest's depends pins and cycles, reporting broken chains the loader only logs at runtime.</summary>
public void Validate(ValidationContext context){var manifests=new Dictionary<string,ModManifest>(StringComparer.OrdinalIgnoreCase);foreach(var mod in context.Mods){context.ThrowIfCancellationRequested();var manifest=(mod as ModInfo)?.Manifest;if(manifest!=null)manifests[mod.Id]=manifest;}foreach(var entry in manifests){var depends=entry.Value.Depends;if(depends==null)continue;foreach(var dependency in depends){if(!dependency.IsValid){context.AddError("SCHEMA_WRONG_TYPE",$"invalid dependency string: {dependency.Error}",entry.Key,"manifest.json",0,"use format modId[op][version][?]");continue;}if(dependency.IsOptional||manifests.ContainsKey(dependency.ModId))continue;context.AddError("REF_NOT_FOUND",$"required dependency '{dependency.ModId}' is not loaded",entry.Key,"manifest.json",0,$"install mod '{dependency.ModId}' or mark the dependency optional with '?'");}}DetectCycles(context,manifests);}private static void DetectCycles(ValidationContext context,Dictionary<string,ModManifest>manifests){var state=new Dictionary<string,int>(StringComparer.OrdinalIgnoreCase);var path=new List<string>();var reported=new HashSet<string>(StringComparer.OrdinalIgnoreCase);foreach(var modId in manifests.Keys)Visit(context,manifests,modId,state,path,reported);}private static void Visit(ValidationContext context,Dictionary<string,ModManifest>manifests,string modId,Dictionary<string,int>state,List<string>path,HashSet<string>reported){if(state.TryGetValue(modId,out var existing)){if(existing==1)ReportCycle(context,modId,path,reported);return;}state[modId]=1;path.Add(modId);var depends=manifests[modId].Depends;if(depends!=null){foreach(var dependency in depends){if(!dependency.IsValid|| !manifests.ContainsKey(dependency.ModId))continue;Visit(context,manifests,dependency.ModId,state,path,reported);}}path.RemoveAt(path.Count-1);state[modId]=2;}private static void ReportCycle(ValidationContext context,string repeated,List<string>path,HashSet<string>reported){var start= -1;for(var i=0;i<path.Count;i++){if(string.Equals(path[i],repeated,StringComparison.OrdinalIgnoreCase)){start=i;break;}}if(start<0)return;var cycle=new List<string>();for(var i=start;i<path.Count;i++)cycle.Add(path[i]);cycle.Add(repeated);var key=BuildCycleKey(cycle);if(!reported.Add(key))return;context.AddError("REF_CIRCULAR",$"circular dependency: {string.Join(" -> ", cycle)}",repeated,"manifest.json",0,"break the cycle by removing one of the dependencies");}private static string BuildCycleKey(List<string>cycle){var sorted=new List<string>(cycle);sorted.Sort(StringComparer.OrdinalIgnoreCase);return string.Join("|",sorted);}}}

===== FILE mods/validator/src/EntityFactCollector.cs =====
using System.Text.Json;using Cthangover.Core.Mods;namespace Cthangover.Validator{/// <summary> Shared extraction helpers for <see cref="IEntityKindStrategy"/> implementations. Keeps the per-kind strategies focused on "what is interesting about my entities" while the mechanical work — walking declared object keys, action property keys, loot and ingredient references — lives in one place. All helpers are schema-light (case-insensitive raw <see cref="JsonElement"/> reads) so they survive DTO field changes. </summary>
public static class EntityFactCollector{/// <summary>Declares every top-level property name of an entity as a known key.</summary>
public static void CollectObjectKeys(ValidationGraph graph,JsonElement entity){if(entity.ValueKind!=JsonValueKind.Object)return;foreach(var property in entity.EnumerateObject())graph.AddDeclaredKey(property.Name);}/// <summary>Declares the <c>Key=Value</c> property bag keys of an action's <c>Values</c> array.</summary>
public static void CollectActionKeys(ValidationGraph graph,JsonElement values){foreach(var value in values.EnumerateArray()){if(value.ValueKind!=JsonValueKind.String)continue;var text=value.GetString();if(string.IsNullOrEmpty(text))continue;var separator=text.IndexOf('=');if(separator>0)graph.AddDeclaredKey(text.Substring(0,separator));}}/// <summary>Records item references from a recipe's ingredient array (e.g. <c>Input</c>, <c>Output</c>).</summary>
public static void CollectIngredientReferences(ValidationContext context,IModInfo mod,string file,JsonElement entity,string field){var ingredients=JsonReadHelper.GetProperty(entity,field);if(!JsonReadHelper.IsArray(ingredients))return;foreach(var ingredient in ingredients.Value.EnumerateArray()){var itemId=JsonReadHelper.GetString(ingredient,"Id","ID","id");if(!string.IsNullOrWhiteSpace(itemId))context.Graph.AddReference(ValidationKind.Item,itemId,mod.Id,file,0);}}/// <summary>Records item references from a character's <c>Loot</c> table.</summary>
public static void CollectLootReferences(ValidationContext context,IModInfo mod,string file,JsonElement entity){var loot=JsonReadHelper.GetProperty(entity,"Loot");if(!JsonReadHelper.IsArray(loot))return;foreach(var drop in loot.Value.EnumerateArray()){var itemId=JsonReadHelper.GetString(drop,"ItemId");if(!string.IsNullOrWhiteSpace(itemId))context.Graph.AddReference(ValidationKind.Item,itemId,mod.Id,file,0);}}/// <summary>Records action references from a character's <c>Actions</c> array.</summary>
public static void CollectActionReferences(ValidationContext context,IModInfo mod,string file,JsonElement entity){var actions=JsonReadHelper.GetProperty(entity,"Actions");if(!JsonReadHelper.IsArray(actions))return;foreach(var action in actions.Value.EnumerateArray()){if(action.ValueKind!=JsonValueKind.String)continue;var actionId=action.GetString();if(!string.IsNullOrWhiteSpace(actionId))context.Graph.AddReference(ValidationKind.Action,actionId,mod.Id,file,0);}}}}

===== FILE mods/validator/src/EntityKindStrategy.cs =====
using System.Text.Json;using Cthangover.Core.Mods;namespace Cthangover.Validator{/// <summary> Shared skeleton for <see cref="IEntityKindStrategy"/> implementations: iterates the envelope's items, resolves each entity id case-insensitively and records the declaration. Kinds that carry no extra structure (quests, skills, status effects) only override <see cref="Kind"/>; richer kinds override <see cref="CollectEntity"/> to add declared keys and references through <see cref="EntityFactCollector"/>. </summary>
public abstract class EntityKindStrategy:IEntityKindStrategy{/// <inheritdoc />
public abstract string Kind{get;}/// <inheritdoc />
public void Collect(ValidationContext context,IModInfo mod,string file,JsonElement items){foreach(var item in items.EnumerateArray()){context.ThrowIfCancellationRequested();var id=JsonReadHelper.GetString(item,"Id","ID","id");if(string.IsNullOrWhiteSpace(id))continue;CollectEntity(context,mod,file,id,item);}}/// <summary> Records one entity. The default implementation adds the declaration under <see cref="Kind"/>, which doubles as the graph vocabulary (<see cref="ValidationKind"/>). </summary>
protected virtual void CollectEntity(ValidationContext context,IModInfo mod,string file,string id,JsonElement item){context.Graph.AddDeclaration(Kind,id,mod.Id,file,0);}}}

===== FILE mods/validator/src/EntityKindStrategyConsumer.cs =====
using Cthangover.Core.Mods.Consumers;namespace Cthangover.Validator{/// <summary> Feeds <see cref="EntityKindStrategyRegistry"/> with every mod's <see cref="IEntityKindStrategy"/> implementations as the mods load and removes them when their mod unloads. </summary>
public class EntityKindStrategyConsumer:ModTypeConsumer<IEntityKindStrategy>{/// <inheritdoc />
public override string Id=>"validator.entity_kinds";/// <inheritdoc />
protected override void Add(IEntityKindStrategy instance,string modId)=>EntityKindStrategyRegistry.Register(instance);/// <inheritdoc />
protected override void Remove(IEntityKindStrategy instance,string modId)=>EntityKindStrategyRegistry.Unregister(instance);}}

===== FILE mods/validator/src/EntityKindStrategyRegistry.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Utils;namespace Cthangover.Validator{/// <summary> Registry of <see cref="IEntityKindStrategy"/> implementations, indexed by the entity kind each one handles. It discovers nothing itself: <see cref="EntityKindStrategyConsumer"/> delivers the strategies of every mod as the kernel registers its assembly and takes them back when the mod unloads, so the map never holds a strategy of a dead assembly. Adding kind handling is just shipping a new public class with a parameterless constructor in any mod. </summary>
public static class EntityKindStrategyRegistry{private static readonly object sync=new();private static readonly Dictionary<string,IEntityKindStrategy>strategies=new(StringComparer.OrdinalIgnoreCase);/// <summary> Returns the strategy registered for the entity kind, or <c>null</c> when no strategy claims it — the caller reports the unknown kind as a validation warning. </summary>
public static IEntityKindStrategy Resolve(string kind){if(string.IsNullOrWhiteSpace(kind))return null;lock(sync){strategies.TryGetValue(kind,out var strategy);return strategy;}}/// <summary> Registers a strategy under its kind. The first strategy of a kind wins; a second one is reported as an error and ignored. </summary>
public static void Register(IEntityKindStrategy strategy){if(strategy==null||string.IsNullOrWhiteSpace(strategy.Kind))return;lock(sync){if(strategies.TryGetValue(strategy.Kind,out var existing)){GameLogger.Log("VALIDATOR",$"entity kind '{strategy.Kind}' is handled by both '{existing.GetType().Name}' and '{strategy.GetType().Name}'; keeping the first",LogLevel.Error);return;}strategies[strategy.Kind]=strategy;}}/// <summary> Removes <paramref name="strategy"/> (its mod is being unloaded). A kind owned by another strategy instance is left alone. </summary>
public static void Unregister(IEntityKindStrategy strategy){if(strategy==null||string.IsNullOrWhiteSpace(strategy.Kind))return;lock(sync){if(strategies.TryGetValue(strategy.Kind,out var registered)&&registered==strategy)strategies.Remove(strategy.Kind);}}}}

===== FILE mods/validator/src/IEntityKindStrategy.cs =====
using System.Text.Json;using Cthangover.Core.Mods;namespace Cthangover.Validator{/// <summary> One collection strategy per entity kind, discovered by reflection like every other plugin point in the project (<c>Reflections.FindAndCreate</c>). The JSON entity walker (<see cref="JsonEntityStrategy"/>) scans every file once, reads the envelope's <c>"Kind"</c> and delegates the items to the strategy registered for that kind — so adding a new entity type means shipping one class, not editing a switch. <para/> A strategy fills <see cref="ValidationContext.Graph"/> with declarations and references for its kind. It must not throw on malformed data: missing ids are skipped silently, missing references are reported through the context. </summary>
public interface IEntityKindStrategy{/// <summary> Entity kind this strategy handles — matches the <c>"Kind"</c> string in the JSON envelope and the <see cref="ValidationKind"/> used in the graph. Duplicate kinds across strategies are logged and the first discovery wins. </summary>
string Kind{get;}/// <summary> Collects facts for one file of this strategy's kind. The caller has already verified the envelope shape and that the declared kind matches <see cref="Kind"/>. </summary> <param name="context">Validation pass state and report sink.</param> <param name="mod">Mod that owns the file.</param> <param name="file">Mod-relative file path, used as the declaration/reference source.</param> <param name="items">The envelope's <c>Items</c> array.</param>
void Collect(ValidationContext context,IModInfo mod,string file,JsonElement items);}}

===== FILE mods/validator/src/IValidationStrategy.cs =====
namespace Cthangover.Validator{/// <summary> One self-contained validation step, discovered by reflection like every other plugin point in the project (<c>Reflections.FindAndCreate</c>). Strategies run sequentially in (Phase, Order, Id) order on the validator background thread: Schema strategies validate source shapes, Contracts strategies fill <see cref="ValidationContext.Graph"/> with declarations, references and code usages, and Reconciliation strategies compare those facts and emit findings. To add scenario validation, implement this interface in a new class: extract declarations and references into the graph in the Contracts phase and match them in Reconciliation — no service changes are needed. </summary>
public interface IValidationStrategy{/// <summary>Stable identifier used in logs and in the sort order.</summary>
string Id{get;}/// <summary>Execution stage; strategies of earlier phases run first.</summary>
ValidationPhase Phase{get;}/// <summary>Order inside the phase; lower runs first.</summary>
int Order{get;}/// <summary>Capability group gated by the user settings.</summary>
ValidationFeature Feature{get;}/// <summary> Runs the strategy. Data problems must be reported through the <see cref="ValidationContext"/> and must not throw; unexpected exceptions are caught and logged by <see cref="ValidatorService"/>. </summary>
void Validate(ValidationContext context);}}

===== FILE mods/validator/src/ItemEntityStrategy.cs =====
using System.Text.Json;using Cthangover.Core.Mods;namespace Cthangover.Validator{/// <summary> Phase: Contracts. Collects item declarations and their declared keys. Items are the base entity other kinds reference (loot, recipe ingredients), so their keys feed the unknown-property checks while the declarations feed the reference reconciliation. </summary>
public class ItemEntityStrategy:EntityKindStrategy{public override string Kind=>ValidationKind.Item;protected override void CollectEntity(ValidationContext context,IModInfo mod,string file,string id,JsonElement item){base.CollectEntity(context,mod,file,id,item);EntityFactCollector.CollectObjectKeys(context.Graph,item);}}}

===== FILE mods/validator/src/JsonEntityStrategy.cs =====
using System;using System.Collections.Generic;using System.Text.Json;using Cthangover.Core.Mods;using Cthangover.Core.Utils;namespace Cthangover.Validator{/// <summary> Phase: Contracts. Walks every mod's JSON entities the same way the runtime resolver does — every <c>.json</c> file is opened and routed by its top-level <c>"Kind"</c>, with <c>${include}</c> expansion applied — and records declarations and references in the shared graph. Files without a kind (manifests, collections, live2d models, scene definitions, patches) are not entity data and are skipped; scene and interactive definitions plus raw asset files keep their directory-based collection. It also validates manifest <c>ui</c>/<c>main_menu</c> <c>scene_path</c> entries against the owning mod's files and turns <c>ui[].scene</c> into a scene reference. Extraction is intentionally schema-light (raw <see cref="JsonDocument"/> plus case-insensitive reads) so it keeps working when DTOs gain fields and so it can run on the background thread without touching Godot types that some DTOs embed. </summary>
public class JsonEntityStrategy:IValidationStrategy{private static readonly HashSet<string>EffectExtensions=new(StringComparer.OrdinalIgnoreCase){".tscn",".scn"};public string Id=>"json_entities";public ValidationPhase Phase=>ValidationPhase.Contracts;public int Order=>100;public ValidationFeature Feature=>ValidationFeature.Json;/// <summary>Walks every mod's JSON entities, scene definitions, interactives and texture assets, recording declarations and references into the fact graph.</summary>
public void Validate(ValidationContext context){foreach(var mod in context.Mods){context.ThrowIfCancellationRequested();var provider=mod.FileProvider;if(provider==null)continue;CollectEntities(context,mod,provider);CollectSceneDefinitions(context,mod,provider);CollectEnvelopes(context,mod,provider,"interactives",true,ValidationKind.Interactive);var textureExtensions=ModConfig.Instance.GetTextureExtensionSet();CollectAssets(context,mod,provider,"backgrounds",ValidationKind.Background,textureExtensions);CollectAssets(context,mod,provider,"avatars",ValidationKind.Avatar,textureExtensions);var audioExtensions=new HashSet<string>(ModConfig.Instance.AudioExtensions,StringComparer.OrdinalIgnoreCase);CollectAssets(context,mod,provider,"music",ValidationKind.Music,audioExtensions);CollectAssets(context,mod,provider,"sounds",ValidationKind.Sound,audioExtensions);CollectAssets(context,mod,provider,"effects",ValidationKind.Effect,EffectExtensions);ValidateManifestEntries(context,mod,provider);}}/// <summary> Scans every JSON file of the mod and routes it by its declared <c>"Kind"</c>, mirroring the runtime <c>EntityResolver</c>. The kind resolves to an <see cref="IEntityKindStrategy"/> that records the declarations, declared keys and references for that entity type — so an action can no longer be mistaken for a character just because both lived in the same directory, and a new kind is a new strategy class instead of a new switch case. </summary>
private static void CollectEntities(ValidationContext context,IModInfo mod,IModFileProvider provider){foreach(var file in ModFileReader.ListFiles(provider,string.Empty,true)){context.ThrowIfCancellationRequested();if(!file.EndsWith(".json",StringComparison.OrdinalIgnoreCase))continue;var document=ReadDocument(context,mod,file);if(document==null)continue;using(document){var kind=JsonReadHelper.GetString(document.RootElement,"Kind","kind");if(string.IsNullOrWhiteSpace(kind))continue;var envelope=JsonReadHelper.GetProperty(document.RootElement,"Items");if(!JsonReadHelper.IsArray(envelope))continue;var strategy=EntityKindStrategyRegistry.Resolve(kind);if(strategy==null){context.AddWarning("UNKNOWN_ENTITY_KIND",$"unknown entity kind '{kind}'",mod.Id,file,0);continue;}strategy.Collect(context,mod,file,envelope.Value);}}}private static void CollectSceneDefinitions(ValidationContext context,IModInfo mod,IModFileProvider provider){foreach(var file in ModFileReader.ListFiles(provider,"scenes",true)){context.ThrowIfCancellationRequested();if(!file.EndsWith(".json",StringComparison.OrdinalIgnoreCase))continue;var document=ReadDocument(context,mod,file);if(document==null)continue;using(document){var name=JsonReadHelper.GetString(document.RootElement,"name","Name");if(string.IsNullOrWhiteSpace(name)){context.AddWarning("SCHEMA_MISSING_FIELD","scene definition has no 'name'",mod.Id,file);continue;}context.Graph.AddDeclaration(ValidationKind.Scene,name,mod.Id,file,0);CollectBackgroundReferences(context,mod,document.RootElement,file);}}}private static void CollectEnvelopes(ValidationContext context,IModInfo mod,IModFileProvider provider,string directory,bool recursive,string kind){var documents=new List<JsonDocument>();var items=new List<JsonElement>();try{OpenEnvelopes(context,mod,provider,directory,recursive,documents,items);foreach(var item in items){var id=JsonReadHelper.GetString(item,"Id","ID","id");context.Graph.AddDeclaration(kind,id,mod.Id,directory,0);}}finally{DisposeAll(documents);}}private static void CollectAssets(ValidationContext context,IModInfo mod,IModFileProvider provider,string directory,string kind,HashSet<string>extensions){foreach(var file in ModFileReader.ListFiles(provider,directory,true)){context.ThrowIfCancellationRequested();if(!ModFileReader.HasExtension(file,extensions))continue;var prefixLength=directory.Length+1;var relative=file.Length>prefixLength?file.Substring(prefixLength):file;var dot=relative.LastIndexOf('.');if(dot<=0)continue;context.Graph.AddDeclaration(kind,relative.Substring(0,dot),mod.Id,file,0);}}private static void ValidateManifestEntries(ValidationContext context,IModInfo mod,IModFileProvider provider){var manifest=(mod as ModInfo)?.Manifest;if(manifest==null)return;if(manifest.Ui!=null){foreach(var entry in manifest.Ui){if(entry==null)continue;if(!string.IsNullOrWhiteSpace(entry.ScenePath)&& !provider.FileExists(entry.ScenePath))context.AddError("REF_NOT_FOUND",$"ui scene_path '{entry.ScenePath}' not found in mod '{mod.Id}'",mod.Id,"manifest.json",0,"fix scene_path or add the scene file");if(!string.IsNullOrWhiteSpace(entry.Scene)&& !string.Equals(entry.Scene,"shell",StringComparison.OrdinalIgnoreCase))context.Graph.AddReference(ValidationKind.Scene,entry.Scene,mod.Id,"manifest.json",0);}}if(manifest.MainMenu!=null){foreach(var entry in manifest.MainMenu){if(entry==null||string.IsNullOrWhiteSpace(entry.ScenePath))continue;if(!provider.FileExists(entry.ScenePath))context.AddError("REF_NOT_FOUND",$"main_menu scene_path '{entry.ScenePath}' not found in mod '{mod.Id}'",mod.Id,"manifest.json",0,"fix scene_path or add the scene file");}}}private static void OpenEnvelopes(ValidationContext context,IModInfo mod,IModFileProvider provider,string directory,bool recursive,List<JsonDocument>documents,List<JsonElement>items){foreach(var file in ModFileReader.ListFiles(provider,directory,recursive)){context.ThrowIfCancellationRequested();if(!file.EndsWith(".json",StringComparison.OrdinalIgnoreCase))continue;var document=ReadDocument(context,mod,file);if(document==null)continue;var envelope=JsonReadHelper.GetProperty(document.RootElement,"Items");if(!JsonReadHelper.IsArray(envelope)){document.Dispose();continue;}documents.Add(document);foreach(var item in envelope.Value.EnumerateArray())items.Add(item);}}private static JsonDocument ReadDocument(ValidationContext context,IModInfo mod,string file){string text;try{text=ModFileReader.ReadResolved(mod.FileProvider,file);}catch(Exception ex){context.AddError("JSON_PARSE_ERROR",$"include resolution failed: {ex.Message}",mod.Id,file);return null;}if(text==null)return null;try{return JsonDocument.Parse(text,JsonOptionsProvider.DocumentOptions);}catch(JsonException ex){context.AddError("JSON_PARSE_ERROR",$"invalid JSON: {ex.Message}",mod.Id,file);return null;}}private static void CollectBackgroundReferences(ValidationContext context,IModInfo mod,JsonElement root,string file){var background=JsonReadHelper.GetProperty(root,"defaultBackground","DefaultBackground");if(background==null)return;if(background.Value.ValueKind==JsonValueKind.String){context.Graph.AddReference(ValidationKind.Background,background.Value.GetString(),mod.Id,file,0);return;}if(background.Value.ValueKind!=JsonValueKind.Array)return;foreach(var entry in background.Value.EnumerateArray()){if(entry.ValueKind==JsonValueKind.String)context.Graph.AddReference(ValidationKind.Background,entry.GetString(),mod.Id,file,0);}}private static void DisposeAll(List<JsonDocument>documents){foreach(var document in documents)document.Dispose();}}}

===== FILE mods/validator/src/JsonReadHelper.cs =====
using System;using System.Text.Json;namespace Cthangover.Validator{/// <summary> Case-insensitive reads over <see cref="JsonElement"/> used by the JSON contract strategy. Mod JSON deliberately mixes naming styles (<c>"Id"</c>, <c>"id"</c>, <c>"defaultBackground"</c>) and the runtime reads it with case-insensitive options, so the validator must not depend on exact casing either. </summary>
public static class JsonReadHelper{/// <summary> Returns the first matching property by exact name, then by case-insensitive comparison. Null when the element is not an object or no name matches. </summary>
public static JsonElement?GetProperty(JsonElement element,params string[]names){if(element.ValueKind!=JsonValueKind.Object)return null;foreach(var name in names){if(element.TryGetProperty(name,out var exact))return exact;}foreach(var property in element.EnumerateObject()){foreach(var name in names){if(string.Equals(property.Name,name,StringComparison.OrdinalIgnoreCase))return property.Value;}}return null;}/// <summary>Returns the string value of the first matching property, or null.</summary>
public static string GetString(JsonElement element,params string[]names){var value=GetProperty(element,names);if(value==null||value.Value.ValueKind!=JsonValueKind.String)return null;return value.Value.GetString();}/// <summary>True when the element exists and is an array.</summary>
public static bool IsArray(JsonElement?element)=>element!=null&&element.Value.ValueKind==JsonValueKind.Array;}}

===== FILE mods/validator/src/ManifestSchemaStrategy.cs =====
using System.Text.Json;using Cthangover.Core.Utils;namespace Cthangover.Validator{/// <summary> Phase: Schema. Validates the mandatory shape of every mod's <c>manifest.json</c>: <c>id</c> and <c>version</c> must be present and be strings, and <c>id</c> must not be empty. The runtime silently falls back to the filesystem entry name for a missing id and tolerates a missing version, so these are exactly the problems an author should fix before publishing. </summary>
public class ManifestSchemaStrategy:IValidationStrategy{public string Id=>"manifest_schema";public ValidationPhase Phase=>ValidationPhase.Schema;public int Order=>100;public ValidationFeature Feature=>ValidationFeature.Json;/// <summary>Checks the required manifest.json fields: non-empty string id and string version.</summary>
public void Validate(ValidationContext context){foreach(var mod in context.Mods){context.ThrowIfCancellationRequested();var text=mod.FileProvider?.ReadFileText("manifest.json");if(string.IsNullOrWhiteSpace(text)){context.AddError("SCHEMA_MISSING_FIELD","manifest.json is missing or empty",mod.Id,"manifest.json");continue;}JsonDocument document;try{document=JsonDocument.Parse(text,JsonOptionsProvider.DocumentOptions);}catch(JsonException ex){context.AddError("JSON_PARSE_ERROR",$"manifest.json is not valid JSON: {ex.Message}",mod.Id,"manifest.json");continue;}using(document){ValidateStringField(context,mod.Id,document.RootElement,"id",required:true);ValidateStringField(context,mod.Id,document.RootElement,"version",required:true);ValidateStringField(context,mod.Id,document.RootElement,"name",required:false);}}}private static void ValidateStringField(ValidationContext context,string modId,JsonElement root,string field,bool required){var value=JsonReadHelper.GetProperty(root,field);if(value==null){if(required)context.AddError("SCHEMA_MISSING_FIELD",$"manifest.json has no '{field}'",modId,"manifest.json");return;}if(value.Value.ValueKind!=JsonValueKind.String){context.AddError("SCHEMA_WRONG_TYPE",$"'{field}' must be a string",modId,"manifest.json");return;}if(required&&string.IsNullOrWhiteSpace(value.Value.GetString()))context.AddError("SCHEMA_MISSING_FIELD",$"'{field}' must not be empty",modId,"manifest.json");}}}

===== FILE mods/validator/src/ModFileReader.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Mods;using Cthangover.Core.Mods.Resolvers;namespace Cthangover.Validator{/// <summary> Pure .NET file access used by validation strategies. Works with both folder and zip providers through <see cref="IModFileProvider"/> and resolves the project's <c>${include}</c> directives with the same public helper the runtime resolvers use, so the validator sees exactly the JSON the game will see. Safe on the background validation thread because it touches neither Godot nor the resolver caches. </summary>
public static class ModFileReader{/// <summary> Lists files under a mod-relative directory. Directories are pushed onto the work stack only when <paramref name="recursive"/> is set, so the caller can mirror the recursive entity scan or the recursive scene scan. </summary>
public static List<string>ListFiles(IModFileProvider provider,string directory,bool recursive){var result=new List<string>();if(provider==null)return result;var pending=new Stack<string>();pending.Push(directory??string.Empty);while(pending.Count>0){var current=pending.Pop();foreach(var entry in provider.ListFiles(current)){if(entry.EndsWith("/")){if(recursive)pending.Push(entry.TrimEnd('/'));continue;}result.Add(entry);}}return result;}/// <summary> Reads a mod file and expands every <c>${path}</c> include. Returns null when the file is absent; include failures propagate as <see cref="InvalidOperationException"/> so the caller can report them with the owning file name. </summary>
public static string ReadResolved(IModFileProvider provider,string path){var content=provider.ReadFileText(path);if(content==null)return null;var visited=new HashSet<string>(StringComparer.OrdinalIgnoreCase){path};return ModResolutionContext.ExpandModIncludes(provider,content,path,visited);}/// <summary>True when the file path ends with one of the given extensions.</summary>
public static bool HasExtension(string path,HashSet<string>extensions){var dot=path.LastIndexOf('.');return dot>0&&extensions.Contains(path.Substring(dot));}}}

===== FILE mods/validator/src/QuestEntityStrategy.cs =====
namespace Cthangover.Validator{/// <summary> Phase: Contracts. Collects quest declarations. Quests carry no references the validator can check yet, so the base skeleton — parse the envelope, key by <c>Id</c>, declare it under the quest kind — is all there is to do. </summary>
public class QuestEntityStrategy:EntityKindStrategy{public override string Kind=>ValidationKind.Quest;}}

===== FILE mods/validator/src/RecipeEntityStrategy.cs =====
using System.Text.Json;using Cthangover.Core.Mods;namespace Cthangover.Validator{/// <summary> Phase: Contracts. Collects recipe declarations and turns their <c>Input</c>/<c>Output</c> ingredients into item references, so a recipe pointing at a deleted item surfaces as a missing reference. </summary>
public class RecipeEntityStrategy:EntityKindStrategy{public override string Kind=>ValidationKind.Recipe;protected override void CollectEntity(ValidationContext context,IModInfo mod,string file,string id,JsonElement item){base.CollectEntity(context,mod,file,id,item);EntityFactCollector.CollectIngredientReferences(context,mod,file,item,"Input");EntityFactCollector.CollectIngredientReferences(context,mod,file,item,"Output");}}}

===== FILE mods/validator/src/ReferenceStrategy.cs =====
namespace Cthangover.Validator{/// <summary> Phase: Reconciliation. Matches every reference collected by contract strategies against the declarations in the graph and reports <c>REF_NOT_FOUND</c> for the missing ones. Because the graph is global, a reference in one mod may resolve to a declaration in another — exactly how the runtime factories and scene loader resolve content. </summary>
public class ReferenceStrategy:IValidationStrategy{public string Id=>"references";public ValidationPhase Phase=>ValidationPhase.Reconciliation;public int Order=>200;public ValidationFeature Feature=>ValidationFeature.Json;/// <summary>Reports every recorded reference whose target id has no declaration in the merged fact graph (REF_NOT_FOUND).</summary>
public void Validate(ValidationContext context){foreach(var reference in context.Graph.References){context.ThrowIfCancellationRequested();if(context.Graph.HasDeclaration(reference.Kind,reference.TargetId))continue;context.AddError("REF_NOT_FOUND",$"{reference.Kind} '{reference.TargetId}' is referenced but never declared",reference.ModId,reference.File,reference.Line,$"declare {reference.Kind} '{reference.TargetId}' or fix the reference");}}}}

===== FILE mods/validator/src/ReportWriter.cs =====
using System;using System.IO;using System.Text.Json;using Cthangover.Core.Utils;namespace Cthangover.Validator{/// <summary> Persists the completed <see cref="ValidationReport"/> as JSON. Called from the background validation task, so it relies only on plain <c>System.IO</c>; the Godot-style directory is resolved to an absolute path earlier, on the main thread, by <see cref="ValidatorSettings.GetReportDirectory"/>. </summary>
public static class ReportWriter{/// <summary>File name inside the configured report directory.</summary>
public const string FileName="report.json";/// <summary> Writes the report and returns the absolute path, or null when the directory could not be resolved or the write failed. Never throws — a failed report must not interrupt the game. </summary>
public static string Write(ValidationReport report,string directory){if(string.IsNullOrWhiteSpace(directory)){GameLogger.Log("VALIDATOR","validator: report directory is not resolved",LogLevel.Error);return null;}try{Directory.CreateDirectory(directory);var path=Path.Combine(directory,FileName);var json=JsonSerializer.Serialize(report,JsonOptionsProvider.WriteIndentedOptions);File.WriteAllText(path,json);GameLogger.Log("VALIDATOR",$"validator: report written to {path}");return path;}catch(Exception ex){GameLogger.Log("VALIDATOR",$"validator: report write failed: {ex.Message}",LogLevel.Error);return null;}}}}

===== FILE mods/validator/src/ScenarioCommandValidationStrategy.cs =====
using System;using Cthangover.Mods.Dialog.Scenarios;using System.Collections.Generic;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Mods;using Cthangover.Core.Scenarios;using Cthangover.Core.Scenes;namespace Cthangover.Validator{/// <summary> Phase: Contracts. Validates <c>.scenario</c> DSL files. The command vocabulary is not hardcoded: known command words come from <see cref="ScenarioCommandStrategyFactory"/> (mods add commands through <c>RegisterAssembly</c>), and each command's argument contract comes from <see cref="IScenarioCommandSpec"/> implemented by the strategy itself. Commands without a spec are still recognised, just not argument-checked. Checks performed: <list type="bullet"> <item>unknown command words (<c>SCENARIO_UNKNOWN_COMMAND</c>);</item> <item>missing / extra / unknown / empty arguments based on the command spec;</item> <item>jump targets (<c>goto</c>, <c>option</c>) must point at an existing <c>:label</c>;</item> <item><c>select</c> without options and <c>option</c> outside a select block;</item> <item>references collected by <see cref="ICommandReferenceMetadata"/> are added to the shared graph (backgrounds, scenes, music, sounds, effects, avatars) and checked by <see cref="ReferenceStrategy"/>; scenario actions are checked against the action registry.</item> <item>content references inside action arguments (<see cref="ActionArgumentReferences"/>: character ids, skills, quest ids, battle enemy lists) are added to the same graph, so a typo is reported against the declaring mods with file and line.</item> </list> </summary>
public class ScenarioCommandValidationStrategy:IValidationStrategy{private static readonly string[]SelectAllowedNamed={"key","first","second"};private static readonly string[]OptionAllowedNamed={"key","goto"};private static readonly object actionLock=new();private static HashSet<string>actionNames;public string Id=>"scenario_commands";public ValidationPhase Phase=>ValidationPhase.Contracts;public int Order=>300;public ValidationFeature Feature=>ValidationFeature.Scenarios;/// <summary>Parses every .scenario file and validates DSL commands, named parameters and known action names.</summary>
public void Validate(ValidationContext context){foreach(var mod in context.Mods){context.ThrowIfCancellationRequested();var provider=mod.FileProvider;if(provider==null)continue;foreach(var file in ModFileReader.ListFiles(provider,"scenarios",true)){context.ThrowIfCancellationRequested();if(!file.EndsWith(".scenario",StringComparison.OrdinalIgnoreCase))continue;ValidateFile(context,mod,file);}}}private static void ValidateFile(ValidationContext context,IModInfo mod,string file){string text;try{text=ModFileReader.ReadResolved(mod.FileProvider,file);}catch(Exception ex){context.AddError("SCENARIO_PARSE_ERROR",$"cannot read scenario: {ex.Message}",mod.Id,file);return;}if(text==null)return;var metadataResult=ScenarioParser.ParseMetadata(text);var body=metadataResult.remainingText;var lines=body.Split('\n');var lineOffset=text.Split('\n').Length-lines.Length;var labels=CollectLabels(context,mod,file,lines,lineOffset);var pendingSelectLine= -1;var pendingSelectHasOptions=false;var lineNumber=lineOffset;foreach(var rawLine in lines){lineNumber++;var line=rawLine.Trim();if(line.Length==0||line.StartsWith('#')){FinalizeSelect(context,mod,file,ref pendingSelectLine,ref pendingSelectHasOptions);continue;}if(line.StartsWith(':')){FinalizeSelect(context,mod,file,ref pendingSelectLine,ref pendingSelectHasOptions);continue;}var tokens=Tokenize(line);if(tokens.Count==0)continue;var command=tokens[0].ToLowerInvariant();tokens.RemoveAt(0);var positional=new List<string>();var named=new Dictionary<string,string>();SplitArguments(tokens,positional,named,out var arrowTarget);if(command=="select"){FinalizeSelect(context,mod,file,ref pendingSelectLine,ref pendingSelectHasOptions);pendingSelectLine=lineNumber;pendingSelectHasOptions=false;CheckEmptyNamed(context,mod,file,lineNumber,command,named);CheckAllowedNamed(context,mod,file,lineNumber,command,named,SelectAllowedNamed);CheckPositionalRange(context,mod,file,lineNumber,command,positional,0,1);AddAvatarReferences(context,mod,file,lineNumber,named);continue;}if(command=="option"){if(pendingSelectLine<0)context.AddWarning("SCENARIO_ORPHAN_OPTION","option outside a select block is ignored by the parser",mod.Id,file,lineNumber);else pendingSelectHasOptions=true;CheckEmptyNamed(context,mod,file,lineNumber,command,named);CheckAllowedNamed(context,mod,file,lineNumber,command,named,OptionAllowedNamed);var target=arrowTarget;if(string.IsNullOrEmpty(target)&&named.TryGetValue("goto",out var goTo))target=goTo;else if(string.IsNullOrEmpty(target)&&positional.Count>1&&positional[1].StartsWith(':'))target=positional[1].TrimStart(':');CheckLabel(context,mod,file,lineNumber,command,target,labels);continue;}FinalizeSelect(context,mod,file,ref pendingSelectLine,ref pendingSelectHasOptions);var strategy=ScenarioCommandStrategyFactory.Get(command);if(strategy==null){context.AddError("SCENARIO_UNKNOWN_COMMAND",$"unknown scenario command '{command}'",mod.Id,file,lineNumber,"check the command name or make sure the mod that defines it is enabled");continue;}var spec=strategy as IScenarioCommandSpec;if(spec!=null)CheckSpec(context,mod,file,lineNumber,command,positional,named,arrowTarget,spec,labels);CheckEmptyNamed(context,mod,file,lineNumber,command,named);AddAvatarReferences(context,mod,file,lineNumber,named);if(!string.IsNullOrEmpty(arrowTarget))CheckLabel(context,mod,file,lineNumber,command,arrowTarget,labels);var referenceMetadata=strategy as ICommandReferenceMetadata;if(referenceMetadata!=null&&referenceMetadata.Positional0Kind!=PositionalReferenceKind.None&&positional.Count>0){if(string.IsNullOrWhiteSpace(positional[0]))context.AddError("SCENARIO_EMPTY_ARGUMENT",$"'{command}' requires a non-empty {referenceMetadata.Positional0Kind} reference",mod.Id,file,lineNumber);else AddReference(context,mod,file,lineNumber,referenceMetadata.Positional0Kind,positional[0]);}if(command=="action"&&positional.Count>0)AddActionArgumentReferences(context,mod,file,lineNumber,positional[0],named);}FinalizeSelect(context,mod,file,ref pendingSelectLine,ref pendingSelectHasOptions);}private static HashSet<string>CollectLabels(ValidationContext context,IModInfo mod,string file,string[]lines,int lineOffset){var labels=new HashSet<string>(StringComparer.Ordinal);var lineNumber=lineOffset;foreach(var rawLine in lines){lineNumber++;var line=rawLine.Trim();if(line.Length==0||line.StartsWith('#'))continue;if(!line.StartsWith(':'))continue;var label=line.Substring(1).Trim();if(label.Length==0){context.AddError("SCENARIO_MISSING_ARGUMENT","label name is empty",mod.Id,file,lineNumber);continue;}if(!labels.Add(label))context.AddWarning("SCENARIO_DUPLICATE_LABEL",$"label ':{label}' is defined more than once",mod.Id,file,lineNumber);}return labels;}private static void FinalizeSelect(ValidationContext context,IModInfo mod,string file,ref int pendingSelectLine,ref bool pendingSelectHasOptions){if(pendingSelectLine>=0&& !pendingSelectHasOptions)context.AddWarning("SCENARIO_EMPTY_SELECT","select is not followed by any option and is dropped from the queue",mod.Id,file,pendingSelectLine);pendingSelectLine= -1;pendingSelectHasOptions=false;}private static void CheckSpec(ValidationContext context,IModInfo mod,string file,int line,string command,List<string>positional,Dictionary<string,string>named,string arrowTarget,IScenarioCommandSpec spec,HashSet<string>labels){var argumentCount=positional.Count+named.Count;if(argumentCount==0&& !string.IsNullOrEmpty(arrowTarget))argumentCount=1;if(positional.Count<spec.MinPositional)context.AddError("SCENARIO_MISSING_ARGUMENT",$"'{command}' requires at least {spec.MinPositional} positional argument(s), got {positional.Count}",mod.Id,file,line);if(argumentCount<spec.MinArguments)context.AddError("SCENARIO_MISSING_ARGUMENT",$"'{command}' requires at least {spec.MinArguments} argument(s), got {argumentCount}",mod.Id,file,line);if(spec.MaxPositional>=0&&positional.Count>spec.MaxPositional)context.AddWarning("SCENARIO_UNEXPECTED_ARGUMENT",$"'{command}' accepts at most {spec.MaxPositional} positional argument(s), got {positional.Count}",mod.Id,file,line);CheckPositionalRange(context,mod,file,line,command,positional,spec.MinPositional,spec.MaxPositional);var required=spec.RequiredNamed;if(required!=null){foreach(var name in required){if(!named.TryGetValue(name.ToLowerInvariant(),out var value)||string.IsNullOrWhiteSpace(value))context.AddError("SCENARIO_MISSING_ARGUMENT",$"'{command}' requires named argument '{name}'",mod.Id,file,line);}}var allowed=spec.AllowedNamed;if(allowed!=null&&allowed.Count>0)CheckAllowedNamed(context,mod,file,line,command,named,allowed);var jumpKey=spec.JumpTargetNamedKey;if(!string.IsNullOrEmpty(jumpKey)&&named.TryGetValue(jumpKey,out var jumpTarget))CheckLabel(context,mod,file,line,command,jumpTarget,labels);}private static void CheckPositionalRange(ValidationContext context,IModInfo mod,string file,int line,string command,List<string>positional,int min,int max){if(min<1||positional.Count==0)return;for(var i=0;i<positional.Count&&i<min;i++){if(string.IsNullOrEmpty(positional[i]))context.AddError("SCENARIO_EMPTY_ARGUMENT",$"'{command}' positional argument #{i + 1} is empty",mod.Id,file,line);}}private static void CheckEmptyNamed(ValidationContext context,IModInfo mod,string file,int line,string command,Dictionary<string,string>named){foreach(var pair in named){if(string.IsNullOrEmpty(pair.Value))context.AddError("SCENARIO_EMPTY_ARGUMENT",$"argument '{pair.Key}' of '{command}' has no value",mod.Id,file,line);}}private static void CheckAllowedNamed(ValidationContext context,IModInfo mod,string file,int line,string command,Dictionary<string,string>named,IReadOnlyList<string>allowed){foreach(var pair in named){if(!ContainsName(allowed,pair.Key))context.AddWarning("SCENARIO_UNKNOWN_ARGUMENT",$"'{command}' does not accept argument '{pair.Key}'",mod.Id,file,line);}}private static bool ContainsName(IReadOnlyList<string>names,string name){for(var i=0;i<names.Count;i++){if(string.Equals(names[i],name,StringComparison.OrdinalIgnoreCase))return true;}return false;}private static void CheckLabel(ValidationContext context,IModInfo mod,string file,int line,string command,string target,HashSet<string>labels){if(string.IsNullOrEmpty(target)||labels.Contains(target))return;context.AddError("SCENARIO_MISSING_LABEL",$"'{command}' jumps to ':{target}' but no such label exists in the file",mod.Id,file,line,"add the label or fix the jump target");}private static void AddAvatarReferences(ValidationContext context,IModInfo mod,string file,int line,Dictionary<string,string>named){AddAvatarReference(context,mod,file,line,named,"first");AddAvatarReference(context,mod,file,line,named,"second");}private static void AddAvatarReference(ValidationContext context,IModInfo mod,string file,int line,Dictionary<string,string>named,string name){if(!named.TryGetValue(name,out var value)||string.IsNullOrWhiteSpace(value))return;context.Graph.AddReference(ValidationKind.Avatar,value,mod.Id,file,line);}/// <summary> Collects content references hidden in the named arguments of an <c>action</c> command. The scenario DSL treats action arguments as opaque dialog variables, so without this pass a typo like <c>action character.add_to_party type=Murakamiy</c> would only surface at runtime as a factory error log. Values from <see cref="ActionArgumentReferences"/> become graph references and are reconciled by <see cref="ReferenceStrategy"/>, which reports the offending scenario file and line. Comma-separated values (e.g. battle.init enemies=) produce one reference per entry; values with the ${name} marker are computed at play time and cannot be resolved statically, so they are skipped instead of misreported. </summary>
private static void AddActionArgumentReferences(ValidationContext context,IModInfo mod,string file,int line,string actionName,Dictionary<string,string>named){if(!ActionArgumentReferences.TryGetKinds(actionName,out var kinds))return;foreach(var pair in kinds){if(!named.TryGetValue(pair.Key,out var value)||string.IsNullOrWhiteSpace(value))continue;if(value.Contains("$"+"{"))continue;foreach(var part in value.Split(',')){var target=part.Trim();if(target.Length==0)continue;if(pair.Value==ValidationKind.Scene&&SceneManager.IsNativeSceneName(target))continue;context.Graph.AddReference(pair.Value,target,mod.Id,file,line);}}}private static void AddReference(ValidationContext context,IModInfo mod,string file,int line,PositionalReferenceKind kind,string target){switch(kind){case PositionalReferenceKind.Background:context.Graph.AddReference(ValidationKind.Background,target,mod.Id,file,line);break;case PositionalReferenceKind.Scene:if(!SceneManager.IsNativeSceneName(target))context.Graph.AddReference(ValidationKind.Scene,target,mod.Id,file,line);break;case PositionalReferenceKind.Music:context.Graph.AddReference(ValidationKind.Music,target,mod.Id,file,line);break;case PositionalReferenceKind.Sound:context.Graph.AddReference(ValidationKind.Sound,target,mod.Id,file,line);break;case PositionalReferenceKind.Effect:context.Graph.AddReference(ValidationKind.Effect,target,mod.Id,file,line);break;case PositionalReferenceKind.Action:CheckAction(context,mod,file,line,target);break;}}private static void CheckAction(ValidationContext context,IModInfo mod,string file,int line,string target){if(GetActionNames().Contains(target))return;context.AddError("REF_NOT_FOUND",$"scenario action '{target}' is not registered",mod.Id,file,line,"check the action name or make sure the mod that defines it is enabled");}private static HashSet<string>GetActionNames(){lock(actionLock){if(actionNames!=null)return actionNames;var names=new HashSet<string>(StringComparer.OrdinalIgnoreCase);foreach(var action in ScenarioActionFactory.Instance.GetAll())names.Add(action.Name);actionNames=names;return actionNames;}}private static void SplitArguments(List<string>tokens,List<string>positional,Dictionary<string,string>named,out string arrowTarget){arrowTarget=string.Empty;for(var i=0;i<tokens.Count;i++){var token=tokens[i];if(token=="->"){if(i+1<tokens.Count){arrowTarget=tokens[i+1].TrimStart(':');i++;}continue;}var equalsIndex=token.IndexOf('=');if(equalsIndex>0){var name=token.Substring(0,equalsIndex);if(IsValidIdent(name)){named[name.ToLowerInvariant()]=token.Substring(equalsIndex+1);continue;}}positional.Add(token);}}private static List<string>Tokenize(string line){var result=new List<string>();var i=0;while(i<line.Length){if(char.IsWhiteSpace(line[i])){i++;continue;}if(line[i]=='"'){i++;var start=i;while(i<line.Length&&line[i]!='"'){if(line[i]=='\\'&&i+1<line.Length)i++;i++;}result.Add(line.Substring(start,i-start).Replace("\\\"","\""));if(i<line.Length)i++;continue;}var tokenStart=i;while(i<line.Length&& !char.IsWhiteSpace(line[i]))i++;result.Add(line.Substring(tokenStart,i-tokenStart));}return result;}private static bool IsValidIdent(string value){if(string.IsNullOrEmpty(value))return false;for(var i=0;i<value.Length;i++){var c=value[i];if(!char.IsLetterOrDigit(c)&&c!='_')return false;}return true;}}}

===== FILE mods/validator/src/ScenePatchStrategy.cs =====
using System;using System.Collections.Generic;using Cthangover.Core.Mods;using Cthangover.Core.Scenes.Patching;using Cthangover.Core.Scenes.TscnParsing;namespace Cthangover.Validator{/// <summary> Phase: Contracts. Walks every mod's <c>patches/scenes/**</c> tree, parses each patch with the same <see cref="ScenePatchParser"/> the runtime uses and reports authoring problems: files that are not inherited scenes, targets no mod provides, attempts to delete the scene root, malformed connection selectors and unknown <c>metadata/_patch_*</c> keys. <para/> Target existence is checked only against mod scenes — native <c>res://scenes/**</c> files are invisible to the background validator, so a native target is accepted with a note instead of an error. Deep merge results (whether an override path exists in the target) cannot be validated without loading the target, which the validator deliberately avoids. </summary>
public class ScenePatchStrategy:IValidationStrategy{private static readonly HashSet<string>KnownReservedKeys=new(StringComparer.Ordinal){PatchMetadata.Delete,PatchMetadata.Remove,PatchMetadata.AddGroups,PatchMetadata.RemoveGroups,PatchMetadata.DeleteNodes,PatchMetadata.RemoveConnections,PatchMetadata.RemoveExtResources,PatchMetadata.Gc,};public string Id=>"scene_patches";public ValidationPhase Phase=>ValidationPhase.Contracts;public int Order=>110;public ValidationFeature Feature=>ValidationFeature.Json;/// <summary>Validates scene patch .tscn files: known patch operations and target scenes that actually exist.</summary>
public void Validate(ValidationContext context){var sceneKeys=CollectSceneKeys(context);foreach(var mod in context.Mods){context.ThrowIfCancellationRequested();var provider=mod.FileProvider;if(provider==null)continue;foreach(var file in ModFileReader.ListFiles(provider,ScenePatchResolver.PatchDirectory,true)){context.ThrowIfCancellationRequested();if(!file.EndsWith(".tscn",StringComparison.OrdinalIgnoreCase))continue;ValidatePatch(context,mod,provider,file,sceneKeys);}}}private static HashSet<string>CollectSceneKeys(ValidationContext context){var keys=new HashSet<string>(StringComparer.OrdinalIgnoreCase);foreach(var mod in context.Mods){var provider=mod.FileProvider;if(provider==null)continue;foreach(var file in ModFileReader.ListFiles(provider,"scenes",true)){if(file.EndsWith(".tscn",StringComparison.OrdinalIgnoreCase)||file.EndsWith(".scn",StringComparison.OrdinalIgnoreCase))keys.Add(file.Replace('\\','/'));}}return keys;}private static void ValidatePatch(ValidationContext context,IModInfo mod,IModFileProvider provider,string file,HashSet<string>sceneKeys){var text=provider.ReadFileText(file);if(!ScenePatchParser.TryParse(text,mod.Id,file,out var patch,out var error)){context.AddError("SCENE_PATCH_INVALID",$"invalid scene patch: {error}",mod.Id,file,0,"a scene patch is an inherited scene: root node with instance=ExtResource(\"PackedScene\")");return;}if(!TargetExists(patch.TargetPath,sceneKeys)){context.AddWarning("SCENE_PATCH_TARGET_NOT_FOUND",$"patch target '{patch.TargetPath}' is not provided by any mod (native scene targets are not visible to the validator)",mod.Id,file,0,"check the ext_resource path or ship the target scene");}var roots=patch.Scene.Nodes.FindAll(n=>n.Parent==null);if(roots.Count!=1){context.AddError("SCENE_PATCH_ROOT_COUNT",$"patch declares {roots.Count} root nodes, expected exactly one",mod.Id,file,0);return;}var root=roots[0];if(IsTrue(root.Properties.TryGetValue(PatchMetadata.Delete,out var delete)?delete:null)){context.AddError("SCENE_PATCH_ROOT_DELETE","patch tries to delete the scene root",mod.Id,file,0,"remove the _patch_delete marker from the root node");}ValidateReservedKeys(context,mod,file,patch);ValidateConnectionSelectors(context,mod,file,root);}private static void ValidateReservedKeys(ValidationContext context,IModInfo mod,string file,ScenePatch patch){foreach(var node in patch.Scene.Nodes)CheckKeys(context,mod,file,node.Properties?.Keys);foreach(var sub in patch.Scene.SubResources)CheckKeys(context,mod,file,sub.Properties?.Keys);}private static void CheckKeys(ValidationContext context,IModInfo mod,string file,IEnumerable<string>keys){if(keys==null)return;foreach(var key in keys){if(!key.StartsWith(PatchMetadata.Prefix,StringComparison.Ordinal)||KnownReservedKeys.Contains(key))continue;context.AddWarning("SCENE_PATCH_UNKNOWN_KEY",$"unknown reserved scene-patch key '{key}'",mod.Id,file,0,"reserved keys are listed in the scene patch documentation");}}private static void ValidateConnectionSelectors(ValidationContext context,IModInfo mod,string file,ParsedNode root){if(!root.Properties.TryGetValue(PatchMetadata.RemoveConnections,out var value))return;foreach(var raw in EnumerateValues(value)){if(!ConnectionSelector.TryParse(raw,out _,out var error)){context.AddError("SCENE_PATCH_BAD_SELECTOR",$"invalid connection selector '{raw}': {error}",mod.Id,file,0,"use key=value pairs separated by ';', e.g. from=Panel/Btn;signal=pressed");}}}private static bool TargetExists(string targetPath,HashSet<string>sceneKeys){if(string.IsNullOrEmpty(targetPath))return false;foreach(var key in sceneKeys){if(key.Equals(targetPath,StringComparison.OrdinalIgnoreCase)||key.EndsWith("/"+targetPath,StringComparison.OrdinalIgnoreCase)||targetPath.EndsWith("/"+key,StringComparison.OrdinalIgnoreCase))return true;}return false;}private static IEnumerable<string>EnumerateValues(object value){switch(value){case string[]array:return array;case string single:return new[]{single};default:return Array.Empty<string>();}}private static bool IsTrue(object value){return(value is bool flag&&flag)||(value is string text&&bool.TryParse(text,out var parsed)&&parsed);}}}

===== FILE mods/validator/src/SkillEntityStrategy.cs =====
namespace Cthangover.Validator{/// <summary> Phase: Contracts. Collects skill declarations. Skills are a standalone entity kind — they used to share the <c>characters/</c> directory with characters and actions, which is exactly the ambiguity the kind system removed; here they are declared under their own kind. </summary>
public class SkillEntityStrategy:EntityKindStrategy{public override string Kind=>ValidationKind.Skill;}}

===== FILE mods/validator/src/StatusEffectEntityStrategy.cs =====
namespace Cthangover.Validator{/// <summary> Phase: Contracts. Collects status effect declarations. Definitions name behaviour sets and icons that are resolved at runtime by the battle mods; for now the validator records the declaration so unused or duplicated effects can be reported. </summary>
public class StatusEffectEntityStrategy:EntityKindStrategy{public override string Kind=>ValidationKind.StatusEffect;}}

===== FILE mods/validator/src/UnusedDeclarationStrategy.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Validator{/// <summary> Phase: Reconciliation. Reports declarations that no static reference points at. Coverage is deliberately limited to kinds whose references are fully collected today — items (loot, recipe ingredients) and actions (character action lists). Backgrounds, avatars and scenes are consumed mostly from <c>.scenario</c> files, so they stay out until a scenario strategy contributes those references; otherwise every asset would be flagged. </summary>
public class UnusedDeclarationStrategy:IValidationStrategy{public string Id=>"unused_declarations";public ValidationPhase Phase=>ValidationPhase.Reconciliation;public int Order=>300;public ValidationFeature Feature=>ValidationFeature.Json;/// <summary>Reports items and actions that are declared but never referenced (DECL_UNUSED).</summary>
public void Validate(ValidationContext context){var used=new HashSet<string>(StringComparer.OrdinalIgnoreCase);foreach(var reference in context.Graph.References)used.Add(Key(reference.Kind,reference.TargetId));foreach(var declaration in context.Graph.Declarations){context.ThrowIfCancellationRequested();if(declaration.Kind!=ValidationKind.Item&&declaration.Kind!=ValidationKind.Action)continue;if(used.Contains(Key(declaration.Kind,declaration.Id)))continue;context.AddInfo("DECL_UNUSED",$"{declaration.Kind} '{declaration.Id}' is never referenced statically",declaration.ModId,declaration.File,declaration.Line);}}private static string Key(string kind,string id)=>kind+"|"+id;}}

===== FILE mods/validator/src/ValidationContext.cs =====
using System.Collections.Generic;using System.Threading;using Cthangover.Core.Mods;namespace Cthangover.Validator{/// <summary> Everything a strategy needs: the mod snapshot, the report to fill, the shared fact graph and the cancellation token. Instances are created per pass on the validator background thread; strategies run sequentially, so no locking is required inside the context or the graph. </summary>
public class ValidationContext{public ValidationContext(IReadOnlyList<IModInfo>mods,ValidationReport report,ValidationGraph graph,CancellationToken cancellationToken){Mods=mods;Report=report;Graph=graph;CancellationToken=cancellationToken;}/// <summary>Ordered snapshot of the mods to validate.</summary>
public IReadOnlyList<IModInfo>Mods{get;}/// <summary>Report being assembled; strategies append issues to it.</summary>
public ValidationReport Report{get;}/// <summary>Facts collected by contract strategies.</summary>
public ValidationGraph Graph{get;}/// <summary>Cooperative cancellation checked between mods and documents.</summary>
public CancellationToken CancellationToken{get;}/// <summary>Appends an error issue to the report (validation failure).</summary>
public void AddError(string code,string message,string mod,string file=null,int line=0,string suggestion=null,List<string>related=null)=>Report.Issues.Add(Create(ValidationSeverity.Error,code,message,mod,file,line,suggestion,related));/// <summary>Appends a warning issue (suspicious but not fatal).</summary>
public void AddWarning(string code,string message,string mod,string file=null,int line=0,string suggestion=null,List<string>related=null)=>Report.Issues.Add(Create(ValidationSeverity.Warning,code,message,mod,file,line,suggestion,related));/// <summary>Appends an informational issue (e.g. unused content).</summary>
public void AddInfo(string code,string message,string mod,string file=null,int line=0,string suggestion=null,List<string>related=null)=>Report.Issues.Add(Create(ValidationSeverity.Info,code,message,mod,file,line,suggestion,related));/// <summary>Cooperative cancellation hook for strategies to call between documents.</summary>
public void ThrowIfCancellationRequested()=>CancellationToken.ThrowIfCancellationRequested();private static ValidationIssue Create(string severity,string code,string message,string mod,string file,int line,string suggestion,List<string>related){return new ValidationIssue{Severity=severity,Code=code,Message=message,Mod=mod,File=file,Line=line,Suggestion=suggestion,Related=related,};}}}

===== FILE mods/validator/src/ValidationDeclaration.cs =====
namespace Cthangover.Validator{/// <summary> A declared entity discovered while collecting contracts (JSON id, asset file id, …). Declarations are matched against <see cref="ValidationReference"/> during reconciliation, and declarations of kinds with reliable reference coverage can be checked for being unused. </summary>
public class ValidationDeclaration{public string Kind{get;set;}public string Id{get;set;}public string ModId{get;set;}public string File{get;set;}public int Line{get;set;}}}

===== FILE mods/validator/src/ValidationFeature.cs =====
namespace Cthangover.Validator{/// <summary> Capability group of a validation strategy, mapped to a user setting by <see cref="ValidatorService"/>. Strategies in a disabled feature are skipped, so expensive analysis (Roslyn, GDScript) stays opt-in without every strategy having to read the settings blob itself. </summary>
public enum ValidationFeature{/// <summary>Cheap file-level and JSON analysis; always enabled.</summary>
Json,/// <summary>Roslyn analysis of mod C# sources; gated by <c>parse_csharp</c>.</summary>
CSharp,/// <summary>GDScript probe; gated by <c>parse_gdscript</c> (off by default).</summary>
GdScript,/// <summary>Scenario DSL analysis; reserved for a future strategy.</summary>
Scenarios,}}

===== FILE mods/validator/src/ValidationGraph.cs =====
using System;using System.Collections.Generic;using System.Linq;namespace Cthangover.Validator{/// <summary> Shared fact base of a validation pass. Contract strategies append to it and reconciliation strategies query it, which keeps cross-cutting checks (missing references, type conflicts, unused declarations) independent of the parser that produced the facts. A scenario strategy added later only needs to call <see cref="AddDeclaration"/> and <see cref="AddReference"/>. The graph is filled and read sequentially by one background thread, so it needs no synchronisation; it is never exposed to the main thread. </summary>
public class ValidationGraph{private readonly Dictionary<string,List<ValidationDeclaration>>declarations=new(StringComparer.OrdinalIgnoreCase);private readonly List<ValidationReference>references=new();private readonly HashSet<string>declaredKeys=new(StringComparer.OrdinalIgnoreCase);/// <summary>All references collected so far.</summary>
public IReadOnlyList<ValidationReference>References=>references;/// <summary>All declarations collected so far, flattened across kinds.</summary>
public IEnumerable<ValidationDeclaration>Declarations=>declarations.Values.SelectMany(list=>list);/// <summary> Registers an entity declaration. Empty kinds/ids are ignored so parsers can call this without nil-checks. </summary>
public void AddDeclaration(string kind,string id,string modId,string file,int line){if(string.IsNullOrWhiteSpace(kind)||string.IsNullOrWhiteSpace(id))return;if(!declarations.TryGetValue(kind,out var list)){list=new List<ValidationDeclaration>();declarations[kind]=list;}list.Add(new ValidationDeclaration{Kind=kind,Id=id,ModId=modId,File=file,Line=line,});}/// <summary> Registers a reference to an entity. Empty kinds/targets are ignored. </summary>
public void AddReference(string kind,string targetId,string modId,string file,int line){if(string.IsNullOrWhiteSpace(kind)||string.IsNullOrWhiteSpace(targetId))return;references.Add(new ValidationReference{Kind=kind,TargetId=targetId,ModId=modId,File=file,Line=line,});}/// <summary> Registers a property key seen in JSON entity data. Used by the C# strategy to distinguish declared properties from typos. </summary>
public void AddDeclaredKey(string key){if(!string.IsNullOrWhiteSpace(key))declaredKeys.Add(key);}/// <summary>True when at least one declaration of the given kind and id exists.</summary>
public bool HasDeclaration(string kind,string id){return declarations.TryGetValue(kind,out var list)&&list.Any(d=>string.Equals(d.Id,id,StringComparison.OrdinalIgnoreCase));}/// <summary>True when the key was seen in any JSON entity or action Values list.</summary>
public bool IsKeyDeclared(string key)=>declaredKeys.Contains(key);}}

===== FILE mods/validator/src/ValidationIssue.cs =====
using System.Collections.Generic;using System.Text.Json.Serialization;namespace Cthangover.Validator{/// <summary> A single machine-readable finding. The code catalogue (<c>SCHEMA_MISSING_FIELD</c>, <c>REF_NOT_FOUND</c>, <c>TYPE_CONFLICT</c>, …) is shared with the validator plan, so the report stays stable for LLM consumers while the set of checks grows. </summary>
public class ValidationIssue{/// <summary><c>error</c>, <c>warning</c> or <c>info</c>.</summary>
[JsonPropertyName("severity")]public string Severity{get;set;}/// <summary>Stable machine code of the check that produced the issue.</summary>
[JsonPropertyName("code")]public string Code{get;set;}/// <summary>Human-readable explanation.</summary>
[JsonPropertyName("message")]public string Message{get;set;}/// <summary>Mod the issue belongs to.</summary>
[JsonPropertyName("mod")]public string Mod{get;set;}/// <summary>Mod-relative path of the offending file.</summary>
[JsonPropertyName("file")]public string File{get;set;}/// <summary>One-based line number, or 0 when unknown.</summary>
[JsonPropertyName("line")]public int Line{get;set;}/// <summary>Optional remediation hint.</summary>
[JsonPropertyName("suggestion")]public string Suggestion{get;set;}/// <summary>Related locations in <c>mod:file:line</c> form, when the issue spans several usages.</summary>
[JsonPropertyName("related")]public List<string>Related{get;set;}}}

===== FILE mods/validator/src/ValidationKind.cs =====
namespace Cthangover.Validator{/// <summary> Entity kinds shared between JSON declaration collection and reference validation. A scenario strategy added later reuses these constants when it extracts background, avatar, quest and scene references from <c>.scenario</c> files, so both sides of the graph speak the same vocabulary. </summary>
public static class ValidationKind{public const string Item="item";public const string Quest="quest";public const string Character="character";public const string Action="action";public const string StatusEffect="status_effect";public const string Skill="skill";public const string Recipe="recipe";public const string Scene="scene";public const string Interactive="interactive";public const string Background="background";public const string Avatar="avatar";public const string Music="music";public const string Sound="sound";public const string Effect="effect";public const string ScenePath="scene_path";}}

===== FILE mods/validator/src/ValidationModStatus.cs =====
using System.Text.Json.Serialization;namespace Cthangover.Validator{/// <summary> Per-mod section of the report. <c>status</c> is <c>ok</c> when the mod contributed no errors, otherwise <c>errors</c> — the field lets a consumer triage mods without scanning the flat issue list. </summary>
public class ValidationModStatus{[JsonPropertyName("id")]public string Id{get;set;}[JsonPropertyName("version")]public string Version{get;set;}[JsonPropertyName("install_sha256")]public string InstallSha256{get;set;}[JsonPropertyName("status")]public string Status{get;set;}="ok";}}

===== FILE mods/validator/src/ValidationPhase.cs =====
namespace Cthangover.Validator{/// <summary> Ordered stages of a validation pass. Strategies declare the stage they belong to, which lets later strategies consume facts collected by earlier ones: schema checks need nothing, contract collectors (JSON graph, C# usages) produce the facts, and reconciliation strategies compare the collected declarations, references and usages against each other. A scenario-validation strategy added later normally belongs to Contracts (extract declarations and references from <c>.scenario</c> files) and Reconciliation (match them against the graph). </summary>
public enum ValidationPhase{/// <summary>Shape of source files: manifest fields, JSON syntax.</summary>
Schema=0,/// <summary>Fact collection: entity declarations, references, code usages.</summary>
Contracts=1,/// <summary>Cross-checks: missing references, conflicts, unused declarations.</summary>
Reconciliation=2,}}

===== FILE mods/validator/src/ValidationReference.cs =====
namespace Cthangover.Validator{/// <summary> A reference from one entity to another (loot item, default background, character action, …). Collected by contract strategies and matched against declarations during reconciliation. </summary>
public class ValidationReference{public string Kind{get;set;}public string TargetId{get;set;}public string ModId{get;set;}public string File{get;set;}public int Line{get;set;}}}

===== FILE mods/validator/src/ValidationReport.cs =====
using System.Collections.Generic;using System.Text.Json.Serialization;namespace Cthangover.Validator{/// <summary> Root DTO of the machine-readable validation report written to <c>user://validation/report.json</c>. Snake_case JSON names are fixed here rather than in serialiser options, so the format cannot drift when global options change. </summary>
public class ValidationReport{/// <summary>UTC timestamp in round-trip format.</summary>
[JsonPropertyName("generated_at")]public string GeneratedAt{get;set;}/// <summary>Game version the report was produced for.</summary>
[JsonPropertyName("game_version")]public string GameVersion{get;set;}/// <summary>Validator mod version; part of the report cache key.</summary>
[JsonPropertyName("validator_version")]public string ValidatorVersion{get;set;}/// <summary>Issue counters.</summary>
[JsonPropertyName("summary")]public ValidationSummary Summary{get;set;}=new();/// <summary>Validated mods in load order with their status.</summary>
[JsonPropertyName("mods")]public List<ValidationModStatus>Mods{get;set;}=new();/// <summary>All findings of the pass.</summary>
[JsonPropertyName("issues")]public List<ValidationIssue>Issues{get;set;}=new();}}

===== FILE mods/validator/src/ValidationSeverity.cs =====
namespace Cthangover.Validator{/// <summary> Severity labels used by <see cref="ValidationIssue"/> and counted into <see cref="ValidationSummary"/>. Kept as string constants because the report is a machine-readable JSON contract consumed by external tooling and LLMs that expect lower-case labels. </summary>
public static class ValidationSeverity{public const string Error="error";public const string Warning="warning";public const string Info="info";}}

===== FILE mods/validator/src/ValidationStrategyConsumer.cs =====
using Cthangover.Core.Mods.Consumers;namespace Cthangover.Validator{/// <summary> Feeds <see cref="ValidationStrategyRegistry"/> with every mod's <see cref="IValidationStrategy"/> implementations as the mods load and removes them when their mod unloads. </summary>
public class ValidationStrategyConsumer:ModTypeConsumer<IValidationStrategy>{/// <inheritdoc />
public override string Id=>"validator.strategies";/// <inheritdoc />
protected override void Add(IValidationStrategy instance,string modId)=>ValidationStrategyRegistry.Register(instance);/// <inheritdoc />
protected override void Remove(IValidationStrategy instance,string modId)=>ValidationStrategyRegistry.Unregister(instance);}}

===== FILE mods/validator/src/ValidationStrategyRegistry.cs =====
using System;using System.Collections.Generic;namespace Cthangover.Validator{/// <summary> Registry of all <see cref="IValidationStrategy"/> implementations, served in deterministic execution order. It discovers nothing itself: <see cref="ValidationStrategyConsumer"/> delivers the strategies of every mod as the kernel registers its assembly and takes them back when the mod unloads (game switch, hot reload), so a validation run never executes a strategy of a dead assembly. Adding a new strategy (for example scenario validation) is just shipping a new public class with a parameterless constructor in any mod. </summary>
public static class ValidationStrategyRegistry{private static readonly object sync=new();private static readonly List<IValidationStrategy>strategies=new();/// <summary>Returns a snapshot of the registered strategies sorted by phase and order.</summary>
public static IReadOnlyList<IValidationStrategy>GetStrategies(){lock(sync)return strategies.ToArray();}/// <summary>Adds a strategy and keeps the execution order.</summary>
public static void Register(IValidationStrategy strategy){if(strategy==null)return;lock(sync){strategies.Add(strategy);strategies.Sort(Compare);}}/// <summary>Removes exactly <paramref name="strategy"/> (its mod is being unloaded).</summary>
public static void Unregister(IValidationStrategy strategy){lock(sync)strategies.Remove(strategy);}private static int Compare(IValidationStrategy a,IValidationStrategy b){var phase=a.Phase.CompareTo(b.Phase);if(phase!=0)return phase;var order=a.Order.CompareTo(b.Order);if(order!=0)return order;return string.Compare(a.Id,b.Id,StringComparison.OrdinalIgnoreCase);}}}

===== FILE mods/validator/src/ValidationSummary.cs =====
using System.Text.Json.Serialization;namespace Cthangover.Validator{/// <summary> Aggregated issue counters placed at the top of the report so consumers can decide whether to read the full issue list without deserialising it. </summary>
public class ValidationSummary{[JsonPropertyName("errors")]public int Errors{get;set;}[JsonPropertyName("warnings")]public int Warnings{get;set;}[JsonPropertyName("infos")]public int Infos{get;set;}}}

===== FILE mods/validator/src/ValidatorInitializer.cs =====
using Cthangover.Core.Mods;namespace Cthangover.Validator{/// <summary> Lifecycle entry point of the validator mod. <see cref="OnModResourcesReady"/> is the earliest moment when every mod has been discovered, compiled and registered, so it is the only reliable trigger for a global pass — the mod list cannot be assembled from <see cref="IModInitializer.OnModLoaded"/>, because asset-only mods (JSON/PNG without code) never produce an assembly and therefore never raise <c>ModLoadedEvent</c>. The hook runs on the main thread; all Godot-bound work (settings load, <c>user://</c> path resolution) happens inside <see cref="ValidatorService.Start"/>, and the validation itself continues on a background task so the game start is never blocked. On unload (or re-registration during an in-process reload) the pass in flight is stopped, see <see cref="ValidatorService.Stop"/>. </summary>
public class ValidatorInitializer:IModInitializer,IModUnloadAware{/// <summary> Intentionally empty: asset-only mods are invisible to this callback, so the validator always works from the complete <c>ModRegistry</c> snapshot taken in <see cref="ValidatorService.Start"/> instead of accumulating mods here. </summary>
public void OnModLoaded(string modId){}/// <summary> Starts the background validation pass once all mods and their JSON resources are available. </summary>
public void OnModResourcesReady(){ValidatorService.Start();}/// <summary>Stops the background pass while the validator's types are still alive.</summary>
public void OnModUnloading(){ValidatorService.Stop();}/// <summary>Nothing to drop: the validator keeps no references to other mods between passes.</summary>
public void OnModUnloaded(string modId){}}}

===== FILE mods/validator/src/ValidatorService.cs =====
using System;using Cthangover.Mods.Dialog.Scenarios;using System.Collections.Generic;using System.Threading;using System.Threading.Tasks;using Cthangover.Core.Factories.Impls;using Cthangover.Core.Mods;using Cthangover.Core.Utils;namespace Cthangover.Validator{/// <summary> Orchestrates the validator pass. <see cref="Start"/> is called on the main thread and returns immediately: it snapshots the ordered mod list and the resolved report directory, then hands pure .NET work to a background task so the game start is never blocked. The background task may touch only <see cref="IModInfo"/> and <see cref="IModFileProvider"/> — never Godot types and never resolver caches — because mod providers are disposed by <c>ModManager.Reload</c> on the main thread. Strategies are discovered by reflection and executed in (Phase, Order, Id) order: contract strategies fill the shared <see cref="ValidationGraph"/>, reconciliation strategies consume it. A pass in flight is cancelled by <see cref="Stop"/> when the mod is unloaded or re-registered by an in-process mod-set reload: its thread runs validator code (and reflects over other mods' assemblies), so a pass left running would keep every one of those assemblies loaded, and it would read file providers the reload disposes. </summary>
public static class ValidatorService{/// <summary>Version reported in the JSON report; bump alongside the manifest.</summary>
public const string Version="0.1.0";private static readonly object stateLock=new();private static bool running;private static CancellationTokenSource cancellation;private static Task pass;private static List<IModInfo>queuedMods;private static string queuedReportDirectory;/// <summary>True while a validation pass is in flight.</summary>
public static bool IsRunning{get{lock(stateLock)return running;}}/// <summary> Main-thread entry point invoked once after all mods are loaded. Reloads settings (persisted values are not applied by the engine yet at this point), checks the enable flags, guards against concurrent passes, takes the mod snapshot, and schedules the background run. </summary>
public static void Start(){ValidatorSettings.Reload();if(!ValidatorSettings.Enabled|| !ValidatorSettings.RunOnStart){GameLogger.Log("VALIDATOR","validator: disabled by settings");return;}lock(stateLock){if(running)return;running=true;}var mods=SnapshotMods();var reportDirectory=ValidatorSettings.GetReportDirectory();WarmUp();GameLogger.Log("VALIDATOR",$"validator: started, {mods.Count} mod(s) queued");lock(stateLock){queuedMods=mods;queuedReportDirectory=reportDirectory;cancellation=new CancellationTokenSource();pass=Task.Run(RunQueued);}}/// <summary> Cancels the pass in flight and waits (bounded) until its thread has left validator code. Main thread, from the unload notification. </summary>
public static void Stop(){Task running;lock(stateLock){cancellation?.Cancel();running=pass;}if(running==null)return;try{if(!running.Wait(TimeSpan.FromSeconds(10)))GameLogger.Log("VALIDATOR","validator: pass did not stop within 10 s; its mods stay in memory until it ends",LogLevel.Warning);}catch(AggregateException){// Run logs its own failures; a cancelled pass is the expected outcome here.
}}private static void RunQueued(){List<IModInfo>mods;string reportDirectory;CancellationToken token;lock(stateLock){mods=queuedMods;reportDirectory=queuedReportDirectory;token=cancellation.Token;queuedMods=null;}Run(mods,reportDirectory,token);}private static List<IModInfo>SnapshotMods(){var result=new List<IModInfo>();var registry=ModRegistry.Instance;foreach(var modId in registry.GetOrderedModIds()){if(registry.Mods.TryGetValue(modId,out var info)&&info!=null)result.Add(info);}return result;}private static void Run(List<IModInfo>mods,string reportDirectory,CancellationToken token){try{var report=BuildReport(mods,token);GameLogger.Log("VALIDATOR",$"validator: {report.Summary.Errors} errors, {report.Summary.Warnings} warnings, {report.Summary.Infos} infos");if(ValidatorSettings.WriteReport)ReportWriter.Write(report,reportDirectory);}catch(OperationCanceledException){GameLogger.Log("VALIDATOR","validator: pass cancelled (mod set reloading)");}catch(Exception ex){GameLogger.Log("VALIDATOR",$"validator: failed: {ex.Message}\n{ex.StackTrace}",LogLevel.Error);}finally{lock(stateLock)running=false;}}private static ValidationReport BuildReport(List<IModInfo>mods,CancellationToken token){var report=new ValidationReport{GeneratedAt=DateTime.UtcNow.ToString("O"),GameVersion=ValidatorSettings.GameVersion,ValidatorVersion=Version,};foreach(var mod in mods){report.Mods.Add(new ValidationModStatus{Id=mod.Id,Version=mod.Version,InstallSha256=(mod as ModInfo)?.InstallSha256,Status="ok",});}Analyze(mods,report,token);ApplySummary(report);return report;}/// <summary> Runs every enabled strategy over <paramref name="mods"/> and returns the populated context — declarations, references and issues — without writing a report. Other tool mods (e.g. the resource atlas) build on the same graph instead of re-parsing scenarios and data. Call <see cref="WarmUp"/> on the main thread first. </summary>
public static ValidationContext Analyze(IReadOnlyList<IModInfo>mods,ValidationReport report,CancellationToken cancellationToken){var graph=new ValidationGraph();var context=new ValidationContext(mods,report??new ValidationReport(),graph,cancellationToken);var strategies=ValidationStrategyRegistry.GetStrategies();GameLogger.Log("VALIDATOR",$"validator: running {strategies.Count} strategy(ies)");foreach(var strategy in strategies){if(!IsFeatureEnabled(strategy.Feature)){GameLogger.Log("VALIDATOR",$"validator: strategy {strategy.Id} skipped, feature {strategy.Feature} disabled",LogLevel.Debug);continue;}GameLogger.Log("VALIDATOR",$"validator: strategy {strategy.Id} ({strategy.Phase})",LogLevel.Debug);try{strategy.Validate(context);}catch(OperationCanceledException){throw;}catch(Exception ex){GameLogger.Log("VALIDATOR",$"validator: strategy {strategy.Id} failed: {ex.Message}\n{ex.StackTrace}",LogLevel.Error);}}return context;}private static bool IsFeatureEnabled(ValidationFeature feature){switch(feature){case ValidationFeature.CSharp:return ValidatorSettings.ParseCSharp;case ValidationFeature.GdScript:return ValidatorSettings.ParseGdScript;case ValidationFeature.Scenarios:return ValidatorSettings.ParseScenarios;default:return true;}}/// <summary> Touches the scenario command and action registries while still on the main thread. Both are static/Lazy registries that instantiate plugin classes on first use; warming them here keeps the background validation pass free of first-touch instantiation of game types. </summary>
public static void WarmUp(){var commands=0;foreach(var _ in ScenarioCommandStrategyFactory.GetAllCommandNames())commands++;var actions=0;foreach(var action in ScenarioActionFactory.Instance.GetAll())actions++;GameLogger.Log("VALIDATOR",$"validator: scenario registries warmed ({commands} command(s), {actions} action(s))",LogLevel.Debug);}private static void ApplySummary(ValidationReport report){var errors=0;var warnings=0;var infos=0;foreach(var issue in report.Issues){switch(issue.Severity){case ValidationSeverity.Error:errors++;break;case ValidationSeverity.Warning:warnings++;break;case ValidationSeverity.Info:infos++;break;}}report.Summary.Errors=errors;report.Summary.Warnings=warnings;report.Summary.Infos=infos;foreach(var status in report.Mods)status.Status=HasErrors(report,status.Id)?"errors":"ok";}private static bool HasErrors(ValidationReport report,string modId){foreach(var issue in report.Issues){if(issue.Severity==ValidationSeverity.Error&&string.Equals(issue.Mod,modId,StringComparison.OrdinalIgnoreCase))return true;}return false;}}}

===== FILE mods/validator/src/ValidatorSettings.cs =====
using System.Collections.Generic;using Cthangover.Core.Mods;using Cthangover.Core.Settings;using Cthangover.Core.Utils;using Godot;namespace Cthangover.Validator{/// <summary> User-facing settings of the validator, discovered by <see cref="ModSettingsRegistry"/> from the compiled mod assembly and rendered by the standard mod settings tab. Values are held in static properties so the background validation task can read them without owning the discovered instance. <see cref="Reload"/> is called on the main thread from <see cref="ValidatorService.Start"/> before the task is scheduled: the engine applies persisted settings in <c>ModSettingsRegistry.LoadAll</c> only after <c>ModResourcesReady</c>, which is too late for the start decision, so the mod reads its own settings file eagerly. </summary>
public class ValidatorSettings:IModSettings{/// <summary>Mod id — also the settings file name under <c>user://mod_settings/</c>.</summary>
public const string ModId="validator";/// <summary>Default report directory used when the setting is empty or missing.</summary>
public const string DefaultReportPath="user://validation/";/// <summary>Master switch; when false the validation task is not started at all.</summary>
public static bool Enabled{get;private set;}=true;/// <summary>When false, the mod stays idle until a future manual trigger is added.</summary>
public static bool RunOnStart{get;private set;}=true;/// <summary>Enables the Roslyn-based analysis of mod C# sources.</summary>
public static bool ParseCSharp{get;private set;}=true;/// <summary>Enables the GDScript probe; off by default because it is expensive.</summary>
public static bool ParseGdScript{get;private set;}/// <summary>Enables .scenario DSL validation (command names, arguments, labels).</summary>
public static bool ParseScenarios{get;private set;}=true;/// <summary>When false the report is kept in memory only and no file is written.</summary>
public static bool WriteReport{get;private set;}=true;/// <summary>Godot-style directory (<c>user://...</c>) the report is written to.</summary>
public static string ReportPath{get;private set;}=DefaultReportPath;/// <summary> Reserved for the future "block mod on error" behaviour. Cannot be honoured today: mods are registered once and never unloaded, so the most it can do is disable the mod for the next launch. </summary>
public static bool FailOnError{get;private set;}/// <summary>Game version resolved from <c>application/config/version</c>, cached for the report.</summary>
public static string GameVersion{get;private set;}="";/// <inheritdoc/>
public IReadOnlyList<ModSettingDefinition>GetDefinitions(){return new List<ModSettingDefinition>{new(){Key="enabled",Name="validator/enabled",Type=SettingType.Bool,DefaultValue="true"},new(){Key="run_on_start",Name="validator/run_on_start",Type=SettingType.Bool,DefaultValue="true"},new(){Key="parse_csharp",Name="validator/parse_csharp",Type=SettingType.Bool,DefaultValue="true"},new(){Key="parse_gdscript",Name="validator/parse_gdscript",Type=SettingType.Bool,DefaultValue="false"},new(){Key="parse_scenarios",Name="validator/parse_scenarios",Type=SettingType.Bool,DefaultValue="true"},new(){Key="write_report",Name="validator/write_report",Type=SettingType.Bool,DefaultValue="true"},new(){Key="report_path",Name="validator/report_path",Type=SettingType.String,DefaultValue=DefaultReportPath},new(){Key="fail_on_error",Name="validator/fail_on_error",Type=SettingType.Bool,DefaultValue="false"},};}/// <inheritdoc/>
public void WriteValues(DataBlob blob){blob.SetBool("enabled",Enabled);blob.SetBool("run_on_start",RunOnStart);blob.SetBool("parse_csharp",ParseCSharp);blob.SetBool("parse_gdscript",ParseGdScript);blob.SetBool("parse_scenarios",ParseScenarios);blob.SetBool("write_report",WriteReport);blob.SetString("report_path",ReportPath);blob.SetBool("fail_on_error",FailOnError);}/// <inheritdoc/>
public void ReadValues(DataBlob blob){Apply(blob);}/// <summary> Loads persisted values before the validation task starts and caches the game version for the report. Must be called on the main thread because it reads through <c>Godot.FileAccess</c> and <c>ProjectSettings</c>. </summary>
public static void Reload(){Apply(ModSettingsRegistry.LoadModSettings(ModId));GameVersion=ResolveGameVersion();}/// <summary> Resolves the configured report directory to an absolute filesystem path. Must be called on the main thread because it touches <c>ProjectSettings</c>; returns null when the path cannot be resolved. </summary>
public static string GetReportDirectory(){if(!GameEnvironment.IsRuntime)return ReportPath;try{return ProjectSettings.GlobalizePath(ReportPath);}catch{return null;}}private static void Apply(DataBlob blob){Enabled=ReadBool(blob,"enabled",true);RunOnStart=ReadBool(blob,"run_on_start",true);ParseCSharp=ReadBool(blob,"parse_csharp",true);ParseGdScript=ReadBool(blob,"parse_gdscript",false);ParseScenarios=ReadBool(blob,"parse_scenarios",true);WriteReport=ReadBool(blob,"write_report",true);FailOnError=ReadBool(blob,"fail_on_error",false);ReportPath=blob.GetString("report_path",DefaultReportPath);if(string.IsNullOrWhiteSpace(ReportPath))ReportPath=DefaultReportPath;}/// <summary> Reads a boolean the way the settings UI persists it — as text. The typed <c>DataBlob.GetBool</c> is only used as a fallback because the UI writes every control through <c>SetString</c>, which a persisted blob later exposes as a JSON string. </summary>
private static bool ReadBool(DataBlob blob,string key,bool defaultValue){var raw=blob.GetString(key,null);if(raw==null)return defaultValue;return bool.TryParse(raw,out var parsed)?parsed:defaultValue;}private static string ResolveGameVersion(){if(!GameEnvironment.IsRuntime)return"";try{return ProjectSettings.GetSetting("application/config/version").AsString();}catch{return"";}}}}

===== FILE sdk/gd_cubism-0.9.1/demo/addons/gd_cubism/cs/gd_cubism_effect_cs.cs =====
// SPDX-License-Identifier: MIT SPDX-FileCopyrightText: 2023 MizunagiKB <mizukb@live.jp>
using Godot;
#pragma warning disable CA1050
public partial class GDCubismEffectCS:Node{private static readonly StringName NativeName="GDCubismEffect";protected Node InternalObject=null;public GDCubismEffectCS(){this.InternalObject=(SubViewport)Godot.ClassDB.Instantiate(NativeName);}public GDCubismEffectCS(Node o){this.InternalObject=o;}public Node GetInternalObject(){return this.InternalObject;}public void Attach(Node o){this.InternalObject=o;}public Node Detach(){Node o=this.InternalObject;this.InternalObject=null;return o;}// ------------------------------------------------------------ Property(s)
public bool Active{set{this.InternalObject.Call("set_active",(bool)value);}get{return(bool)this.InternalObject.Call("get_active");}}// -------------------------------------------------------------- Method(s) -------------------------------------------------------------- Signal(s)
}

===== FILE sdk/gd_cubism-0.9.1/demo/addons/gd_cubism/cs/gd_cubism_effect_custom_cs.cs =====
// SPDX-License-Identifier: MIT SPDX-FileCopyrightText: 2023 MizunagiKB <mizukb@live.jp>
using Godot;using Godot.NativeInterop;
#pragma warning disable CA1050
public partial class GDCubismEffectCustomCS:GDCubismEffectCS{private static readonly StringName NativeName="GDCubismEffectCustom";public GDCubismEffectCustomCS(Node o):base(o){}public static readonly StringName CubismEpilogueName="cubism_epilogue";public static readonly StringName CubismInitName="cubism_init";public static readonly StringName CubismProcessName="cubism_process";public static readonly StringName CubismPrologueName="cubism_prologue";public static readonly StringName CubismTermName="cubism_term";// ------------------------------------------------------------ Property(s) -------------------------------------------------------------- Method(s) -------------------------------------------------------------- Signal(s)
public delegate void CubismEpilogueEventHandler(Node2D value,double delta);private static void CubismEpilogueTrampoline(object delegateObj,NativeVariantPtrArgs args,out godot_variant ret){((CubismEpilogueEventHandler)delegateObj)(VariantUtils.ConvertToGodotObject(args[0])as Node2D,VariantUtils.ConvertToFloat32(args[1]));ret=default;}public unsafe event CubismEpilogueEventHandler CubismEpilogue{add=>this.InternalObject.Connect(CubismEpilogueName,Callable.CreateWithUnsafeTrampoline(value,&CubismEpilogueTrampoline));remove=>this.InternalObject.Disconnect(CubismEpilogueName,Callable.CreateWithUnsafeTrampoline(value,&CubismEpilogueTrampoline));}public delegate void CubismInitEventHandler(Node2D value);private static void CubismInitrampoline(object delegateObj,NativeVariantPtrArgs args,out godot_variant ret){((CubismInitEventHandler)delegateObj)(VariantUtils.ConvertToGodotObject(args[0])as Node2D);ret=default;}public unsafe event CubismInitEventHandler CubismInit{add=>this.InternalObject.Connect(CubismInitName,Callable.CreateWithUnsafeTrampoline(value,&CubismInitrampoline));remove=>this.InternalObject.Disconnect(CubismInitName,Callable.CreateWithUnsafeTrampoline(value,&CubismInitrampoline));}public delegate void CubsimPrologueEventHandler(Node2D value,double delta);private static void CubismPrologueTrampoline(object delegateObj,NativeVariantPtrArgs args,out godot_variant ret){((CubsimPrologueEventHandler)delegateObj)(VariantUtils.ConvertToGodotObject(args[0])as Node2D,VariantUtils.ConvertToFloat32(args[1]));ret=default;}public unsafe event CubsimPrologueEventHandler CubismPrologue{add=>this.InternalObject.Connect(CubismPrologueName,Callable.CreateWithUnsafeTrampoline(value,&CubismPrologueTrampoline));remove=>this.InternalObject.Disconnect(CubismPrologueName,Callable.CreateWithUnsafeTrampoline(value,&CubismPrologueTrampoline));}public delegate void CubismProcessEventHandler(Node2D value,double delta);private static void CubismProcessTrampoline(object delegateObj,NativeVariantPtrArgs args,out godot_variant ret){((CubismProcessEventHandler)delegateObj)(VariantUtils.ConvertToGodotObject(args[0])as Node2D,VariantUtils.ConvertToFloat32(args[1]));ret=default;}public unsafe event CubismProcessEventHandler CubismProcess{add=>this.InternalObject.Connect(CubismProcessName,Callable.CreateWithUnsafeTrampoline(value,&CubismProcessTrampoline));remove=>this.InternalObject.Disconnect(CubismProcessName,Callable.CreateWithUnsafeTrampoline(value,&CubismProcessTrampoline));}public delegate void CubismTermEventHandler(Node2D value);private static void CubismTermrampoline(object delegateObj,NativeVariantPtrArgs args,out godot_variant ret){((CubismTermEventHandler)delegateObj)(VariantUtils.ConvertToGodotObject(args[0])as Node2D);ret=default;}public unsafe event CubismTermEventHandler CubismTerm{add=>this.InternalObject.Connect(CubismTermName,Callable.CreateWithUnsafeTrampoline(value,&CubismTermrampoline));remove=>this.InternalObject.Disconnect(CubismTermName,Callable.CreateWithUnsafeTrampoline(value,&CubismTermrampoline));}}

===== FILE sdk/gd_cubism-0.9.1/demo/addons/gd_cubism/cs/gd_cubism_effect_hit_area_cs.cs =====
// SPDX-License-Identifier: MIT SPDX-FileCopyrightText: 2023 MizunagiKB <mizukb@live.jp>
using System;using Godot;
#pragma warning disable CA1050
public partial class GDCubismEffectHitAreaCS:GDCubismEffectCS{private static readonly StringName NativeName="GDCubismEffectHitArea";public GDCubismEffectHitAreaCS(Node o):base(o){}// ------------------------------------------------------------ Property(s)
public bool Monitoring{set{this.InternalObject.Call("set_monitoring",(bool)value);}get{return(bool)this.InternalObject.Call("set_monitoring");}}// -------------------------------------------------------------- Method(s)
public void SetTarget(Vector2 v){this.InternalObject.Call("set_target",v);}public Vector2 GetTarget(){return(Vector2)this.InternalObject.Call("get_target");}public Godot.Collections.Dictionary GetDetail(GDCubismUserModelCS model,String id){return(Godot.Collections.Dictionary)this.InternalObject.Call("get_detail",model.GetInternalObject(),id);}// -------------------------------------------------------------- Signal(s)
}

===== FILE sdk/gd_cubism-0.9.1/demo/addons/gd_cubism/cs/gd_cubism_effect_target_point_cs.cs =====
// SPDX-License-Identifier: MIT SPDX-FileCopyrightText: 2023 MizunagiKB <mizukb@live.jp>
using System;using Godot;
#pragma warning disable CA1050
public partial class GDCubismEffectTargetPointCS:GDCubismEffectCS{private static readonly StringName NativeName="GDCubismEffectTargetPoint";public GDCubismEffectTargetPointCS(Node o):base(o){}// ------------------------------------------------------------ Property(s)
public String HeadAngleX{set{this.InternalObject.Call("set_head_angle_x",(String)value);}get{return(String)this.InternalObject.Call("get_head_angle_x");}}public String HeadAngleY{set{this.InternalObject.Call("set_head_angle_y",(String)value);}get{return(String)this.InternalObject.Call("get_head_angle_y");}}public String HeadAngleZ{set{this.InternalObject.Call("set_head_angle_z",(String)value);}get{return(String)this.InternalObject.Call("get_head_angle_z");}}public float HeadRange{set{this.InternalObject.Call("set_head_range",(float)value);}get{return(float)this.InternalObject.Call("get_head_range");}}public String BodyAngleX{set{this.InternalObject.Call("set_body_angle_x",(String)value);}get{return(String)this.InternalObject.Call("get_body_angle_x");}}public float BodyRange{set{this.InternalObject.Call("set_body_range",(float)value);}get{return(float)this.InternalObject.Call("get_body_range");}}public String EyesAngleX{set{this.InternalObject.Call("set_eyes_angle_x",(String)value);}get{return(String)this.InternalObject.Call("get_eyes_angle_x");}}public String EyesAngleY{set{this.InternalObject.Call("set_eyes_angle_y",(String)value);}get{return(String)this.InternalObject.Call("get_eyes_angle_y");}}public float EyesRange{set{this.InternalObject.Call("set_eyes_range",(float)value);}get{return(float)this.InternalObject.Call("get_eyes_range");}}// -------------------------------------------------------------- Method(s)
public void SetTarget(Vector2 v){this.InternalObject.Call("set_target",v);}public Vector2 GetTarget(){return(Vector2)this.InternalObject.Call("get_target");}// -------------------------------------------------------------- Signal(s)
}

===== FILE sdk/gd_cubism-0.9.1/demo/addons/gd_cubism/cs/gd_cubism_parameter_cs.cs =====
// SPDX-License-Identifier: MIT SPDX-FileCopyrightText: 2023 MizunagiKB <mizukb@live.jp>
using Godot;
#pragma warning disable CA1050
public partial class GDCubismParameterCS:GDCubismValueAbsCS{// private static readonly StringName NativeName = "GDCubismParameter";
public GDCubismParameterCS(GodotObject o):base(o){}// ------------------------------------------------------------ Property(s)
public float DefaultValue{get{return(float)this.InternalObject.Call("get_default_value");}}public float MaximumValue{get{return(float)this.InternalObject.Call("get_maximum_value");}}public float MinimumValue{get{return(float)this.InternalObject.Call("get_minimum_value");}}// -------------------------------------------------------------- Method(s) -------------------------------------------------------------- Signal(s)
}

===== FILE sdk/gd_cubism-0.9.1/demo/addons/gd_cubism/cs/gd_cubism_part_opacity_cs.cs =====
// SPDX-License-Identifier: MIT SPDX-FileCopyrightText: 2023 MizunagiKB <mizukb@live.jp>
using Godot;
#pragma warning disable CA1050
public partial class GDCubismPartOpacityCS:GDCubismValueAbsCS{// private static readonly StringName NativeName = "GDCubismPartOpacity";
public GDCubismPartOpacityCS(GodotObject o):base(o){}// ------------------------------------------------------------ Property(s) -------------------------------------------------------------- Method(s) -------------------------------------------------------------- Signal(s)
}

===== FILE sdk/gd_cubism-0.9.1/demo/addons/gd_cubism/cs/gd_cubism_user_model_cs.cs =====
// SPDX-License-Identifier: MIT SPDX-FileCopyrightText: 2023 MizunagiKB <mizukb@live.jp>
using System;using Godot;using Godot.NativeInterop;
#pragma warning disable CA1050
public partial class GDCubismUserModelCS:GodotObject{public enum Moc3FileFormatVersionEnum:long{/// <summary>Unknown or failed to load.</summary>
CSM_MOC_VERSION_UNKNOWN=0,/// <summary>moc3 file version 3.0.00 - 3.2.07</summary>
CSM_MOC_VERSION_30=1,/// <summary>moc3 file version 3.3.00 - 3.3.03</summary>
CSM_MOC_VERSION_33=2,/// <summary>moc3 file version 4.0.00 - 4.1.05</summary>
CSM_MOC_VERSION_40=3,/// <summary>moc3 file version 4.2.00 - 4.2.02</summary>
CSM_MOC_VERSION_42=4,/// <summary>moc3 file version 5.0.00 -</summary>
CSM_MOC_VERSION_50=5}public enum PriorityEnum:long{PriorityNone=0,PriorityIdle=1,PriorityNormal=2,PriorityForce=3}public enum ParameterModeEnum:long{FullParameter=0,NoneParameter=1}public enum MotionProcessCallbackEnum:long{/// <summary> The animation update process is performed within the <c>_physics_process</c> function. This is used when you want to interact with physical simulations. </summary>
Physics=0,/// <summary> The animation update process is performed within the _process function. </summary>
Idle=1,/// <summary> No animation update process is performed. Use the <c>advance</c> function to process the animation. </summary>
Manual=2}private static readonly StringName NativeName="GDCubismUserModel";public static readonly StringName MotionEventName="motion_event";public static readonly StringName MotionFinishedName="motion_finished";protected Node2D InternalObject=null;public GDCubismUserModelCS(){this.InternalObject=(Node2D)Godot.ClassDB.Instantiate(NativeName);}public GDCubismUserModelCS(Node2D o){this.InternalObject=o;}public Node2D GetInternalObject(){return this.InternalObject;}public void Attach(Node2D o){this.InternalObject=o;}public Node2D Detach(){Node2D o=this.InternalObject;this.InternalObject=null;return o;}// ------------------------------------------------------------ Property(s) <value> Property <c>Assets</c> <br /> You can load a Live2D model by specifying a file with the *.model3.json extension. The file is loaded immediately upon specification. <br /> if you want to switch the Live2D model, you can do so simply by specifying a new file. </value>
public String Assets{set{this.InternalObject.Call("set_assets",value);}get{return(String)this.InternalObject.Call("get_assets");}}/// <value> Property <c>LoadExpressions</c> <br /> If set to <c>false</c>, it will not load _Expressions_ when loading the Live2D Model. </value>
public bool LoadExpressions{set{this.InternalObject.Call("set_load_expressions",value);}get{return(bool)this.InternalObject.Call("get_load_expressions");}}/// <value> Property <c>LoadMotions</c> <br /> If set to <c>false</c>, it will not load _Motions_ when loading the Live2D Model. </value>
public bool LoadMotions{set{this.InternalObject.Call("set_load_motions",value);}get{return(bool)this.InternalObject.Call("get_load_motions");}}/// <value> Property <c>MaskViewportSize</c> <br /> Specify the maximum resolution for any individual mask required for the Live2D model. <br /> If set to 0, size of the mask will be relative to the source pixel resolution of the model's canvas. <br /> Mask resolution is also dynamically resized relative to the model's scale within its viewport. <br /> <br /> Reducing the resolution can help conserve GPU memory, however reducing resolution too far can cause visual issues where masks are applied. <br /> For many, it's best to leave this at default and rely solely on the automatic scaling. </value>
public int MaskViewportSize{set{this.InternalObject.Call("set_mask_viewport_size",value);}get{return(int)this.InternalObject.Call("get_mask_viewport_size");}}/// <value> Property <c>ParameterMode</c> <br /> Specifies the control method for the currently held Live2D model. </value>
public ParameterModeEnum ParameterMode{set{this.InternalObject.Call("set_parameter_mode",(int)value);}get{int value=(int)this.InternalObject.Call("get_parameter_mode");return(ParameterModeEnum)Enum.ToObject(typeof(ParameterModeEnum),value);}}/// <value> Property <c>PhysicsEvaluate</c> <br /> Setting this parameter to `false` disables physical calculations. </value>
public bool PhysicsEvaluate{set{this.InternalObject.Call("set_physics_evaluate",value);}get{return(bool)this.InternalObject.Call("get_physics_evaluate");}}/// <value> Property <c>PlaybackProcessMode</c> <br /> Specifies the playback method for the currently held Live2D model. </value>
public MotionProcessCallbackEnum PlaybackProcessMode{set{this.InternalObject.Call("set_process_callback",(int)value);}get{int value=(int)this.InternalObject.Call("get_process_callback");return(MotionProcessCallbackEnum)Enum.ToObject(typeof(MotionProcessCallbackEnum),value);}}/// <value> Property <c>PoseUpdate</c> <br /> Setting this parameter to `false` disables transparency calculations between drawing parts specified in the pose group. If you want to manually handle all transparency calculations, set this parameter to `false`. </value>
public bool PoseUpdate{set{this.InternalObject.Call("set_pose_update",value);}get{return(bool)this.InternalObject.Call("get_pose_update");}}public Shader ShaderAdd{set{this.InternalObject.Call("set_shader_add",(Shader)value);}get{return(Shader)this.InternalObject.Call("get_shader_add");}}public Shader ShaderMix{set{this.InternalObject.Call("set_shader_mix",(Shader)value);}get{return(Shader)this.InternalObject.Call("get_shader_mix");}}public Shader ShaderMul{set{this.InternalObject.Call("set_shader_mul",(Shader)value);}get{return(Shader)this.InternalObject.Call("get_shader_mul");}}public Shader ShaderMask{set{this.InternalObject.Call("set_shader_mask",(Shader)value);}get{return(Shader)this.InternalObject.Call("get_shader_mask");}}public Shader ShaderMaskAdd{set{this.InternalObject.Call("set_shader_mask_add",(Shader)value);}get{return(Shader)this.InternalObject.Call("get_shader_mask_add");}}public Shader ShaderMaskAddInv{set{this.InternalObject.Call("set_shader_mask_add_inv",(Shader)value);}get{return(Shader)this.InternalObject.Call("get_shader_mask_add_inv");}}public Shader ShaderMaskMix{set{this.InternalObject.Call("set_shader_mask_mix",(Shader)value);}get{return(Shader)this.InternalObject.Call("get_shader_mask_mix");}}public Shader ShaderMaskMixInv{set{this.InternalObject.Call("set_shader_mask_mix_inv",(Shader)value);}get{return(Shader)this.InternalObject.Call("get_shader_mask_mix_inv");}}public Shader ShaderMaskMul{set{this.InternalObject.Call("set_shader_mask_mul",(Shader)value);}get{return(Shader)this.InternalObject.Call("get_shader_mask_mul");}}public Shader ShaderMaskMulInv{set{this.InternalObject.Call("set_shader_mask_mul_inv",(Shader)value);}get{return(Shader)this.InternalObject.Call("get_shader_mask_mul_inv");}}/// <value> Property <c>SpeedScale</c> <br /> Specifies the playback speed for the currently held Live2D model. </value>
public float SpeedScale{set{this.InternalObject.Call("set_speed_scale",(float)value);}get{return(float)this.InternalObject.Call("get_speed_scale");}}// -------------------------------------------------------------- Method(s) <summary> Advances the animation by the specified <paramref name="delta">delta</paramref> time (in seconds). Please specify a value of 0.0 or more for delta. </summary> <param name="delta">The time to progress the animation.</param>
public void Advance(double delta){this.InternalObject.Call("advance",delta);}/// <summary> Returns the latest file version that GDCubismUserModel can load. </summary> <returns>Moc3FileFormatVersionEnum</returns>
public Moc3FileFormatVersionEnum CsmGetLatestMocVersion(){long value=(long)this.InternalObject.Call("csm_get_latest_moc_version");return(Moc3FileFormatVersionEnum)Enum.ToObject(typeof(Moc3FileFormatVersionEnum),value);}/// <summary> Returns the version of the loaded moc3 file. </summary> <returns>Moc3FileFormatVersionEnum</returns>
public Moc3FileFormatVersionEnum CsmGetMocVersion(){long value=(long)this.InternalObject.Call("csm_get_moc_version");return(Moc3FileFormatVersionEnum)Enum.ToObject(typeof(Moc3FileFormatVersionEnum),value);}/// <summary> Returns the version number of the Cubism Native SDK Core used by GDCubism in <c>Dictionary</c> format <list type="bullet"> <item> <term>version</term> <description>int: The value returned by the csmVersion function is stored as is.</description> </item> <item> <term>major</term> <description>int: Only the major version is extracted from the version and stored.</description> </item> <item> <term>minor</term> <description>int: Only the minor version is extracted from the version and stored.</description> </item> <item> <term>patch</term> <description>int: Only the patch number is extracted from the version and stored.</description> </item> </list> </summary> <returns>Godot.Collections.Dictionary</returns>
public Godot.Collections.Dictionary CsmGetVersion(){return(Godot.Collections.Dictionary)this.InternalObject.Call("csm_get_version");}/// <summary> Returns the following information in <c>Dictionary</c> format. <list type="bullet"> <item> <term>size_in_pixels</term> <description>Vector2: Returns the width and height of the canvas of the loaded Live2D model in pixels.</description> </item> <item> <term>origin_in_pixels</term> <description>Vector2: Returns the center position of the loaded Live2D model in pixels.</description> </item> <item> <term>ixels_per_unit</term> <description>float: Returns the pixelsPerUnit of the loaded Live2D model.</description> </item> </list> </summary> <returns>Godot.Collections.Dictionary</returns>
public Godot.Collections.Dictionary GetCanvasInfo(){return(Godot.Collections.Dictionary)this.InternalObject.Call("get_canvas_info");}/// <summary> Returns the information of the <c>Motion</c> currently being played. </summary> <returns>Godot.Collections.Array</returns>
public Godot.Collections.Array GetCubismMotionQueueEntries(){return(Godot.Collections.Array)this.InternalObject.Call("get_cubism_motion_queue_entries");}/// <summary> Returns a list of Expressions from the currently held Live2D model. The information obtained can be used as an argument for the <c>start_expression</c> function. </summary> <returns>Godot.Collections.Array</returns>
public Godot.Collections.Array<String>GetExpressions(){return(Godot.Collections.Array<String>)this.InternalObject.Call("get_expressions");}public Godot.Collections.Array GetHitAreas(){return(Godot.Collections.Array)this.InternalObject.Call("get_hit_areas");}/// <summary> Retrieves the <c>ArrayMesh</c> information of the <c>MeshInstance</c> used in the current Motion state. The keys held by this <c>Dictionary</c> are identical to the names of the <c>MeshInstance</c> generated by <c>GDCubismUserModel</c>. <br /> The information that can be obtained is the <c>ArrayMesh</c> used internally. <br /> CAUTION<br /> get_meshes is an experimental function added in v0.1. Please be aware that the specifications may change or be removed in the future. </summary> <returns>Godot.Collections.Dictionary</returns>
public Godot.Collections.Dictionary GetMeshes(){return(Godot.Collections.Dictionary)this.InternalObject.Call("get_meshes");}/// <summary> Returns a list of <c>Motions</c> from the currently held Live2D model. The returned <c>Dictionary</c> is the <c>group</c> and the number of motions it contains. </summary> <returns>Godot.Collections.Dictionary</returns>
public Godot.Collections.Dictionary<String,int>GetMotions(){return(Godot.Collections.Dictionary<String,int>)this.InternalObject.Call("get_motions");}/// <summary> Retrieves the controller class for operating the currently held Live2D model. </summary> <returns>Godot.Collections.Array</returns>
public Godot.Collections.Array<GDCubismParameterCS>GetParameters(){Godot.Collections.Array<GodotObject>ary=(Godot.Collections.Array<GodotObject>)this.InternalObject.Call("get_parameters");Godot.Collections.Array<GDCubismParameterCS>ary_result=new();foreach(GodotObject o in ary){ary_result.Add(new GDCubismParameterCS(o));}return ary_result;}/// <summary> Retrieves the controller class for operating the transparency of the parts of the currently held Live2D model. </summary> <returns>Godot.Collections.Array</returns>
public Godot.Collections.Array<GDCubismPartOpacityCS>GetPartOpacities(){Godot.Collections.Array<GodotObject>ary=(Godot.Collections.Array<GodotObject>)this.InternalObject.Call("get_part_opacities");Godot.Collections.Array<GDCubismPartOpacityCS>ary_result=new();foreach(GodotObject o in ary){ary_result.Add(new GDCubismPartOpacityCS(o));}return ary_result;}/// <summary> Plays the specified <paramref name="expression_id">expression_id</paramref>. </summary> <param name="expression_id"></param>
public void StartExpression(String expression_id){this.InternalObject.Call("start_expression",expression_id);}/// <summary> Plays the specified <paramref name="grp_name">group</paramref> and <paramref name="no">no</paramref>. </summary> <param name="grp_name"></param> <param name="no"></param> <param name="priority"></param>
public void StartMotion(String grp_name,int no,PriorityEnum priority){this.InternalObject.Call("start_motion",grp_name,no,(int)priority);}/// <summary> Plays the specified <paramref name="grp_name">group</paramref> and <paramref name="no">no</paramref>. If you want to play it repeatedly, specify it. If the loop argument is <c>false</c>, it will behave the same as the <c>start_motion</c> function. </summary> <param name="grp_name"></param> <param name="no"></param> <param name="priority"></param> <param name="loop"></param> <param name="loop_in_fade"></param>
public void StartMotionLoop(String grp_name,int no,PriorityEnum priority,bool loop,bool loop_in_fade){this.InternalObject.Call("start_motion_loop",grp_name,no,(int)priority,loop,loop_in_fade);}/// <summary> It stops the currently playing <c>Expression</c>. </summary>
public void StopExpression(){this.InternalObject.Call("stop_expression");}/// <summary> It stops the currently playing <c>Motion</c>. </summary>
public void StopMotion(){this.InternalObject.Call("stop_motion");}// -------------------------------------------------------------- Signal(s)
public delegate void MotionEventEventHandler(string value);private static void MotionEventTrampoline(object delegateObj,NativeVariantPtrArgs args,out godot_variant ret){((MotionEventEventHandler)delegateObj)(VariantUtils.ConvertTo<string>(args[0]));ret=default;}public unsafe event MotionEventEventHandler MotionEvent{add=>this.InternalObject.Connect(MotionEventName,Callable.CreateWithUnsafeTrampoline(value,&MotionEventTrampoline));remove=>this.InternalObject.Disconnect(MotionEventName,Callable.CreateWithUnsafeTrampoline(value,&MotionEventTrampoline));}public event Action MotionFinished{add=>this.InternalObject.Connect(MotionFinishedName,Callable.From(value));remove=>this.InternalObject.Disconnect(MotionFinishedName,Callable.From(value));}}

===== FILE sdk/gd_cubism-0.9.1/demo/addons/gd_cubism/cs/gd_cubism_value_abs_cs.cs =====
// SPDX-License-Identifier: MIT SPDX-FileCopyrightText: 2023 MizunagiKB <mizukb@live.jp>
using System;using Godot;
#pragma warning disable CA1050
public partial class GDCubismValueAbsCS:GodotObject{private static readonly StringName NativeName="GDCubismValueAbs";protected GodotObject InternalObject=null;public GDCubismValueAbsCS(){this.InternalObject=(SubViewport)Godot.ClassDB.Instantiate(NativeName);}public GDCubismValueAbsCS(GodotObject o){this.InternalObject=o;}public GodotObject GetInternalObject(){return this.InternalObject;}public void Attach(GodotObject o){this.InternalObject=o;}public GodotObject Detach(){GodotObject o=this.InternalObject;this.InternalObject=null;return o;}// ------------------------------------------------------------ Property(s)
public String Id{get{return(String)this.InternalObject.Call("get_id");}}public float Value{set{this.InternalObject.Call("set_value",(float)value);}get{return(float)this.InternalObject.Call("get_value");}}// -------------------------------------------------------------- Method(s) -------------------------------------------------------------- Signal(s)
}

===== FILE sdk/gd_cubism-0.9.1/demo/addons/gd_cubism/example/demo_effect_custom_01.cs =====
// SPDX-License-Identifier: MIT SPDX-FileCopyrightText: 2023 MizunagiKB <mizukb@live.jp>
using Godot;using System;
#pragma warning disable CA1050
#pragma warning disable IDE1006
public partial class demo_effect_custom_01:Node2D{private const System.String DEFAULT_ASSET="res://addons/gd_cubism/example/res/live2d/mao_pro_jp/runtime/mao_pro.model3.json";private GDCubismUserModelCS cubism_model;private GDCubismEffectCustomCS cubism_efx;private enum E_PARAM_MODE{PROCESS,SIGNAL}private bool param_valid=false;private E_PARAM_MODE param_mode=E_PARAM_MODE.PROCESS;private GDCubismParameterCS l_eye;private GDCubismParameterCS r_eye;private void recalc_model_position(GDCubismUserModelCS model){if(model.Assets=="")return;Godot.Collections.Dictionary canvas_info=model.GetCanvasInfo();if(canvas_info.Count>0){Vector2 vct_viewport_size=new(GetViewportRect().Size.X,GetViewportRect().Size.Y);Vector2 size_in_pixels=(Vector2)canvas_info["size_in_pixels"];float scale=vct_viewport_size.Y/Math.Max(size_in_pixels.X,size_in_pixels.Y);model.GetInternalObject().Position=new(vct_viewport_size.X*0.5f,vct_viewport_size.Y*0.5f);model.GetInternalObject().Scale=new(scale,scale);}}// Called when the node enters the scene tree for the first time.
public override void _Ready(){this.cubism_efx=new(GetNode<Node>("GDCubismUserModel/GDCubismEffectCustom"));// this.cubism_efx.CubismPrologue += this._on_cubism_prologue; this.cubism_efx.CubismEpilogue += this._on_cubism_epilogue; this.cubism_efx.CubismInit += this._on_cubism_init; this.cubism_efx.CubismTerm += this._on_cubism_term; this.cubism_efx.CubismProcess += this._on_cubism_process;
this.cubism_model=new(GetNode<Node2D>("GDCubismUserModel"));if(this.cubism_model.Assets=="")this.cubism_model.Assets=DEFAULT_ASSET;}// Called every frame. 'delta' is the elapsed time since the previous frame.
public override void _Process(double delta){recalc_model_position(this.cubism_model);if(this.param_mode==E_PARAM_MODE.PROCESS){Godot.Collections.Array<GDCubismParameterCS>ary_param=this.cubism_model.GetParameters();foreach(GDCubismParameterCS o in this.cubism_model.GetParameters()){if(o.Id=="ParamEyeLOpen"){o.Value=0.0f;}if(o.Id=="ParamEyeROpen"){o.Value=0.0f;}}}}}

===== FILE sdk/gd_cubism-0.9.1/demo/addons/gd_cubism/example/demo_effect_custom_02.cs =====
// SPDX-License-Identifier: MIT SPDX-FileCopyrightText: 2023 MizunagiKB <mizukb@live.jp>
using Godot;using Godot.NativeInterop;using System;
#pragma warning disable CA1050
#pragma warning disable IDE1006
public partial class demo_effect_custom_02:Node2D{private const System.String DEFAULT_ASSET="res://addons/gd_cubism/example/res/live2d/mao_pro_jp/runtime/mao_pro.model3.json";private GDCubismUserModelCS cubism_model;private GDCubismEffectCustomCS cubism_efx;private bool first_time=true;private float counter=0.0f;private Vector2 base_vct;private Vector2 base_scale;[Export]private String art_mesh_name="ArtMesh278";[Export]private int vector_index_0=0;[Export]private int vector_index_1=1;private void recalc_model_position(GDCubismUserModelCS model){if(model.Assets=="")return;Godot.Collections.Dictionary canvas_info=model.GetCanvasInfo();if(canvas_info.Count>0){Vector2 vct_viewport_size=new(GetViewportRect().Size.X,GetViewportRect().Size.Y);Vector2 size_in_pixels=(Vector2)canvas_info["size_in_pixels"];float scale=vct_viewport_size.Y/Math.Max(size_in_pixels.X,size_in_pixels.Y);model.GetInternalObject().Position=new(vct_viewport_size.X*0.5f,vct_viewport_size.Y*0.5f);model.GetInternalObject().Scale=new(scale,scale);}}public override void _Ready(){this.cubism_efx=new(GetNode<Node>("GDCubismUserModel/GDCubismEffectCustom"));// this.cubism_efx.CubismPrologue += this._on_cubism_prologue; this.cubism_efx.CubismEpilogue += this._on_cubism_epilogue; this.cubism_efx.CubismInit += this._on_cubism_init; this.cubism_efx.CubismTerm += this._on_cubism_term;
this.cubism_efx.CubismProcess+=this._on_cubism_process;this.cubism_model=new(GetNode<Node2D>("GDCubismUserModel"));if(this.cubism_model.Assets=="")this.cubism_model.Assets=DEFAULT_ASSET;Godot.Collections.Dictionary<String,int>dict_motion=this.cubism_model.GetMotions();foreach(var(k,item_count)in dict_motion){this.cubism_model.StartMotionLoop(k,0,GDCubismUserModelCS.PriorityEnum.PriorityForce,true,true);break;}}public override void _Process(double delta){recalc_model_position(this.cubism_model);}private void _on_cubism_process(Node2D model,double delta){const int ARRAY_VERTEX=(int)ArrayMesh.ArrayType.Vertex;var ref_sprite=GetNode<Sprite2D>("GDCubismUserModel/GDCubismEffectCustom/Sprite2D");Godot.Collections.Dictionary dict_mesh=this.cubism_model.GetMeshes();if(dict_mesh.ContainsKey(this.art_mesh_name)==false)return;MeshInstance2D ary_mesh=(dict_mesh[this.art_mesh_name]).AsGodotObject()as MeshInstance2D;Godot.Collections.Array ary_surface=ary_mesh.Mesh.SurfaceGetArrays(0);Godot.Collections.Array<Vector2>mesh_vertex=(Godot.Collections.Array<Vector2>)ary_surface[ARRAY_VERTEX];if(this.first_time==true){var _fr=mesh_vertex[this.vector_index_0];var _to=mesh_vertex[this.vector_index_1];this.base_vct=(_to-_fr);this.base_scale=ref_sprite.Scale;this.first_time=false;}var fr=mesh_vertex[this.vector_index_0];var to=mesh_vertex[this.vector_index_1];var calc_vct=(to-fr);var scale=calc_vct.Length()/base_vct.Length();ref_sprite.Position=this.cubism_model.GetInternalObject().ToGlobal(fr);ref_sprite.Rotation=base_vct.Normalized().Cross(calc_vct.Normalized());ref_sprite.Scale=base_scale*scale;}}

===== FILE sdk/gd_cubism-0.9.1/demo/addons/gd_cubism/example/demo_effect_custom_03.cs =====
// SPDX-License-Identifier: MIT SPDX-FileCopyrightText: 2023 MizunagiKB <mizukb@live.jp>
using System;using Godot;
#pragma warning disable CA1050
#pragma warning disable IDE1006
public partial class demo_effect_custom_03:Node2D{private const String DEFAULT_ASSET="res://addons/gd_cubism/example/res/live2d/mao_pro_jp/runtime/mao_pro.model3.json";private GDCubismUserModelCS cubism_model;private GDCubismEffectCustomCS cubism_efx;private String param_mouth_name="ParamMouthA";[Export]private float min_db=60.0f;[Export]private int min_voice_freq=0;[Export]private int max_voice_freq=3200;[Export]private int history_size=6;[ExportCategory("Audio")][Export]private String audio_bus_name="Voice";[Export]private int audio_efx_index=0;private AudioEffectSpectrumAnalyzerInstance spectrum;private GDCubismParameterCS param_mouth;private Godot.Collections.Array<float>ary_volume_history;private int history_position=0;private bool lipsync_ready=false;private void array_rebuild(){if(this.history_size!=this.ary_volume_history.Count){this.ary_volume_history.Resize(this.history_size);this.ary_volume_history.Fill(0.0f);}}private void recalc_model_position(GDCubismUserModelCS model){if(model.Assets=="")return;Godot.Collections.Dictionary canvas_info=model.GetCanvasInfo();if(canvas_info.Count>0){Vector2 vct_viewport_size=new(GetViewportRect().Size.X,GetViewportRect().Size.Y);Vector2 size_in_pixels=(Vector2)canvas_info["size_in_pixels"];float scale=vct_viewport_size.Y/Math.Max(size_in_pixels.X,size_in_pixels.Y);model.GetInternalObject().Position=new(vct_viewport_size.X*0.5f,vct_viewport_size.Y*0.5f);model.GetInternalObject().Scale=new(scale,scale);}}public override void _Ready(){this.cubism_efx=new(GetNode<Node>("GDCubismUserModel/GDCubismEffectCustom"));this.cubism_efx.CubismPrologue+=this._on_cubism_prologue;this.cubism_efx.CubismEpilogue+=this._on_cubism_epilogue;this.cubism_efx.CubismInit+=this._on_cubism_init;this.cubism_efx.CubismTerm+=this._on_cubism_term;this.cubism_efx.CubismProcess+=this._on_cubism_process;this.cubism_model=new(GetNode<Node2D>("GDCubismUserModel"));if(this.cubism_model.Assets=="")this.cubism_model.Assets=DEFAULT_ASSET;}public override void _Process(double delta){this.recalc_model_position(this.cubism_model);}private float array_avg(){float sum_v=0.0f;foreach(float v in this.ary_volume_history){sum_v+=v;}return sum_v/this.ary_volume_history.Count;}private void _on_cubism_prologue(Node2D _model,float v){}private void _on_cubism_epilogue(Node2D _model,float v){}private void _on_cubism_init(Node2D _model){}private void _on_cubism_term(Node2D _model){this.lipsync_ready=false;this.param_mouth=null;this.ary_volume_history.Clear();this.history_position=0;}private void _on_cubism_process(Node2D _model,double delta){if(this.lipsync_ready==false)return;this.array_rebuild();float m=this.spectrum.GetMagnitudeForFrequencyRange(this.min_voice_freq,this.max_voice_freq).Length();var v=Math.Clamp((min_db+Mathf.LinearToDb(m))/this.min_db,0.0f,1.0f);this.ary_volume_history[this.history_position]=v;this.history_position+=1;if(this.history_position>=this.ary_volume_history.Count){this.history_position=0;}float avg_v=this.array_avg();if(this.param_mouth!=null){this.param_mouth.Value=Math.Max(this.param_mouth.Value-0.2f,0.0f);if(avg_v>0.1f){this.param_mouth.Value=1.0f;}}}}

===== FILE sdk/gd_cubism-0.9.1/demo/addons/gd_cubism/example/demo_effect_hit_area.cs =====
// SPDX-License-Identifier: MIT SPDX-FileCopyrightText: 2023 MizunagiKB <mizukb@live.jp>
using Godot;using System;
#pragma warning disable IDE1006
public partial class demo_effect_hit_area:Node2D{const String DEFAULT_ASSET="res://addons/gd_cubism/example/res/live2d/mao_pro_jp/runtime/mao_pro.model3.json";private GDCubismUserModelCS cubism_model;private bool pressed=false;private Vector2 local_pos=new(0.0f,0.0f);private Vector2 adjust_pos=new(0.0f,0.0f);private Godot.Collections.Dictionary dict_detail=new();private Godot.Collections.Dictionary dict_pickup=new();private String id_mesh="";private void recalc_model_position(GDCubismUserModelCS model){if(model.Assets=="")return;Godot.Collections.Dictionary canvas_info=model.GetCanvasInfo();if(canvas_info.Count>0){Vector2 vct_viewport_size=new(GetViewportRect().Size.X,GetViewportRect().Size.Y);Vector2 size_in_pixels=(Vector2)canvas_info["size_in_pixels"];float scale=vct_viewport_size.Y/Math.Max(size_in_pixels.X,size_in_pixels.Y);model.GetInternalObject().Position=new(vct_viewport_size.X*0.5f,vct_viewport_size.Y*0.5f);model.GetInternalObject().Scale=new(scale,scale);}}public override void _Ready(){this.cubism_model=new(GetNode<Node2D>("GDCubismUserModel"));if(this.cubism_model.Assets=="")this.cubism_model.Assets=DEFAULT_ASSET;GetNode<ItemList>("Control/ItemList").Clear();Godot.Collections.Dictionary dict_meshes=this.cubism_model.GetMeshes();foreach(var id in dict_meshes.Keys){GetNode<ItemList>("Control/ItemList").AddItem(id.AsString());}}public override void _Process(double delta){this.recalc_model_position(this.cubism_model);GDCubismEffectHitAreaCS hit_area=new(GetNode<Node>("GDCubismUserModel/GDCubismEffectHitArea"));if(this.pressed==true){hit_area.SetTarget(this.local_pos);}GetNode<Label>("Control/lbl_mouse_x").Text=String.Format("{0:######.000}",this.local_pos.X);GetNode<Label>("Control/lbl_mouse_y").Text=String.Format("{0:######.000}",this.local_pos.Y);if(this.id_mesh!=""){this.dict_pickup=hit_area.GetDetail(this.cubism_model,this.id_mesh);}GetNode<CanvasItem>("Canvas").QueueRedraw();base._Process(delta);}public override void _Input(InputEvent@event){if(@event is InputEventMouseButton mouseButton){this.pressed=mouseButton.IsPressed();}if(@event is InputEventMouseMotion mouseMotion){this.local_pos=this.cubism_model.GetInternalObject().ToLocal(mouseMotion.Position)+this.adjust_pos;}base._Input(@event);}private void mark_hit_area(Godot.Collections.Dictionary dict_hit_area,Color color_box,Color color_tri){if(dict_hit_area.Count>0){Rect2 rect=(Rect2)dict_hit_area["rect"];Rect2 r=new((rect.Position-this.adjust_pos)*this.cubism_model.GetInternalObject().Scale,rect.Size*this.cubism_model.GetInternalObject().Scale);GetNode<CanvasItem>("Canvas").DrawRect(r,color_box,false,5);if(dict_hit_area.ContainsKey("vertices")==true){Godot.Collections.Array<Vector2>vtx=(Godot.Collections.Array<Vector2>)dict_hit_area["vertices"];Godot.Collections.Array<Vector2>v=new();v.Add(vtx[0]*this.cubism_model.GetInternalObject().Scale);v.Add(vtx[1]*this.cubism_model.GetInternalObject().Scale);v.Add(vtx[2]*this.cubism_model.GetInternalObject().Scale);GetNode<CanvasItem>("Canvas").DrawLine(v[0]-this.adjust_pos,v[1]-adjust_pos,color_tri,3);GetNode<CanvasItem>("Canvas").DrawLine(v[1]-this.adjust_pos,v[2]-adjust_pos,color_tri,3);GetNode<CanvasItem>("Canvas").DrawLine(v[2]-this.adjust_pos,v[0]-adjust_pos,color_tri,3);}}}private void _on_gd_cubism_effect_hit_area_hit_area_entered(Node2D _model,String id){GDCubismEffectHitAreaCS hit_area=new(GetNode<Node>("GDCubismUserModel/GDCubismEffectHitArea"));this.dict_detail=hit_area.GetDetail(this.cubism_model,id);GetNode<Label>("Control/lbl_id").Text=id;}private void _on_gd_cubism_effect_hit_area_hit_area_exited(Node2D _model,String id){this.dict_detail=new();GetNode<Label>("Control/lbl_id").Text="";}private void _on_canvas_draw(){this.mark_hit_area(this.dict_detail,new Color(0.0f,1.0f,0.0f),new Color(1.0f,0.0f,0.0f));this.mark_hit_area(this.dict_pickup,new Color(0.0f,0.0f,1.0f),new Color(1.0f,0.0f,0.0f));}private void _on_item_list_item_selected(long index){this.id_mesh=GetNode<ItemList>("Control/ItemList").GetItemText((int)index);}}

===== FILE sdk/gd_cubism-0.9.1/demo/addons/gd_cubism/example/demo_effect_target_point.cs =====
// SPDX-License-Identifier: MIT SPDX-FileCopyrightText: 2025 MizunagiKB <mizukb@live.jp>
using Godot;using System;
#pragma warning disable IDE1006
public partial class demo_effect_target_point:Node2D{const System.String DEFAULT_ASSET="res://addons/gd_cubism/example/res/live2d/mao_pro_jp/runtime/mao_pro.model3.json";private GDCubismUserModelCS cubism_model;private GDCubismEffectTargetPointCS cubism_efx;private bool pressed=false;private void recalc_model_position(GDCubismUserModelCS model){if(model.Assets=="")return;Godot.Collections.Dictionary canvas_info=model.GetCanvasInfo();if(canvas_info.Count>0){Vector2 vct_viewport_size=new(GetViewportRect().Size.X,GetViewportRect().Size.Y);Vector2 size_in_pixels=(Vector2)canvas_info["size_in_pixels"];float scale=vct_viewport_size.Y/Math.Max(size_in_pixels.X,size_in_pixels.Y);model.GetInternalObject().Position=new(vct_viewport_size.X*0.5f,vct_viewport_size.Y*0.5f);model.GetInternalObject().Scale=new(scale,scale);}}public override void _Ready(){this.cubism_model=new(GetNode<Node2D>("GDCubismUserModel"));if(this.cubism_model.Assets=="")this.cubism_model.Assets=DEFAULT_ASSET;this.cubism_efx=new(GetNode<Node>("GDCubismUserModel/GDCubismEffectTargetPoint"));}public override void _Process(double delta){this.recalc_model_position(this.cubism_model);base._Process(delta);}public override void _Input(InputEvent@event){if(@event is InputEventMouseButton evt_mouse_button){this.pressed=evt_mouse_button.IsPressed();}if(@event is InputEventMouseMotion evt_mouse_motion){if(this.pressed==true){Vector2 calc_pos=this.cubism_model.GetInternalObject().ToLocal(evt_mouse_motion.Position)*new Vector2(1.0f,-1.0f);this.cubism_efx.SetTarget(calc_pos.Normalized());}else{this.cubism_efx.SetTarget(Vector2.Zero);}}base._Input(@event);}}

===== FILE sdk/gd_cubism-0.9.1/demo/addons/gd_cubism/example/demo_fade.cs =====
// SPDX-License-Identifier: MIT SPDX-FileCopyrightText: 2023 MizunagiKB <mizukb@live.jp>
using System;using Godot;
#pragma warning disable IDE1006
public partial class demo_fade:Node2D{const System.String DEFAULT_ASSET="res://addons/gd_cubism/example/res/live2d/mao_pro_jp/runtime/mao_pro.model3.json";GDCubismUserModelCS cubism_model;GDCubismUserModelCS cubism_model_sv;double color_h;private void recalc_model_position(GDCubismUserModelCS model){if(model.Assets=="")return;Godot.Collections.Dictionary canvas_info=model.GetCanvasInfo();if(canvas_info.Count>0){Vector2 vct_viewport_size=new(GetViewportRect().Size.X,GetViewportRect().Size.Y);Vector2 size_in_pixels=(Vector2)canvas_info["size_in_pixels"];float scale=vct_viewport_size.Y/Math.Max(size_in_pixels.X,size_in_pixels.Y);model.GetInternalObject().Position=new(vct_viewport_size.X*0.5f,vct_viewport_size.Y*0.5f);model.GetInternalObject().Scale=new(scale,scale);}}public override void _Ready(){this.cubism_model=new(GetNode<Node2D>("GDCubismUserModel"));if(this.cubism_model.Assets==""){this.cubism_model.Assets=DEFAULT_ASSET;}this.cubism_model_sv=new(GetNode<Node2D>("Sprite2D/SubViewport/GDCubismUserModel"));if(this.cubism_model_sv.Assets==""){this.cubism_model_sv.Assets=DEFAULT_ASSET;}this.color_h=GetNode<HSlider>("HSlider").Value;}public override void _Process(double delta){this.color_h+=(delta*0.25);if(this.color_h>360.0){this.color_h-=360.0;}Color color=Color.FromHsv((float)this.color_h,1.0f,1.0f,(float)GetNode<HSlider>("HSlider").Value/100.0f);this.cubism_model.GetInternalObject().Modulate=color;GetNode<Sprite2D>("Sprite2D").Modulate=color;}}

===== FILE sdk/gd_cubism-0.9.1/demo/addons/gd_cubism/example/demo_transparent.cs =====
// SPDX-License-Identifier: MIT SPDX-FileCopyrightText: 2023 MizunagiKB <mizukb@live.jp>
using System;using Godot;
#pragma warning disable IDE1006
public partial class demo_transparent:Node2D{private const System.String DEFAULT_ASSET="res://addons/gd_cubism/example/res/live2d/mao_pro_jp/runtime/mao_pro.model3.json";private GDCubismUserModelCS cubism_model;private System.String[]CONVEX_MESH_SRC={"ArtMesh121","ArtMesh122","ArtMesh135","ArtMesh146","ArtMesh147","ArtMesh231","ArtMesh278"};private bool order_window_position=false;private bool mouse_button_pressed=false;private Vector2I window_position;private Vector2 mouse_position;private bool enable_transparent=false;private Godot.Collections.Array<System.String>ary_character_expression=new();private Godot.Collections.Array ary_character_motion=new();private Polygon2D polygon_2d;// ----------------------------------------------------------------- ported I have ported the code for check_cross and convex_hull to GDScript. * https://tjkendev.github.io/procon-library/ * https://tjkendev.github.io/procon-library/cpp/geometry/graham_scan.html
private static bool check_cross(Godot.Collections.Array<Vector2>ary_check,Vector2 vtx){Vector2 va=ary_check[^2];Vector2 vb=ary_check[^1];return(((vb.X-va.X)*(vtx.Y-va.Y))-((vb.Y-va.Y)*(vtx.X-va.X)))>0;}private static Godot.Collections.Array<Vector2>convex_hull(Godot.Collections.Array<Vector2>ary_vertex){ary_vertex.Sort();Godot.Collections.Array<Vector2>ary_result=new();int n=ary_vertex.Count;foreach(Vector2 vtx in ary_vertex){while(ary_result.Count>1&&check_cross(ary_result,vtx)){ary_result.RemoveAt(ary_result.Count-1);}ary_result.Insert(ary_result.Count,vtx);}int t=ary_result.Count;int i=n-2;while(i>=0){Vector2 vtx=ary_vertex[i];while(ary_result.Count>t&&check_cross(ary_result,vtx)){ary_result.RemoveAt(ary_result.Count-1);}ary_result.Insert(ary_result.Count,vtx);i--;}ary_result.RemoveAt(ary_result.Count-1);return ary_result;}private void recalc_model_position(GDCubismUserModelCS model){if(model.Assets=="")return;Godot.Collections.Dictionary canvas_info=model.GetCanvasInfo();if(canvas_info.Count>0){Vector2 vct_viewport_size=new(GetViewportRect().Size.X,GetViewportRect().Size.Y);Vector2 size_in_pixels=(Vector2)canvas_info["size_in_pixels"];float scale=vct_viewport_size.Y/Math.Max(size_in_pixels.X,size_in_pixels.Y);model.GetInternalObject().Position=new(vct_viewport_size.X*0.5f,vct_viewport_size.Y*0.5f);model.GetInternalObject().Scale=new(scale,scale);}}public override void _Ready(){this.cubism_model=new(GetNode<Node2D>("GDCubismUserModel"));if(this.cubism_model.Assets=="")this.cubism_model.Assets=DEFAULT_ASSET;this.recalc_model_position(this.cubism_model);this.polygon_2d=GetNode<Polygon2D>("Polygon2D");this.ary_character_expression=this.cubism_model.GetExpressions();Godot.Collections.Dictionary<System.String,int>dict_motion=this.cubism_model.GetMotions();foreach(var(k,item_count)in dict_motion){for(int no=0;no<item_count;no++){this.ary_character_motion.Add(new Godot.Collections.Dictionary{{"group",k},{"no",no}});}}}public override void _Process(double delta){if(this.order_window_position==true){DisplayServer.WindowSetPosition(new Vector2I(this.window_position.X,this.window_position.Y));this.order_window_position=false;}Godot.Collections.Dictionary dict_mesh=this.cubism_model.GetMeshes();Godot.Collections.Array<Vector2>ary=new();foreach(System.String name in CONVEX_MESH_SRC){Godot.Collections.Array<Vector2>ary_vtx=(Godot.Collections.Array<Vector2>)((MeshInstance2D)dict_mesh[name]).Mesh.SurfaceGetArrays(0)[(int)Mesh.ArrayType.Vertex];ary+=ary_vtx;}Godot.Collections.Array<Vector2>ary_polygon=convex_hull(ary);if(ary_polygon.Count>2){Vector2[]region=System.Array.Empty<Vector2>();foreach(Vector2 _v in ary_polygon){Vector2 v=_v;v*=this.cubism_model.GetInternalObject().Scale;v+=this.cubism_model.GetInternalObject().Position;System.Array.Resize(ref region,region.Length+1);region[^1]=v;}this.polygon_2d.Polygon=region;}}public override void _Input(InputEvent@event){if(@event is InputEventKey inputKey){if(inputKey.IsPressed()==true){if(inputKey.Keycode==Key.Escape){GetTree().Quit();}if(inputKey.Keycode==Key.Space){var random=new RandomNumberGenerator();Godot.Collections.Dictionary motion=(Godot.Collections.Dictionary)ary_character_motion[random.RandiRange(0,ary_character_motion.Count-1)];this.cubism_model.StartMotionLoop((System.String)motion["group"],(int)motion["no"],GDCubismUserModelCS.PriorityEnum.PriorityForce,true,true);}if(inputKey.Keycode==Key.Tab){if(this.enable_transparent==true){this.enable_transparent=false;DisplayServer.WindowSetMousePassthrough(System.Array.Empty<Vector2>());}else{this.enable_transparent=true;DisplayServer.WindowSetMousePassthrough(this.polygon_2d.Polygon);}GetTree().Root.TransparentBg=this.enable_transparent;DisplayServer.WindowSetFlag(DisplayServer.WindowFlags.Transparent,this.enable_transparent);DisplayServer.WindowSetFlag(DisplayServer.WindowFlags.AlwaysOnTop,true);}if(inputKey.Keycode>=Key.Key0&&inputKey.Keycode<=Key.Key9){long index=inputKey.Keycode-Key.Key0;if(index<this.ary_character_expression.Count){this.cubism_model.StartExpression(this.ary_character_expression[(int)index]);}}}}if(@event is InputEventMouseButton eventMouseButton){if(eventMouseButton.Pressed==true){this.window_position=DisplayServer.WindowGetPosition();this.mouse_position=GetViewport().GetMousePosition();}else{GetViewport().WarpMouse(this.mouse_position);}this.mouse_button_pressed=eventMouseButton.Pressed;}if(@event is InputEventMouseMotion inputMouseMotion){if(this.mouse_button_pressed==true){this.order_window_position=true;this.window_position+=new Vector2I((int)inputMouseMotion.Relative.X,(int)inputMouseMotion.Relative.Y);}}}}

===== FILE sdk/gd_cubism-0.9.1/demo/addons/gd_cubism/example/viewer.cs =====
// SPDX-License-Identifier: MIT SPDX-FileCopyrightText: 2023 MizunagiKB <mizukb@live.jp>
using System;using Godot;
#pragma warning disable IDE1006
public partial class viewer:Control{const bool ENABLE_MOTION_FINISHED=true;GDCubismUserModelCS cubism_model;Godot.Collections.Dictionary last_motion;private void recalc_model_position(GDCubismUserModelCS model){if(model.Assets=="")return;Godot.Collections.Dictionary canvas_info=model.GetCanvasInfo();if(canvas_info.Count>0){Vector2 vct_viewport_size=new(GetViewportRect().Size.X,GetViewportRect().Size.Y);Vector2 size_in_pixels=(Vector2)canvas_info["size_in_pixels"];float scale=vct_viewport_size.Y/Math.Max(size_in_pixels.X,size_in_pixels.Y);model.GetInternalObject().Position=new(vct_viewport_size.X*0.5f,vct_viewport_size.Y*0.5f);model.GetInternalObject().Scale=new(scale,scale);}}public void Setup(String pathname){this.cubism_model.Assets=pathname;this.recalc_model_position(this.cubism_model);int idx=0;Godot.Collections.Dictionary<String,int>dict_motion=this.cubism_model.GetMotions();GetNode<ItemList>("UI/ItemListMotion").Clear();foreach(var(k,item_count)in dict_motion){for(int v=0;v<item_count;v++){GetNode<ItemList>("UI/ItemListMotion").AddItem($"{k}_{v}");GetNode<ItemList>("UI/ItemListMotion").SetItemMetadata(idx,new Godot.Collections.Dictionary{{"group",k},{"no",v}});idx++;}}GetNode<ItemList>("UI/ItemListExpression").Clear();Godot.Collections.Array<String>ary_item=this.cubism_model.GetExpressions();foreach(var item in ary_item){GetNode<ItemList>("UI/ItemListExpression").AddItem($"{item}");}this.cubism_model.PlaybackProcessMode=GDCubismUserModelCS.MotionProcessCallbackEnum.Idle;}public void Model3Search(String dirname){DirAccess dir=DirAccess.Open(dirname);if(dir==null)return;dir.ListDirBegin();var name=dir.GetNext();while(name!=""){if(dir.CurrentIsDir()){Model3Search(dirname.PathJoin(name));}else{if(name.EndsWith(".model3.json")){GD.Print(dirname.PathJoin(name));GetNode<OptionButton>("UI/OptModel").AddItem(dirname.PathJoin(name));}}name=dir.GetNext();}}public override void _Ready(){this.cubism_model=new GDCubismUserModelCS();this.AddChild(this.cubism_model.GetInternalObject());if(ENABLE_MOTION_FINISHED==true){this.cubism_model.MotionFinished+=this._on_motion_finished;}GetNode<OptionButton>("UI/OptModel").Clear();GetNode<OptionButton>("UI/OptModel").AddItem("");Model3Search("res://addons/gd_cubism/example/res/live2d");}private void _on_motion_finished(){this.cubism_model.StartMotion((String)this.last_motion["group"],(int)this.last_motion["no"],GDCubismUserModelCS.PriorityEnum.PriorityForce);}public void _on_opt_model_item_selected(long index){Setup(GetNode<OptionButton>("UI/OptModel").GetItemText((int)index));}private void _on_item_list_motion_item_selected(long index){var _motion=GetNode<ItemList>("UI/ItemListMotion").GetItemMetadata((int)index);Godot.Collections.Dictionary motion=(Godot.Collections.Dictionary)_motion;this.cubism_model.StartMotion((String)motion["group"],(int)motion["no"],GDCubismUserModelCS.PriorityEnum.PriorityForce);this.last_motion=motion;}public void _on_item_list_expression_item_selected(long index){var _expression_id=GetNode<ItemList>("UI/ItemListExpression").GetItemText((int)index);this.cubism_model.StartExpression((String)_expression_id);}}