HumankindAssetFramework

Wonder Spike — custom 3D models on (artificial) wonders

Research spike, 2026-08-07/08 — the Oracle arc. Status: SHIPPED, and upgraded to the NATIVE chain — the Oracle (a custom Greek temple on a player-authored Artificial Wonder) renders through the game’s own Artificial Wonder pipeline: native affinity, its own row in the wonder visual database, the vanilla bottom-to-roof level-build reveal on reload, custom mesh + marble surface maps + card portraits. Publicly announced. This page records the recipe, the decompile that cracked the native chain, and what remains.

The original donor recipe (proven in-game; superseded by the native chain below)

A custom wonder is just a district with better press. The district axis (District-Visuals.md) carries it unchanged — the swap machinery never asks what a constructible is, only that its tile resolves to a leaf it can clone:

  1. Author the wonder definition (ENC data, e.g. Extension_ArtificialWonder_Era1_Oracle, class ArtificialWonderDefinition): gameplay effects, unicity, costs as desired.
  2. ConstructibleVisualAffinity → a RENDERABLE donor district affinityDistrictVisualAffinity_MissileSilo is the proven donor (the reactor’s). This is the whole “link to a donor district”: it decides which vanilla building (and crucially which loaded, context-bearing material chain) the tile resolves to before the swap.
  3. Clear Additional Visual Levels — a non-empty list drives DistrictState, the lookup is keyed on the full combo (constructible + affinity + era + state), and an unregistered combo resolves to material 0,0,0,0 = an empty tile (the trap that ate days in July).
  4. District Factory: type the wonder’s exact ConstructibleDefinitionName (the Pick dropdown lists only *DistrictDefinition classes — ArtificialWonderDefinition isn’t one), bake the model (Size / Facing / Position offset / hex clip all apply), Isolate ON, rebuild the mod.
  5. The plugin does the rest: leaf clone, mesh swap, private-output-layer texture injection (the temple renders with its own atlas).

Construction phases: the artificial-wonder pattern splits the build site into a companion definition (Extension_ArtificialWonderParticipation_<name>). The tile presents under the Participation name while under construction and under the main name when complete — so vanilla scaffolding during the build and the custom model on completion, which is the right feel by default. (A second registry entry keyed to the Participation name could skin the construction site too; untested.)

Card images / portraits: a wonder’s UI images ride the standard UIMapper mechanism — the wonder’s UIMapper entry (e.g. in a ConstructibleArtificialWonderUIMappers asset) has an Images list keyed ArtificialWonderCard / Small / Tooltip. Drop portrait PNGs into Assets/Resources/Images/ and assign them on the mapper in the Inspector (the serialized reference is Amplitude’s {a,b,c,d} GUID encoding — assignment via Inspector handles it). The Oracle ships a 512×366 card and a 512² small/tooltip portrait this way.

THE NATIVE CHAIN — solved by decompile (2026-08-08, verified in-game incl. reload)

The July/early-spike verdict — “the native DistrictVisualAffinity_ArtificialWonder affinity gives a custom wonder nothing to see and nothing to swap; game-core data a mod can’t extend” — was wrong, and the way it was wrong is the best news of the axis. Decompiling the visual-resolution chain (FxEvolverMaterialLevelBuildSelectorAssetReferenceRepositoryAssetReferenceDatabaseContent, in Amplitude.Mercury.Terrain.dll / Amplitude.Mercury.Data.dll) showed:

The working native recipe (replaces the donor-district hack entirely):

  1. ConstructibleVisualAffinity = DistrictVisualAffinity_ArtificialWonder — the native one.
  2. The plugin’s [WonderRow] poll (WonderNativeRows = "WonderName=a,b,c,d;..." in config) fills the wonder’s cell in the ArtificialWonder database with an FxEvolverMaterial GUID and force-loads the cell asset. A vanilla wonder’s material (the Oracle used the Temple of Artemis) is a zero-bake proof AND the loaded template for step 3. Re-arms after session reloads.
  3. The district walker sources its swap template from that database cell when the channel selector has no inline leaves — then the proven isolate machinery runs unchanged: private leaf clone, custom mesh, albedo + surface-map atlases on the private layer.

