Skip to content

Review Backlog

github-actions[bot] edited this page Sep 14, 2026 · 48 revisions

Auto-generated from the repo docs by tools/sync_wiki.sh — edit the source Markdown in the repo, not this wiki page.

Review backlog — deferred findings from the 2026-07-19 two-round audit

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.

⚠ How to use this file (added after the 2026-08-23 sweep)

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:

  1. Re-read the code it names. Line numbers and helper names drift; some entries describe code that no longer exists.
  2. Check the feature is switched ON. A defect behind a blank config key is not the one a player hits.
  3. Check the number is still the number. Panel/log figures quoted here were true on the day they were written.

Verification sweep — 2026-08-23

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

Needs a decision first

  • Gate the rest-fold on the convertRig flag? — DECIDED + IMPLEMENTED 2026-07-19: split gating. The destructive rest-fold (rest rewrite + visual rebake) is now conversion-path only (_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.

From the 2026-09-14 critical review (confirmed in source, unfixed)

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.

Verification machinery (fix first — a green gate that cannot see the shape is worse than no gate)

  • check-member-shape.sh matches only Convert.To*(GetMember( — blind to the Mem( wrapper, and one live dead-sentinel sits behind it.FIXED 2026-09-14, drilled. Worse than reviewed: the regex was also blind to the qualified 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.FIXED 2026-09-14, drilled. Five catalogued (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.
  • Descriptor repoint has no test at any tier.FIXED 2026-09-14. 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.FIXED 2026-09-14 for the estimator and the BSP partition: both moved to the pure 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.FIXED 2026-09-14 (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.FIXED 2026-09-14 (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.
  • Tier-1 bake rows that cannot fail for the feature they name.FIXED 2026-09-14, NOT YET RUN (Unity was open, the headless lane needs it closed): windingFix bakes an INSIDE-OUT cube, asserts the premise (inward without the fix) and then outward with it, keel-plane faces excluded (undecidable from the origin by design); atlas1024 asserts exactly 512×512; Multi asserts both materials' colours reach the atlas. Run by the user the same day: atlas rows PASS; the winding row's first version failed and exposed that 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.
  • No nested-parent fixture for the plane/facing cut.FIXED 2026-09-14 (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.FIXED 2026-09-14 (one 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.

Editor — bake pipeline

  • P1 — a GLB re-export can bake a stale .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.

Editor — Lab / Workshop windows

  • Workshop cut preview sums three 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.
  • Flat-surface filter judges "level" in import orientation; the inside-out verdicts honour the Orientation dial. 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.
  • Formation Override "Remove" re-selects the neighbouring entry and erases its own confirmation. 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.
  • A probe-mode Blender traceback is reported as "no mesh parts — is this a mesh model?". 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.
  • Preview helper objects leak per window close / domain reload. 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).
  • Workshop pays the full probe (BVH visibility + island flood) just to obtain a preview FBX. Unmeasured; worth a _lap on a large ship before deciding whether a --preview-only flag is warranted.

Blender tools

  • RunBlender discards stderr on the success path (see the probe-traceback item above — same root).
  • Probe writes its 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.

Plugin — runtime core

  • Vanilla-scaled pawns take the boxed-reflection path every frame while compiled accessors exist.FIXED 2026-09-14 (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-347GetMember(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.FIXED 2026-09-14 (reset moved above the 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.FIXED 2026-09-14 (key = poll + exception type). 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.FIXED 2026-09-14 (census once per entry per process; the 3 s poll stays for the donor-struct source fix and the comment now says so). 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.FIXED 2026-09-14 (compiled manager + slot reads, managers pruned after five empty sweeps, the sweep excluded from the "ours ns/add" mean and stated as its own segment). Pose.cs:445-467arr.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.
  • Two hooks claim a per-session AnimationLoad re-arm that cannot happen. 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).
  • The [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/445ratio 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.

Plugin — district / combat

  • District tracking is gated on the global config, not on the registry. 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.
  • Live config runs the "experimental, untested" battle hold. 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.

From the 2026-08-23 critical review

  • Four float x = D; TryParse(cfg, out x) sites — the default is dead code.FIXED 2026-08-23, mutation-drilled. 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 liveDistrictInject'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: pack.json and the global cfg both set them, with an implicit precedence.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 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.

  • SelectorTile is 219 µs/frame — 36% of HAF's per-frame cost — and unexplained.FIXED 2026-08-23, 219 → 6.3 µs, drilled twice. The measurement ended it: 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.

  • Plugin.Update's try/catch is one bag — one throwing poll skips every poll after it.FIXED 2026-08-23. Each step now runs in its own 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).

  • haf_districts.json has no regex fallback — one malformed char disables ALL custom districts.FIXED 2026-08-23. The twin of the pack-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.

  • PackValidator has no rules for pack WRAPPER metadata — only for model entries.FIXED 2026-08-23. 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.

  • schemaVersion is parsed, printed, and never enforced.FIXED 2026-08-23. The decision the entry asked for was already on the page: 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.

From the 2026-08-22 critical review (confirmed, unfixed)

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.FIXED 2026-08-22. The guard is now the single definition used by all four Factory write paths, the button greys like its neighbours, and the check runs before the confirm dialog. It also grew the shape it had always missed (<new model> typed onto an existing name), and fixing it surfaced a third gap: 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.

  • The trail-spread sign heuristic may test the wrong axisDRILLED AND DOWNGRADED 2026-08-22. The headless drill (M114, 13 yaw angles) shows the shipped rig is correct: at yaw 0/90/180 the two trails take opposite signs and open to a ~102-unit spread. The critical does not reproduce. What the drill did confirm is a narrower fragility: at every yaw in between, both arms take the same sign and the spread collapses to ~12 units — off-axis the test measures the arm's foreshortening in x rather than distance from the centreline. The live path is real (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 CachedField( family.FIXED 2026-08-22, and it was worse than reported: teaching it 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)).

  • The sub-pawn walk double-counts, so its coverage number can read better than complete.FIXED 2026-08-23. Deduped by 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.

  • Two gates have blind spots and neither is in CI.HOT-PATH HALF FIXED 2026-08-22: the grep is case-insensitive with stand-alone word matching (the naive -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.FIXED 2026-08-22. Guard restored as !(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.FIXED 2026-08-22. The queue is drained by the re-arm sweep, and the fence is redrawn by intent rather than by "the type has 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. Still open from the same review: FIXED 2026-08-23 — reset in 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.FIXED 2026-08-22. An unreadable 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.FIXED 2026-08-22: 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.

Worth fixing before the next model of the affected kind

  • 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 block — FIXED 2026-07-19: (a) the tube's parent is now sampled into src_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.

  • Feature Test Tier-2 bypasses ConfigFor — FIXED 2026-07-19: the animated fixtures now clone the registry entry and route through ModelFactoryWindow.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.

  • Unify the delete-first suffix lists across bake paths — FIXED 2026-07-19: 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.

  • District axis has no session re-arm — FIXED 2026-07-19: 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.

  • Plugin perf pass (late-game GC stutter) — DONE 2026-07-19 (allocation-elimination scope, behavior untouched): precomputed 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).

  • Plugin unbounded per-instance dictionaries — FIXED 2026-07-19 (cross-session): all per-instance maps (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.

Quality-of-life / lower risk

  • RetextureWindow Apply-without-Edit — MOSTLY FIXED 2026-07-19: Apply onto an existing entry the form wasn't loaded from now asks first (Edit pre-loads and skips the dialog). Still open: Apply can create a duplicate Retex_ entry for a pawn that already has a model entry (two entries, same pawn, undefined winner).
  • TechTreeWindow — PARTLY FIXED 2026-07-19: skipped edits now survive a partial save (only fully-written entries leave the overlay), and a MouseUp outside the canvas ends the drag (no more phantom teleport). Still open: _pending isn't serialized — a domain reload drops staged, unsaved edits.
  • ProjectileBaker — MOSTLY FIXED 2026-07-19: sprite donors (null mesh) now refuse to bake with the verdict's guidance; Dump only auto-fills the donor field on a ✓ verdict; ApplyTint no longer wipes the clipboard. Still open: invalid impact-donor GUID silently ignored; muzzle swapped beyond the tooltip's documented scope.
  • PropBaker — FIXED 2026-07-19: 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.
  • DatabaseBrowser — FIXED 2026-07-19: ExitGUIException is rethrown before the generic catch.
  • Animated multi-material albedosFIXED 2026-09-02 (editor 0.5.2), and this note undersold itself: the .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.
  • Regex-fallback parser drift (plugin)STALE, closed 2026-08-23 sweep. 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.
  • Misc small: registry Save wipes hand-edited pack wrapper metadata (STALE, closed 2026-08-23 sweep: 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 lists (fixed 07-19); ConversionGateTest litmus synthesis sequential ReadToEnd pipe-deadlock pattern; texture leaks on bake failure paths; corrupt-registry error-spam from per-OnGUI Load() in Retexture/Sound windows; SoundWindow ParseWav negative chunk-size guard (fixed 07-19); parity script false-PASS shapes (empty N/R sets; awk section extraction); no-op root collapse is dead code post-rebake (every bone gets keyed by the visual rebake); multi-armature sources mis-convert silently; blend_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.

Queued for the package release (not review findings — branding/packaging debt)

  • Framework identity migrationEXECUTED 2026-07-19 (user call: zero external installs yet, so no compat period needed): assembly/DLL → 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.)
  • Pack pre-flight validator (third-party author DX)STALE, closed 2026-08-23 sweep. Built: 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.

Future feature seams (mapped, not built — the discovery is done, only the 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).

  • Normal-map atlas support (shelved 2026-07-24; the one real UV-pipeline gap for the Ehrhardt's 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):

    1. UDIM assembly — the chassis normal is 5 tiles (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.)
    2. Tangent-correct repack — the real work. A normal map can't be atlas-packed like albedo: for every UV island the atlas rotates or flips, the normal's R/G channels must be rotated/flipped to match, or lighting inverts on those islands. This is why it's a pipeline feature, not just a second texture slot.
    3. Normal-appropriate import — linear (NOT sRGB) sampling, TextureImporterType.NormalMap, normal-safe compression + mips; a naively-imported normal atlas is read as colour and lights wrong.
    4. Runtime wire-in, per variant — point the pawn material's normal slot at our atlas instead of neutralizing it. The set ships 5 variants (V1–V5), each with its own 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.
  • ✅ Aim-layer REMAP — SHIPPED as "turretize" (2026-07-24), verified in-game on the Ehrhardt armored car. Built as the 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-layer REMAP — vanilla-style turret/head target tracking (requested 2026-07-24). Vanilla units aim by a procedural bone-rotation layer: the sim streams the aim angle, the presentation writes it onto specific bones on top of the playing animation. The layer still streams for our injected units — but addressed to the DONOR's bone indices, which resolve to the invalid-index sentinel (0xFFFFFFFF) on our replaced skeleton and land on nothing (proven during the Law-5 fire investigation; the throttled [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).

  • Donor ground-FX suppression (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).

  • Muzzle-flash relocate — ✅ VERIFIED IN-GAME 2026-07-24 (commit 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.

  • DONOR SOCKETS (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).

  • TRANSLATION UNLOCK — SHIPPED & VERIFIED (2026-07-25/26). The engine plays 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.

  • Fire-effect refinements on the verified muzzle system (spotted 2026-07-24, unbuilt). The pin log showed the AA-gun donor's multiple barrels as VARYING per-event offsets (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.

  • "Vehicleize" — VERIFIED IN-GAME 2026-07-25: the shipped ArmouredCar now runs a Lab-generated rig (grounded, turret aiming, muzzle flash calibrated). The first real-model run (3,350-shard Ehrhardt rip) drove a day of hardening, all field-verified: per-hub wheel clustering (per-part bones shred wheels — off-axis spokes pinwheel about their own bbox centers), per-bone join (3,350 objects timed out the bake's 180 s Blender step; 6 meshes take ~11 s), stowaway-skeleton strip (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).

  • Death clip role (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).

  • Idle perimeter patrol — presentation-only stroll around the tile while plain-idle (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.

Verified clean (don't re-litigate without new evidence)

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.


2026-07-31 audit — "silently disarmed" class

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.

2026-08-16 critical review — whole framework (plugin + editor)

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.

Bake scripts — PLAUSIBLE (ENCReload Tools/, needs a failing repro before touching gating)

  • 1A — 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.
  • 4C — empty/constant primary action → frozen clip with exit 0 (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.
  • 1B — ordinal vs culture sort (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).
  • 1C — %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.

Plugin — low-risk hardening (Tier 4)

  • Harmony 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.)
  • Belt-and-braces 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.
  • Diagnostic-map growth: deployMoveState (Combat.cs) is nulled cross-session but never pruned within a session (siblings are).
  • Muzzle-compensation stash (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.

Data contract — low (editor ⇄ plugin)

  • 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. FIXED 2026-08-16 by the shared-schema field initializers (one authoritative default = 25f, test-pinned; see the Framework-Review generic-parse row).
  • haf_districts.json has no regex fallbackSTALE (duplicate): already struck above, fixed 2026-08-22. ParseDistricts = 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 readSTALE, closed 2026-08-23 sweep — this is BY DESIGN and already policed. check_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.
  • Regex-fallback float fields can't parse exponent notation (1E-05); narrow (malformed-JSON path only).

GameBinding — remaining catalog gaps (extend the "make drift loud" template)

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:

  • District-ground/hex support types resolved reflectively but off-catalogSTALE, closed 2026-08-23. All of them are catalogued now (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.)
  • Members read off types not yet in 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).

2026-08-17 verified critical review — surviving deferrals

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:

  • Generic parse binds runtime-state fields from pack JSONModelEntry'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. FIXED 2026-08-17 with a config-key whitelist strip before the generic map (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.
  • No CI service — the entire automated gate is the per-clone opt-in pre-push hook on one machine; the docs say "CI-able" three times but nothing runs the build/tests/bindcheck automatically. A GitHub Actions workflow closes it (the gitignored References\ DLLs need a strategy first). FIXED 2026-08-17: .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).
  • No offsite copy of the unversioned working setSTALE, closed 2026-08-17/22. 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 namingSTALE, closed 2026-08-23 sweep. The confusable clip set (cb/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.
  • ~~Ghost-hunt log tags bypass the quiet-by-default 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).~~ FIXED 2026-08-17: the 15 automatic lines (incl. [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).
  • Stale commentPlugin.cs:83 still names community.humankind.encaccessproof.cfg; the live config is community.humankind.haf.cfg. FIXED 2026-08-17.

Clone this wiki locally