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.
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
ModManagerowns a registry ofIModAssetResolvers. Mods can register more withRegisterResolver.- 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.Reloadflushes 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>.jsonandpatches/<kind>/*.jsonfrom 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, eitherIMergeableor auto-detectedId/…Id),Key$removeandKey$set; a bare key replaces the array; - property names match in any case, like the case-insensitive JSON reading.
- wildcard
- The per-kind dictionary is cached and handed out as-is. Factories copy before mutating, for
example
CharacterFactory.GetreturnsCharacter.Copy()with deep-copied attributes.
Files, textures, sounds, shaders
FileListResolvercollects files under a group directory across mods. The key is the path inside the group, and the last mod wins.TextureResolveruses it for the configuredtexture_groups, where the key is the file name (one namespace across groups), then decodes to anImageTextureand caches it.ShaderResolverloads.gdshaderdirectly from folder mods. From zips it extracts to the mod cache first, underuser://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.ScenarioResolverreads every.scenarioheader (scene:,priority,after,condition,is_one_run, …). Definitions are collected flat in load order: overlays first, whose names shadow mod scenarios, then mods.GroupReplacingkeeps 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 (
healthvsHealth) 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.