HumankindAssetFramework

Dedicated District Visual — Feasibility Study

📁 ARCHIVED NOTE — frozen 2026-08-13, not maintained. The investigation that produced the shipped recipe. Current reference: District-Dedicated-Visual.md. Nothing below is current instructions.

✅ SOLVED & SHIPPED (2026-08-12). This study’s hybrid is done and verified in-game. The working recipe, config, and runtime steps live in District-Dedicated-Visual.md — read that first. What follows is the investigation that got there.

Branch: spike/district-dedicated-visual (off spike/district-footprint). Goal: a clean single reactor + footprint at ALL zoom levels — which the runtime deep-clone cannot deliver (its mid-zoom LOD geometry is shared across all Industry districts; see District-Footprint-Investigation.md).

Verdict: the RUNTIME dedicated-affinity path is DEAD — a criteria axis cannot grow at runtime

The idea is sound in principle (give the reactor its own criteria-resolved selector so the game draws it natively at every zoom band, no runtime swap). But it hinged on one unverified question — can we add a new value to a criteria axis at runtime? — and a probe answered NO, decisively.

The axis-growth probe (ProbeAxisGrowth, DistrictDebug-gated)

Called matrix.Add(new StaticString("HAF_AxisProbe"), guid, null) on two live 1D matrices and measured the axis before/after (same box, a fresh box read from the array, and after writing the box back):

'ArtificialWonder'        : before=760 afterBox=760 afterFresh=760(found=False) afterWriteback=760(found=False)
'*/District/Construction' : before=34  afterBox=34  afterFresh=34(found=False)  afterWriteback=34(found=False)

Add does not grow the CriteriaNames axis at all — not on the box, not persisted, and the new value is never found. It only fills the cell of a name already on the axis (which is exactly why the wonder work succeeded — every wonder name pre-existed). The axis is fixed at data-load time. So a custom affinity value cannot be introduced at runtime, and the reactor (whose name isn’t on the ArtificialWonder axis either) cannot be given a dedicated criteria-resolved selector by the plugin.

What’s left (all data/content, not runtime)

Bottom line: there is no runtime path to a clean cross-zoom footprint. Every runtime avenue is now walled (deep-clone mid-zoom LOD is shared; criteria axes can’t grow). A clean result requires authoring the reactor as a data-level wonder / district with its own baked selector — a content-pipeline effort, outside runtime injection.


(original framing, now falsified by the probe)

The idea was: give the reactor its own criteria-resolved selector so the game’s own resolution + LOD draws it at every zoom band. The blocker turned out to be that criteria axes are immutable at runtime (above).

Why a native dedicated selector dodges the deep-clone wall

The deep-clone failed at mid-zoom because it swaps a borrowed, shared selector at runtime, and the mid/far LOD elements resolve through the shared pairs table (can’t be privatized per-tile). A dedicated visual never borrows or swaps: the reactor resolves to its own selector through the engine’s normal path, LOD and all. Nothing runs per-frame.

How a district’s building visual is chosen (confirmed by decompile)

Building visuals are criteria-resolved, not hard-pointed:

The applicable path: a custom affinity value that only the reactor uses

  1. Add a custom affinity value — e.g. DistrictVisualAffinity_BreederReactor — to the DistrictVisualAffinity matrix, pointing at our dedicated selector’s GUID. Mechanism on hand: the matrix.Add(StaticString, guid, null) call FillWonderCell uses (UniversalInject.RepoDump.cs), which mutates the live repository’s matrix via its shared cells/axis arrays. ⚠️ UNVERIFIED: the wonder work only ever filled existing empty cells — every wonder name was already on the axis. Whether Add grows an axis with a brand-new value (and whether resolution then honours it) is the make-or-break unknown.
  2. Make the reactor resolve to it — point the reactor’s BuildingVisualAffinity criteria value at the custom affinity: either change its definition data (ConstructibleVisualAffinity), or intercept/override the criteria value at runtime for the reactor’s tile. (Open: which is cleaner.)
  3. Build the dedicated selector asset (see below).
  4. Because the custom affinity is used only by the reactor, the selector is dedicated: other Industry districts are untouched, and the engine resolves + LODs it natively at all zoom bands.

