The two-round adversarial review (5 parallel reviewers per round over both repos; see the 07-19 Framework-Review row) fixed every HIGH-severity finding the same day. This file tracks what was found, verified real, and deliberately deferred — so the list survives outside the session that produced it. Ranked by when they’ll bite.
An entry here is a HYPOTHESIS, not a finding. It records what someone believed when they wrote it, and the code has moved since. Restating one as fact — in a ranking, a commit message, or to the user — launders a guess into a finding.
That is not a theoretical caution. On 2026-08-23 three entries were acted on without re-checking, and all three were wrong in a different way: one described a bug whose diagnosis was backwards (the sub-pawn “double-count” is a legitimate superset — the fix collapsed zero); one was dormant, sitting behind a config key that is blank by default and was blank in the live setup, yet was ranked the top user-visible defect; and one had been fixed weeks earlier and never struck through. Before touching an entry:
Every open entry that could be checked mechanically was re-read against current source. 8 of the 18 checked were stale — struck through in place below, each with the evidence. The rest are confirmed still-real and left open.
| verdict | entries |
|---|---|
| Stale — closed in this sweep | district regex fallback · district-ground/hex types off-catalog · no offsite copy · “pre-flight validator not yet built” · registry Save wipes wrapper metadata · regex-fallback overrides-as-models drift · cb vs cbb naming · animated written-not-read |
| Confirmed still open | editor’s 4th schemaVersion · future-schema warning can’t name the dials · Hk_SilenceEvents eo.name per event · LongestMatch tiebreak · TryLearnClass first-not-nearest · rotorSpin* plugin-only · deployMoveState unpruned · alphaBoost mapping |
| Real but not exercisable today | Harmony TargetMethod param filters — typeprobe says both names have exactly one declaring type and one method in this build, so there is no ambiguity to fix; pure future-proofing |
| Not re-verified | the feature seams (unbuilt by design, they do not go stale), the bake-script items (need a failing repro), and the editor items needing Unity: BoneRotation slot clobber, muzzle-compensation stash, converted-rig rest pose, entry-state coherence, LoadOrderedAlbedos |
convertRig flag?_loc0 and convert_rig) — a
legacy model with location keys + shape keys no longer aborts, and legacy means no rig manipulation. The
location-STRIP stays on BOTH paths deliberately: every verified legacy bake (drone, howitzer) went through it, and
un-stripping risked re-introducing the drone’s unscaled-translation wobble. Rationale: legacy rigs have a sane rest
by definition, and for them the fold was a near-no-op (frame-0 pose ≈ rest) — so gating it off converges on the
same output. Bake-level verification DONE (same day): smoke test 14/14 with the howitzer fresh-baked
animated-legacy through the gated pipeline. In-game verification DONE (2026-08-02) — the howitzer checked out
correctly after a real re-bake.Six read-only passes over the 400 commits since 08-22; every item below was re-read in source on 09-14 (line numbers are of that day — re-check before touching, per the rules above). Items already tracked elsewhere in this file were skipped. Ranked by consequence within each group.
check-member-shape.sh matches only Convert.To*(GetMember( — blind to the Mem( wrapper, and one live
dead-sentinel sits behind it.UniversalInject.GetMember(, so the widened gate found four more live sites (FacingPersist
IsLoaded / SimulationEntityGUID / FormationAngle, FormationOverride PawnDefinitionId) — six rewritten as
typed reads (new TryMemberULong for the GUID). READERS list + self-check on object-returning (object, string)
helpers; a planted Peek( wrapper FAILS. See CHANGELOG “The gates learn to see their own wrappers”.
FormationOverridePatch.cs:358 defines Mem(o,name) => GetMember(o,name); :432
bool loaded = true; try { loaded = Convert.ToBoolean(Mem(unit, "IsLoaded")); } catch { } then if (!loaded)
continue; — a rename → null → false → every unit skipped by the formation re-form loop forever, no log. Same shape
at :443 (IsNaval). Drill: the shipped regex → 0 hits on that file; (?:GetMember|Mem|Member)\b fires on both.
Fix: rewrite both sites as MemberBool(unit, "IsLoaded", true) (as UniversalInject.Combat.cs:126 already does)
and widen the reader alternation in patterns (a) and (b).check-catalog.sh “all 370 catalogued” excludes three accessor families; at least four members are
uncatalogued.striker too; SimulationArtilleryStrike is a
new binding, bindcheck 135/135), surface 370 → 382. One HELPERS list drives the alternation; the self-check
discovers (object, string) helpers that reach a reader and FAILS on an unknown one — it tripped on TryMemberULong
the moment it was added to the source, before it was added to the list. databaseMatrices0D (RepoDump diagnostic)
site-allowlisted with the reason. The alternation at tools/check-catalog.sh:126,129 lacked Mem( (17 sites), FireProbe.Member(/
FireProbe.Int( (CombatEventPatch.cs:31-32, 6 sites) and the typed MemberBool/Float/Int/Long/UInt + TryMember*
readers (38 sites). Behind them: StrikerUnit (CombatEventPatch.cs:44), StrikerArmy (:54),
AttackerEmpireIndex (:43), PrimitivePerParticleCount (UniversalInject.Inject.cs:1548) — none in
GameBinding.cs. CombatEventPatch is the fire-on-attack hook: a rename silently disables fireOnAttack. This is
the third widening of this alternation (08-21, 08-22 CachedField/GF, now). Fix: add the names, catalogue the
four, and add a self-check that greps static \w+ \w+\(object \w+, string \w+\) =>.*GetMember wrappers and fails
when one is not in the alternation — so the next consolidation of helpers trips the gate instead of blinding it.Patches/DescriptorRepoint.cs is the
shared pure kernel both sites call (6 tests incl. the chained hand-prop-then-chunks sequence, growth, refusal);
the smoke’s full tier now reads each repointed descriptor back and FAILS if the block moved (“descriptor repoint(s)
undone”). Mutation-drilled: an append one slot too far and a non-advanced FragmentCount both go red. UniversalInject.Inject.cs:2026-2056 (InjectExtraMeshFragments)
and :1884-1930 (InjectHandProp) do the tail-block copy / StartFragment=tail / FragmentCount=count+N /
persistentFragmentEntryCount / grow-by-need+100 arithmetic; an off-by-one is the “spike plague” family. Nothing
in Tests/ reaches it; the smoke has no fragment-count verdict; BakeFeatureTest.cs:162 asserts the baker side
only. It is pure Array + FieldInfo work — extract RepointDescriptor(...) and test with test-defined structs
(3 existing + 2 chunks → {tail, 5}, tail advanced by 5, growth when the array is short). Smallest in-game guard: a
smoke line descriptor[defId].FragmentCount == bodyFrags + chunks per multi-mesh entry.SplitForQuadCeiling/EstimateQuads covered only by the opt-in editor lane; ReportBakedQuads can verify
nothing and pass.editor/QuadEstimate.cs, compiled into the test project (8 tests: edge-sharing pair, disjoint, 3-fan, Faceted
quads == tris, welded grid, partition covers every triangle once under budget in a stable order, chunk cap). Drilled:
tris/2 and an unsorted partition both go red. ReportBakedQuads’s “verified nothing” warning is unchanged (game-type). editor/UniversalBaker.cs:1686-1793; assertions live in BakeFeatureTest.cs:128-186, which run
via tools/editor_tests.ps1 — not in check.sh nor ci.yml. ReportBakedQuads returning 0 (“NOT verified”) is a
warning; a game-type rename turns the ceiling check into silence. EstimateQuads(int[] tris, IList<int> cell) has
no Unity dependency — move it to a pure file compiled into Tests (the EditorRules.cs pattern): 2 tris sharing an
edge → 1; 2 disjoint → 2; 3-fan → 2; N faceted → N (the tris/2 trap of 09-12).EffectiveDensityBoost unreachable in xunit as written.DistrictRules.NeededBoost,
9 rows incl. the opt-out and the no-evidence cases). DistrictInject.cs:862-879; FxMeshTriangles returns 0
without the game so the auto-size branch never executes in a test. Extract NeededBoost(int ppc, long tris, int
configBoost): (3,10000,8)→14, (3,10000,1)→1 (the 09-12 opt-out), (3,0,8)→8, (0,10000,8)→8.PART| parser hard-caps at 8 tokens; a 9th field empties the Vehicle Lab silently.VehicleLabRules.TryParsePartLine + FlatShare in EditorRules.cs, 10 tests: a pipe in a name folds back into the
name, a genuine 9th column is rejected WITH a reason and the Lab logs the rejected rows, nan → 0, exact-name-first
alias merge). Drilled: no-fold and strip-instead-of-exact both go red. VehicleLabWindow.cs:1370
okLen = t.Length == 5 || (t.Length >= 6 && t.Length <= 8 …), vehicle_rig.py:615 prints exactly 8; the 7th and 8th
were each added within a month. A part name containing | shifts the count too (:1368-1371, dropped with no
log). vehicle_rig.py:6 still documents the 5-field shape. Fix: TryParsePartLine(string, out Part) with rows for
5/6/7/8/9 tokens (9 → parse-with-extras or FAIL loudly), nan → 0; log every rejected line.WriteCube’s fixture had been wound
inside-out since it was written — corrected (outward by default, insideOut: true for the fixture); the full
Bake Tests run after PR #51: 60 passed, 0 failed, 2 skipped — the “NOT YET RUN” caveat is closed. BakeFeatureTest.cs:121-126 “windingFix keeps
geometry” asserts m != null && r.ok on a consistently wound cube; :100-102 “atlasMaxDim=1024 keeps the 512
source” accepts 128 ≤ width ≤ 1024; :188-193 Multi asserts only atlas != null. Fix: one reversed face + every
normal away from the centroid; t2.width == 512.BuildGlb(underParent: true):
root with translation (0,0,5) + rotation Z90, the Hull as its child; a world-Y cut splits 2/2 and ExtractPart
bounds carry the parent’s rotation and Z offset). GlbDisconnectedParts.cs:830-852 composes the parent chain;
every fixture in Tests/GlbPlaneCutTests.cs is a root node, so reversing Mul(world, local) passes all 11 tests.
One fixture: Strip under {translation (0,0,5), rotation Z90}, cut on world Y at 1 → 2/2, ExtractPart bounds shifted.BakeSmokeTest “one representative per bake path” can pick a texture-only override as the static/Auto
representative, skip it, and PASS.IsTextureOnly predicate, applied before the
GroupBy and at the per-entry skip, so the two cannot disagree). BakeSmokeTest.cs:39-41 groups by (animated, materialMode, converted) and
takes g.First(); a Retexture entry lands in (static, Auto) and, with the registry sorted by name, wins whenever it
sorts first; Run() then skips it at :99-106. PLAUSIBLE (not drilled). Fix: exclude texture-only entries before
GroupBy..obj. UniversalBaker prefers the .obj beside the source
(cachedFull), objPath defaults to .obj, and viaGlbconv is read from the current config: a model whose GLB
changed but whose old .obj is still on disk bakes the old geometry, silently. Fix: key the cache on the GLB’s
mtime/size (or always re-run glbconv when viaGlbconv), and log which file was actually loaded.ModelRegistry.Load() Thread.Sleep(250) per OnGUI event. Reached through the Factory’s name-collision block
at ModelFactoryWindow.cs:994; while the block is shown every repaint/layout event pays 250 ms.BackupAuto exclusion misses _PreviewMesh1..31. Only the first preview mesh name is excluded; the chunked
previews are backed up on every bake.floats before widening; the writer sums doubles. ModelWorkshopWindow.cs:393
(p[i0+a] + p[i1+a] + p[i2+a]) / 3.0 on float[] — single-precision sum, then widened; GlbDisconnectedParts.cs:629
sums Vec3 doubles. Same for the facing normal (:398-402 vs :652-654). The 09-13 Snap fix made the inputs
identical, not the arithmetic: facing rule at 0 % ⇒ v = Min; a bottom face with all three verts at y = 0.7f sums
to fl(3·0.7f) which rounds DOWN, /3 ⇒ 0.69999997 < v ⇒ grey in the preview, yellow in the file. Roughly half of all
float heights round down this way. Fix: ((double)p[i0+a] + p[i1+a] + p[i2+a]) / 3.0 and (double)p[i1] - p[i0];
better, expose the two sideA lambdas from GlbDisconnectedParts and have the preview call them; add a
GlbPlaneCutTests row on a flat face at exactly Min with a value that rounds down.VehicleLabWindow.cs:242-246, 291 tests |c.y|/a2 ≥ 0.866 on the preview instance, which never applies
modelRot (vehicle_rig.py:544-546 “stays in import orientation”), while ProbeRotArg (:2139-2144) straightens
the flip verdicts. A hull imported on its side with Roll X = 90 dialed: every deck reads vertical ⇒ “Only flat parts”
hides all decks. Fix: rotate the triangle normal by the inverse of modelRot before the cos-30° test.FormationOverrideWindow.cs:115 reads sel = Popup(selected, …) BEFORE the Remove button; Remove sets selected =
0; status = "Removed …"; :132 if (sel != selected) { selected = sel; OnSelect(); } then loads the entry that
shifted into the old slot and blanks status. Remove the LAST entry ⇒ selected out of range. Fix: move the
select-check above the button (as AnimationLabWindow.cs:734-744 already orders it) or set sel = 0 on removal.vehicle_rig.py:473-634
(probe) runs outside _guard (defined at :641, rig-only); VehicleLabWindow.cs:2189-2199 logs stderr only when
stdout contains VEHICLE ERROR, so a crash in the flip-verdict/visibility passes leaves 0 parts and Probe() blames
the model at :1400-1403. ModelWorkshopWindow.cs:308 discards both streams. Fix: partCount == 0 && done == null
(or stderr contains Traceback) ⇒ Debug.LogError both streams; wrap probe mode in _guard.VehicleLabWindow.cs:431-436 destroys
inst/pru only — waterMesh (:1666), levelMesh (:1710) never; ModelWorkshopWindow.cs:322-330 never destroys
highlightMat/cutMatA/cutMatB; AnimationLabWindow.cs:209-217 misses fitRefManMesh (:397)._lap on a large ship before deciding whether a --preview-only flag is warranted.RunBlender discards stderr on the success path (see the probe-traceback item above — same root).VEHICLE sentinel before the preview FBX export, so a crash in the export leaves the Lab showing
the previous preview with a fresh part list._project is O(V × 512) (vehicle_rig.py ≈ :2322, the per-vertex loop over probe samples) — the leading
candidate for the post-reduce Teutonic whale once PR #46’s laps name it; KDTree (mathutils.kdtree) is the fix.PawnFast.Scale/SetScale with the reflection fallback; MaybeSwapFormationBySize short-circuits on
the last settled scale). Measured the same evening (Performance.md §8): vanilla gate 1.0 µs/add as before, sweep
1.5 µs/frame, total unchanged at 1.6 % — the cost is PoseOurs (32 × 7 µs: PoseAnim + DonorWorld), the next
target. The scaled-vanilla path itself was not on screen (Biremes off-map) and is still unread.
UniversalInject.ScaleEra.cs:343-347 — GetMember(entry,"ObjectSpace") / GetMember(oss,"Scale") / two
SetMembers per scaled vanilla pawn per frame (≈3–5 µs each on a 0.94 µs baseline); PawnFast.Scale/SetScale
(PawnFast.cs:102) are used only by the entry path (Muzzle.cs:1155). Ahead of it MaybeSwapFormationBySize
(:233-262) runs FormationOverride.SizeThresholdsFor — a linear OrdinalIgnoreCase scan over every formation
link (FormationOverridePatch.cs:611-617) — before the sizeFormApplied early-out at :262. Live today (Biremes
×2); a rules-only pack scaling every ship multiplies it by the fleet. Fix: PawnFast with the reflection fallback;
move the early-out above the scan.lastPawnMatched is reset after the pose gate.UniversalInject.Pose.cs:205 returns before :206
lastPawnMatched = false; Hooks.cs:108 then bills every vanilla add to PoseOurs while the flag holds the last
matched pawn’s true. Reachable with a static-only pack after a session re-arm or UniversalInject=false — the
vanilla/ours split in the perf docs inverts. Meter-only. Fix: move the reset above the gate.Plugin.Poll’s log-once key includes ex.Message.Plugin.cs:497 "poll:"+name+":"+ex.GetType().Name+":"+
ex.Message — a varying message (KeyNotFoundException, Unity’s “has been destroyed”) logs the full stack every
frame and grows onceKeys (:58) by a string per frame — the spam the 08-19 hygiene note above it forbids. Latent
(0 poll throws in the 09-13 log). Fix: key on name + type, message in the text.ProcessSubPawnVisuals is documented as a one-shot dump but is a permanent 3 s poll with a 15 s full-scene
FindObjectsOfType<Renderer> that mutates renderers.Plugin.cs:563 says “no-op once dumped”; Inject.cs:461
“keep polling”, :509-527 scans the scene every 15 s per hideSubPawns entry and sets r.enabled = false on
Gunship/Helix/Rotor/Blur within 15 u — Performance.md rule 2, in the file that says the scan was removed (:52).
82 [REND] lines in ~22 min, all “0 renderer(s)”. Fix: latch per sub-pawn instance id, or delete now that
CrushGhostSlice/PruneCloneRenderOutputs kill the ghost; correct the comment either way.SweepForStrays is O(entries × managers × pawnCount) boxed reflection on a 2 s timer, bucketed inside
PoseOurs.Pose.cs:445-467 — arr.GetValue(i) + TryMemberInt ×2 per slot although PawnFast.SkelId/DescId
are compiled; knownManagers (:438) retains every manager ever seen (battle managers included). Small today with
one manager; the risk is late-game multi-manager. Keep the sweep (it rescues real strays); read through PawnFast,
prune managers whose pawnCount reads 0 for N sweeps, give it its own bucket.Hooks.cs:424-425, 446-447 vs
UniversalInjectPatch.cs:1041-1043, FacingPersistPatch.cs:60-61, Architecture.md:110, Animated-Runtime.md:36
(fires once per process, proven 08-16). RearmPropRegistration (PropsBudget.cs:117) and RearmProjectileOverrides
never run for a second session — props survive only via the TickPropRegister safety net, projectile overrides only
because they mutate process-lived assets. Both axes off by default. Fix: hang both on the PawnManager.Load seam
the model axis uses (Hooks.cs:279); fix the comments (UniversalInjectPatch.cs:1036-1038 contradicts itself
three lines apart).[MainThread] audit on _typeCache is incomplete. GameBinding.cs:66-69 names three sim-thread hooks;
Hk_AnimatedBonePoolHeadroom.Prefix (Hooks.cs:284) reads GameBinding.AnimationManager on PawnManager.Load,
documented “possibly off the main thread”. Practical risk ≈ 0 (the only post-Awake writer is the late-loaded
AudioEventHandle); the written contract is wrong. Name the hook, or ConcurrentDictionary.ScaleDescriptorMeshes scales the descriptor bbox by the LAST fragment’s ratio. ScaleEra.cs:399/423/445 —
ratio is overwritten per matched fragment; a descriptor whose body was rescaled but whose last fragment is shared
with an already-scaled descriptor gets descRatio = 1 ⇒ vanilla-sized BBoxMin/Max ⇒ the enlarged unit culls at
the screen edge. PLAUSIBLE — not traced to a shipped mesh-sharing pair.DistrictInject.cs:1694-1700 wantTrack =
DistrictMainRows set || DistrictSelectorTile set; a district scoped only through pack.json is never added to
trackedDistricts, so RearmDistrictScan() never fires for it and the terrain-hug district map stays stale for
registry-only setups. Fix: || Registry has any scoped district.groundApplied latches per entry NAME, not per district instance. DistrictInject.Scoped.cs:1318-1322 applies
once and sets entry.groundApplied; the prefix at :1351-1357 then returns false (suppressing the game’s own
ApplyGroundMaterialDefinition) for every district with that name. A second instance of the same district — a
second city building the same wonder-class district, or the same scoped district twice — never receives its ground
paint AND has the game’s apply suppressed. Fix: key the latch on the district object (a ConditionalWeakTable), not
the entry.meshPersistLogged says “diagnostic log dedup” but gates the strategic-zoom mesh work once per process.
Scoped.cs:1128 [ProcessLived("diagnostic once-per-name log dedup")]; :1133 if (!meshPersistLogged.Add(name))
return; sits before the work in KeepDistrictMeshAtStrategicZoom. A second session (new game, same process)
never re-applies the element visibility. Fix: [SessionScoped(District)] and a separate log-dedup set — the
annotation is currently lying to the fence audit.refreshArgs NRE in the documented scoped + isolate coexistence. DistrictInject.cs:702 scratch buffer is
allocated only at :752 and :821 (isolate resolvers), but miRefreshChannel is also resolved at :1235, :1624
and Scoped.cs:420 without it; :754, :823, :2073 index refreshArgs[0] guarded only by miRefreshChannel !=
null. A scoped district placing its selector first, then an isolate wonder ⇒ NRE; in DistrictApplyTexture it is
caught and counted toward the 3-strike texErrors latch, which then blames “apply failed 3x”. Fix: allocate beside
every resolve (one EnsureRefreshChannel(plbc) helper).matchedFromTracked/matchedFromGuids sit outside the session fence. DistrictInject.cs:1084 plain static int
beside [SessionScoped] matchedDistricts (:1083); after a reset the early-out at :1090 can fire when the new
session’s counts coincide with the old, leaving the last district unmatched. Annotate, reset to −1.BepInEx/config/haf_battleturn.txt:7 hold=1, while
docs/Turn-Ease.md:113 calls that path experimental and untested, and CombatEventPatch.cs:88 returns early on
BattleTurn.holdFire so the ranged-fire clip arms later. Either the doc is stale (it has been drilled) or the
operator is running an untested path — decide and make the two agree.Four — FIXED 2026-08-23,
mutation-drilled. float x = D; TryParse(cfg, out x) sites — the default is dead code.out is definitely-assigned, so a failed parse overwrites the initializer: the shape reads
“D unless the config overrides it” and means “0 unless it parses”. Two were live — DistrictInject’s
footprint flat height on both its resolve and its accessor path, which then handed 0 to a consumer whose own
SetFlatHeight clamps to [0.02, 1], i.e. a value the rest of the system treats as illegal. The other two were
latent, and instructively so: one is rescued by a range check on the next line, the other is harmless only because
its fallback happens to equal the failure value. The review over-counted these as four live bugs — they are two
live plus two copies of the shape; all four were converted anyway, because a shape that is safe by coincidence is
the one that gets copied a fifth time. Fixed as Plugin.ParseFloat/CfgFloat — one pure function, fallback as a
return value, never an out-param — per Decisions “move the DECISION out of the method that does the
I/O”. 21 tests (CfgParseTests); drilled by restoring the old shape inside the helper: 9 fail, including the
null and blank cases. The config description for the same key advertised “Default 0.08” against a bound default
of 0.17 — corrected in the same pass. The shape is now gated (tools/check-parse-shape.sh, in the pre-push
gate and CI): it strips comments before scanning — the policy note at Plugin.ParseFloat quotes the banned shape
verbatim, and a gate that trips on its own documentation is one nobody keeps — and back-references the variable, so
it fires only when the parse targets the same local the initializer just set. Drilled 7 ways: the original bug
verbatim, the two-line form and an int variant all FAIL; an uninitialised out-target, an inline out float b,
parse-then-assign, and the fixed ParseFloat idiom all PASS. What it cannot see is written into the script
(statements separated by a brace, out into a field, a wrapper it doesn’t know) — per
Decisions, a gate’s all-clear is only as wide as its regex, so widen it when a new shape appears
and drill the NEW shape rather than assuming the old pattern reaches it.
The footprint settings fork: —
RESOLVED 2026-08-23 by decision: keep both, state the rule, log the winner. The fork itself was never the
defect — the registry is what a pack authors, the config is what an operator tunes live, and collapsing them
would cost the tuning loop. The defect was that the rule lived in a comment on pack.json and the global cfg both set them, with an implicit precedence.DistrictModel and in the shape of
an if/else, so nothing told anyone which source had won. Now one pure resolver
(Patches/FootprintPrecedence.cs) states it — an entry with footprintMesh ON claims the district and supplies
all five values; otherwise the global config governs all five — and every resolution logs
[Footprint] '<district>' -> Entry|GlobalConfig: <reason> once per district per process. All-or-nothing rather
than per-field is deliberate and the reasoning is in Decisions: a bool cannot distinguish
unset from false, so a per-field merge would treat every un-authored false as an override. 9 tests, 4
mutations drilled. The side effect that matters most: the config branch is unreachable on the shipped pack
(every entry sets footprintMesh=true), so it never ran in-game — and that is precisely how the dead-default
parse bug hid in it. A pure resolver makes both branches reachable from tests even where the game reaches only one.
The runtime is multi-tenant; the authoring tools are not. — DECIDED 2026-08-23: intentional, deferred to
packaging. The Factory writes one hardcoded pack identity — ModelRegistry.PackLiveDir/PackRepoDir
(haf_packs/ENCReload, Assets/Pack/ENCReload, 19 call sites over 4 windows), PackDef.modId = "enc" with no window
field, and an ENCReload.* bundle glob in DistrictFactoryWindow/ShipStatusWindow/HafCli. So a second author
baking with these tools writes into ENC’s pack. Not a bug to fix in place: the tools compile and run only inside
the ENCReload project, so a pack-identity setting today has exactly one legal value and adds a way to bake into the
wrong folder; parameterising the write target is part of packaging the tools, and lands with it. Recorded in
Decisions; the README’s roadmap, Building.md and Multi-Mod.md now say so where an adopter reads
them. The review’s second point stands and was fixed: the README called what remained “neutral naming”, which
undersold it — naming is done (32 MenuItems, all Tools ▸ HAF, none carrying ENC); the write target is what is
left. Re-open when the tools get a package.json/asmdef and a home outside ENCReload — that is the commit where
these sites read an authored mod id.
— FIXED 2026-08-23,
219 → 6.3 µs, drilled twice. The measurement ended it: SelectorTile is 219 µs/frame — 36% of HAF’s per-frame cost — and unexplained.districts 2668 skipped 237.3 µs, 1 ours 5.6 µs — the
poll walked every tracked district each frame to find one, on a list nothing ever pruned, with an O(n) dedup on
add. Update fell 391 → 167 µs, matching the 237 µs of measured scan. 9 tests, 4 mutations. See
Performance.md §6. Still open in that bucket: the per-match work reads ~6 µs steady but
~497 µs during the load window — visible only now that the scan no longer hides it; same shape as the §2 load
spike, so not urgent. Original entry follows, kept because the reasoning is the reusable part. Looked at twice:
08-21 called it “diffuse, left as is”, 08-23 accounted for ~9 µs (uncached reflection in the Fx-tree walk) and
left ~210 µs. Ruled out by reading: all six per-loop diagnostics are DistrictDebug-gated and latched, and
ResolveMainLayer is cached — none contribute. Known from the existing buckets: SelTileLoop ≈ SelectorTile and
bind/albedo/flat never reach the top six, so it is the loop’s own head. The loop walks EVERY district the game
presents to find the one or two that are ours, so the cost is either many cheap skips (fix: keep a matched
subset, don’t walk the rest — the skip still pays a Unity fake-null check, a native interop call) or few
expensive matches (fix: the per-match work). SelTileSkip/SelTileOurs now split it and their call counts are
the district counts. Do not fix until the numbers say which — that is what the 08-21 “diffuse” verdict got
wrong.
— FIXED
2026-08-23. Each step now runs in its own Plugin.Update’s try/catch is one bag — one throwing poll skips every poll after it.Poll(bucket, name, run) guard, with the failure attributed to its
own site and cached readonly delegates so the hot path allocates nothing. The outer catch survives as a
fan-out backstop. 6 tests; drilled by making Poll propagate again. Drilled in-game 2026-08-23: 0 poll
throws, every bucket populated, smoke PASS, Update 391 µs vs 396 µs before (the guards cost nothing measurable).
—
FIXED 2026-08-23. The twin of the pack-haf_districts.json has no regex fallback — one malformed char disables ALL custom districts.modId crash. Now primary parse + per-entry isolation + regex
fallback, all sharing one accept/reject gate. 12 tests including a parity oracle between the two extractors.
See the CHANGELOG entry, and the two drill lessons in it (a CRLF-broken fixture that was never malformed, and
assertions made vacuous by a game-dependent filter).
Two registry links writing one formation name are undetected. — FIXED 2026-08-23. 'Formation_1'
warned twice in a clean load because three links target it and two carry data; created tracked only INJECTED
formations, never OVERWRITTEN ones, so a repeat write re-emitted a warning that blamed vanilla for a same-registry
collision. A formation is shared BY NAME, so the last write wins for every link on it. FormationSignature +
ReportFormationCollisions now detect it at parse: identical data is a Diag, differing data is an error naming
both links. The write path is unchanged. 12 tests, four mutations drilled. See the CHANGELOG entry.
One malformed third-party pack disables ALL custom content for the session. — FIXED 2026-08-23, and
it was the highest-consequence finding in the range: "modId": null reached ResolvePacks’ dictionary as a null
key, and the resulting ArgumentNullException latched the whole registry off with a stack trace naming no file.
Fixed in three layers (source guard + post-condition, defence-in-depth skip, a discovered-pack breadcrumb in the
failure log); see the CHANGELOG entry. 20 tests, mutation-drilled 15/20.
— FIXED 2026-08-23.
PackValidator has no rules for pack WRAPPER metadata — only for model entries.PackValidator.ValidatePack adds rules for modId / schemaVersion / dependsOn / loadAfter / overrides, each
one mirroring behaviour verified in UniversalInjectPatch first rather than invented: a blank wrapper key falls
back to the file name (WrapperStr); an overrides entry with a blank field is silently dropped at parse; an
unsatisfiable dependsOn means the pack is SKIPPED (the one Error — everything else is advisory, so the fail-soft
contract stands, and a test asserts that); a future schemaVersion is advisory (CheckSchema).
The rule worth having: an override with no ordering constraint. An override replaces a pawn already claimed, so
the pack must load AFTER its target; with neither dependsOn nor loadAfter naming it, load order is whatever the
game’s module order happens to be, and if this pack lands first the target’s entry is dropped as an undeclared
CONFLICT — the override silently doing the opposite of its intent.
Wired into both surfaces: the plugin writes wrapper issues into haf_load_report.txt (in WriteLoadReport, not
the pre-flight pass — the pre-flight iterates entries, and a wrapper mistake bad enough to get the pack skipped
contributes no entries, so it would be invisible exactly when it matters), and the editor’s Validate pack button
reports them first, which is the surface the whole item was about. 23 tests, five mutations drilled, plus one pinning
that the shipped ENC wrapper stays SILENT — a rule that fires on a healthy pack trains authors to ignore the report.
Deliberately still self-contained: the cross-pack questions (does dependsOn resolve, does the override’s target
pawn exist) need the whole pack set, which an author validating one pack does not have, and which the runtime’s
resolution report already answers better.
The original entry: the residue of the fix
above. The validator is the shared rule core behind all four surfaces (pre-bake, the Validate pack button,
-strict in CI, the boot pre-flight), and it has ~30 content checks for bones/files/pawns/formats/ranges and
zero for modId / schemaVersion / dependsOn / loadAfter / overrides. So a pack whose wrapper is
wrong is now handled gracefully at runtime but is still never caught at authoring time, which is where the
author can actually fix it. Not urgent — the runtime path is safe and warns by name — but this is the surface
that should have caught it first. Note the boot pre-flight cannot cover this on its own: it runs after
registration, so a registry that fails to load never reaches it.
— FIXED 2026-08-23. The decision the entry
asked for was already on the page: schemaVersion is parsed, printed, and never enforced.Multi-Mod.md has documented the contract since the pack format shipped
(“Currently 1. Evolves additively — new keys are added, old files keep loading”), so the work was to
implement the documented contract, not to invent one. Additive evolution makes refusal the wrong lever — a pack
from the future is one whose extra keys are stripped and whose known keys read exactly as intended — so the
version is now an advisory that never gates: Haf.Schema.HafSchema owns the number, CheckSchema classifies
each pack against it, a future pack warns (naming the consequence and the remedy), a legacy unversioned pack gets
a quiet note, and the implemented version prints in the load-report header beside each pack’s own. See the
CHANGELOG entry. 18 tests, three mutations drilled; the doc/code agreement is now in the push gate.
The editor holds a FOURTH copy of the schema version, as a literal. The residue of the fix above.
HafSchema.Version is the definition, and tools/check-docs.sh now fails the push if docs/Multi-Mod.md or
docs/haf-pack.example.json quotes a different number — but ENCReload’s ModelRegistry.cs:101 declares
public int schemaVersion = 1; independently, and nothing compares the two. Bumping the constant here would
therefore leave the editor stamping the OLD number into every pack it bakes, which is precisely the silent drift
the constant was introduced to end. The editor already references Haf.Schema (its ModelDef inherits
HafModelSchema), so the fix is small — write HafSchema.Version instead of the literal — but it is a
cross-repo change, and the guard that would enforce it belongs in ENCReload’s Tools/check_schema_parity.sh
beside the field-list comparison it already does.
A “from the future” warning still can’t name WHICH dials are being ignored. The advisory says features may
silently do nothing; it cannot yet say which, and that is the sentence a modder actually needs. The data is
already computed — ParseModels strips every key not in registryConfigKeys and knows their names — but it
can’t be reported usefully, because a real pack carries ~56 legitimate bake-time editor keys (targetTris,
windingFix, convertRig, …) that the plugin has never read by design, so naming unknown keys would bury the
two that matter in fifty-odd that don’t. Separating them needs the editor’s bake-only field list, which the
plugin cannot know without a hand-list that drifts — the thing this codebase keeps (rightly) refusing to add.
The clean close-out is to declare that set once in the shared Haf.Schema project, where the existing cross-repo
parity guard (which already computes “baker fields not read at runtime”) can hold it honest.
Every item below was re-verified in source during the review; the range’s critical (the strike hold reusing a stale aim marker) was fixed the same day and is not repeated here. Ranked by consequence.
Make static… bypasses the name-collision guard.Upsert removed with ordinal == while the guard compares OrdinalIgnoreCase,
so a case-only rename left two entries sharing one set of asset files on Windows. Upsert is case-insensitive
now; no shipped pack has case-duplicate names.The Vehicle Lab’s trail/gun/recoil dials are dead on rigged sources. — FIXED 2026-08-22: all eight
sites read ActiveParts, and the UI and Generate now share one FastPath predicate and one list.
model_rot is applied and baked before the rig is built), but the dials exist precisely to
square a model up, so it needs someone to leave a gun at an odd angle. Two rewrites were tried against the same
harness and both scored worse (they failed at yaw 0, where the current rule passes), because the real
off-axis fault is upstream: the arm’s ends come from the dominant axis-aligned bbox extent, which mis-picks the
ends of a diagonal arm. Fixed instead: the silence — an un-mirrored pair now warns, promoted to the Lab’s status
box. Still open (low priority, needs a diagonally-authored gun to matter): rotation-invariant arm-end
extraction, after which the sign rule can be re-derived from the trails’ own centreline.The live-pawn check is fed by the hook it is checking. — FIXED 2026-08-22. The smoke now samples an
independent oracle (CountLiveArmies(), read from the presentation entity factory — a surface no HAF hook
writes) alongside the registered-manager count. Zero managers while armies are live and entries are injected is
a FAIL naming the consequence; the benign shapes (no armies; managers but no matching descriptor ids) produce a
NOTE and a printed 0 live pawn(s) examined, never a dropped clause. An unreadable oracle returns -1 and cannot
pose as a confident zero. Five tests, mutation-drilled.
One report can still say PASS on nothing. — FIXED 2026-08-22: the bake-test verdict reads NOTHING
VERIFIED when no section passed, and the Console line becomes a warning. (Two of the original three are fixed:
the smoke’s live-pawn clause and the matched-but-never-repointed misfiling — see the entries above. Still
cosmetic-but-dishonest: the remaining coverage clauses, SubPawnScene / LayersChecked / SeamsChecked /
RolesChecked / SoundsChecked, are suppressed at zero rather than printed.)
The catalog gate cannot see the — FIXED 2026-08-22, and it was worse than
reported: teaching it CachedField( family.CachedField(/GF( surfaced 19 uncatalogued names, and a second pass for nested calls
(GetMember(GetMember(x, "Inner"), "Outer") only ever yielded Inner) surfaced 13 more — including
FacingAngleOffset, the member the 08-21 review had named, which was never actually catalogued; its only
mention in GameBinding.cs was the comment describing that review. Now catalogued with TagAsAbilities and
bindcheck-validated. Still open (low priority): ~30 duck-typed reads over runtime-resolved types
(mat.GetType(), voBox.GetType(), the skeleton buffer element at Pose.cs:42) are site-scoped allowlist
entries with reasons, not catalog bindings — the functional ones among them still degrade silently on a game
rename. Promoting them via the A6 CachedDerived mechanism (anchored on the type that produced the instance)
is the real close-out.
A district that never binds retries forever, silently. — FIXED 2026-08-23. Two compounding faults:
the one-shot log key was the REASON (notgt/nodonor) rather than the DISTRICT, so the first district to stall
silenced every other one for that reason; and the line was Plugin.Diag, off by default. A district could fail
to render for a whole session emitting nothing at any severity. Now keyed (district, reason), with one
escalating WARNING after BindEscalateAfter (~30 s) naming the district, reason and consequence. The retry is
unchanged and still never gives up. 8 tests, three mutations drilled. See the CHANGELOG entry.
alphaBoost is far weaker than its slider implies, and its diagnostic can’t show it. (Editor-side, ENCReload
DistrictBaker.cs.) Its own comment records that the alpha GAIN is a no-op on a binary-alpha foliage sheet, so
the dial collapses to rounds = Clamp(RoundToInt(boost - 1), 0, 6) — 2 texels of dilation at 2.5, 3 at the
slider’s max of 4. The UI advertises “2-4 = fuller crown”. Separately the log line reads “opaque coverage now
~20%” with no BEFORE figure, so it says where the bake landed but not whether the dial moved anything — which is
why “do the leaf dials work?” cost a re-bake to answer instead of a log read. Both worth fixing together: a
stronger/decoupled rounds mapping, and a before→after coverage pair. Neither is a correctness bug; the dials do
run (drilled 2026-08-23: scaled 2171 of 2592 card island(s), 2 dilation round(s)).
GetInstanceID() at the boundary of WalkSubPawns, first occurrence wins, order preserved. Deliberately
NOT inside the adders: AddUnitSubPawns decides whether to fall back to a holder-subtree search by testing
result.Count == before, so suppressing a duplicate mid-walk would read as “the pawn list yielded nothing” and fire
that fallback for a unit already fully collected. The collapsed count is reported by the self-verify, so an overlap
that grows (a new holder list that re-reaches an existing one) shows up instead of being silently absorbed.
Eight tests, four mutations drilled — including dedupe-by-reference, which is the mistake that would collapse nothing
in production, since each path yields a different managed wrapper for the same sub-pawn.
BUT THE PREMISE OF THIS ENTRY WAS WRONG — drilled 2026-08-23. The 56/46 gap is not duplicates. With the
dedupe shipped and reporting, the log read walk verified against the scene scan: 55 sub-pawn(s), none missed (scene
scan 45) and no duplicates were collapsed. The real cause: SceneScan only counts a sub-pawn whose own
gameObject name matches a pawnDescription, whereas the walk — once a unit resolves to one of our entries — adds
every sub-pawn of that unit’s pawns regardless of name. The walk is a legitimate SUPERSET; the verify block has
always collected the difference as walkOnly. The count on the panel is therefore correct as printed, and the claim
that ProcessEngineAudio processes duplicated pairs twice per poll is unsupported.
The dedupe is kept on honest terms: the overlap it guards is structurally real (a battling unit is reachable via
both the army list and the battle’s AllUnits; a squadron via both its subtree and its air formation), but the drill
session exercised neither — 0 battle-start events, no air unit on the map. Defensive, self-reporting, and
unproven in the wild. To exercise it: fight a battle with an air unit present, then check the self-verify line for
a “duplicate(s) collapsed” clause.
The original entry, whose diagnosis did not survive measurement: a PresentationUnit
reached twice during a battle (armies and battle units), and a squadron reachable both via the holder subtree
and the air-formation MainPawn walk, are counted twice — the F8 panel showed sub-pawn walk 56/46 on
2026-08-22, i.e. ten duplicates against a superset oracle. The miss detection is set-based on instance ids so the
verdict is sound, but the printed number is misleading and ProcessEngineAudio processes the duplicated pairs
twice per poll. Fix: dedupe by GetInstanceID() before counting.-i false-positived on docs/Wonder-Spike.md),
verified in both directions, the three stale (spike) labels promoted (all three are documented shipped dials),
and both source-only guards moved into CI beside the docs guard. The catalog half was fixed the same day —
see the CachedField( entry above, which turned out to be hiding 32 uncatalogued names.PackTuning dropped the sv > 0f guard.!(sv > 0f) so NaN
is rejected too, and it now WARNS with the pack, key and value instead of skipping in silence. No shipped pack
was affected. More importantly the missing discipline was supplied: PackTuningLegacyParityTests keeps the
pre-extraction loop verbatim as an oracle and compares over a 19-entry corpus — mutation-drilled, re-introducing
the bug fails 6 tests. The remaining PackTuning gap from the review is unrelated and still open: the
cross-pack conflict NOTE is keyed on exact match strings while the runtime matches by substring, so
"Tank" in one pack and "Tanks_01" in another both apply (×0.36) with no note.fireGuidQueue is never drained on re-arm, and the fence sees 137 of 549 statics.Clear()”: queues/bags are drained, arrays zeroed, ConditionalWeakTable forced to declare a
lifetime. 27 previously-invisible statics are now annotated. Scalars remain outside on purpose (shape cannot
distinguish a constant from a per-session latch) and UnpolicedStaticCount() reports how many, so the edge is
measured. ResetDistrictSessionState, with the
two clones it guards (ourAtlas, hostClone) handed to districtOwnedClones in the same change, because resetting
the latch alone would have turned a one-shot leak into a per-reload one. reactorMaskTex deliberately stays
[ProcessLived]. Five mutations drilled; the same new test then found _subPawnScan holding destroyed sub-pawn
references after the model reset. See the CHANGELOG.
DRILLED IN-GAME 2026-08-23 — latch and leak confirmed, RENDERING still untested. With the mask temporarily
enabled and five session resets in one process, the log shows three complete [Footprint] done blocks where the
old code would have produced exactly one; step1: loaded mask 512x512 appears once, so the [ProcessLived]
texture is reused rather than re-decoded; and [District] freed 4 then freed 5 runtime clone(s) shows the newly
owned atlas and decal clone being released. Every free lands after a reset and before the next injection, so the
ownership tracking is not eating the new clones. The residual below also resolved: placed=True on all three runs.
What was NOT established is that the silhouette draws. The drill turned DistrictFootprintMesh off (it drops the
decal the mask injects), which removed the reactor from the strategic map — the operator reported that as the visual
result, so it tells us nothing about the mask. Lesson, mine: the mask path was blank while MaskSize, Rotation
and Cut were all tuned to non-defaults — the feature had been used and then abandoned for the mesh footprint. That
was legible in the config and I read past it, ranked a dormant feature as the top user-visible defect, and changed a
live setup to test it. Check whether a feature is switched on before calling its bug the one a player would hit.
Original residual (now resolved, kept for the reasoning): InjectReactorFootprint trims sel.levelBuildItems in place,
and sel is a bundle asset the game loads once per app run — so the second injection runs against an array the first
one already trimmed, whose single decal item points at a hostClone the reset has since destroyed. Reading the code,
that path repoints it at the new clone and comes out right; a destroyed UnityEngine.Object’s managed wrapper still
answers GetType(), and the child == null test is reference equality on an object, so the item is not skipped.
But that is a chain of Unity lifetime details, not something source review settles — load a save, load a second, and
look for [Footprint] done a second time with the silhouette actually drawn.
The original entry, for the record: footprintMaskInjected is exactly that unpoliceable shape — a
static bool latch that survives a session reset while ResetDistrictSessionState destroys the clone it
guards, leaving the strategic-zoom footprint dead until a process restart. It needs a per-session reset by
hand; the fence cannot find it for you.Hk_BattleHoldFire fails closed.creationTime is now treated
like an expired one (release, with a one-shot warning naming the likely cause), matching the policy every
sibling hold follows. The decision is a pure TryElapsedSince(clock, now, out seconds) with four tests,
mutation-drilled.A fourth hand-maintained field list is ungated. — FIXED 2026-08-22: check_handlists.sh compares
ModelDef’s 11 int[] guid fields against the Clone block; drilled by re-removing clipIdleAlt2.
Plugin.Update has no try/catch, and the meter overstates its scope.try/finally closes the frame accounting on a throwing poll (the meter no longer reads healthiest when HAF is
most broken), and Performance.md now states exactly what the 33 buckets cover and what they do not.A CONVERTED RIG’S CLIPS DON’T SHARE A FRAME WITH ITS REST POSE (measured 2026-08-22, the howitzer wheels) —
every clip deploy_convert produces for the M114 poses the model 90° rotated from its own rest pose
(rest bbox (52.1, 135.7, 37.6) vs folded (41.7, 27.6, 119.3)), the legacy clip additionally at 2× scale;
the baked skeleton then carries compensating scales (howitzer:main Local 2, wheel BindPose 0.005, where
a Vehicle-Lab rig reads 1/1). Pawn-level features are blind to it — everything shipped today works — but
bone-level ones inherit a frame that disagrees with the geometry, so authored bone motion (a wheel roll, and
by extension any future bone-driven feature on a converted model) pivots wrongly and cannot be compensated
reliably. Motion the SOURCE animates rides through fine (the T-62’s wheels spin), which is why this went
unnoticed for so long. Acceptance test, offline, no bake and no game: folded at frame 1 must have the rest
pose’s bbox orientation. Guarded by the existing conversion golden-master gate. Full write-up + the four
offline verification recipes: Animation-Pitfalls.md ▸ “Authoring INTO a converted rig”.
ENTRY-STATE COHERENCE (user verdict 2026-07-26, tread-saga fallout: “this seems like a serious configuration
bug”) — an entry’s config lives in FOUR places (Factory window memory, Animation Lab memory, the DEPLOYED
pack.json the editor reads as its registry, the project dual-write copy) and the reconciliation rules ambushed
the user repeatedly in one afternoon: (a) a stale Factory Model-file field silently baked the WRONG MODEL (the
translation-test cube overwrote a good Jagdpanzer bake); (b) animated→static downgrade is IMPOSSIBLE without
Remove — the bake-time ownership rebase resurrects the saved animation config even after Reset, and the animated
pipeline then hard-fails on an unrigged file; (c) “Reduce to ~tris (0 = off)” silently substituted 12,000 on the
animated path for years (FIXED same day); (d) external registry edits are detected by the Lab (yellow banner)
but not by the Factory. Proposed fixes, in impact order: (1) Factory gets the Lab’s outside-change banner +
a bake-time confirm when its Model file differs from the registry’s DONE + DRILLED 2026-08-18 (banner +
explicit Reload-entry choice, coherence-aware cross-window nudge — a Backup-window restore now raises the banner
instead of silently reloading — and the bake-time model-file confirm with both paths shown; plus the SelectEntry
funnel: every selection change routes through one path, structurally retiring the 08-16..18 stale-window
family. All five drills passed; drill 3 caught a real unreachable-banner defect — see CHANGELOG); (2) a real animated→static path largely covered by the “Make static…” button (strips the
animation config from the saved registry; the offer-on-armature-less-failure variant remains nice-to-have);
(3) document (or collapse) the two-pack.json design COLLAPSED 2026-08-19: the git-tracked project
file is the single source; the deployed copy is a regenerated build artifact with hand-edit drift warnings
and a one-time migration (districts/formations inherited it 2026-08-20 via the shared SingleSourceRegistry); (4) audit
remaining “label lies” like the tris slider DONE 2026-08-19 — swept both families mechanically
(UI-field extraction diffed against every hand-list; every runtime/no-re-bake claim read against its code
path). Hand-lists: the Factory rebase (34 fields), the Lab rebase (56) and the bake-config capture are all
COMPLETE — zero UI-edited fields uncovered. Three findings, all fixed same day: MakeStatic left
gunElevMax/gunElevAxis/animPhaseSpread uncleared (gunElev is runtime-applied — a made-static gun kept
elevating: the cursed-leftover class MakeStatic exists to kill); the Save-settings status claimed Position
offset/Size “apply on load” unconditionally (false for statics — now entry-type-conditional); Browse’s
animUnitFix auto-set is discarded by Save settings (animation-owned — the status now says so). The original
tris-slider example was already clean (tooltip + bake log disclose the double-sided halving). Residual risk
is the MAINTENANCE-TRAP comments at each hand-list — no gate enforces them. Gated 2026-08-19:
Tools/check_handlists.sh (pre-push, drilled at birth on the planted combatZ omission) — the silent-reset
class is structurally impossible now.
deploy_convert.py recoil blocksrc_w when
its name isn’t barrel/cannon (was a guaranteed KeyError on non-M114 naming); (b) the RecoilArm holds now key an
IDENTITY BASIS (true pass-through at any parent pose) and the arc targets build on a parent-aware pass-through
baseline, so a parent chain that moves during the deploy no longer displaces the tube; (c) empty tube match now
fails loudly listing the animated part names; (d) dead key_bone removed. NOTE: the shipped m114_deploy.glb was
generated by the OLD code and stays as-is (verified in-game); the fixes matter for the next artillery-style model.ConfigForModelFactoryWindow.ConfigFor like the smoke test, so convertRig/rotation/keep-flags all
carry and the soldier is exercised on the conversion pipeline it actually ships on.SweepAllOutputs (the full
OutputSuffixes union, now incl. _ClipsPoseData.bytes) runs at the start of BOTH paths, so an animated↔static flip
leaves no orphans in shipped Resources; the E5 rollback and the Feature-Test cleanup cover the pose bytes too, and
the animated path gained the static path’s up-front resource-name validation.RearmModelRegistration now nulls distFxManager
and every entry’s plbc/privateLeaf/leaves/collected; DistrictApplyEntries re-derives them as the new
session loads. Verify alongside the model-axis second-session test.PoseNames/BoneRotationNames (was "Pose"+i strings per pawn per frame); the pose hook’s
descId fallback is a plain loop (was a ctx-capturing lambda per pawn add); ProcessFireQueues prunes with a reverse
for-loop (was a dur-capturing RemoveAll closure per entry per frame); ProcessEngineAudio throttles FIRST and
caches its filtered subset keyed on the entries reference (was Where().ToList() 60×/s); TickOne hoists the
field-name array and skips the 7 texture re-sets when _MainTex is already ours (re-set kept as the recovery path
when the game recreates the material); the [Grey] no _MainTex retry warns once; the audio-trace postfix gained
the try/catch every other patch body has. NOT done (deliberately): GetMember boxing elimination — it needs typed
delegates over reflected structs, high risk for marginal gain; revisit only if profiling shows it matters.
VERIFIED in-game same day: full animation sweep clean including the drone attack (fire-once path — exercises
the queue prune, the descId-fallback loop, and the pose-name arrays in one action). Residual: informally watch a
BIG late-game battle for stutter (the improvement claim, as opposed to the no-regression claim).deployProgress/deployLastPos/customSources/loopHoldUntil/engineLastPos/engineMoving, plus static
deployMoveState and respawnBase/respawnCount) clear on session re-arm, and deployLastPos joined the
in-session deploy prune. Remaining in-session growth of the engine-audio maps folds into the perf pass above.Retex_
entry for a pawn that already has a model entry (two entries, same pawn, undefined winner)._pending isn’t serialized — a domain reload drops staged, unsaved edits.ApplyTint no longer wipes the clipboard. Still
open: invalid impact-donor GUID silently ignored; muzzle swapped beyond the tooltip’s documented scope.FindType is cached (the per-repaint full-AppDomain scan is gone) and null
Amplitude GUIDs now fail the bake with the rebuild-then-re-bake guidance instead of writing zero-GUIDs.ExitGUIException is rethrown before the generic catch..tga red placeholder wasn’t hypothetical, it was live in every flat-colour bake ever made (the all-red Bell H-13)
— and the static path did NOT “handle both”: it skipped .tga entirely (grey tile). Both paths now share a TGA
decoder, and every rect-shifting drop (no map_Kd line, missing albedo file, undecodable file) warns loudly.ModelChunks anchors on "models"s*:s*[ and brace-counts inside it, so an overrides array can never be read as models; index alignment was retired the same day (each entry is read from its OWN object text). Original:: overrides-array objects parsed as models when models is empty; count
truncation via min(pd,skel,atlas); early-entry key omission misaligns later entries; resourceName default differs.ModelRegistry.Save() MERGES onto the on-disk file and explicitly preserves the pack header —
schemaVersion/modId/dependsOn/loadAfter/overrides — “no window edits these, so they must survive every
Save”; also Upsert/Remove became case-insensitive on 08-22, closing the case-only-rename twin below);
Lab bakes a brand-new never-baked entry with default model fields (Factory→Lab handoff carries only name/file/pawn);
Browse’s auto-set animUnitFix announcement is discarded by the ownership merge for existing entries; case-sensitive
Upsert/Remove matching (case-only rename → twin entries); atlasGuid never validated; _ClipsPoseData.bytes
missing from the E5 rollback + Feature-Test cleanup listsReadToEnd pipe-deadlock pattern; texture leaks on bake failure paths; corrupt-registry error-spam from
per-OnGUI Load() in Retexture/Sound windows; ParseWav negative chunk-size guardblend_export.py repoints packed images it shouldn’t; prep_model strip matches object names only (not mesh-data
names, unlike deploy_convert); AtlasDebug likely double-converts in a Linear-color-space project; RefreshList comment
contradicts the settled Factory-lists-all design; 3-strike registry give-up latches per-process (“this session” log
text is wrong); Hk_AudioTrace postfix unguarded + per-event string scans; 4u fire-radius / 3u deploy-match adjacency.HumankindAssetFramework.dll (csproj FILE name kept — local clones, build docs and
the CLI compile-check unchanged), BepInEx GUID → community.humankind.haf (old cfg copied to the new name on this
machine, old DLL removed from plugins in the same deploy — BepInEx would load both and double-patch), editor menu
root → Tools ▸ HAF (all windows + Tech Tree + Database Browser consolidated under it; Tests submenu intact),
instructional docs swept (Framework-Review’s dated history rows keep their period-correct Tools ▸ ENC paths).
Deliberately NOT migrated (framework/pack split, decided 07-14 and reaffirmed 07-19): haf_models.json /
haf_sounds / haf_skins are ENC-the-PACK’s files — packs are branded, only the framework is neutral, and a
third-party pack never touches an haf_* path. Verified in-game same day (first session clean: new identity
loads, settings carried, units/districts/audio normal). Still open for the package release: hardcoded paths,
package scaffolding. (The ENCAccessProof C# namespace + project filename were renamed to HumankindAssetFramework on 2026-08-01; the local repo FOLDER followed on 2026-08-16 — nothing left of the old name.)Haf.Schema/PackValidator.cs (the rule core, now including WRAPPER rules), Patches/UniversalInject.Preflight.cs (boot pre-flight into haf_load_report.txt), and the editor’s Validate pack button. Original: Today pack structure
resolution is loud and human-readable (malformed JSON, duplicate modId, missing dependsOn, cycles, conflicts →
clear warnings + haf_load_report.txt), and bad input fails soft (never crashes). But there’s no entry-level
content validation: a wrong bone name, an unresolvable GUID, or a missing texture path degrades silently rather
than producing a “pack X, entry Y: bone Z not found” message. For a distributable framework this is a real
barrier to entry for external authors. Build a pre-flight linter (editor button + a boot-time pass) that checks
each entry’s referenced assets/bones and reports mismatches in plain language before render. Fits the “guided, not
guessy” design goal; scoped for the package phase. (Raised by an external review 2026-08-02; the structure half was
already done in the 07-14/07-19 multi-mod work.) Designed — see
Pack-Validator-Design.md (what to validate, editor vs boot-time surfaces, message format,
phasing); build remains.TREADIZE v2 — hybrid link/shuttle rig (user’s design, 2026-07-26). On a straight run every link moves identically → ONE translating shuttle bone can carry the whole run (pattern maps at restart); per-link bones only on the WRAPS + RAMPS where links genuinely rotate. Bone math: Bradley ~23/track at full per-link wrap detail vs 75 today — quarter-link wrap smoothness inside half the budget. Skirted vehicles: the hidden top run can be fully STATIC (zero bones). The one risk is the two run↔wrap seams (static skin weights can’t switch carriers) — mitigated by everything v1 learned: seams AT the tangent points, where a wrap link’s velocity equals the run direction, speed-matched on the exact belt path. Prereq: none — build whenever tread bone budgets start pinching again (or for the twitch-ceiling escape).
Textures/ set).
Today the bake produces a SINGLE albedo atlas and the runtime injection NEUTRALIZES the donor’s PBR (flat albedo). The
albedo half of a source set is already consumable — bake the BaseOp down onto the game-mesh UVs — but the
_Normal maps are not processable at all; that is the missing pipeline. To render surface detail the Factory would
bake a matching normal atlas repacked to the combined-atlas UVs, and the injector would wire it into the pawn
material’s normal slot (_BumpMap) instead of clearing it.
What “fully process Ehrhardt_E_V/Textures/” actually takes (read off the shipped files, not hand-waved):
T_..._C_V*_Normal.1001–1005) and the gun a single tile
(T_..._G_V1_Normal); assemble the UDIM set into one image before repacking. (Same assembly the albedo/UDIM note
below needs — build it once, feed both maps.)TextureImporterType.NormalMap, normal-safe
compression + mips; a naively-imported normal atlas is read as colour and lights wrong.BaseOp + Normal, so this composes with the
runtime-retexture-variant axis (one skeleton/atlas, swap the pair per descriptor).
Priority moderate: at map zoom (~80px units) the payoff is subtle — but this is the concrete build if/when we want it,
and the Ehrhardt set is the ready test bed. Escape hatch today: bake the normal into the albedo’s lighting in Blender
(static, no runtime normal response) — cosmetic only. (If a source set also ships ORM/roughness/metallic, the same four
steps extend to a packed ORM atlas + the material’s metallic/smoothness slots.)
Related same-bucket gap — UDIM / multi-tile ALBEDO: the bake assumes ONE texture per material in a single 0–1 UV
tile, so the armored car’s cinematics mesh + its 5-tile .1001–.1005 UDIM camo can’t be consumed directly. Escape
hatch is the same manual Blender texture-transfer bake onto the single-tile game UVs. NOTE: a mesh authored with
single-tile UVs (the armored car’s game mesh) needs none of this — it bakes fine on the current flat-albedo path.TurretizeAimLayer runtime handler: turretBone (substring) + turretAxis (Lab dropdown) retarget the
streamed heading slot onto our turret bone. Axis is per-model (Ehrhardt: 2 = yaw; 1/0 = pitch — the pitch axis is
the future artillery-barrel elevation knob). Original design notes retained below for the static-model corollary.[Aim] log in ClearAimLayer shows the
stream). The feature is therefore an ADDRESS REWRITE, not an aiming system: an aimBone registry knob (name
substring on our skeleton, the handPropBone pattern) + a remap mode where ClearAimLayer currently drops the
entries — rewrite their bone index to ours, with an axis/offset knob (donor axis conventions won’t match every
model; stamp explicitly, the props import-angles lesson). Open: does elevation stream separately from traverse
(second bone)?; does the sim only stream for donors it considers aim-capable (a donor-matching criterion)?
Intended first test candidate (2026-07-24): an Ehrhardt‑style armored car (“Ehrhdrdt E V” by Red Blue Pixel
Studio, Fab, Standard License, FBX + PBR) — it ships already rigged with a turret bone, so it’s the EASY case
(point aimBone at the existing turret bone; no auto-rig step). Bake static first, then remap the aim stream onto
the turret bone once the feature lands.
The static-model corollary (“turretize”): this gives STATIC models a tracking turret with zero animation
authoring — split turret from hull (part-name detection exists), auto-create a 2-bone rig at the turret pivot
and bind each part full-weight (the mech bone-parent→skin conversion’s exact mechanics, just with created bones),
bake through the animated path with a 2-frame identity clip (the held-stance pattern), then remap the aim stream
onto the turret bone — the ENGINE animates the aiming, same as vanilla armor. Reactive motion (aim/facing) never
needed clips even in vanilla; only cyclic motion (walks, bobs) does. Open extra: pivot placement quality
(auto part-centroid vs a manual nudge knob).silenceDonorGroundFx, spotted 2026-07-24). Ground effects ride the DONOR like
audio does: the Light Assault Mech (legged) stamps WHEELED TRACK decals from its APC donor. Fix = the donor-audio
pattern, not a re-donor (animal donors are melee-presentation pawns — swapping would break the mech’s ranged fight
infrastructure): find the track/decal emitter chokepoint (likely MecanimEvent- or movement-state-driven FX on the
sub-pawn — the same neighborhood the audio investigation mapped) and gate it per opted-in unit. Later composable
with a “replace with footprints” mode. Adds GROUND FX to the donor-matching criteria list (rotor/wheels, audio,
ranged capability, aim streaming, now decals).1751b74 “muzzle endgame lands — flash, smoke and tracers on the tracking turret”; was its own scoped session).
Implemented as the muzzleBone field + Hk_MuzzleRelocate prefix on PresentationSubPawn.GetBoneTRS(string) — see the
cracked mechanism + fix below; ArmouredCar set to muzzleBone: "Turret". The flash now anchors on the turret/gun on
fire. (If a turret pivot ever reads too low/centre on another model, pick a barrel-tip bone instead.)
On the Ehrhardt armored car the MG muzzle flash fires off-side (“mirrored”). ROOT CAUSE (verified): the donor is
Unit_Era6_Common_AntiAirGuns_01 (an anti-air gun — bones Azimuth, bras-*, Canon_down_*), and the flash is
the projectile’s Muzzle FxEvolverMaterial (“launch flash”, ProjectileAsset.muzzle) — a TRANSIENT VFX (NOT a
fragment; every donor lists only its body mesh) spawned at the AA gun’s Canon weapon socket, which doesn’t exist
on our renamed b###_ rig → it lands off-side. The spawn is NOT in PawnRangedFightSequence (stores the shooter
only) nor PresentationPawn (3525 lines, no muzzle/socket) — it’s buried in the HgFx projectile/particle
system. CHAIN TRACED (2026-07-24): the projectile+muzzle fire from a FireProjectile mecanim event on the
attack clip – PresentationSubPawn scans the clip for MecanimEvent.AlterationType.FireProjectile and stores it
as SimpleAttackMecanimEvent (~L1255-1267), processed by MecanimEventInterpreter (Amplitude.Mercury.Animation)
as the clip plays. The bone->world resolver is PresentationSubPawn.GetBoneTRS(boneName) (~L378:
GetBoneIndex(boneName) -> AnimationManager.GetBoneTRS). The AIM layer resolves the SAME way (SubPawn ~L639/657:
GetBoneIndex(reference.BoneName) – the donor’s Azimuth/Canon names), so the muzzle socket almost certainly
resolves by the donor’s weapon-bone NAME -> invalid on our b###_ rig -> off-side. Fire info via
IAlterationFireProjectileInfoProvider (SubPawn L179/813 = the pawn). NEXT: decompile MecanimEventInterpreter’s
FireProjectile handling (NESTED-type friction with ilspycmd 8.2 -> use dnSpy or a newer ilspycmd) to pin the
socket-NAME source + the muzzle-FX spawn call. QUICK-ALT CAVEAT: the ProjectileAsset is SHARED across all AA guns,
so nulling its Muzzle in place breaks the real anti-air units -> needs a per-unit projectile OVERRIDE.
✅ MECHANISM FULLY CRACKED (2026-07-24, decompiled Assembly-CSharp whole). AlterationFireProjectile.StartEvent
(the FireProjectile alteration handler): TRS boneTRS = controller.SubPawn.GetBoneTRS(mecanimEvent.ParentNameToLaunchVFXPosition);
Vector3 startPosition = boneTRS.Transform(mecanimEvent.PositionToLaunchVFX); then
PresentationProjectileManager.Instance.LaunchMuzzle(projectileAsset, startPosition, startDirection, up) (or
LaunchProjectile for the flying shot). So the muzzle position = SubPawn.GetBoneTRS(<donor socket name>).Transform(offset)
— and ParentNameToLaunchVFXPosition is the DONOR clip’s socket name (the AA gun’s Canon socket), absent on our
renamed rig. THE FIX (low risk): Harmony postfix on PresentationSubPawn.GetBoneTRS(string boneName) — for
our unit (match SubPawn→entry by SkeletonId, GetEntryBySkeletonId exists) with a muzzleBone set, when
Skeleton.GetBoneIndex(boneName) < 0 (donor socket not on our rig), replace __result with GetBoneTRS(ourMuzzleBone)
(our bone IS found → no re-redirect → recursion terminates). Config = muzzleBone (substring, e.g. Turret or a
central bone), runtime-only. Broadness note: this redirects ALL unfound-socket VFX on our unit to muzzleBone,
which for a donor-mismatched rig is the DESIRED behavior (all its VFX land on our gun instead of off-side). QUICK ALT (no relocate): null the
projectile’s Muzzle → no launch flash (Projectiles.md already documents clearing it). Note: this donor is one of
the few that fire MULTIPLE times (AA burst) so the flash repeats. General lesson recorded: a donor’s effect = its
skeleton + weapon sockets (already half-logged: donor.Skeleton / BoneInfos / donor fragment[N]). The new
Disable override flag (ModelDef.disabled, runtime) A/B’s our model vs the raw donor for exactly this kind of probe.socketBones) — ✅ VERIFIED IN-GAME 2026-07-24 night (ArmouredCar): flash, smoke AND tracers
all on the tracking turret. The winning recipe: socketBones: "Canon_Up_left=MW_T;Move_bloc=MW_T" (socket
ROLES decoded from the pin log: Move_bloc = fire POSITION anchor, Canon_Up_left = rotation/direction — not
what the names suggest) + runtime donor-offset compensation on native socket hits + the muzzleOffset world
dial ("0,2.6,0" — the rig’s gun-bone head sits at the model base, and the socket’s correct BIND height
provably does not reach the runtime pose; open engine question, the dial closes it empirically, no re-bake per
step). War-story hazards now guarded: prefix reentrancy (stack-overflow crash), the external-registry-edit slim
cache trap, per-shot log throttled to once-per-entry after calibration. Wired
end-to-end: rig_anim argv[11] (exact-named zero-weight leaf bones after the rename, before the fold; A###_
prefix on socketed models; loud failures for unmatched parents and sort-order violations), BakeConfig/ConfigFor/
slim-cache diff, Lab “Donor sockets (bake)” field, ModelDef.socketBones (bake-time; guard PASS). The ArmouredCar
entry is pre-configured (Canon_Up_left=MW_T; Move_bloc=Root) — next session: Unity recompile → re-Bake →
rebuild → fire: flash, smoke AND tracer origin should all sit on the (tracking) turret gun natively. Original
design rationale below. The interception chain
(GetBoneTRS redirect → StartVFXEvent pin → offset compensation) moved/killed the FLASH but smoke + tracer origin
still read the donor socket, and the compensated TRS raised a space question (flash vanished off-screen). The
correct architecture: bake EXACT-NAMED donor socket bones onto our rig (socketBones: "Canon_Up_left=MW_T;...",
zero-weight leaves, optional tip offset) so the game’s own lookups resolve NATIVELY — flash, smoke, and bullet
origin all correct-by-construction and turret-following. Wrinkle: Amplitude sorts bones alphabetically requiring
parents-first — socketed models switch the rename prefix b###_→A###_ so every real bone precedes any donor
name (gated; existing bakes byte-identical). Obsoletes muzzleBone for rebaked models; the runtime knobs stay for
quick fixes. Donor socket names discovered via the [Muzzle] GetBoneTRS diagnostic (armoured car donor asks for
Canon_Up_left + Move_bloc).RotationTranslation clips
(decompiled: vanilla tank treads/shuttle bones; GetPoseTRS zeroes translation only for Rotation-encoded
curves) — Laws 1/5 were OUR bake’s strip. Built: per-model keepTranslations (registry + Lab toggle), kept
curves scoped to the attack clip, delta-rebased, ×100 sandwich-compensated on the legacy path; multi-segment
recoil windows with /N speed steps. Verified end-to-end twice: a sliding test bone, then the M114’s real
kickback (recipe 442..530,305..441/2, Return 0, Slam 0). Root-caused en route: the slam-0 R=1e9 sentinel
put the RecoilArm pivot at a billion units → float32 chain collapse → every historical NaN import warning.
OPENS: treadize (tank tread shuttle bones — design ready, Jagdpanzer waiting), real deploy translations,
whole-carriage recoil, soldier run-bob restoration.donorOff= 0.80/0.85/1.20) from the single
Move_bloc anchor; the compensation currently flattens all onto one point. (1) Barrel variation — subtract
the MEAN donor offset instead of each event’s own: flashes scatter slightly around the muzzle like the donor’s
real barrels, essentially free. (2) Multi-mount fire — rotate successive fire events across several of the
model’s own gun bones (the Ehrhardt has four rigged MG mounts, MW_B/F/L/T) — needs per-event socket selection
state; bigger. Both are polish on a verified base, not fixes.SKM_ rips carry their own armature — two skeletons
in one GLB), @file part lists (the ~32 k Windows command-line limit), Blender 5.x Action.fcurves removal
(curves live in layers→strips→channelbags), spin-sign rule (+360 = forward for a +X nose), review UI
(6 roles incl. Edgecase, keyboard marking, classification filter, 4 hide sliders), JSON recipes, and a
clustering-accurate Verify report. Generated-rig calibration: turret axis Y, sockets/muzzle bone →
Turret, offset re-dialed from the dome center. SKM fast path — BUILT same day, preview-verified:
probe detects skeleton + ≥90% weights → bone-marking mode → rigfast spins the SOURCE bones (local axle axis,
signed for mirrored rigs), artist skeleton shipped unchanged (pivots + MW_* socket bones free). Field
finding: it inherits the artist’s weighting — the Ehrhardt’s front steering knuckles are weighted to the wheel
bones and rotate with them, so the shard path stays the quality reference (the shipped unit uses it); the fast
path is the four-checkbox route for clean-weighted rips. Original spec below. The Ehrhardt’s
_Spin.glb was hand-made in Blender (now documented step-by-step in Animated-Models.md); the tool version is the
missing sibling of turretize and the biggest lever on the “huge pool of static vehicle models” thesis: a headless
Blender script that (1) detects wheel parts — name pattern wheel|tyre|tire first, geometric fallback (cylindrical,
near-ground, mirrored pairs — the organ-gun classifier’s approach), (2) creates Root + a bone per wheel at each
part’s centroid (+ a Turret bone for a turret-named part), rigid full-weight skinning, (3) generates the LINEAR
Spin action (frame 0 = rest), (4) exports <name>_Spin.glb. Factory affordance: a “Prepare static vehicle…”
button that runs it and repoints the Model file. Output feeds the EXISTING verified path (Spin[0..0] idle +
Spin slice movement + convertRig + auto-ground + turretize/sockets). Risks: wheel detection on messy meshes
(single-mesh models need loose-part separation), axle-axis inference (mirrored left/right wheels spin opposite
if the axis flips — normalize to model-space).clipDeath) — play the model’s own death animation on PresentationPawn.TriggerDeath (the
hook already fires for the death SOUND; arming a one-shot clip window from the same seam is the pattern the
attack clip proved). Proving model: the gray wolf’s idle injured to dead reaction lft/rgt (private test rig).idlePatrolRadius/
idlePatrolSpeed): offset ObjectSpace.Translation along a slow closed loop (the position-offset path already
writes Translation per frame), play the MOVE clip, face the path tangent (needs an ObjectSpace.Rotation write —
read-only today). Risks: stride matching (path speed vs walk-clip foot speed, or it ice-skates), yielding to
every real state, battle second-PresentationUnit interactions. Composes with idle-alt: stroll → pause →
howl/eat → stroll.GUID nibble-swap encoding + keep-GUID re-bake; registry corrupt-guard/atomic-write/backup lifecycle; two-window ownership merge (both directions, post-fix); Harmony patch exception discipline; cross-thread sample locking + ConcurrentQueue handoff; deploy ramp math; join/decimate + albedo-extraction blocks; frame clamping; noise-filter re-entrancy; district bake+registry editor flow; Plugin.cs config wiring.
Ten open findings from the pass that followed the Abomination spike-geometry incident (root cause: a safety net that could never arm itself). Recorded separately with file:line, in-game symptom, trigger and suggested fix: Audit-2026-07-31.md.
Top item (now FIXED in c6154a6, pending in-game verification) — the wrong-skeleton rescue was gated on Hooked (animated-or-freeze), so eight shipped STATIC models have
no rescue path at all: the same failure 0c0b12f fixed, still live for them.
A full multi-agent review of the plugin and editor. All CONFIRMED findings were fixed, verified in-game/at-bake,
and merged — district clone leak, hideSubPawns coexistence, coreDesc matcher unification, formation
pure-repoint reform, GameBinding army-walk-root coverage, audio death/battle gate, three runtime-clone leaks,
state-machine gate mismatch, the facing-after-respawn interaction, and the three bake silent-mis-bake guards (4A/2A/4B).
See the dated CHANGELOG entries. What remains below is the PLAUSIBLE / low-confidence tail — deferred, not
dismissed.
Tools/, needs a failing repro before touching gating)convert_rig vs clean_units gating asymmetry (rig_anim.py: topological bone rename gated on
convert_rig alone, but the clean-unit export + rest/scale fold on convert_rig OR clean_units_input). A
DeployArmV2 FBX with argv[8] absent/0 + zero rotation → convert_rig=False, clean_units_input=True → clean
export runs but bones keep raw part names → Amplitude’s alphabetical sort can put a child before its parent →
ParentIndex ≥ own → model explodes. Fix candidate: make the two gates the same flag. RISK: changing bake
gating without a failing repro can break a verified path — get a repro first.rig_anim.py ~510-520): the kept == 0
hard-fail only runs when a bone-prefix filter is supplied; a no-prefix bake of a constant action bakes frozen.rig_anim.py ~1038): the socket-order guard uses Python ordinal >, but it is
predicting C# string.Compare (culture-sensitive) — a donor name whose culture order differs from ordinal order
passes the guard yet sorts the socket before its parent. Narrow (uppercase donors agree).%03d bone-index width (rig_anim.py ~998): A1000_ sorts before A999_, inverting order above 999
bones. Unreachable under the 240-bone cap today; a hard assumption worth a comment.TargetMethod param-count filters: Hk_DistrictGroundMaterial / Hk_DistrictHexSculpt
(UniversalInject.Hooks.cs) resolve by method name with no GetParameters().Length filter (unlike their
siblings) — a future overload could be patched silently. Hk_BattleTurnProbe (BattleTurnPatch.cs) indexes
GetParameters()[0] without a length check.Hk_SilenceEvents.Prefix (Hooks.cs) reads eo.name (native marshal alloc) on every Wwise PostEvent
before its gate; mirror Hk_AudioTrace’s early-out. (Both also patch the same PostEvent = two detours/sound.)try/catch on the multi-call postfix bodies of UniRegisterHook / UniRepointHook /
Hk_DistrictRepoint — they sit inside core loading methods and rely entirely on every callee being self-guarded.LongestMatch equal-length tiebreak (UniversalInjectPatch.cs ~889): among equal-length key matches the
first in registry order wins; the count>1 warning fires but the (possibly wrong) bind still proceeds.TryLearnClass (UniversalInject.Clips.cs ~198): takes the FIRST class-sample within 2u (not nearest) and
caches it permanently — a stacked neighbour of a different class can mis-categorise a unit’s turn-ease for the session.deployMoveState (Combat.cs) is nulled cross-session but never pruned within a
session (siblings are).Combat.cs ~605-624): module statics assume StartEvent→GetBoneTRS→EndEvent is
atomic; nested/interleaved fires of coexisting shooters could cross offsets. Confidence limited (needs the engine
to actually nest these).BoneRotation slot clobber (UniversalInject.Pose.cs): on the useDonorClip path ApplyRotorSpin /
ApplyRotorTrim / ApplyGunElevation write overlapping low slots — a rotor-spin + trim combo can clobber.rotorSpinBones / rotorSpinSpeed are plugin-only fields with no editor ModelDef field. Partly addressed
2026-08-16: the schema-parity guard now allowlists them as intentional runtime-only keys (like scale), so the
gate is green — but the underlying risk stands: a hand-authored pack.json value is silently wiped on the next Factory
Save (JsonUtility serializes only ModelDef’s fields → unknown keys dropped). Same wipe hits every allowlisted
runtime-only key. The real fix is a round-trip that preserves unknown keys (or promoting these to ModelDef); latent
(ENC unused), so deferred.idleAltInterval default mismatch (editor 25f / plugin 0f) — a pack.json missing the key gets idle-alt
disabled instead of the documented 25s cadence.haf_districts.json has no regex fallbackParseDistricts = Usable(ParseDistrictsRaw(text)) with a per-entry try and RegexDistricts as the fallback. Original: — one malformed char disables ALL custom districts (the model
registry has a fallback; districts don’t).animated flag written but not readcheck_schema_parity.sh lists animated in its “baker fields not read at runtime (bake-time-only, expected)” allowlist, so the asymmetry is declared and gated, not drifting. Original: — the plugin infers animation from the clip-GUID presence, so the field
is a silently-ignored authored value.1E-05); narrow (malformed-JSON path only).Progress (2026-08-16, reflection-fragility A5): the catalog now also writes a machine-readable
haf_bindings_report.txt every launch, the last raw Type.GetType site was migrated onto an accessor, and a
member audit took coverage from 31 types / ~49 members to 49 types / ~124 members (verified missing_members=0).
See CHANGELOG + Framework-Review A5. What remains:
AssetReferenceRepository, GroundMaterialDefinition, GroundMaterialAuthoringData, StaticString, Databases), and check-catalog.sh — widened the same day to see AccessTools.Field(x.GetType(), ...) — passes over 371 names. Original: AssetReferenceRepository,
Amplitude.StaticString, GroundMaterialDefinition, AnimationVariableNames, HgFxAnchorComponent. A rename
there degrades silently. (The SimulationEvent_* combat types resolve with their own local warnings, so they’re
loud-but-off-catalog.)GameBinding at all — the Skeleton, pawn-entry / GPUPawnDescriptorEntry /
fragment structs, FxOneMeshStruct, PresentationLevelBuildComponent on the hottest injection path (the
member audit surfaced these; structs need new accessors + a different resolution, so it’s a distinct batch).A full-framework review, adversarially verified finding-by-finding against the code and this project’s own record (see the Framework-Review 08-17 row; the fixed-same-day HIGH — the glbconv source split-brain — is in the CHANGELOG). Most findings were already admitted here or ADR-settled. What survived as new and deliberately deferred:
ModelEntry’s public repointed / descId /
animId bind from any name-matching key (the old hand-list parse was an implicit whitelist), and a key colliding
with a readonly collection (phaseTracks) throws inside ToObject → the whole pack silently drops to the regex
fallback. The parse-site comment assumes no matching keys exist; nothing guards it.registryConfigKeys: shared-schema fields by reflection +
the GUID arrays + plugin-only config) — fail-safe for new runtime-state fields, chosen over per-field
[JsonIgnore] (fail-open: one forgotten attribute reopens the hole). Two pinning tests (hostile state keys →
defaults; readonly-collection collision → stays on the object parse). Suite 61 → 63.References\ DLLs need a strategy first)..github/workflows/ci.yml
builds + runs all 61 tests on every push, using tools/fetch-refs.ps1 (reference DLLs from public sources — the
vestigial Amplitude reference turned out removable, so no game files are needed). bindcheck stays manual (needs
the game’s DLLs).BackupWindow has an offsite destination (HAF.Backup.OffsiteDest), auto-offsite, per-backup zip, a skip-when-unchanged signature, and a refusal when the destination is missing; the operator keeps the compressed copy in cloud storage. Original: — all code is on public GitHub, but the licensed source models
and baked assets exist only on this machine plus same-machine D:\HAF_Backups (now noted in Backup.md). One disk
event loses the un-reproducible half of the project.cb vs cbb GUID-component namingcb/cbb/ca/cba/aca/a2a) no longer exists: clip guids go through the ClipRoles table (one enum value + one name/tag/key per role). Only the two unambiguous prefix groups remain — sa..sd (skeleton), ta..td (texture). Original: (clip vs combat-clip; also ca/cba/aca/a2a) — a one-character typo
in the 44-int wiring compiles clean and mis-wires a clip role; nothing tests the field→InjectClipCollections
wiring. Rename or add a wiring test when next touching the schema.Diag gate ([REND]/[SRCFIX]/[CRUSH]/[GHOST]/[DESC],
added 08-03/04 after the Phase-3 quiet-logging pass; most are change-gated or one-shot, but the [REND] census can
log ~26 LogInfo lines / 15 s per hideSubPawns entry).[HIER]/[LAYER]/[FX]) now go through Plugin.Diag; the operator-driven [BISECT]/[REND2] command responses
deliberately stay loud (they answer a typed haf_ghostbisect.txt command).Plugin.cs:83 still names community.humankind.encaccessproof.cfg; the live config is
community.humankind.haf.cfg.