Files
cthangover/docs/concepts/data-resolvers.md
T
cthangover b9dd464bd1 Zip shader cache belongs to its mod and follows its content
Extracted shaders were cached on disk by shader name only, the cache was
consulted before the owning mod was known and a cached file was never
rewritten: an updated zip mod kept its old shader across launches, a removed
mod's copy beat the mod serving the name now, and another mod's same-named
shader could overwrite it. The cache is now per mod, files are rewritten
when their bytes change and the resolver picks the owning mod first.
Concept docs for interactives, effects and Live2D, UI and shell.
2026-10-21 10:58:45 +02:00

5.0 KiB

Data resolvers: entities, files, scenarios, patches (Core/Mods/Resolvers, Core/Mods/Patches)

Every piece of game data (characters, items, skills, quests, recipes, scenes, scenarios, textures, sounds, shaders, wrappers) is read from the loaded mods through one layer of resolvers. Factories and subsystems never touch files themselves, so folder and zip mods behave identically, and the override rules live in one place.

Shape

  • ModManager owns a registry of IModAssetResolvers. Mods can register more with RegisterResolver.
  • Each resolver gets a ModResolutionContext:
    • ordered mod ids;
    • providers;
    • ReadResolvedText, which expands ${path} includes relative to the including file, with a visited set against cycles;
    • lookup of other resolvers.
  • Caches live per resolver. ModManager.Reload flushes them all, so a reload rereads everything.
  • Static facades proxy to the resolver of the running ModManager: Entities.Collect<T>(), Patches.Collect(kind), ScenarioDefs, Textures.Resolve, Shaders.Resolve.

Entities

EntityResolver walks all files of all mods once, in dependency order. It peeks the top-level "Kind" of every .json file (EntityKindPeek) and routes the file to that kind. The kind, not the directory, decides the owner, and an unknown kind is an authoring error (ENTITIES).

  • Files have the shape FileData<T> ({"Kind": "...", "Items": [...]}). Each DTO declares its kind with [EntityKind].
  • A later mod overrides an earlier one by entity id. The override is logged and recorded in the conflict registry: same id without a dependency is an atlas error.
  • Patches are applied after collection. The entity is serialized, merged with patches/<kind>.json and patches/<kind>/*.json from all mods, and deserialized back:
    • wildcard "*" patches come first, then targeted ones; each group goes in load order, so later patches overwrite earlier ones;
    • wildcard values overwrite the entity's own values, and targeted values overwrite both;
    • array properties take operations: Key$add (merged by identity key, either IMergeable or auto-detected Id/…Id), Key$remove and Key$set; a bare key replaces the array;
    • property names match in any case, like the case-insensitive JSON reading.
  • The per-kind dictionary is cached and handed out as-is. Factories copy before mutating, for example CharacterFactory.Get returns Character.Copy() with deep-copied attributes.

Files, textures, sounds, shaders

  • FileListResolver collects files under a group directory across mods. The key is the path inside the group, and the last mod wins.
  • TextureResolver uses it for the configured texture_groups, where the key is the file name (one namespace across groups), then decodes to an ImageTexture and caches it.
  • ShaderResolver loads .gdshader directly from folder mods. From zips it extracts to the mod cache first, under user://mod_cache/shaders/<mod>/ (rewritten when the content changes); the owning mod is decided before any cached copy is used.

Scenes and scenarios

  • SceneDefinitionResolver: for the same scene name, the first mod's definition is the base and later mods merge into it (backgrounds added, defaults changed). Studio overlays merge last.
  • ScenarioResolver reads every .scenario header (scene:, priority, after, condition, is_one_run, …). Definitions are collected flat in load order: overlays first, whose names shadow mod scenarios, then mods. GroupReplacing keeps the last definition of every scenario name across all scenes, so a dependent mod replaces a scenario by file name, even when it moves it to another scene.

Replacement rule

To replace something, ship the same name and declare depends on the owner. Dependencies load first, so the dependent mod wins. The same name in unrelated mods has no defined order, and resource_atlas reports it.

Tests

tests/Cthangover.Tests/Mods (CollectPatches, ScenarioReplacement, ResolveScenario, include expansion, providers) and tests/Cthangover.Tests/Utils/JsonPatchMergerTests.cs. At runtime the property_patches fixture suite (test_defaults_and_patches), coverage_* and the resource atlas.

Fixed in the 2026-10-21 review

  • A patch key in a different case than the entity property (health vs Health) wrote both keys. The entity's own value, written last, won, and the patch was silently lost.
  • Scenario replacement picked winners in scene-key order rather than load order. A replacement moved into a scene that appeared earlier lost to the original.
  • The patch docs called wildcard values a fallback that the entity's own values beat. The code and its tests overwrite instead, and the docs now say so.
  • The zip shader cache was keyed by the shader name only, checked before the owning mod was known, and never rewritten. An updated mod kept its old shader, a removed mod's copy beat the mod now serving the name, and a same-named shader of another mod could overwrite it.