The build piece: a baked dedicated selector asset

The affinity cell needs a loadable GUID, so the selector must be a baked asset (runtime-constructed materials have no persistent GUID the DB can resolve). It is a FxEvolverMaterialLevelBuildSelector (or the minimal tree the engine renders) containing:

  1. Our reactor building — an FxEvolverMaterialLevelBuildElement pointing at our baked BreederReactor_FxMesh, present across the close/mid LOD bands (one mesh at all bands is fine; real LOD levels optional).
  2. The footprint — a decal drawer referencing a city-map decal (reuse an Industry Decal_CityMap_*, tile-agnostic, or author one) so the far band shows the footprint like vanilla.
  3. Our texture layer — the private-layer + albedo recipe we proved at runtime (ClonePrivateOutputLayer / BindAlbedo), baked in instead of bound live.

Open questions for the build phase

Status & next step

Premise corrected (the reactor is affinity-resolved, not a wonder). The dedicated-visual path is plausible but gated by the unverified axis-growth question. Next concrete step — a tiny probe before any asset work: call matrix.Add on the DistrictVisualAffinity matrix with a test value + a known selector GUID, point the reactor at that value, and check whether the axis grows and the tile resolves to it. If yes → build the selector asset. If no → fall back to making the reactor a real ArtificialWonder in data (bigger), or accept the runtime deep-clone’s close+far result.


The proper native-style path (concrete) — data-authored district visual

“Configure it like a native district” means giving the reactor its own district-visual, in data, the way every native type (Industry, Science, MissileSilo…) has one. Located the exact wiring:

The build (a real content-pipeline chapter, in order)

  1. Author a district-visual selector asset for the reactor — a FxEvolverMaterialLevelBuildSelector (or the minimal tree the engine renders) referencing our baked BreederReactor_FxMesh as its building element + a city-map decal drawer for the footprint + our texture layer. New baker capability: DistrictBaker today bakes only the FxMesh; it (or a Unity-authored asset) must emit a selector asset with a GUID. Likely approach: clone a native single-building selector at bake time and swap in our mesh, rather than build from scratch.
  2. Introduce a dedicated affinity + mapping in data — add DistrictVisualAffinity_Base_BreederReactor (or similar) to the criteria vocabulary and a district-visual datatable row mapping it → our selector GUID, through the game’s datatable modding pipeline (so the axis grows at load). Open: confirm the exact datatable(s) the native Base_Industry → CityMapSelector_Industry_00 mapping lives in, and that a mod can extend the affinity axis at load.
  3. Point the reactor at it — change ConstructibleVisualAffinity in the definition asset above to the new value (authored data — the user’s call).
  4. Drop the runtime injection for the reactor — no plugin swap needed; the engine resolves + LODs our selector natively, giving one clean reactor + footprint at every zoom, like any native district.

✅ GATING QUESTION ANSWERED — the data path is VIABLE (verified in the mod’s own data)

Traced it end to end in the ENCReload mod’s data:

The verified build recipe (this is the plan)

  1. Author the reactor’s CityMapSelector asset — bake/clone a single-building selector (the NuclearTest/MissileSilo one is the ideal template: single building + footprint) with our BreederReactor_FxMesh + our texture as its building element, keeping its decal/footprint. Get its GUID. (Baker capability: clone the template selector at bake time and swap the mesh, rather than authoring from scratch.)
  2. Define a dedicated affinity — add DistrictVisualAffinity_Base_BreederReactor (or NationalProject_BreederReactor) to the mod’s ConstructibleVisualAffinityDefinition.asset, mirroring the existing NationalProject_NuclearTest entry.
  3. Map affinity → our selector — add a mod content row to */District/Main.Level1 and .Level2 (via the mod’s own DistrictDefinition_ContentCollection-style asset that the game merges): Name: <new affinity>Value: <our selector GUID>.
  4. Point the reactor at it — set ConstructibleVisualAffinity in ConstructibleCommonExtensionDefinitionENC.asset (Extension_Base_BreederReactor) to the new affinity.
  5. Drop the runtime injection for the reactor — the engine now resolves + LODs our selector natively: one clean reactor
    • footprint at every zoom, like any native district, with no plugin.

