-
Notifications
You must be signed in to change notification settings - Fork 0
Review Backlog
Auto-generated from the repo docs by
tools/sync_wiki.sh— edit the source Markdown in the repo, not this wiki page.
The two-round adversarial review (5 parallel reviewers per round over both repos; see the 07-19 Framework-Review row) fixed every HIGH-severity finding the same day. This file tracks what was found, verified real, and deliberately deferred — so the list survives outside the session that produced it. Ranked by when they'll bite.
An entry here is a HYPOTHESIS, not a finding. It records what someone believed when they wrote it, and the code has moved since. Restating one as fact — in a ranking, a commit message, or to the user — launders a guess into a finding.
That is not a theoretical caution. On 2026-08-23 three entries were acted on without re-checking, and all three were wrong in a different way: one described a bug whose diagnosis was backwards (the sub-pawn "double-count" is a legitimate superset — the fix collapsed zero); one was dormant, sitting behind a config key that is blank by default and was blank in the live setup, yet was ranked the top user-visible defect; and one had been fixed weeks earlier and never struck through. Before touching an entry:
- Re-read the code it names. Line numbers and helper names drift; some entries describe code that no longer exists.
- Check the feature is switched ON. A defect behind a blank config key is not the one a player hits.
- Check the number is still the number. Panel/log figures quoted here were true on the day they were written.
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
|
-
Gate the rest-fold on the— DECIDED + IMPLEMENTED 2026-07-19: split gating. The destructive rest-fold (rest rewrite + visual rebake) is now conversion-path only (convertRigflag?_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-bakedanimated-legacythrough the gated pipeline. In-game verification DONE (2026-08-02) — the howitzer checked out correctly after a real re-bake.
Six read-only passes over the 400 commits since 08-22; every item below was re-read in source on 09-14 (line numbers are of that day — re-check before touching, per the rules above). Items already tracked elsewhere in this file were skipped. Ranked by consequence within each group.
-
— FIXED 2026-09-14, drilled. Worse than reviewed: the regex was also blind to the qualifiedcheck-member-shape.shmatches onlyConvert.To*(GetMember(— blind to theMem(wrapper, and one live dead-sentinel sits behind it.UniversalInject.GetMember(, so the widened gate found four more live sites (FacingPersistIsLoaded/SimulationEntityGUID/FormationAngle, FormationOverridePawnDefinitionId) — six rewritten as typed reads (newTryMemberULongfor the GUID).READERSlist + self-check on object-returning(object, string)helpers; a plantedPeek(wrapper FAILS. See CHANGELOG "The gates learn to see their own wrappers".FormationOverridePatch.cs:358definesMem(o,name) => GetMember(o,name);:432bool loaded = true; try { loaded = Convert.ToBoolean(Mem(unit, "IsLoaded")); } catch { }thenif (!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)\bfires on both. Fix: rewrite both sites asMemberBool(unit, "IsLoaded", true)(asUniversalInject.Combat.cs:126already does) and widen the reader alternation in patterns (a) and (b). -
— FIXED 2026-09-14, drilled. Five catalogued (check-catalog.sh"all 370 catalogued" excludes three accessor families; at least four members are uncatalogued.strikertoo;SimulationArtilleryStrikeis a new binding, bindcheck 135/135), surface 370 → 382. OneHELPERSlist drives the alternation; the self-check discovers(object, string)helpers that reach a reader and FAILS on an unknown one — it tripped onTryMemberULongthe moment it was added to the source, before it was added to the list.databaseMatrices0D(RepoDump diagnostic) site-allowlisted with the reason. The alternation attools/check-catalog.sh:126,129lackedMem((17 sites),FireProbe.Member(/FireProbe.Int((CombatEventPatch.cs:31-32, 6 sites) and the typedMemberBool/Float/Int/Long/UInt+TryMember*readers (38 sites). Behind them:StrikerUnit(CombatEventPatch.cs:44),StrikerArmy(:54),AttackerEmpireIndex(:43),PrimitivePerParticleCount(UniversalInject.Inject.cs:1548) — none inGameBinding.cs.CombatEventPatchis the fire-on-attack hook: a rename silently disablesfireOnAttack. This is the third widening of this alternation (08-21, 08-22CachedField/GF, now). Fix: add the names, catalogue the four, and add a self-check that grepsstatic \w+ \w+\(object \w+, string \w+\) =>.*GetMemberwrappers 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.csis 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-advancedFragmentCountboth 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+100arithmetic; an off-by-one is the "spike plague" family. Nothing inTests/reaches it; the smoke has no fragment-count verdict;BakeFeatureTest.cs:162asserts the baker side only. It is pureArray+FieldInfowork — extractRepointDescriptor(...)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 linedescriptor[defId].FragmentCount == bodyFrags + chunksper multi-mesh entry. -
— FIXED 2026-09-14 for the estimator and the BSP partition: both moved to the pureSplitForQuadCeiling/EstimateQuadscovered only by the opt-in editor lane;ReportBakedQuadscan verify nothing and pass.editor/QuadEstimate.cs, compiled into the test project (8 tests: edge-sharing pair, disjoint, 3-fan, Faceted quads == tris, welded grid, partition covers every triangle once under budget in a stable order, chunk cap). Drilled: tris/2 and an unsorted partition both go red.ReportBakedQuads's "verified nothing" warning is unchanged (game-type).editor/UniversalBaker.cs:1686-1793; assertions live inBakeFeatureTest.cs:128-186, which run viatools/editor_tests.ps1— not incheck.shnorci.yml.ReportBakedQuadsreturning 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 intoTests(theEditorRules.cspattern): 2 tris sharing an edge → 1; 2 disjoint → 2; 3-fan → 2; N faceted → N (the tris/2 trap of 09-12). -
— FIXED 2026-09-14 (EffectiveDensityBoostunreachable in xunit as written.DistrictRules.NeededBoost, 9 rows incl. the opt-out and the no-evidence cases).DistrictInject.cs:862-879;FxMeshTrianglesreturns 0 without the game so the auto-size branch never executes in a test. ExtractNeededBoost(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. -
— FIXED 2026-09-14 (PART|parser hard-caps at 8 tokens; a 9th field empties the Vehicle Lab silently.VehicleLabRules.TryParsePartLine+FlatSharein 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:1370okLen = t.Length == 5 || (t.Length >= 6 && t.Length <= 8 …),vehicle_rig.py:615prints 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:6still 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 thatWriteCube's fixture had been wound inside-out since it was written — corrected (outward by default,insideOut: truefor 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" assertsm != null && r.okon a consistently wound cube;:100-102"atlasMaxDim=1024 keeps the 512 source" accepts128 ≤ width ≤ 1024;:188-193Multi asserts onlyatlas != 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 andExtractPartbounds carry the parent's rotation and Z offset).GlbDisconnectedParts.cs:830-852composes the parent chain; every fixture inTests/GlbPlaneCutTests.csis a root node, so reversingMul(world, local)passes all 11 tests. One fixture: Strip under{translation (0,0,5), rotation Z90}, cut on world Y at 1 → 2/2,ExtractPartbounds shifted. -
— FIXED 2026-09-14 (oneBakeSmokeTest"one representative per bake path" can pick a texture-only override as the static/Auto representative, skip it, and PASS.IsTextureOnlypredicate, applied before theGroupByand at the per-entry skip, so the two cannot disagree).BakeSmokeTest.cs:39-41groups by(animated, materialMode, converted)and takesg.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 beforeGroupBy.
-
P1 — a GLB re-export can bake a stale
.obj.UniversalBakerprefers the.objbeside the source (cachedFull),objPathdefaults to.obj, andviaGlbconvis read from the current config: a model whose GLB changed but whose old.objis still on disk bakes the old geometry, silently. Fix: key the cache on the GLB's mtime/size (or always re-run glbconv whenviaGlbconv), and log which file was actually loaded. -
ModelRegistry.Load()Thread.Sleep(250)per OnGUI event. Reached through the Factory's name-collision block atModelFactoryWindow.cs:994; while the block is shown every repaint/layout event pays 250 ms. -
BackupAutoexclusion misses_PreviewMesh1..31. Only the first preview mesh name is excluded; the chunked previews are backed up on every bake.
-
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.0onfloat[]— single-precision sum, then widened;GlbDisconnectedParts.cs:629sumsVec3doubles. Same for the facing normal (:398-402vs: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.0and(double)p[i1] - p[i0]; better, expose the twosideAlambdas fromGlbDisconnectedPartsand have the preview call them; add aGlbPlaneCutTestsrow on a flat face at exactlyMinwith 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, 291tests|c.y|/a2 ≥ 0.866on the preview instance, which never appliesmodelRot(vehicle_rig.py:544-546"stays in import orientation"), whileProbeRotArg(: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 ofmodelRotbefore the cos-30° test. -
Formation Override "Remove" re-selects the neighbouring entry and erases its own confirmation.
FormationOverrideWindow.cs:115readssel = Popup(selected, …)BEFORE the Remove button; Remove setsselected = 0; status = "Removed …";:132if (sel != selected) { selected = sel; OnSelect(); }then loads the entry that shifted into the old slot and blanksstatus. Remove the LAST entry ⇒selectedout of range. Fix: move the select-check above the button (asAnimationLabWindow.cs:734-744already orders it) or setsel = 0on 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-2199logs stderr only when stdout containsVEHICLE ERROR, so a crash in the flip-verdict/visibility passes leaves 0 parts andProbe()blames the model at:1400-1403.ModelWorkshopWindow.cs:308discards both streams. Fix:partCount == 0 && done == null(or stderr containsTraceback) ⇒Debug.LogErrorboth streams; wrap probe mode in_guard. -
Preview helper objects leak per window close / domain reload.
VehicleLabWindow.cs:431-436destroysinst/pruonly —waterMesh(:1666),levelMesh(:1710) never;ModelWorkshopWindow.cs:322-330never destroyshighlightMat/cutMatA/cutMatB;AnimationLabWindow.cs:209-217missesfitRefManMesh(:397). -
Workshop pays the full probe (BVH visibility + island flood) just to obtain a preview FBX. Unmeasured; worth a
_lapon a large ship before deciding whether a--preview-onlyflag is warranted.
-
RunBlenderdiscards stderr on the success path (see the probe-traceback item above — same root). -
Probe writes its
VEHICLEsentinel before the preview FBX export, so a crash in the export leaves the Lab showing the previous preview with a fresh part list. -
_projectis 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.
-
Vanilla-scaled pawns take the boxed-reflection path every frame while compiled accessors exist.— FIXED 2026-09-14 (PawnFast.Scale/SetScalewith the reflection fallback;MaybeSwapFormationBySizeshort-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 isPoseOurs(32 × 7 µs:PoseAnim+DonorWorld), the next target. The scaled-vanilla path itself was not on screen (Biremes off-map) and is still unread.UniversalInject.ScaleEra.cs:343-347—GetMember(entry,"ObjectSpace")/GetMember(oss,"Scale")/ twoSetMembers 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 itMaybeSwapFormationBySize(:233-262) runsFormationOverride.SizeThresholdsFor— a linearOrdinalIgnoreCasescan over every formation link (FormationOverridePatch.cs:611-617) — before thesizeFormAppliedearly-out at:262. Live today (Biremes ×2); a rules-only pack scaling every ship multiplies it by the fleet. Fix:PawnFastwith the reflection fallback; move the early-out above the scan. -
— FIXED 2026-09-14 (reset moved above the gate).lastPawnMatchedis reset after the pose gate.UniversalInject.Pose.cs:205returns before:206 lastPawnMatched = false;Hooks.cs:108then bills every vanilla add toPoseOurswhile the flag holds the last matched pawn'strue. Reachable with a static-only pack after a session re-arm orUniversalInject=false— the vanilla/ours split in the perf docs inverts. Meter-only. Fix: move the reset above the gate. -
— FIXED 2026-09-14 (key = poll + exception type).Plugin.Poll's log-once key includesex.Message.Plugin.cs:497"poll:"+name+":"+ex.GetType().Name+":"+ ex.Message— a varying message (KeyNotFoundException, Unity's "has been destroyed") logs the full stack every frame and growsonceKeys(: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 onname + type, message in the text. -
— 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).ProcessSubPawnVisualsis documented as a one-shot dump but is a permanent 3 s poll with a 15 s full-sceneFindObjectsOfType<Renderer>that mutates renderers.Plugin.cs:563says "no-op once dumped";Inject.cs:461"keep polling",:509-527scans the scene every 15 s perhideSubPawnsentry and setsr.enabled = falseon 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 thatCrushGhostSlice/PruneCloneRenderOutputskill the ghost; correct the comment either way. -
— 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).SweepForStraysis O(entries × managers × pawnCount) boxed reflection on a 2 s timer, bucketed insidePoseOurs.Pose.cs:445-467—arr.GetValue(i)+TryMemberInt×2 per slot althoughPawnFast.SkelId/DescIdare 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 throughPawnFast, prune managers whosepawnCountreads 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-447vsUniversalInjectPatch.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) andRearmProjectileOverridesnever run for a second session — props survive only via theTickPropRegistersafety net, projectile overrides only because they mutate process-lived assets. Both axes off by default. Fix: hang both on thePawnManager.Loadseam the model axis uses (Hooks.cs:279); fix the comments (UniversalInjectPatch.cs:1036-1038contradicts itself three lines apart). -
The
[MainThread]audit on_typeCacheis incomplete.GameBinding.cs:66-69names three sim-thread hooks;Hk_AnimatedBonePoolHeadroom.Prefix(Hooks.cs:284) readsGameBinding.AnimationManageronPawnManager.Load, documented "possibly off the main thread". Practical risk ≈ 0 (the only post-Awake writer is the late-loadedAudioEventHandle); the written contract is wrong. Name the hook, orConcurrentDictionary. -
ScaleDescriptorMeshesscales the descriptor bbox by the LAST fragment's ratio.ScaleEra.cs:399/423/445—ratiois overwritten per matched fragment; a descriptor whose body was rescaled but whose last fragment is shared with an already-scaled descriptor getsdescRatio = 1⇒ vanilla-sizedBBoxMin/Max⇒ the enlarged unit culls at the screen edge. PLAUSIBLE — not traced to a shipped mesh-sharing pair.
-
District tracking is gated on the global config, not on the registry.
DistrictInject.cs:1694-1700wantTrack = DistrictMainRows set || DistrictSelectorTile set; a district scoped only throughpack.jsonis never added totrackedDistricts, soRearmDistrictScan()never fires for it and the terrain-hug district map stays stale for registry-only setups. Fix:|| Registry has any scoped district. -
groundAppliedlatches per entry NAME, not per district instance.DistrictInject.Scoped.cs:1318-1322applies once and setsentry.groundApplied; the prefix at:1351-1357then returnsfalse(suppressing the game's ownApplyGroundMaterialDefinition) 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 (aConditionalWeakTable), not the entry. -
meshPersistLoggedsays "diagnostic log dedup" but gates the strategic-zoom mesh work once per process.Scoped.cs:1128[ProcessLived("diagnostic once-per-name log dedup")];:1133if (!meshPersistLogged.Add(name)) return;sits before the work inKeepDistrictMeshAtStrategicZoom. 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. -
refreshArgsNRE in the documented scoped + isolate coexistence.DistrictInject.cs:702scratch buffer is allocated only at:752and:821(isolate resolvers), butmiRefreshChannelis also resolved at:1235,:1624andScoped.cs:420without it;:754,:823,:2073indexrefreshArgs[0]guarded only bymiRefreshChannel != null. A scoped district placing its selector first, then an isolate wonder ⇒ NRE; inDistrictApplyTextureit is caught and counted toward the 3-striketexErrorslatch, which then blames "apply failed 3x". Fix: allocate beside every resolve (oneEnsureRefreshChannel(plbc)helper). -
matchedFromTracked/matchedFromGuidssit outside the session fence.DistrictInject.cs:1084plainstatic intbeside[SessionScoped] matchedDistricts(:1083); after a reset the early-out at:1090can 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:7hold=1, whiledocs/Turn-Ease.md:113calls that path experimental and untested, andCombatEventPatch.cs:88returns early onBattleTurn.holdFireso the ranged-fire clip arms later. Either the doc is stale (it has been drilled) or the operator is running an untested path — decide and make the two agree.
-
Four— FIXED 2026-08-23, mutation-drilled.float x = D; TryParse(cfg, out x)sites — the default is dead code.outis definitely-assigned, so a failed parse overwrites the initializer: the shape reads "D unless the config overrides it" and means "0 unless it parses". Two were live —DistrictInject's footprint flat height on both its resolve and its accessor path, which then handed 0 to a consumer whose ownSetFlatHeightclamps 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 asPlugin.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 of0.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 atPlugin.ParseFloatquotes 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 anintvariant all FAIL; an uninitialised out-target, an inlineout float b, parse-then-assign, and the fixedParseFloatidiom all PASS. What it cannot see is written into the script (statements separated by a brace,outinto a field, a wrapper it doesn't know) — per Decisions, a gate's all-clear is only as wide as its regex, so widen it when a new shape appears and drill the NEW shape rather than assuming the old pattern reaches it. -
The footprint settings fork:— RESOLVED 2026-08-23 by decision: keep both, state the rule, log the winner. The fork itself was never the defect — the registry is what a pack authors, the config is what an operator tunes live, and collapsing them would cost the tuning loop. The defect was that the rule lived in a comment onpack.jsonand the global cfg both set them, with an implicit precedence.DistrictModeland in the shape of anif/else, so nothing told anyone which source had won. Now one pure resolver (Patches/FootprintPrecedence.cs) states it — an entry withfootprintMeshON 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: aboolcannot distinguish unset from false, so a per-field merge would treat every un-authoredfalseas an override. 9 tests, 4 mutations drilled. The side effect that matters most: the config branch is unreachable on the shipped pack (every entry setsfootprintMesh=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 anENCReload.*bundle glob inDistrictFactoryWindow/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.mdandMulti-Mod.mdnow 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 (32MenuItems, allTools ▸ HAF, none carrying ENC); the write target is what is left. Re-open when the tools get apackage.json/asmdef and a home outside ENCReload — that is the commit where these sites read an authored mod id. -
— FIXED 2026-08-23, 219 → 6.3 µs, drilled twice. The measurement ended it:SelectorTileis 219 µs/frame — 36% of HAF's per-frame cost — and unexplained.districts 2668 skipped 237.3 µs, 1 ours 5.6 µs— the poll walked every tracked district each frame to find one, on a list nothing ever pruned, with an O(n) dedup on add.Updatefell 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 areDistrictDebug-gated and latched, andResolveMainLayeris cached — none contribute. Known from the existing buckets:SelTileLoop ≈ SelectorTileand 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/SelTileOursnow split it and their call counts are the district counts. Do not fix until the numbers say which — that is what the 08-21 "diffuse" verdict got wrong. -
— FIXED 2026-08-23. Each step now runs in its ownPlugin.Update's try/catch is one bag — one throwing poll skips every poll after it.Poll(bucket, name, run)guard, with the failure attributed to its own site and cachedreadonlydelegates 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,Update391 µs vs 396 µs before (the guards cost nothing measurable). -
— FIXED 2026-08-23. The twin of the pack-haf_districts.jsonhas no regex fallback — one malformed char disables ALL custom districts.modIdcrash. 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;createdtracked 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+ReportFormationCollisionsnow 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": nullreachedResolvePacks' dictionary as a null key, and the resultingArgumentNullExceptionlatched the whole registry off with a stack trace naming no file. Fixed in three layers (source guard + post-condition, defence-in-depth skip, a discovered-pack breadcrumb in the failure log); see the CHANGELOG entry. 20 tests, mutation-drilled 15/20. -
— FIXED 2026-08-23.PackValidatorhas no rules for pack WRAPPER metadata — only for model entries.PackValidator.ValidatePackadds rules formodId/schemaVersion/dependsOn/loadAfter/overrides, each one mirroring behaviour verified inUniversalInjectPatchfirst rather than invented: a blank wrapper key falls back to the file name (WrapperStr); anoverridesentry with a blank field is silently dropped at parse; an unsatisfiabledependsOnmeans the pack is SKIPPED (the one Error — everything else is advisory, so the fail-soft contract stands, and a test asserts that); a futureschemaVersionis 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 neitherdependsOnnorloadAfternaming 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 intohaf_load_report.txt(inWriteLoadReport, not the pre-flight pass — the pre-flight iteratesentries, 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 (doesdependsOnresolve, 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,-strictin CI, the boot pre-flight), and it has ~30 content checks for bones/files/pawns/formats/ranges and zero formodId/schemaVersion/dependsOn/loadAfter/overrides. So a pack whose wrapper is wrong is now handled gracefully at runtime but is still never caught at authoring time, which is where the author can actually fix it. Not urgent — the runtime path is safe and warns by name — but this is the surface that should have caught it first. Note the boot pre-flight cannot cover this on its own: it runs after registration, so a registry that fails to load never reaches it. -
— FIXED 2026-08-23. The decision the entry asked for was already on the page:schemaVersionis parsed, printed, and never enforced.Multi-Mod.mdhas documented the contract since the pack format shipped ("Currently1. 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.HafSchemaowns the number,CheckSchemaclassifies 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.Versionis the definition, andtools/check-docs.shnow fails the push ifdocs/Multi-Mod.mdordocs/haf-pack.example.jsonquotes a different number — but ENCReload'sModelRegistry.cs:101declarespublic 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 referencesHaf.Schema(itsModelDefinheritsHafModelSchema), so the fix is small — writeHafSchema.Versioninstead of the literal — but it is a cross-repo change, and the guard that would enforce it belongs in ENCReload'sTools/check_schema_parity.shbeside 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 —
ParseModelsstrips every key not inregistryConfigKeysand 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 sharedHaf.Schemaproject, where the existing cross-repo parity guard (which already computes "baker fields not read at runtime") can hold it honest.
Every item below was re-verified in source during the review; the range's critical (the strike hold reusing a stale aim marker) was fixed the same day and is not repeated here. Ranked by consequence.
-
— 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:Make static…bypasses the name-collision guard.Upsertremoved with ordinal==while the guard comparesOrdinalIgnoreCase, so a case-only rename left two entries sharing one set of asset files on Windows.Upsertis 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 readActiveParts, and the UI and Generate now share oneFastPathpredicate and one list. -
The trail-spread sign heuristic may test the wrong axis— DRILLED 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_rotis 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 printed0 live pawn(s) examined, never a dropped clause. An unreadable oracle returns -1 and cannot pose as a confident zero. Five tests, mutation-drilled. -
One report can still say PASS on nothing.— FIXED 2026-08-22: the bake-test verdict reads NOTHING VERIFIED when no section passed, and the Console line becomes a warning. (Two of the original three are fixed: the smoke's live-pawn clause and the matched-but-never-repointed misfiling — see the entries above. Still cosmetic-but-dishonest: the remaining coverage clauses,SubPawnScene/LayersChecked/SeamsChecked/RolesChecked/SoundsChecked, are suppressed at zero rather than printed.) -
The catalog gate cannot see the— FIXED 2026-08-22, and it was worse than reported: teaching itCachedField(family.CachedField(/GF(surfaced 19 uncatalogued names, and a second pass for nested calls (GetMember(GetMember(x, "Inner"), "Outer")only ever yielded Inner) surfaced 13 more — includingFacingAngleOffset, the member the 08-21 review had named, which was never actually catalogued; its only mention inGameBinding.cswas the comment describing that review. Now catalogued withTagAsAbilitiesand bindcheck-validated. Still open (low priority): ~30 duck-typed reads over runtime-resolved types (mat.GetType(),voBox.GetType(), the skeleton buffer element atPose.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 A6CachedDerivedmechanism (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 wasPlugin.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 afterBindEscalateAfter(~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. -
alphaBoostis far weaker than its slider implies, and its diagnostic can't show it. (Editor-side, ENCReloadDistrictBaker.cs.) Its own comment records that the alpha GAIN is a no-op on a binary-alpha foliage sheet, so the dial collapses torounds = 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 byGetInstanceID()at the boundary ofWalkSubPawns, first occurrence wins, order preserved. Deliberately NOT inside the adders:AddUnitSubPawnsdecides whether to fall back to a holder-subtree search by testingresult.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. The56/46gap is not duplicates. With the dedupe shipped and reporting, the log readwalk verified against the scene scan: 55 sub-pawn(s), none missed (scene scan 45)and no duplicates were collapsed. The real cause:SceneScanonly counts a sub-pawn whose own gameObject name matches apawnDescription, 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 aswalkOnly. The count on the panel is therefore correct as printed, and the claim thatProcessEngineAudioprocesses 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'sAllUnits; 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: aPresentationUnitreached twice during a battle (armies and battle units), and a squadron reachable both via the holder subtree and the air-formationMainPawnwalk, are counted twice — the F8 panel showedsub-pawn walk 56/46on 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 andProcessEngineAudioprocesses the duplicated pairs twice per poll. Fix: dedupe byGetInstanceID()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-ifalse-positived ondocs/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 theCachedField(entry above, which turned out to be hiding 32 uncatalogued names. -
— FIXED 2026-08-22. Guard restored asPackTuningdropped thesv > 0fguard.!(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:PackTuningLegacyParityTestskeeps 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 remainingPackTuninggap from the review is unrelated and still open: the cross-pack conflict NOTE is keyed on exactmatchstrings while the runtime matches by substring, so"Tank"in one pack and"Tanks_01"in another both apply (×0.36) with no note. -
— 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 hasfireGuidQueueis never drained on re-arm, and the fence sees 137 of 549 statics.Clear()": queues/bags are drained, arrays zeroed,ConditionalWeakTableforced 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) andUnpolicedStaticCount()reports how many, so the edge is measured.Still open from the same review:FIXED 2026-08-23 — reset inResetDistrictSessionState, with the two clones it guards (ourAtlas,hostClone) handed todistrictOwnedClonesin the same change, because resetting the latch alone would have turned a one-shot leak into a per-reload one.reactorMaskTexdeliberately stays[ProcessLived]. Five mutations drilled; the same new test then found_subPawnScanholding 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] doneblocks where the old code would have produced exactly one;step1: loaded mask 512x512appears once, so the[ProcessLived]texture is reused rather than re-decoded; and[District] freed 4thenfreed 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=Trueon all three runs. What was NOT established is that the silhouette draws. The drill turnedDistrictFootprintMeshoff (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 whileMaskSize,RotationandCutwere 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):InjectReactorFootprinttrimssel.levelBuildItemsin place, andselis 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 ahostClonethe reset has since destroyed. Reading the code, that path repoints it at the new clone and comes out right; a destroyedUnityEngine.Object's managed wrapper still answersGetType(), and thechild == nulltest is reference equality on anobject, 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] donea second time with the silhouette actually drawn. The original entry, for the record:footprintMaskInjectedis exactly that unpoliceable shape — astatic boollatch that survives a session reset whileResetDistrictSessionStatedestroys 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. -
— FIXED 2026-08-22. An unreadableHk_BattleHoldFirefails closed.creationTimeis 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 pureTryElapsedSince(clock, now, out seconds)with four tests, mutation-drilled. -
A fourth hand-maintained field list is ungated.— FIXED 2026-08-22:check_handlists.shcomparesModelDef's 11int[]guid fields against the Clone block; drilled by re-removingclipIdleAlt2. -
— FIXED 2026-08-22:Plugin.Updatehas no try/catch, and the meter overstates its scope.try/finallycloses the frame accounting on a throwing poll (the meter no longer reads healthiest when HAF is most broken), andPerformance.mdnow states exactly what the 33 buckets cover and what they do not.
-
A CONVERTED RIG'S CLIPS DON'T SHARE A FRAME WITH ITS REST POSE (measured 2026-08-22, the howitzer wheels) — every clip
deploy_convertproduces for the M114 poses the model 90° rotated from its own rest pose (rest bbox(52.1, 135.7, 37.6)vsfolded(41.7, 27.6, 119.3)), the legacy clip additionally at 2× scale; the baked skeleton then carries compensating scales (howitzer:mainLocal 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:foldedat 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'sDONE + 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 pathlargely 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 designCOLLAPSED 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 sharedSingleSourceRegistry);(4) audit remaining "label lies" like the tris sliderDONE 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. -
— FIXED 2026-07-19: (a) the tube's parent is now sampled intodeploy_convert.pyrecoil blocksrc_wwhen its name isn't barrel/cannon (was a guaranteedKeyErroron 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) deadkey_boneremoved. NOTE: the shippedm114_deploy.glbwas 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— FIXED 2026-07-19: the animated fixtures now clone the registry entry and route throughConfigForModelFactoryWindow.ConfigForlike the smoke test, soconvertRig/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:RearmModelRegistrationnow nullsdistFxManagerand every entry'splbc/privateLeaf/leaves/collected;DistrictApplyEntriesre-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): precomputedPoseNames/BoneRotationNames(was"Pose"+istrings per pawn per frame); the pose hook's descId fallback is a plain loop (was a ctx-capturing lambda per pawn add);ProcessFireQueuesprunes with a reverse for-loop (was a dur-capturingRemoveAllclosure per entry per frame);ProcessEngineAudiothrottles FIRST and caches its filtered subset keyed on the entries reference (wasWhere().ToList()60×/s);TickOnehoists the field-name array and skips the 7 texture re-sets when_MainTexis already ours (re-set kept as the recovery path when the game recreates the material); the[Grey] no _MainTexretry 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 staticdeployMoveStateandrespawnBase/respawnCount) clear on session re-arm, anddeployLastPosjoined the in-session deploy prune. Remaining in-session growth of the engine-audio maps folds into the perf pass above.
-
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:
_pendingisn'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;
ApplyTintno 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:FindTypeis 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:ExitGUIExceptionis rethrown before the generic catch. -
Animated multi-material albedos— FIXED 2026-09-02 (editor 0.5.2), and this note undersold itself: the.tgared 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.tgaentirely (grey tile). Both paths now share a TGA decoder, and every rect-shifting drop (nomap_Kdline, missing albedo file, undecodable file) warns loudly. -
Regex-fallback parser drift (plugin)— STALE, closed 2026-08-23 sweep.ModelChunksanchors on"models"s*:s*[and brace-counts inside it, so anoverridesarray 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 whenmodelsis 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"; alsoUpsert/Removebecame 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-setanimUnitFixannouncement is discarded by the ownership merge for existing entries; case-sensitive Upsert/Remove matching (case-only rename → twin entries);atlasGuidnever validated;(fixed 07-19); ConversionGateTest litmus synthesis sequential_ClipsPoseData.bytesmissing from the E5 rollback + Feature-Test cleanup listsReadToEndpipe-deadlock pattern; texture leaks on bake failure paths; corrupt-registry error-spam from per-OnGUILoad()in Retexture/Sound windows;SoundWindow(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;ParseWavnegative chunk-size guardblend_export.pyrepoints 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.
-
Framework identity migration— EXECUTED 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-correctTools ▸ ENCpaths). Deliberately NOT migrated (framework/pack split, decided 07-14 and reaffirmed 07-19):haf_models.json/haf_sounds/haf_skinsare ENC-the-PACK's files — packs are branded, only the framework is neutral, and a third-party pack never touches anhaf_*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. (TheENCAccessProofC# namespace + project filename were renamed toHumankindAssetFrameworkon 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 intohaf_load_report.txt), and the editor's Validate pack button. Original: Today pack structure resolution is loud and human-readable (malformed JSON, duplicatemodId, missingdependsOn, 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: boneZnot found" message. For a distributable framework this is a real barrier to entry for external authors. Build a pre-flight linter (editor button + a boot-time pass) that checks each entry's referenced assets/bones and reports mismatches in plain language before render. Fits the "guided, not guessy" design goal; scoped for the package phase. (Raised by an external review 2026-08-02; the structure half was already done in the 07-14/07-19 multi-mod work.) Designed — see Pack-Validator-Design.md (what to validate, editor vs boot-time surfaces, message format, phasing); build remains.
-
TREADIZE v2 — hybrid link/shuttle rig (user's design, 2026-07-26). On a straight run every link moves identically → ONE translating shuttle bone can carry the whole run (pattern maps at restart); per-link bones only on the WRAPS + RAMPS where links genuinely rotate. Bone math: Bradley ~23/track at full per-link wrap detail vs 75 today — quarter-link wrap smoothness inside half the budget. Skirted vehicles: the hidden top run can be fully STATIC (zero bones). The one risk is the two run↔wrap seams (static skin weights can't switch carriers) — mitigated by everything v1 learned: seams AT the tangent points, where a wrap link's velocity equals the run direction, speed-matched on the exact belt path. Prereq: none — build whenever tread bone budgets start pinching again (or for the twitch-ceiling escape).
-
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 theBaseOpdown onto the game-mesh UVs — but the_Normalmaps 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 processEhrhardt_E_V/Textures/" actually takes (read off the shipped files, not hand-waved):-
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.) - 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.
-
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. -
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–.1005UDIM 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.
-
UDIM assembly — the chassis normal is 5 tiles (
-
✅ Aim-layer REMAP — SHIPPED as "turretize" (2026-07-24), verified in-game on the Ehrhardt armored car. Built as the
TurretizeAimLayerruntime 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 inClearAimLayershows the stream). The feature is therefore an ADDRESS REWRITE, not an aiming system: anaimBoneregistry knob (name substring on our skeleton, thehandPropBonepattern) + a remap mode whereClearAimLayercurrently 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 (pointaimBoneat 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 themuzzleBonefield +Hk_MuzzleRelocateprefix onPresentationSubPawn.GetBoneTRS(string)— see the cracked mechanism + fix below; ArmouredCar set tomuzzleBone: "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 isUnit_Era6_Common_AntiAirGuns_01(an anti-air gun — bonesAzimuth,bras-*,Canon_down_*), and the flash is the projectile'sMuzzleFxEvolverMaterial ("launch flash",ProjectileAsset.muzzle) — a TRANSIENT VFX (NOT a fragment; every donor lists only its body mesh) spawned at the AA gun'sCanonweapon socket, which doesn't exist on our renamedb###_rig → it lands off-side. The spawn is NOT inPawnRangedFightSequence(stores the shooter only) norPresentationPawn(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 --PresentationSubPawnscans the clip forMecanimEvent.AlterationType.FireProjectileand stores it asSimpleAttackMecanimEvent(~L1255-1267), processed byMecanimEventInterpreter(Amplitude.Mercury.Animation) as the clip plays. The bone->world resolver isPresentationSubPawn.GetBoneTRS(boneName)(~L378:GetBoneIndex(boneName)->AnimationManager.GetBoneTRS). The AIM layer resolves the SAME way (SubPawn ~L639/657:GetBoneIndex(reference.BoneName)-- the donor'sAzimuth/Canonnames), so the muzzle socket almost certainly resolves by the donor's weapon-bone NAME -> invalid on ourb###_rig -> off-side. Fire info viaIAlterationFireProjectileInfoProvider(SubPawn L179/813 = the pawn). NEXT: decompileMecanimEventInterpreter'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: theProjectileAssetis SHARED across all AA guns, so nulling itsMuzzlein 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);thenPresentationProjectileManager.Instance.LaunchMuzzle(projectileAsset, startPosition, startDirection, up)(orLaunchProjectilefor the flying shot). So the muzzle position =SubPawn.GetBoneTRS(<donor socket name>).Transform(offset)— andParentNameToLaunchVFXPositionis the DONOR clip's socket name (the AA gun's Canon socket), absent on our renamed rig. THE FIX (low risk): Harmony postfix onPresentationSubPawn.GetBoneTRS(string boneName)— for our unit (match SubPawn→entry by SkeletonId,GetEntryBySkeletonIdexists) with amuzzleBoneset, whenSkeleton.GetBoneIndex(boneName) < 0(donor socket not on our rig), replace__resultwithGetBoneTRS(ourMuzzleBone)(our bone IS found → no re-redirect → recursion terminates). Config =muzzleBone(substring, e.g.Turretor a central bone), runtime-only. Broadness note: this redirects ALL unfound-socket VFX on our unit tomuzzleBone, 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'sMuzzle→ 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 + themuzzleOffsetworld 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 prefixb###_→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 forCanon_Up_left+Move_bloc). -
TRANSLATION UNLOCK — SHIPPED & VERIFIED (2026-07-25/26). The engine plays
RotationTranslationclips (decompiled: vanilla tank treads/shuttle bones;GetPoseTRSzeroes translation only for Rotation-encoded curves) — Laws 1/5 were OUR bake's strip. Built: per-modelkeepTranslations(registry + Lab toggle), kept curves scoped to the attack clip, delta-rebased, ×100 sandwich-compensated on the legacy path; multi-segment recoil windows with/Nspeed steps. Verified end-to-end twice: a sliding test bone, then the M114's real kickback (recipe442..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 singleMove_blocanchor; 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),@filepart lists (the ~32 k Windows command-line limit), Blender 5.xAction.fcurvesremoval (curves live inlayers→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 →rigfastspins 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.glbwas 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 patternwheel|tyre|tirefirst, 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 aturret-named part), rigid full-weight skinning, (3) generates the LINEARSpinaction (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 onPresentationPawn.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'sidle 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.
GUID nibble-swap encoding + keep-GUID re-bake; registry corrupt-guard/atomic-write/backup lifecycle; two-window ownership merge (both directions, post-fix); Harmony patch exception discipline; cross-thread sample locking + ConcurrentQueue handoff; deploy ramp math; join/decimate + albedo-extraction blocks; frame clamping; noise-filter re-entrancy; district bake+registry editor flow; Plugin.cs config wiring.
Ten open findings from the pass that followed the Abomination spike-geometry incident (root cause: a safety net that could never arm itself). Recorded separately with file:line, in-game symptom, trigger and suggested fix: Audit-2026-07-31.md.
Top item (now FIXED in c6154a6, pending in-game verification) — the wrong-skeleton rescue was gated on Hooked (animated-or-freeze), so eight shipped STATIC models have
no rescue path at all: the same failure 0c0b12f fixed, still live for them.
A full multi-agent review of the plugin and editor. All CONFIRMED findings were fixed, verified in-game/at-bake,
and merged — district clone leak, hideSubPawns coexistence, coreDesc matcher unification, formation
pure-repoint reform, GameBinding army-walk-root coverage, audio death/battle gate, three runtime-clone leaks,
state-machine gate mismatch, the facing-after-respawn interaction, and the three bake silent-mis-bake guards (4A/2A/4B).
See the dated CHANGELOG entries. What remains below is the PLAUSIBLE / low-confidence tail — deferred, not
dismissed.
-
1A —
convert_rigvsclean_unitsgating asymmetry (rig_anim.py: topological bone rename gated onconvert_rigalone, but the clean-unit export + rest/scale fold onconvert_rig OR clean_units_input). ADeployArmV2FBX 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): thekept == 0hard-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 —
%03dbone-index width (rig_anim.py~998):A1000_sorts beforeA999_, inverting order above 999 bones. Unreachable under the 240-bone cap today; a hard assumption worth a comment.
-
Harmony
TargetMethodparam-count filters:Hk_DistrictGroundMaterial/Hk_DistrictHexSculpt(UniversalInject.Hooks.cs) resolve by method name with noGetParameters().Lengthfilter (unlike their siblings) — a future overload could be patched silently.Hk_BattleTurnProbe(BattleTurnPatch.cs) indexesGetParameters()[0]without a length check. -
Hk_SilenceEvents.Prefix(Hooks.cs) readseo.name(native marshal alloc) on every WwisePostEventbefore its gate; mirrorHk_AudioTrace's early-out. (Both also patch the samePostEvent= two detours/sound.) -
Belt-and-braces
try/catchon the multi-call postfix bodies ofUniRegisterHook/UniRepointHook/Hk_DistrictRepoint— they sit inside core loading methods and rely entirely on every callee being self-guarded. -
LongestMatchequal-length tiebreak (UniversalInjectPatch.cs~889): among equal-length key matches the first in registry order wins; thecount>1warning 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 assumeStartEvent→GetBoneTRS→EndEventis atomic; nested/interleaved fires of coexisting shooters could cross offsets. Confidence limited (needs the engine to actually nest these). -
BoneRotationslot clobber (UniversalInject.Pose.cs): on theuseDonorClippathApplyRotorSpin/ApplyRotorTrim/ApplyGunElevationwrite overlapping low slots — a rotor-spin + trim combo can clobber.
-
rotorSpinBones/rotorSpinSpeedare plugin-only fields with no editorModelDeffield. Partly addressed 2026-08-16: the schema-parity guard now allowlists them as intentional runtime-only keys (likescale), 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 onlyModelDef'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 toModelDef); latent (ENC unused), so deferred. -
FIXED 2026-08-16 by the shared-schema field initializers (one authoritative default = 25f, test-pinned; see the Framework-Review generic-parse row).idleAltIntervaldefault mismatch (editor 25f / plugin 0f) — a pack.json missing the key gets idle-alt disabled instead of the documented 25s cadence. -
— STALE (duplicate): already struck above, fixed 2026-08-22.haf_districts.jsonhas no regex fallbackParseDistricts=Usable(ParseDistrictsRaw(text))with a per-entry try andRegexDistrictsas the fallback. Original: — one malformed char disables ALL custom districts (the model registry has a fallback; districts don't). -
— STALE, closed 2026-08-23 sweep — this is BY DESIGN and already policed.animatedflag written but not readcheck_schema_parity.shlistsanimatedin 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).
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-catalog— STALE, closed 2026-08-23. All of them are catalogued now (AssetReferenceRepository,GroundMaterialDefinition,GroundMaterialAuthoringData,StaticString,Databases), andcheck-catalog.sh— widened the same day to seeAccessTools.Field(x.GetType(), ...)— passes over 371 names. Original:AssetReferenceRepository,Amplitude.StaticString,GroundMaterialDefinition,AnimationVariableNames,HgFxAnchorComponent. A rename there degrades silently. (TheSimulationEvent_*combat types resolve with their own local warnings, so they're loud-but-off-catalog.) -
Members read off types not yet in
GameBindingat all — theSkeleton, pawn-entry /GPUPawnDescriptorEntry/ fragment structs,FxOneMeshStruct,PresentationLevelBuildComponenton the hottest injection path (the member audit surfaced these; structs need new accessors + a different resolution, so it's a distinct batch).
A full-framework review, adversarially verified finding-by-finding against the code and this project's own record (see the Framework-Review 08-17 row; the fixed-same-day HIGH — the glbconv source split-brain — is in the CHANGELOG). Most findings were already admitted here or ADR-settled. What survived as new and deliberately deferred:
-
Generic parse binds runtime-state fields from pack JSON —FIXED 2026-08-17 with a config-key whitelist strip before the generic map (ModelEntry's publicrepointed/descId/animIdbind from any name-matching key (the old hand-list parse was an implicit whitelist), and a key colliding with a readonly collection (phaseTracks) throws insideToObject→ the whole pack silently drops to the regex fallback. The parse-site comment assumes no matching keys exist; nothing guards it.registryConfigKeys: shared-schema fields by reflection + the GUID arrays + plugin-only config) — fail-safe for new runtime-state fields, chosen over per-field[JsonIgnore](fail-open: one forgotten attribute reopens the hole). Two pinning tests (hostile state keys → defaults; readonly-collection collision → stays on the object parse). Suite 61 → 63. -
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 gitignoredFIXED 2026-08-17:References\DLLs need a strategy first)..github/workflows/ci.ymlbuilds + runs all 61 tests on every push, usingtools/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 set— STALE, closed 2026-08-17/22.BackupWindowhas 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-machineD:\HAF_Backups(now noted in Backup.md). One disk event loses the un-reproducible half of the project. -
— STALE, closed 2026-08-23 sweep. The confusable clip set (cbvscbbGUID-component namingcb/cbb/ca/cba/aca/a2a) no longer exists: clip guids go through theClipRolestable (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; alsoca/cba/aca/a2a) — a one-character typo in the 44-int wiring compiles clean and mis-wires a clip role; nothing tests the field→InjectClipCollectionswiring. Rename or add a wiring test when next touching the schema. - ~~Ghost-hunt log tags bypass the quiet-by-default
Diaggate ([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 ~26LogInfolines / 15 s perhideSubPawnsentry).~~ FIXED 2026-08-17: the 15 automatic lines (incl.[HIER]/[LAYER]/[FX]) now go throughPlugin.Diag; the operator-driven[BISECT]/[REND2]command responses deliberately stay loud (they answer a typedhaf_ghostbisect.txtcommand). -
Stale comment —FIXED 2026-08-17.Plugin.cs:83still namescommunity.humankind.encaccessproof.cfg; the live config iscommunity.humankind.haf.cfg.
Get started
- Getting Started
- Installation
- Troubleshooting
- Authoring State and Deployment
- Mod Editor version.xml Recovery
- Building
- Backup
Author models and behavior
- Editor Tools
- Factory Manual
- Vehicle Lab Quickstart
- Animated Models
- Animation Pitfalls
- Textures
- Unit Size
- Unit Combat Behavior
- Formations
- Pawn Props
- Projectiles
- Game Sound Lab
- Firing on Attack
- Turn Ease
- Facing Persistence
- Donor Clip Flight
Districts and wonders
Ship and operate
Internals and project