-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
Auto-generated from the repo docs by
tools/sync_wiki.sh— edit the source Markdown in the repo, not this wiki page.
Who this is for: anyone changing the plugin who isn't its author. Code-Map.md says where things live; Decisions.md says why individual choices were made; this page says what must stay true — the rules the runtime depends on that no compiler enforces. Every rule here was learned from a real failure; the failure is named so the rule is falsifiable, not folklore. Nearly all of it already exists as comments in the source. This collects it in one place so a maintainer can read it in ten minutes instead of finding it by breaking something.
If you change code and one of these stops being true, update this page in the same commit.
-
Editor half — the installable HAF Authoring Tools in
editor/. It bakes: turns a model into AmplitudeSkeleton/ClipCollection/ mesh / atlas assets and writespack.json; ENCReload is the reference Unity project that consumes the package. - Runtime half — the BepInEx plugin in this repo. It injects: reads the packs, registers the baked assets in the live game, repoints pawns and districts onto them.
-
The contract — a JSON pack, whose shared fields are defined once in
Haf.Schema(netstandard2.0) and inherited by both halves' model classes (Shared-Schema.md). The pre-push gate's parity check fails if a read key isn't written or a GUID hand-list drifts.
Invariant: the runtime has no compile-time reference to Unity-editor or game code. Its only game surface is string-based reflection (§4). That is why CI builds from public sources with no game files, and why the test suite can host the plugin in a plain xUnit process.
Invariant: there is exactly one authoritative editor source, under editor/. Do not recreate the deleted
baker/ editor snapshot or copy the package back into ENCReload; consumers install the package instead.
Humankind runs its simulation on a separate thread from Unity's main thread, and several of HAF's Harmony hooks fire on it. The rule that guards this (and the failure it came from — two confirmed races, 2026-08-21) is in Decisions "Thread safety is about shared HEAP, not the Unity API". The operational facts:
Hooks that run (or may run) off the main thread:
| Hook | Seam | What it may touch |
|---|---|---|
Hk_ArtilleryStrike |
ArtilleryStrikeStarted (sim) |
reads via GetMember; enqueues to ModelEntry.fireGuidQueue (ConcurrentQueue) |
Hk_BattleStarted |
SimulationEvent_BattleStarted.Raise (sim) |
reads via GetMember; enqueues to battleCryQueue
|
Hk_AnimatedBonePoolHeadroom |
PawnManager.Load (per session, possibly off-thread) |
sets reloadRearmPending (volatile) |
| Sandbox.Load hook |
Sandbox.Load (save-load, possibly off-thread) |
sets districtResetPending + reloadRearmPending (volatile) |
FacingPersist save/load |
game save/load (may be off-thread) | reads a main-thread snapshot under lock; arms a file for the tick |
Everything else — Plugin.Update, every Process*/Poll*/Tick*, the pose hook, the district handlers — is
main-thread.
Invariants:
-
Off-thread code never touches Unity objects. It queues work;
Plugin.Updatedrains it (ProcessFireQueues,ProcessBattleCries,ConsumePendingReloadRearm,ConsumePendingDistrictReset,DrainDistrictDestroys).UnityEngine.Object.Destroyin particular is main-thread-only — hence the destroy queues, never a direct Destroy from a reset. -
Off-thread code never mutates a plain collection the main thread reads. Shared state is one of exactly three
shapes: a concurrent type (
ConcurrentQueue, theConcurrentDictionaryreflection caches inUniversalInject.Reflection.cs), locked on every access, or published-once-and-snapshotted (next point). OnModelEntrythis is declared, not memorised (2026-08-22). Its ~90 config members are immutable after load (the inheritedHaf.Schemahalf contributes no mutable collection — a test pins that), and every one of its 24 mutable fields carries[MainThread("owner")],[Locked("why")]or[Concurrent("why")];ModelEntryThreadTestsfails the build on an undeclared one, and[Concurrent]is machine-checked against the field's real type. So the rule reads "config is immutable; every mutable field declares its discipline" rather than a list of four names to remember. Today that resolves to 19 main-thread, 4 locked (stateSamples,activeFires,deploySamples,phaseTracks— pinned by the test), and 1 concurrent (fireGuidQueue, the onlyModelEntryfield the off-thread hooks touch).FacingPersist.liveis locked the same way but lives outsideModelEntry, so the rule doesn't cover it. What the rule does not prove: that a[Locked]field's every access site takes the lock, or that a[MainThread]claim is true — it proves someone wrote an answer down, so a wrong one is a line to argue with in review instead of silence. -
entriesis published once and never mutated.LoadRegistrybuilds a fresh list and assigns the field in one write (entries = built). Readers — including the sim-threadFindEntryForUnitDefinition— takevar snap = entriesand iterate the snapshot. A retry publishes a new list; it neverAdds into the live one. (The 07-19 review found the race that rule fixed.) -
"Main-thread only" in a comment is a claim, not a guard. Before trusting it, grep the hooks in the table above
for the path. The two 08-21 races were both behind exactly that comment —
GetMemberhides a dictionary insert behind a read-shaped call;ResetDistrictSessionStatehid thirteenClear()s behind "reference-nulling". -
Per-frame hot paths don't allocate for logging they won't emit.
Plugin.Diagis gated onVerboseLog, but its argument is built by the caller; on a per-pawn-per-frame path, guard the construction too.
The full treatment — the meter, the baseline, the investigation recipe — is Performance.md; the
invariants are repeated here because they are invariants. FrameCost times the Update fan-out, the pose hook split vanilla/ours, sub-buckets
inside the hot paths) and prints µs/frame per bucket to the F8 panel and the log. The rules it enforces
(Decisions "Per-frame cost is a number"):
- A new per-frame path gets a bucket when it is written. Unbucketed cost is invisible cost.
-
No full-scene
FindObjectsOfTypeon a timer. It is ~50 ms on a busy map. The sub-pawn source (SubPawnScan.cs) walks the presentation tree instead and self-verifies against the scan once per session; the terrain-hug district map is dirty-driven from the district hook. If you must scan, mark it dirty from an event and cap the cadence in tens of seconds. - No retry-every-frame until something exists. The scoped-district bind walked every leaf of every district each frame for the first 5 s of every load. Throttle unbound retries (twice a second is plenty).
-
Resolve reflection once, not per frame.
AccessTools.TypeByNameis an uncached assembly walk; bone-name lookups are a reflection read + a string alloc per bone. Cache per entry, keyed on whatever can change the answer. -
The per-pawn path uses
PawnFast. Boxed-struct reflection costs ~0.5-1 µs per get/set on Mono; the compiled accessors (FastMember) cost ~10 ns and write INTO the box the same way. Every accessor has a reflection fallback — a game update that renames a field degrades to the old speed, never to a crash — and[PawnFast]in the log says which path is live. -
Two
Physics.RaycastAllper pawn per frame is a budget line item. Sample, hold, ease.
The game rebuilds its presentation world per session (new game, save-load, in-session reload), but some of its own registration runs once per process. HAF learned these seams the hard way (Animated-Runtime.md §2/§5); the compressed facts:
| Seam | Fires | HAF uses it for |
|---|---|---|
AnimationManager.AnimationLoad |
once per process (even across a main-menu round trip) | first registration of skeletons + clip collections, before Apply builds the GPU buffers |
PawnManager.Load |
every session (save-load, reload, and New Game) | the universal re-arm request — RequestReloadRearm()
|
Sandbox.Load |
save-load only | additionally flags the district reset so it lands before the district hooks bind |
PresentationPawnDefinitionAddOn.Load |
per unit type, lazily, as units come into view | the repoint itself (RepointMatch) — self-discovers the body mesh name, swaps skeleton/mesh, isolates the skin |
Invariants:
-
Everything a session produces is session-scoped state and is reset on re-arm: learned ids (
skeletonId,animId,descId, the per-role anim ids), the per-unit state maps keyed by unit GUID / sub-pawn instance id (a new game can reuse those ids), the isolated layer / hand-prop layer / adjusted-atlas clones, the AudioListener latch, the district tiles / leaves / bind slots / scoped states. Since 2026-08-21 this is enforced, not remembered: every static collection in the plugin must carry[SessionScoped](theSessionStateregistry clears it on the matching reset —ModelinRearmModelRegistration,DistrictinResetDistrictSessionState),[SessionScoped(Manual = "site")](reset by hand at the named seam — lock-guarded, nulled, or owned by another hook) or[ProcessLived("why")](a type cache, a name-keyed once-log, per-tick scratch). A bare static collection failsSessionStateTestsin CI — no game, no Unity. The first run of that test found two descId-keyed maps that had never been cleared (sizeFormApplied,sizeFormUnitName: the formation-by-size swap silently skipped in a second session) plus the turn/hug/aim state lists. What the registry cannot prove is order — the hand-written lines around the bulk clear (cachedEra, the per-entry id resets, the layer destroys,S = new ScopedState()) still own the sequence; keep them in the same function. Non-collection statics (registered,cachedEra,deployMoveState) are outside the rule and stay on the hand-list. -
Registration must precede
Apply.ApplysnapshotsBoneInfosinto the GPU skeleton buffer; anything you change on a skeleton afterwards (a rebase, a rename) never reaches the GPU. HenceRebaseRootIdentityruns insideEnsureRegistered, beforeRegisterMeshCollection+Apply— not inRepointMatch. -
The district reset must land before the district hooks bind in the new world, or they bind onto the previous
session's dead leaves (the Oracle incident). Since 2026-08-21 the reset is flagged off-thread and performed on
the main thread at the entry of every district handler (idempotent) and on the
Updatetick. Ordering preserved; keep it that way — don't move the consume later, and don't make the handlers skip it. -
Only destroy what you created.
texOwnedis true only for textures HAF built (LoadSkinPng,BuildAdjustedAtlas). The raw bundle atlas fromLoadAtlasis a shared game asset: destroying it makesAssetDatabase.LoadAssetreturn null on the next reload (the organ-gun-goes-red bug). The same discipline applies to layers (isolatedLayer,handPropLayer, the district clones) — every clone HAF makes is queued for destruction on reset; nothing HAF didn't make ever is. -
registeredlatches only on a successful load. A transient registry-load failure must leave it unlatched so the retry can register; andanimMgrRefis captured before the zero-model early return (a rules-only pack still needs the manager for scaling).
HAF binds to Amplitude.* by name, at runtime, through Harmony. That is inherently fragile; the project's answer
is not to remove reflection but to make drift loud and localised (Decisions "Make reflection drift loud").
Invariants:
-
Every game type name lives in exactly one place:
GameBinding. Call sites useGameBinding.<Type>, never a scatteredTypeByName("…"). A rename is fixed in one line. -
A type whose name never appears in code is DERIVED, not guessed. Structs HAF reaches as array elements or
field values (
PawnEntry,FragmentEntry,SkinnedMeshInfo, the level-build channel chain…) are resolved by walking the same path the runtime walks —ElementType(FieldOrPropType(Anchor, "member")). A renamed anchor or a renamed struct member both surface as one named line in the report. Never add aCached("GuessedName")for a type you haven't seen in a decompile. -
Every by-name member read on a non-diagnostic path is in the
Catalog, attributed to the receiver the code actually reads it off (the A1 lesson: a member listed on the wrong type passes validation and guards nothing). What is deliberately outside is listed in the catalog itself (theDistrictDebug-gated dumps, Prober, two members that exist only on runtime subclasses). -
Run
tools/check-bindings.shbefore you launch. It validates the whole catalog — derived chains included — against the game DLLs in seconds, and it catches wrong receivers and non-existent members (it caught five on the day the catalog was closed). The in-gamehaf_bindings_report.txtis the live twin; both must sayN/N. -
All member access goes through
GetMember/SetMember(UniversalInject.Reflection.cs) — property-first, finds non-public, cached per(type, name), null on a miss. The cache is aConcurrentDictionarybecause the sim-thread hooks use it too (§2). Do not regress it to aDictionarywith a comment. -
Harmony patch counts are honest.
Plugin.Awakecounts the methods Harmony actually patched and warns per hook whoseTargetMethodresolved nothing. A hook that self-disables must returnnullfromTargetMethod, not patch a stand-in. -
A failure in one model must not take down the rest. Registration and repoint isolate each entry in its own
try; a missing asset or a reflection miss skips that entry and logs it — it never aborts the loop that would skipApplyfor everyone.
-
Parse is generic, over the shared schema.
ParseModelswhitelist-strips pack JSON to declared config keys, thenToObject<ModelEntry>(); the regex fallback covers a hand-edited file with a syntax error, including the wrapper header (modId/schemaVersion/dependsOn/loadAfter/overrides). A typo must never silently drop the header and downgrade a declared override to a first-wins conflict. -
Pack order follows Humankind's own mod order;
dependsOnis enforced (a missing dependency skips the pack, named inhaf_load_report.txt); duplicatemodIds are rejected; undeclared clashes are first-loaded-wins and logged loud (Multi-Mod.md, Decisions). -
Unit → entry matching is ONE function:
LongestMatchon the fullpawnDescription, thencoreDesc(the_NN-stripped form, >4 chars). Every path — repoint, combat, sound, the movement polls — resolves through it, so they can never disagree about which entry drives a unit.coreDescis computed once at publish, never per call. -
Validation explains, it never blocks. The pack validator's rule set runs pre-bake, on the Validate button, in
the mod build (
-strictfails CI) and at boot; a Warning means the feature degrades, an Error means the entry can't work — but the pack still loads and the report says why (Pack-Validator-Design.md). -
Numbers are invariant-culture everywhere — files HAF reads and every log line that interpolates a float
(
Inv($"…")); thecombatZline once printed-0,13on a Dutch locale.
The district axis is its own class, DistrictInject (DistrictInject.cs + DistrictInject.Scoped.cs, since
2026-08-21). It was a partial of UniversalInject, which meant every one of its ~40 statics was writable from any other
partial — the shape that let the session reset be called from a hook in another file. Now the rest of the plugin sees
only its internal surface (the hook entry points, ResetDistrictSessionState, distModels/IsScopedDistrict/
scopedStates for the smoke test), and DistrictInject reaches back only through using static UniversalInject for
the reflection and asset-loading helpers. Keep it that way: a new district feature goes in DistrictInject; a
new shared helper goes in UniversalInject and is imported, never duplicated.
A custom district renders through one of two paths, and they keep separate state:
| Path | Selected by | Live-tile ledger | Texture ledger |
|---|---|---|---|
| Isolate (private per-instance leaf) | default | DistrictModel.tiles |
DistrictModel.texApplied/texWait/texErrors |
| Scoped (data-authored selector — the reactor) |
selectorGuid in the registry, or DistrictSelectorTile config |
ScopedState.refreshPlbcs |
ScopedState.texApplied/texWait/texErrors |
Invariants:
-
The isolate swap must leave scoped districts alone (
IsScopedDistrictguard inTickDistrictMeshSwap), or the two fight for channel 0. -
Scoped state is per district (
scopedStates[name], theSproxy is pointed at the current one before any scoped work). Two scoped districts in one registry must not share texture / B&W / flatten state. -
Anything that reports on districts reads BOTH ledgers. The smoke harness once read only
d.tilesand declared the district path "UNTESTED" while the reactor was bound on screen. -
texAppliedis not "texture succeeded." Both apply paths give up after 3 exceptions by latchingtexApplied = trueso the poll stops. JudgetexErrorsfirst. -
Per-tile targeting, per-entry sharing. A district built on many tiles has one
PresentationDistricteach; the channels HAF repoints are per tile, while the private leaf / layer clone / texture bindings are one per entry and shared. A single "current plbc" slot made ownership ping-pong between instances — that shape is gone; don't reintroduce it.
Custom animation is rotation-only on the GPU path, pose time is normalized (Time = seconds / duration), and
the per-frame pose decision for every pawn runs in the pose hook. The decisions (which clip, where in it) live in
the pure PoseMath; the hook keeps the I/O and the locks. Phases are tracked by position, not array slot — the
pawn array is rebuilt on every zoom and slot-derived state snaps visibly. The three match radii are deliberately
different (state 4u, fire 4u, deploy 3u) — a tidy-up that unifies them breaks formations. The nine clip roles are
one table (ClipRoles.cs, ModelEntry.Roles[ClipRole]): never add a role as a new field family, and never write
an "all roles" site as a list — loop ClipRoles.All (the lockstep-list shape shipped two bugs). Full detail:
Animated-Runtime.md, Unit-Combat-Behavior.md.
| Layer | Proves | Runs |
|---|---|---|
xUnit suite (Tests/) |
the pure cores: parse/resolve, validator, GameBinding resolution, DialConfig, PoseMath (with legacy-oracle parity), the smoke verdict
|
every push (CI, no game files) |
tools/check.sh pre-push gate |
build, tests, docs links, binding-catalog surface, hot path, parse shape, schema parity | every push, locally — and every source-only guard among them also in CI, because a hook is per-clone config a --no-verify walks past (Testing) |
tools/check-bindings.sh |
the whole reflection catalog against the game DLLs | after a game update; before a launch when the catalog changed |
| In-game Smoke Test (F8) | the plugin came up: bindings, injection, per-entry assets/roles/sounds/files, GPU budget, district tiles on both paths + texture health, seam write-back, shared Harmony seams | by hand, and it writes haf_smoke_report.txt
|
| A drill | the feature actually does the thing on screen | by hand — nothing above replaces it (Decisions: "a tool is not trusted until it is DRILLED") |
Invariant: to make more of the runtime testable, move the decision out of the method that does the I/O
(Decisions) — DialConfig and PoseMath are the template; Districts is the obvious next candidate.
Do not try to unit-test the reflection layer directly, and do not build an in-game test framework.
-
Log once, by key:
Plugin.Once(key)/LogOnceWarning/DiagOncereplace hand-rolledstatic bool xLoggedguards (the pattern's failure mode is forgetting one). -
Verbose is opt-in (
VerboseLog): bring-up detail goes throughPlugin.Diag; a player's log shows decisions and failures, not per-pawn chatter. -
Every launch writes three machine-readable files next to the config:
haf_load_report.txt(which packs, which decisions),haf_bindings_report.txt(which game bindings),haf_smoke_report.txt(the last F8 verdict). A bug report with those three attached is usually diagnosable without a repro.
A rule belongs here if (a) violating it produces a failure that is hard to trace back to the violation, and (b) the compiler and the tests won't catch it. Name the failure. If a rule has a test or a gate check, it belongs in Testing.md instead.
Get started
- Getting Started
- Installation
- Troubleshooting
- Authoring State and Deployment
- Mod Editor version.xml Recovery
- Building
- Backup
Author models and behavior
- Editor Tools
- Factory Manual
- Vehicle Lab Quickstart
- Animated Models
- Animation Pitfalls
- Textures
- Unit Size
- Unit Combat Behavior
- Formations
- Pawn Props
- Projectiles
- Game Sound Lab
- Firing on Attack
- Turn Ease
- Facing Persistence
- Donor Clip Flight
Districts and wonders
Ship and operate
Internals and project