Status: SOLVED (2026-07-16; textured 2026-08-06) — a custom 3D model renders on a single district tile in-game, with its own baked texture. Render, fit, scope, grounding and color are all working: the plant mesh sits level on the reactor tile wearing its own albedo, and the rest of the city is untouched. This was long thought impossible (see the history at the end); it is not. This page documents the working recipe, the mechanism, and the constraints.
The second injection axis, alongside units. Goal: let a pack replace a district’s on-map building with a custom static 3D model. It is far deeper than the unit path, but it works.
Three parts — a data edit, a runtime mesh-swap, and a lean bone-free bake:
1. Data (in the ENCReload Unity project): on the target district definition, set
ConstructibleVisualAffinity to a renderable affinity (e.g. DistrictVisualAffinity_MissileSilo) and clear its
Additional Visual Levels. The visual-levels list drives the district’s DistrictState, which is a criterion in
the building lookup — a mismatched state resolves to no building (material 0,0,0,0 → empty tile). Clearing it falls
back to the default state, which resolves. (This — not the district class — is why foreign affinities kept coming up
empty.)
2. Runtime (the plugin, driven by the haf_districts.json registry — below): the rendered mesh lives deep inside
the resolved material:
channel-0 material = FxEvolverMaterialLevelBuildSelector (picks by CULTURE)
→ pairs[culture] = GUID → FxEvolverMaterialLevelBuildEmitter (emits a SET of parts; may nest)
→ levelBuildItems[].loadedEvolverMaterial → FxEvolverMaterialLevelBuildElement ← THE LEAF
.fxMesh (Guid) ← the mesh handle (field is `fxMesh`, not `mesh`)
.meshIndex (uint) ← the RESOLVED GPU slot (sentinel uint.MaxValue), set in the leaf's Load()
The plugin (Hk_DistrictRepoint → TickDistrictMeshSwap) walks this: loads each pairs sub-material with
FxEvolverMaterial.TryLoad(guid, synchrone:true), recurses levelBuildItems[].loadedEvolverMaterial to the leaf
Elements, sets each leaf’s fxMesh to our FxMesh GUID, then calls the leaf’s Load(fxManager, doublonIndex) so it
re-resolves meshIndex from our GUID. Because we reuse the game’s own leaf (with its selector context/GPU data),
our mesh draws — where a foreign material handed in via SetChannel had no context and drew nothing.
3. Bake (the District Factory window — DistrictFactoryWindow.cs): Tools ▸ HAF ▸ District Factory does the
whole editor side in one Bake: pick the District (searchable dropdown over the project’s district definitions), Browse a
model file, set Size / Rotation / Target-tris / Isolate, press Bake. It runs the same static bake core as the unit
Factory (UniversalBaker.Build — no dummy pawn needed; pawnDescription is registry-only there), wraps the result
as a district FxMesh via DistrictBaker.BakeFxMesh, and writes the registry entry. Two hard requirements the bake
handles for you —
_DistrictMesh copy and wraps that.DistrictBufferHeadroom.Then rebuild the mod (ships the FxMesh) and launch. The reactor renders.
haf_districts.jsonThe runtime side is data-driven: BepInEx/config/haf_districts.json (written by the District Factory window, mirrored
to the git-tracked Assets/Databases/haf_districts.backup.json) holds any number of district models at once:
{ "districts": [
{ "district": "Extension_Base_BreederReactor",
"fxMeshGuid": "1457749632,1176062388,715769744,1624515593",
"atlasGuid": "260107174,1193535976,-95465828,-2065892038",
"isolate": true }
] }
The plugin reads only district / fxMeshGuid / atlasGuid / isolate per entry (Newtonsoft — extra fields are
bake-time state for the window and are ignored). atlasGuid is the baked albedo the texture injection binds (below);
entries without it render untextured like before. Each entry gets its own leaf collection / private-leaf machinery, so several districts can
carry custom models simultaneously. The old single-model [District] keys (DistrictName + DistrictFxMeshGuid)
still work as a fallback only when the registry has no entries. DistrictRepoint = true remains the master enable.
District building meshes upload into a shared GPU mesh buffer — layer ‘Visual’, sized 3,000,000 verts, drawn by
FxComponentMeshContentManager.ContentLayer (GetFxMeshStructIndex → Register → FillMeshVertexAndBufferContent).
In a built-up late-game city it runs ~99.85% full (measured: 2,995,550 / 3,000,000). An oversized mesh gets a slot but
silently overflows the vertex buffer and is dropped — the exact “assigned index 4606, renders nothing” symptom.
Two levers:
DistrictBufferHeadroom (config, opt-in, default 0): a Harmony prefix on ContentLayer.LoadEncodingVertexAndBuffer
enlarges the Visual layer’s baseVertexBufferSize at creation. Setting 2000000 grows it 3M → 5M
(~+96 MB VRAM); the shader reads the buffer size dynamically so it’s transparent. Verified in-game
([District] enlarged 'Visual' mesh buffer: 3000000 -> 5000000).Diagnostics: F8 window ▸ “Dump District” lists every registry entry (matched? leaf built? resolved meshIndex, our
FxMesh verts + bounds) and each mesh-manager layer’s fill (verts used / size).
[District]| Key | Meaning |
|---|---|
DistrictRepoint |
master enable (for the registry AND the legacy keys) |
DistrictBufferHeadroom |
extra verts for the Visual buffer at init (0 = off; 2000000 = +~96 MB VRAM) |
DistrictName |
LEGACY fallback — target ConstructibleDefinitionName; used only when haf_districts.json has no entries |
DistrictFxMeshGuid |
LEGACY fallback — the baked FxMesh GUID a,b,c,d for DistrictName |
DistrictIsolate |
LEGACY fallback — scope the legacy entry to its own tiles (registry entries carry their own isolate) |
DistrictAffinityOverride / DistrictEvolverGuid |
earlier proof/experiment modes (superseded) |
Gotcha: a new config key only appears in the .cfg after the new build runs once; adding it by hand must go
inside the [District] section. The FxMesh ships only after a mod rebuild/export.
DistrictIsolate, multi-instance since 2026-08-08)The leaf Elements are shared across every district of a culture, so the raw swap turns every matching building on
the map into our mesh. DistrictIsolate fixes that by giving the target district a private leaf:
CollectLeaves to a source leaf; UnityEngine.Object.Instantiate
it (a private copy — mutating it can’t touch the shared leaf).fxMesh to our GUID, reset its loadingStatus, and call LoadIFN(fxManager) + Load(fxManager,
doublon) so it gets a valid MaterialIndex and re-resolves meshIndex from our mesh.channels[layer].evolverMaterial = the private leaf (write the boxed struct back
into the array) and call the public RefreshChannel(int, EventNameEnum) so the Shuriken particle re-spawns and
PatchParticle picks up the private leaf’s MaterialIndex.Multi-instance (2026-08-08, verified with a second reactor): a district can be built on many tiles — one
PresentationDistrict each. Each registry entry tracks a list of live instances (added by the UpdateLevelBuild
postfix, pruned via Unity fake-null when razed), and the tick repoints every instance’s channel at the entry’s
one shared private leaf — a leaf is just a material, and vanilla’s shared selectors serve many channels the same
way. (The first implementation held a single instance slot per entry; a second copy of the district made ownership
ping-pong, and only the most recently updated tile showed the custom model.) Build lazily (sub-materials load async →
retry), re-apply every frame (the game reloads the shared selector into the channel on each UpdateLevelBuild).
Verified in-game: two reactors in different cities both show the custom plant; the rest of the map is untouched.
The District Factory has an embedded preview pane (2026-08-06): the baked mesh, textured, standing on a tile-sized ground square pinned at the true in-game surface level. The placement controls split by job:
Z=-90). Dial it in the preview — no relaunch round-trips.Size knob), so resizing needs a re-bake. A district tile hex is ~7 across its flats
(~8 corner to corner) — the preview hex is true size, measured from the map’s center-to-center tile spacing
(6.93). A full site-plan model can carry Size 5–6, a single building ~2.5–5.Health panel (2026-08-08): the District Factory validates on selection / after Bake / Re-check: shipped
GUIDs vs the assets on disk (drift = the silent “waiting for leaves” launch, now a red box), newest baked
asset vs the newest built Community assetbundle (STALE BUNDLE — re-bakes reshuffle atlas packing, the
mesh/atlas halves must ship from the same bake), and the definition’s data prerequisites (non-empty
Additional Visual Levels = a guaranteed empty tile; missing ConstructibleVisualAffinity = nothing to swap).
A base-game target (named Extension_* — its definition lives in the game, not this project) is not a
project asset, so the panel can’t inspect it and stays silent rather than false-warning “typo” (2026-08-09);
only a non-namespaced name that also isn’t a project asset is flagged. Info-level notes never count as “issues”.
A district on a coastal cliff or uneven tile overhangs into empty air — the reactor floated off the ledge.
The Foundation depth bake knob (registry foundationDepth, 0 = off) fixes it: it extrudes the building’s
footprint straight down into the earth as a solid concrete plinth, so the building plants on a base that
runs down past the drop.
DistrictBaker.BakeFxMesh): after the auto-level/hex-clip, the footprint is measured in
drawn space (post-rotation, so “down” is true world −Y regardless of the model’s import angles), a box is
built from the surface down to −depth, then inverse-rotated into stored space so the draw-time rotation lands
it straight down. Four walls + a floor, wound so faces point outward/−Y; the cap is omitted (hidden under the
building).AppendConcreteStrip): districts render one atlas, so the plinth needs concrete in
it. The bake grows the atlas set by a fresh strip along the top — lightly-noised grey albedo, neutral (flat)
normal, rough-concrete roughness — slides the existing content down and remaps the mesh UVs to match, so no
existing texel is overwritten. The plinth faces sample that strip.A district entry can carry parts: extra models composed onto the tile at bake, each with its own model file, Size, Rotation offset (stand it up), Facing (turn it) and Position offset (X/Z slide, Y lift). Each part bakes through the same core, auto-grounds to the base model’s floor, and merges into one mesh + super albedo/normal/rough atlases sharing one set of pack rects — the runtime is untouched (still one FxMesh + one atlas trio per entry, so isolation, wonders, and multi-instance all just work). Parts are baked-in: placement shows in the preview after Bake.
FinalizeAtlas picks DXT5 over DXT1, and
the preview material flips to cutout. Opaque models keep the exact old path (byte-identical re-bakes).A district carves a raised terrain platform — the plinth you see the building stand on. It’s resolved in
PresentationDistrict.UpdateHexagonSculpting (for wonders from the dedicated */District/ArtificialWonder/HexagonSculpting
database) → a HexagonSculptingDefinition index → ApplyHexagonSculptingDefinition. A custom wonder’s cell is
empty → index 0 (None) → flat ground. The fourth empty-cell fix: Hk_DistrictHexSculpt postfixes it and
forces a chosen index. Per-entry Footprint (hex sculpting) field in the Factory (registry hexSculpt) +
global DistrictHexSculpt config; a live dial haf_hexsculpt.txt re-carves every sculpted district without a
relaunch (cycle the ~40 shapes fast, then ship the winner in the Factory).
Global vs per-entry (a footgun): the global DistrictHexSculpt / DistrictGroundMaterial configs apply to
every registry district at once (a district with a blank per-entry field falls back to the global). Handy for
a quick test, but it will raise/repaint districts you didn’t mean to — e.g. a global platform floated the flat-based
Breeder Reactor. For shipping, leave the globals blank and set each district’s Factory fields, so every
district configures itself and nothing bleeds onto its neighbours.
Which shape? Measured ([HexSculpt] NATIVE dump): most districts resolve to None — the city center,
administrative center, camp center, and cultivated tiles carve no platform. The districts with the raised plinth
are the emblematic quarters; e.g. Extension_Era1_OlmecCivilization → EmblematicAndCityCenter26. So to
match a real district’s platform, use EmblematicAndCityCenter26 (the 01–33 variants are different footprint
shapes; POI_* are for natural/resource tiles). Verified in 3D: the Oracle carves the emblematic platform.
Two honest limits. (1) The preview can’t show the platform — hex sculpting is a runtime terrain deformation the game applies with its own terrain engine + the shape’s height data; the preview tile is a flat quad and the FxMesh carries no sculpt. Like final PBR shading, it’s judged in-game, not in preview. (2) The raised 3D platform is NOT the top-down strategic-zoom footprint (the grey building silhouette on the strategic map) — measured with a full zoom-out: the platform appears in 3D but no strategic silhouette. That silhouette is a separate render-mode / strategic-representation path (very likely a fifth empty cell), still OPEN — its own focused spike.
A district also paints the terrain under it — PresentationDistrict.UpdateGroundMaterial resolves a
GroundMaterialDefinition from (Biome × ConstructibleVisualAffinity) and calls ApplyGroundMaterialDefinition.
A custom wonder’s native affinity has no row → index 0 → bare terrain (the temple stood on raw desert). The
plugin postfixes UpdateGroundMaterial and forces a chosen ground index for our districts — the game’s own
terrain paint, blended at the cell edges, not a flat mesh. Each entry carries its own Ground field in the
District Factory (a dropdown of the game’s vocabulary: Prairie_* grass fields, Constructible_* paved
precincts, Sterile_* sparse); a global DistrictGroundMaterial config is the fallback default. Verified: the
Oracle on Prairie_Grassland (index 16) — a lush maintained field under the temple and its grove.
The Factory preview textures its tile with the real terrain image — the ground texture is a tile inside a
shared DefaultTextureAtlas, so the plugin resolves the authoring data → texture layer (Atlas + AtlasElement
GUIDs) → loads the atlas → GUIDToIndex(AtlasElement) → GetElementData(index) (the tile’s min/max UV rect) →
OutputEntries[0].GetTexture (the 4096² page) → blit-crops that UV region → one PNG per material in
haf_ground_tex/ (the material’s true Color is dumped alongside as a fallback). The tile hex gained planar
UVs so it maps; so a terrain-paint choice reads as real grass/pavement/sand in the preview before launch.
count = ceil(primitives / outputLayer.PrimitivePerParticleCount)), and that count is an 8-bit field →
hard-clamped at 255. A high-poly composed model (a temple + a grove) exceeds it and the excess is silently
not drawn (the four-tree grove first showed temple + 1 tree, the rest dropped). Crucially the mesh is fully
stored — the encoder ignores PPC — so only the render clamp bites. Since the private layer is ours to clone,
the plugin multiplies PrimitivePerParticleCount on it (DistrictMeshDensityBoost, default 8): the ceiling
(255 × PPC) rises for the same GPU work — fewer particles, each covering more primitives — and no re-bake is
needed. Verified: PPC 64 → 512, ceiling ~130k primitives, the full grove renders. (A first guess of a 16-bit
vertex limit was decompiled and disproved — the index buffer is 32-bit.)Districts rendered untextured for three weeks — the swap reused the game’s own leaf material, and our atlas had no way in. Two measured facts cracked it:
FxComponentTextureAtlasManager entry; every
leaf resolves textureIndex to the fixed full-texture slot (1) and the shader samples the layer material’s bound
sheet straight through the mesh UVs. (That’s why an untextured custom mesh showed patches of the culture’s building
sheet — its 0..1 UVs swept a texture authored for baked-UV building parts.) An earlier design that painted a rect
into the atlas-manager page was falsified by this trace and never shipped.The unlock is one step up from the leaf clone: clone the whole FxOutputLayer. BuildPrivateLeaf instantiates the
leaf’s output layer alongside the leaf (Unity resets the non-serialized runtime state, so the clone is unregistered);
during the leaf’s own Load, the game’s renderer registers and loads the clone itself
(FxComponentRenderer.GetLayerIndexAddItIFN — a real registration API), creating private runtime materials and command
buffers. DistrictApplyTexture then registers a null atlas-info slot for the new layer (so the game’s own resolve
returns full-texture for it forever), points the leaf at slot 1, and binds the baked albedo on the private runtime
materials (_MainTex when present, else the largest bound sheet — DistrictDebug dumps every property to catch a
wrong pick). The mesh’s own 0..1 UVs sample the albedo exactly; no other building is touched.
Stability (2026-08-07, the Oracle arc): the private layer opts out of texture streaming (its mid/hi-res
material GUIDs are nulled at clone time, so the reduction system never loads a material over our binding — the cause
of a “perfect → brown → corrupt” degradation). District session state fully resets on new games and in-session
save-reloads (Sandbox.Load), so leaves and bindings always rebuild against the living world. All verified
in-game, incl. reload survival. Wonders ride the same machinery — see Wonder-Spike.md.
Surface maps are per-entry, not blanket (2026-08-08 regression + fix, verified on both districts): entries whose bake shipped normal/rough atlases bind them (plus neutral metallic/AO) — the temple’s verified combo. Entries without baked maps keep the donor material’s own vanilla maps under the injected albedo — the reactor’s verified look. The stability pass briefly bound flat neutral maps on every entry; on the reactor’s grey industrial palette that read as chrome domes and near-black walls (“texture got scrambled”) while the temple, which had real maps, was unaffected — a reminder that a shared-code change verified on one district is not verified on the axis.
This was chased from ~8 angles that all looked like a wall before the recipe above cracked it:
SetChannel → vanishes (no selector GPU context).AssetReferenceDatabaseContent) has no mod precedent (game-core).The unlock was: don’t hand the game a material — reuse its own leaf Element and swap only the fxMesh, with a
bone-free lean mesh that fits (or grow) the shared buffer.
Decompiled reference: C:\tmp\reactor\ — Selector.cs, Emitter.cs, Element.cs, DescElem.cs, GenDesc.cs,
MeshMgr.cs, FxMesh.cs.