Only real unknown left: step 1’s authoring — whether the baker can cleanly clone the template selector and swap the mesh into a shippable asset with a stable GUID. Everything downstream (2-5) is verified data edits following patterns the mod already uses.

Step 1 investigation — findings (editor probe, DistrictBaker → Tools/HAF/District/Probe)

Built an editor probe that loads a native */District/Main visual by its Amplitude GUID and dumps its material tree (district_visual_dump_*.txt). Results:

Composition mapped (NuclearTest, with positions + bboxes): the elements are cleanly separable by bbox size —

So large bbox = main structure, small bbox = prop — a clean rule to reduce by. Reduce-to-one recipe (verified by the layout): in the cloned template, keep ONE large-bbox Element slot → point it at our baked reactor element (centered), null the small-prop Element items, and leave every Decal / decal-Selector item intact (the footprint). Optionally keep the ground slab + steam emitters for flavor. This is the principled reduction the authoring command will apply.

Step 1 status: fully investigated. Templates load in-editor; structure + composition mapped; the reduce-to-one is principled (bbox rule). Remaining build = the authoring command: (a) bake our reactor as an FxEvolverMaterialLevelBuildElement asset (its own GUID), (b) clone the NuclearTest template, (c) apply the reduce-to-one (repoint one big slot → our element, null props, keep decals), (d) save → the reactor’s CityMapSelector GUID for the */District/Main data rows.

Step 1 BUILD — implemented, and it hit a serialization WALL (the honest blocker)

Built the baker commands (ENCReload DistrictBaker, branch spike/district-dedicated-visual):

Both load and edit correctly in-editor (reduce-to-one works, footprint preserved) — but saving them as project assets produces broken cross-bundle references that the mod bundle build ([Worker0]) rejects:

Implication — this blocks the 100%-PURE-DATA path only, NOT the goal. The output layer is the one piece that can’t be authored in the project. Everything that mattered — the building geometry, reduce-to-one, the footprint decals, and the native LOD (the exact thing the runtime deep-clone couldn’t fix at mid-zoom) — is data-authored and works. So the clean result is reachable via a HYBRID, which is a good outcome, not a fallback:

Escape routes, cheapest first (next session):

  1. Verify whether the game re-resolves a null/broken outputLayer from the element descriptor at load (would remove even the tiny hook). Unverified — inferred “not drawn” from the decompile (outputLayerIndex = -1), not tested end-to-end.
  2. Fix m_Script so the asset bundles cleanly: create via ScriptableObject.CreateInstance(type) + field-copy instead of Instantiate (the mod’s own assets use guid: b310e23…, type: 3).
  3. Wire the data (*/District/Main.Level1+Level2 rows → our selector GUID; dedicated affinity; point the definition).
  4. Add the output-layer hook — the minimal runtime bind for our element’s texture.

Net: the approach, tooling, reduce-to-one, footprint, and native LOD are all proven. The goal (clean reactor + footprint at every zoom) is not blocked — it’s a data selector + a tiny texture hook. The “wall” framing was about zero-plugin purity, not the deliverable.


Appendix — falsified initial hypothesis (audit trail)

The study first assumed the reactor was a wonder, registerable by name in the ArtificialWonder matrix via the existing PollWonderRows/FillWonderCell + WonderNativeRows path (which renders natively as the non-plugin fallback). The build spike’s first check falsified this: a [RepoDump] of the live AssetReferenceRepository shows the ArtificialWonder name axis is 760 entries — all real wonders / HolySites / participations, and Extension_Base_BreederReactor is not among them. WonderNativeRows today carries only the Oracle. The reactor is a base extension resolved by affinity, so the wonder-name path cannot register it — hence the affinity path above. (Value of the spike: this was caught from one dump, before building any asset against the wrong axis.)