Verified: the temple renders on the native chain, and an in-session reload plays the game’s own bottom-to-roof level-build reveal on the custom mesh — native wonder theatrics for free. Donor roulette (silo / holy site / natural reserve) is over; en route it produced the donor laws below, kept for reference.

Reveal-on-load (SOLVED — fadeInOutMode = Instant, verified in-game): every session load used to replay the game’s bottom-to-roof level-build reveal a few seconds after the loading screen lifts. Mechanism: the reveal is the element’s appearance transition — vanilla wonders play the same ramp on load, it just finishes behind the loading screen; our swap lands after the screen lifts because the template load can’t start until the district machinery tracks an FxManager. The lever (found by dumping every field of the cloned leaf): FxEvolverMaterialLevelBuildElement.fadeInOutMode{Stepped, Smooth, Instant}, encoded into the element’s GPU data. The wonder-path private clone sets Instant before its first Load — the temple stands complete the moment the tile renders, at any load speed. Trade-off (open refinement): Instant is currently unconditional on the wonder path, so a wonder completed mid-game also skips the ceremony; the designed fix is a postfix on PresentationDistrict.UpdateLevelBuild(eventName) — the game itself says None on load vs Build/Upgrade on genuine construction — choosing Stepped only for real construction events.

FALSIFIED (two clean deadlocks): loading the template earlier to hide the ramp behind the screen. Reaching for RenderContextAccess.GetInstance<IFxManager> from a plugin Update tick during the load sequence hangs the loading screen — with the synchronous AND the async loader; the game’s own coroutine may do it only because it runs at a controlled point in the load order. LAW: never touch the render context before distFxManager is tracked. Symptom: silent hang, log stops after early-startup lines, no exception.

Swap-first sequencing (VERIFIED — the template is never visible): the first build filled the cell up front, which raced the walker’s swap against the native reveal — on a cold cache the template (Artemis) showed for a few seconds before the swap landed. Now the template material is loaded plugin-side into a stash (never via the repository cell), the walker builds the private leaf from the stash, and the cell is filled only after the swap is live (fallback only). The native selector never has a drawable template on the tile: briefly empty, then the custom model, at any load speed. The session reset clears the stash (corpse assets after reload) and re-runs the same sequence.

Donor-family laws (measured 2026-08-08, now historical): a donor affinity only worked if its family was a culture-agnostic building-model family with inline variant pairs (Missile Silo: works). Holy Site’s table is empty for a foreign constructible (culture+era criteria live in the repository), and Natural Reserve’s leaves are scatter drawers — the swap lands but draws with scatter semantics (terrain-keyed pairs, 15 variants, one leaf lottery).

Bugs found en route (both in the plugin’s session lifecycle, not the wonder class)

Stability (RESOLVED same day — the stability pass, verified in-game)

All three instability mechanisms were measured and fixed:

Surface-map atlases — SHIPPED & VERIFIED IN-GAME (same day)

The Oracle temple’s wall albedo is 1024² of pure white — the marble lives in the normal + roughness maps (Temple_BaseColor 5 KB, Temple_Normal 879 KB). The bake now packs normal + roughness atlases with the albedo pack’s exact rects (the remapped UVs index them for free), sourced from a Textures/ folder of per-material files next to the model (the Sketchfab original-format layout; missing files keep a neutral fill). The district registry carries normalAtlasGuid/roughAtlasGuid; the runtime binds them on the private layer where the neutral stand-ins go; the editor preview binds a DXT5nm-swizzled variant. Three lessons, paid for in bakes:

  1. Downsample with an area average — a single bilinear tap aliases dense normal maps into rainbow static.
  2. Normal-atlas thumbnails always look like chaos — judge by measurement (neighbor-delta statistics on the pre-compression PNG dump the bake now writes), never by eye; half an evening went to convicting healthy data on its thumbnail.
  3. Relief strength is calibrated into the data (65% toward flat, tuned in-preview) so the preview and the game — whose shader has no scale knob — show the identical result, and a re-bake can never reset it.

Stale-bundle note stands: re-bakes reshuffle atlas packing — ALWAYS rebuild the mod after the final bake.

Open items

Related: District-Visuals.md (the axis this rides), the July wonder-material mapping in that page’s History section.