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/. It bakes: turns a model into
Amplitude Skeleton / ClipCollection / mesh / atlas assets and writes pack.json; ENCReload is the reference
Unity project that consumes the package.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:
Plugin.Update drains it
(ProcessFireQueues, ProcessBattleCries, ConsumePendingReloadRearm, ConsumePendingDistrictReset,
DrainDistrictDestroys). UnityEngine.Object.Destroy in particular is main-thread-only — hence the destroy
queues, never a direct Destroy from a reset.ConcurrentQueue, the ConcurrentDictionary reflection caches in
UniversalInject.Reflection.cs), locked on every access, or published-once-and-snapshotted (next point).
On ModelEntry this is declared, not memorised (2026-08-22). Its ~90 config members are immutable after load
(the inherited Haf.Schema half contributes no mutable collection — a test pins that), and every one of its 24
mutable fields carries [MainThread("owner")], [Locked("why")] or [Concurrent("why")]; ModelEntryThreadTests
fails 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 only ModelEntry field the off-thread
hooks touch). FacingPersist.live is locked the same way but lives outside ModelEntry, 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.entries is published once and never mutated. LoadRegistry builds a fresh list and assigns the field in one
write (entries = built). Readers — including the sim-thread FindEntryForUnitDefinition — take var snap =
entries and iterate the snapshot. A retry publishes a new list; it never Adds into the live one. (The 07-19
review found the race that rule fixed.)GetMember hides a dictionary insert
behind a read-shaped call; ResetDistrictSessionState hid thirteen Clear()s behind “reference-nulling”.Plugin.Diag is gated on VerboseLog, 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”):
FindObjectsOfType on 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.AccessTools.TypeByName is 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.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.Physics.RaycastAll per 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:
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] (the SessionState registry clears it on the
matching reset — Model in RearmModelRegistration, District in ResetDistrictSessionState),
[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
fails SessionStateTests in 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.Apply. Apply snapshots BoneInfos into the GPU skeleton buffer; anything you
change on a skeleton afterwards (a rebase, a rename) never reaches the GPU. Hence RebaseRootIdentity runs inside
EnsureRegistered, before RegisterMeshCollection + Apply — not in RepointMatch.Update tick. Ordering preserved;
keep it that way — don’t move the consume later, and don’t make the handlers skip it.texOwned is true only for textures HAF built (LoadSkinPng,
BuildAdjustedAtlas). The raw bundle atlas from LoadAtlas is a shared game asset: destroying it makes
AssetDatabase.LoadAsset return 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.registered latches only on a successful load. A transient registry-load failure must leave it unlatched so
the retry can register; and animMgrRef is 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:
GameBinding. Call sites use GameBinding.<Type>, never a
scattered TypeByName("…"). A rename is fixed in one line.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 a Cached("GuessedName")
for a type you haven’t seen in a decompile.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 (the DistrictDebug-gated dumps, Prober, two
members that exist only on runtime subclasses).tools/check-bindings.sh before 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-game haf_bindings_report.txt is the live twin; both must say N/N.GetMember/SetMember (UniversalInject.Reflection.cs) — property-first,
finds non-public, cached per (type, name), null on a miss. The cache is a ConcurrentDictionary because the
sim-thread hooks use it too (§2). Do not regress it to a Dictionary with a comment.Plugin.Awake counts the methods Harmony actually patched and warns per
hook whose TargetMethod resolved nothing. A hook that self-disables must return null from TargetMethod, not
patch a stand-in.try; a missing asset or a reflection miss skips that entry and logs it — it never aborts the loop that would
skip Apply for everyone.ParseModels whitelist-strips pack JSON to declared config keys,
then ToObject<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.dependsOn is enforced (a missing dependency skips the pack,
named in haf_load_report.txt); duplicate modIds are rejected; undeclared clashes are first-loaded-wins and
logged loud (Multi-Mod.md, Decisions).LongestMatch on the full pawnDescription, then coreDesc
(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. coreDesc is computed once at publish, never per call.-strict fails 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).Inv($"…")); the combatZ line once printed -0,13 on 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:
IsScopedDistrict guard in TickDistrictMeshSwap), or the
two fight for channel 0.scopedStates[name], the S proxy is pointed at the current one before any
scoped work). Two scoped districts in one registry must not share texture / B&W / flatten state.d.tiles and declared
the district path “UNTESTED” while the reactor was bound on screen.texApplied is not “texture succeeded.” Both apply paths give up after 3 exceptions by latching
texApplied = true so the poll stops. Judge texErrors first.PresentationDistrict each; 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.
Plugin.Once(key) / LogOnceWarning / DiagOnce replace hand-rolled static bool xLogged
guards (the pattern’s failure mode is forgetting one).VerboseLog): bring-up detail goes through Plugin.Diag; a player’s log shows decisions and
failures, not per-pawn chatter.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.