HumankindAssetFramework

Framework Review — Model Factory

Living hardening roadmap. Four review passes so far, most recent first:

  1. 2026-07-12 — architectural / structural review (see “Architectural findings” below): stepped back from line-bugs to system structure, given the stated direction (many more models; eventual distributable package). Surfaced the per-pawn god-method (A1, now fixed), the four-place registry-schema duplication (A3), and the ModelEntry god-object (A2), plus two suspected baker bugs (A4) and the package-readiness gap (A5).
  2. 2026-07-07 — full critical re-review: the runtime plugin read line-by-line, the editor pipeline and all tools swept by independent reviewers, and the two headline Blender findings empirically verified on Blender 5.1.2 (the same install the pipeline auto-detects).
  3. 2026-07-07 — external review (received by mail): five findings + three comment-drift items, all verified against the code and fixed same day (see “Fixed to date”).
  4. 2026-07-05 — self-review: registry safety, process timeouts, dead code. Fixed or consciously deferred.

Overall verdict (unchanged and reinforced): disciplined code for what it is — a reflection-heavy BepInEx mod poking at a closed engine. Verified strengths: per-hook and per-model failure isolation, fail-loud resolution of required SDK methods, empty-GUID validation, atomic registry writes with a versioned git-tracked backup, bounded shell-outs with dual-stream draining, one-shot error logging on hot paths, correct boxed-struct mutate/write-back in the pose hook. The gaps below are mostly silent-failure edges and surviving-a-stranger’s-machine items, not broken-today bugs — everything the author uses daily works.

Severity key: 🔴 fix soon (silent data loss / silent no-inject) · 🟡 worth hardening · 🟢 cleanup.


Fixed to date (condensed history)

Date Fix
07-05 Registry atomic write (.tmp + File.Replace), corrupt-file guard (.corrupt.json, Save refuses), versioned project backup + auto-restore
07-05 RunBounded 3-min cap on all five shell-outs (but see E4 below — one unbounded path remains)
07-05 [SerializeField] form persistence; dead LoadHookPatch removed; F8 window un-broken
07-06 Resource-name whitespace/char guard (window gate + baker Fail), quoted glbconv arg
07-06 Multi-material GLB: glbconv emits usemtl/.mtl/solid swatches; baker matches .jpg albedos
07-07 (external #1) LoadRegistry no longer latches loaded=true before the read — retries transient failures up to 3×
07-07 (external #2) regex fallback parses respawnAfterLoad (parity with Newtonsoft path)
07-07 (external #3) ModelRegistry.Save atomic swap guarded; Save/Upsert/Remove return bool; DoBake reports “Baked, but REGISTRY SAVE FAILED” instead of a false ✓
07-07 (external #5) Prober.TestWrite labelled: leaves an inert placeholder in the live registry until restart
07-07 Comment drift: haf_models.txt.json; ModelRegistry header no longer claims JsonUtility works at game runtime (it silently returns empty there — the plugin must keep Newtonsoft); ReadDonorFragments header matches its whole-log-scan body
07-07 (this review) EnsureRegistered latched registered=true on empty entries, permanently defeating the new load-retry (retry would succeed, registration never ran again → injection silently dead for the session). Now latches only when the load actually succeeded.
07-07 csproj: DefaultItemExcludes baker\** — glbconv’s .NET 8 publish output was being swept in as candidate assemblies, making the net471 build fail with 271 phantom type errors
07-12 A1: OnPawnAdded decomposed from a ~190-line per-pawn-per-frame god-method into a dispatcher + one named handler per behavior (verified in-game + 12/12 bake smoke test)
07-12 Registry kept alphabetical on load AND save (ModelRegistry.SortByName) — a stable Factory dropdown and no more meaningless reorder churn in the git-tracked backup on every bake
07-12 A3: check_schema_parity.sh rewritten — Newtonsoft==regex read paths, read⊆written, and read-cast==declared-type; verified to fail on each drift type (Option A: verify, don’t merge)
07-12 T3/T4 (rig_anim.py): albedo grab traces the Principled Base Color (not the first image node); join re-binds the armature modifier so a bone-parented-prop-first model can’t export rigid. T5 (glbconv mirrored-node winding): source + exe rebuilt (SharpGLTF pinned 1.0.6, verified geometry-identical across all 11 registry models); build now documented in Tools/glbconv/BUILD.md
07-12 E5: Build/BuildAnimated snapshot the baked outputs (asset + .meta) before a re-bake and restore them on any failure — a partway-failed re-bake no longer destroys the last-good model. Fail-safe (restore runs only on failure); 12/12 smoke test
07-12 E2/E3/E4 editor hardening: Remove keys on the selected entry + honest status; static bake fails loud on 0 vertices (no silent invisible unit); RunBounded bounds the pipe drain so a grandchild-held pipe can’t hang the editor. 12/12 smoke test
07-12 Bake Feature Test (Tools ▸ ENC ▸ Bake Feature Test): new integration test proving each baker feature knob does what it claims — bakes a self-contained synthetic cube per-knob and asserts a feature-specific invariant (doubleSided 2× tris, Faceted unweld, atlasMaxDim cap, heightUV, size/position, brightness/saturation, …). Non-destructive; Tier 1 12/12
07-12 Bake Feature Test — Tier 2 (… Tier 2 — Blender + animated): targetTris decimates a generated high-poly grid (5000→600), stripParts drops a generated named object (24→12 tris), and the animated pipeline (BuildAnimated → skeleton + clip) is exercised by borrowing ReconDrone + TowedGunHowitzers from the registry. 4/4
07-12 A4: BuildMultiAtlasAndRemap submesh→rect match now tries EXACT before the loose substring — an animated multi-material model with prefix-colliding material names (Body/Body_Trim) no longer maps the wrong texture. (MeasureLongestAxis half verified-and-dismissed: rig_anim joins to one mesh, so the per-node transform is moot.)
07-12 E6/E8 (🟢): backup restore parses in its own try/catch (a corrupt backup + missing registry no longer locks Save with a misleading error); both multi-material PackTextures sites free their source albedos so a bake no longer strands tens of MB until domain reload
07-13 E7 (🟢): the static re-bake’s delete-first now clears the stale animated _Preview.prefab/_PreviewMesh/_PreviewMat from FactorySource, so the window preview shows the fresh static model instead of the old animated one
07-13 T6 (🟢): rig_anim.py hard-fails when a bone-prefix filter matches nothing (frozen-clip trap; lists the real bones); prep_model.py hard-fails when a strip/reduce leaves no mesh (empty-GLB trap). Both syntax-checked. T7 lows reviewed + consciously deferred (each needs a glbconv rebuild or changes output for ~zero benefit)
07-13 2nd critical review (independent adversarial pass, 3 reviewers): confirmed the OnPawnAdded refactor faithful and E5’s core sound. Fixed a heightUV feature-test false-green (now asserts V tracks vertex height, not merely spans [0,1]); added an E5-rollback test that forces a failed re-bake and proves the assets restore with original GUIDs + content. Feature Test Tier 1 now 13/13.
07-14 HAF multi-mod (phase 1): LoadRegistry is now a multi-pack loader — discovers ENC’s base haf_models.json + any haf_packs/*.json, merges by pawnDescription (first-loaded wins on an undeclared clash, logged loud — no silent overrides), and writes haf_load_report.txt. The old Newtonsoft-primary + regex-fallback parse was lifted verbatim into ParseModels (transient-retry/3×-latch preserved). Baker writes a RegistryFile wrapper (schemaVersion/modId/dependsOn/loadAfter/overrides); check_schema_parity.sh gained wrapper-key parity (and its ModelDef-body terminator was fixed for the class rename). dependsOn/loadAfter/overrides/patches are reserved-and-logged, not yet enforced. Verified in-game (13 models, 1 pack, 0 conflicts). Spec: Multi-Mod.md.
07-16 Vertex budget: per-mesh cap mapped, all limits overridable: ContentLayer.maxMeshTriangleCount is a per-mesh triangle cap that silently truncates excess quads at encode (holes, no log) — but ships 0 = unlimited on every layer, so there is no hard per-unit cap; the practical limit is the pawn layer’s shared pool (1M verts, ~70% baseline). Measured: a FRESH game reads the same fill as a Contemporary save → the pool is filled roster-wide at load, not per-era (kills the era-clustering theory; both Vertex-Budget open questions answered). New generic [Buffers] BufferOverrides = <layer>:verts=+N,idx=+N,meshes=+N,maxtris=N (same creation seam as DistrictBufferHeadroom, any layer); budget readout is now an F8-window “Mesh Budget” button incl. maxTris/mesh. Docs: Vertex-Budget.md rewritten.
07-16 Pawn PROP axis — SOLVED (third injection axis): custom weapons/gear on pawn attachment slots — Slingers now carry an actual sling. Rides the game’s own data path (pawn def Attachements[] → PresentationPawnFragmentMesh {ModelPrefab, ModelName, MaterialRef} = rigid mesh glued to the slot’s bone); the one gate is the fragment’s MeshCollection needing registration with the AnimationManager. Editor: Tools ▸ ENC ▸ Prop Lab (PropBaker.cs) — dump any vanilla fragment by GUID (accepts the Asset Picker’s 32-hex form; nibble-swap encoding) as the authoring template, then one Bake = static bake (no dummy pawn) → bone-free FxMesh (mergeSubMeshes: the fragment GPU encoder draws only submesh 0) → MeshCollection + fragment assets. Runtime: Hk_PropRegister postfix on AnimationManager.AnimationLoad registers our collections via the public RegisterMeshCollection before pawn resolution (an Update-tick loses that race → the pawn def fails Load and units render as pawn definition 0 — a herd of mammoths); Amplitude’s catalog misses mod MeshCollections by GUID → by-name fallback from the mounted Unity bundles. Full recipe + traps: Pawn-Props.md.
07-16 District pipeline productized (dialog + registry): districts got their own Factory. Editor: Tools ▸ ENC ▸ District Factory (DistrictFactoryWindow) — pick a district (searchable dropdown over the project’s district definitions), Browse a model, set Size/Rotation/import-angles/Target-tris/Isolate, one Bake runs the static bake core (no dummy pawnpawnDescription was registry-only in UniversalBaker.Build), wraps a bone-free FxMesh (DistrictBaker.BakeFxMesh, now callable), and writes the entry; DistrictRegistry mirrors ModelRegistry (corrupt-guard, atomic write, git-tracked backup). Runtime: haf_districts.json registry — any number of districts at once, per-entry {district, fxMeshGuid, isolate} with private-leaf machinery per entry (DistrictModel/distModels); legacy [District] single-model keys remain as fallback when the registry is empty; F8 “Dump District” is entries-aware. Reactor migrated (dummy Era0_Common_Bears_01 unit entries removed). Verified in-game end-to-end.
07-16 District model injection — SOLVED (custom mesh on ONE tile): the axis works end-to-end. Recipe: give the district a renderable ConstructibleVisualAffinity + clear its Additional Visual Levels (the DistrictState criterion, not the district class, was why swaps resolved to null); at runtime walk Selector(culture) → pairs → Emitter.levelBuildItems[].loadedEvolverMaterial → FxEvolverMaterialLevelBuildElement, set each leaf’s fxMesh to our baked FxMesh and call the leaf’s Load() to re-resolve meshIndex (reusing the game’s own leaf keeps the selector GPU context — a foreign SetChannel material drew nothing). The mesh must be bone-free (static shader can’t read skinned verts) and lean (the shared ‘Visual’ GPU buffer, 3,000,000 verts, runs ~99.85% full late-game; oversize meshes get a slot but silently overflow). DistrictBufferHeadroom (opt-in) enlarges that buffer (3M→5M verified). DistrictIsolate scopes to one tile: build a private Instantiated leaf, point only that district’s own channel + Shuriken particle at it via RefreshChannel. All off by default. Full recipe: District-Visuals.md.
07-15 District visuals — axis investigated & judged feasible: decompiled the full district-visual pipeline (ConstructibleVisualAffinityPresentationDistrict.UpdateLevelBuildPresentationLevelBuildComponent.SetChannelFxEvolverMaterialDrawer.mesh (static FxMesh GUID) → the same FxComponentMeshContentManager the unit path uses). A district’s visual is a named slot (the district-side pawnDescription) resolving to a single static mesh — simpler than a unit (no skeleton/clip). Clean Harmony seam exists (analogue of UniRepointHook), scoped per-district so the shared affinity is untouched. First experiment started: repoint ENC Stone Quarry at a custom quarry model. Spec: District-Visuals.md.
07-17 PROJECTILE axis — PARTIAL (fourth injection axis): a custom model as a unit’s fired munition — the Era-6 Anti Tank FPV throws a kamikaze drone. A ProjectileAsset has no mesh; the flying mesh is its trailFxEvolverMaterialDrawer.mesh (a swappable FxMesh GUID the drawer auto-loads — no runtime registration). Editor: Tools ▸ ENC ▸ Projectile Lab (ProjectileBaker.cs) — Dump prints a VERDICT (usable donor = a mesh drawer with a non-null mesh; sprite drawers render our mesh invisible), then Bake = static bake → bone-free FxMesh → CopySerialized-clone the donor drawer with our mesh + clone the ProjectileAsset. Wired via the unit’s Projectile field (data; plugin fallback [Projectiles] ProjectileOverrides / Hk_ProjectileOverride, off). Findings: only thrown-solid projectiles are meshes (all modern/siege/naval ones are sprites), and all mesh donors share one brown atlas — no cheap recolor (tint is ignored for textured meshes; the only fix is a custom FX atlas, shelved). Split-donor: mesh trail (ThrownSpear, stable) + impact donor (Mortar → real explosion + audio). Orientation: the drawer welds mesh +Z to velocityImportAngles (0,−90,−90), avoid the X=90 gimbal pole. WriteAssetKeepingGuid keeps the ProjectileAsset’s GUID across re-bakes (else the unit’s field blanks silently → fires nothing). Shipped brown + legibility-limited (a simpler/chunkier model would silhouette better miniaturized). Full recipe: Projectiles.md.
07-18 PROJECTILE axis — count mechanic decompiled + single-pawn fix (now WORKING, one clean drone): the “wall of drones” was the game’s normal multi-fire made visible by a slow big mesh. Fully traced: one projectile per firing pawn (gated by pawnDef.Projectile != null), and waves = ceil(defendersToKill / attackerPawnCount) (UnitActionRangedFightSequence) — a high-kill unit loops the throw until the kill count is met (3 soldiers + ~9 kills = 3 waves = the wall). Empirically ruled out the animator (only changes throw motion/sound), Same Row Attack (pawn-pairing), formation rows, Prepared Attack Loop. Force Ranged Multi Kill collapses to one wave BUT its instant-kill path skips FireProjectile (no drone) — so no data combo gives one-wave+visible-drone (would need a plugin cap, designed-not-built). Fix that shipped: a SINGLE-PAWN (vehicle) baseceil(1/1)=1 → one clean drone (Humvee proven). Also corrected the “cursed brown” claim: the skin colour = which atlas region the model’s UVs sample (+ shifts with lit faces per orientation) — drone_clean.glb rolls grey/tan, so reroll by swapping the model. Orientation follows the trajectory arc (nose-down dive = correct kamikaze); final ImportAngles (0,0,180) for drone_clean. Projectile Lab gains: Dump VERDICT + donor auto-fill, an impact donor field (borrow an explosive sprite’s boom onto a mesh donor), WriteAssetKeepingGuid (re-bakes keep the GUID so the unit ref survives), and [SerializeField] window persistence (Projectile Lab + Prop Lab survive domain reloads). Open: launch audio plays the unit’s gun sound, not a drone whoosh. Docs: Projectiles.md updated.
07-18 Animation Lab window (new) + Factory/Lab settings split: animation authoring moved into a dedicated Animation Lab (Tools ▸ ENC ▸ Animation Lab, docks as a tab next to the Factory). Design settled after iteration: Factory owns the MODEL (identity, file, transform, size, shading; lists ALL entries), Lab owns the ANIMATION (clip/bones/unit-fix + fire-on-attack/deploy/recoil behaviors) — settings mutually exclusive, windows linked by jump buttons (OpenFor context handoff); Bake works identically from either (ConfigFor → BuildAnimated → Upsert, no forked logic). New Save (no bake) persists runtime behavior flags without re-running the pipeline. Hardening from incidents en route: dropdown index-by-name (a stale numeric index presented “Zeppelin” while the form held the howitzer), and animated self-healed from the entry’s config (LooksAnimated) + a confirm dialog on rigged-file static bakes — a lost flag had silently re-baked the howitzer STATIC (tipped-over guns: the static path bakes the animated-path-ignored Rotation offset into the mesh). Regression-verified: TowedGunHowitzers re-baked through the new setup, deploy/fold/recoil in-game identical. Also: CLI editor-assembly compile-check rig established (FrameworkPathOverride + Tools/NoAnalyzers.targets + env ReferencePath); rig_anim.py comment for the pre-join junk cull. Docs: Factory-Manual.md §3 rewritten + new §15.
07-18 First humanoid character + rotation-for-animated + enforced window ownership: the Combine soldier (62-bone rig, Idle1) replaces the DroneSquadFPV Humvee — standing, right-sized, turning with movement, idling in-game. En route, three systemic fixes: (1) Rotation is now REAL for animated models — contract: 0,0,0 = the EXACT legacy pipeline (no rig manipulation; working models stay working — an unconditional-fold attempt flipped the previously-good howitzer upside-down in-game, because the fold is world-preserving for Unity’s mesh import but NOT for Amplitude’s skeleton bake); non-zero = rotate + transform_apply into the data (vertices + bone rests, identity nodes; object-level rotation alone is dropped downstream, and object-level anim fcurves are stripped so they can’t re-assert orientation). Raw glTF rigs that round-trip lying down (soldier: a -90°X armature node) are now correctable — soldier needs 90,0,0. ⚠ preview orientation for animated models is MEANINGLESS (fixed display flips; matched the soldier by luck, contradicted the howitzer) — probe one axis at a time, judge in-game only. (2) Enforced field ownership across Factory/Animation Lab — bake/save rebases on the freshest registry entry, each window contributes only its own fields (stale-copy bakes had clobbered Fix-100×, then rotation+size). (3) BoneRotation layer discovery — the game turns pawns through the procedural PawnEntry.BoneRotation0-3 layer; blanket-clearing it (the old barrel-twist fix) froze the soldier’s facing. Now cleared only for artillery-behavior models. OPEN (one subsystem): drone projectile visual lost on attack (pose hook stomps the throw anim that carries FireProjectile — fix: pass the game’s pose through when it isn’t idle/move), hood stretch after turning (junk BoneRotation slots with invalid bone index + 1500°+ angles on this donor — needs the layer’s writer decompiled), temp [Uni][facing] diagnostic stays in.
07-19 THE SOLDIER STANDS — raw-rig conversion completed (head mystery solved): the true root cause of the “torn/floating head” was neither the BoneRotation layer, nor scales, nor bone order, nor depth (each was a REAL defect, fixed en route, but masked the next): the auto-rig’s clips assemble the body from a scrambled rest pose via structural location keys (frame-0 posed bones up to 91u from rest on a 73u rig; 129 location curves) — unplayable in Amplitude’s rotation-only format by definition. Fix: rest normalization + snapshot visual rebake in rig_anim.py (snapshot all visual matrices per frame → apply armature modifier at frame 0 → Apply-Pose-As-Rest → re-bind → re-derive the entire clip as pure rotations against the new rest; in-bake verification residual 1.67e-4). Methodology wins: a litmus rig (12-deep chain of colored cubes through the full pipeline) exonerated the GPU runtime for clean rigs in one launch; raw-FBX byte inspectors (fbx_binddump/fbx_lclscale) found the exporter’s ×100 root Lcl Scaling; full decompile of bake + runtime (ClipEntry.Reimport, Skeleton.Reimport, AnimationManager GetBoneTRS/ApplyPose/GetPoseTRS) established every invariant. Ship recipe for the soldier: rotation 360,0,0 (identity net, triggers the conversion path — gate refactor pending), size 2, Fix-100× OFF. Public state of the art remains “anything moving is not possible” — this pipeline now converts a raw Sketchfab humanoid to a working in-game unit.
07-18 Evening hardening round (after the soldier landed): (1) automatic geometry cache — the animated Blender re-slim runs exactly when one of its inputs changed (rotation/tris/clip/bones/material/model), checkbox no longer gates it; relabelled “Keep extracted texture (hand-edits)” (its only animated-path effect). Killed the “rotation silently does nothing” trap that burned several ReconDrone bakes. (2) preview renders one material per submesh — a 75-slot mesh previously showed only submesh 0 (a lone propeller blade), read as a lost model. (3) ReconDrone recipe recovered from the registry backup’s GIT HISTORY (the backup history = recipe archive) and settled at 5000 tris user-compared vs 12000; lesson: with reuse ticked, stored bake settings and actual assets can drift — the assets are the truth. (4) head-tear triage on the soldier: three BoneRotation-layer mechanisms tested via the [Uni][facing] diagnostic — full clear (freezes facing), angle wrapping (no effect), axis-selective zero of the donor’s phantom wheel-spin slots (axis 0, invalid bone index; facing kept — kept in as hygiene). Head still tears with the whole layer flat ⇒ the layer is ruled OUT; suspect the baked skeleton/clip or Pose0 playback. Next session: decompile the pose/skinning consumer (with the drone-visual attack window + idle/run state machine — one subsystem).
07-19 PHASE 2 SHIPPED + VERIFIED IN-GAME: state-driven idle/run/after — THE SOLDIER RUNS. The full state machine: registry fields (animStateDriven, animClipMove/animClipAfter, clipMove/clipAfter GUIDs), a one-pass multi-clip Blender bake (all role clips snapshotted up front and rebaked against the PRIMARY clip’s frame-0 rest — per-role rests would displace non-primary clips on the shared skeleton), per-role ClipCollections binding one skeleton, Animation Lab State-driven UI (Idle/Movement/After pickers), a ~20×/s movement poll (render-delta, settle-immune, per-pawn samples) and per-pawn clip selection. Three hard-won runtime facts (decompiled + experimentally proven): (1) PawnManager.DoComputation uploads the FULL pawn array to the GPU every frame — no id latching, per-frame AnimationId switching on Pose0 is safe and is the shipped mechanism; (2) the secondary pose slots (Pose1/Pose2) misbehave in the GPU pass — weight-switching them rendered the pawn invisible while moving (garbage id → a scale-0 entry collapses the mesh); avoid them; (3) Rotation-format clip data cannot explode a mesh (GetPoseTRS forces translation 0 / scale 1) — so invisible ⇒ wrong animation id, frozen ⇒ constant clip data: a diagnostic dichotomy. The frozen-runner bug: Blender’s bone rename syncs fcurve paths ONLY for the assigned action — dormant role clips kept stale bone names, evaluated to nothing, and exported as 18-frame statues; caught by byte-level pose-data variance analysis (a healthy clip = varying+constant curve mix), pinned by elimination probes, fixed by explicit path patching, and guarded by a tool-version cache-buster (the slim cache reuses nothing older than rig_anim.py — the fix was silently bypassed once without it).
07-19 ATTACK + COMBAT-IDLE states shipped + verified in-game — the 5-role state machine is complete (attack > move > after > combat-idle > idle): the Attack clip plays when the unit actually fires — trigger = a Harmony postfix on PawnRangedFightSequence.InitializeCommon (ALL five constructors funnel through it: battle volleys, unit-target shots, district bombards; built presentation-side, so the shooter’s Transform is read straight into the per-entry fire windows — no sim-thread GUID queue like the artillery hook). attackRepeats (runtime-only Lab slider): the window spans N × clip duration and pose Time is fed UNCLAMPED so the sampler’s Repeat(Time,1) replays the clip back-to-back — the soldier’s genuinely-0.17s shootAR2s pop × 18 ≈ 3 s of sustained fire (user-verified). The Combat-idle clip replaces Idle while the army is in a battle (PresentationArmy.IsLockedByBattle); the single-frame CombatIdle1 stance bakes fine — rig_anim now pads 1-frame clips to 2 identical frames (Unity’s importer can drop a zero-length animation whole) and Amplitude’s FrameCount-1 encoding pins the sampler to frame 0 (a held pose). Two field bugs, both read off the BepInEx log: (1) Hk_PawnRangedFight was missing from Plugin.cs’s EXPLICIT hook list — per-hook isolated registration means an unlisted hook fails 100% silently (no TargetMethod log at all); (2) a battle spawns a SECOND PresentationUnit per combatant on its combat tile (PresentationBattleReportController.Battles → AllUnits) while the army walk’s sample sits at the STACK position (27.7u off, outside the 4u radius → “NO sample match”: no stance, no movement state in battle) — the state poll now walks BOTH collections, key-salted (a shared movement key would ping-pong between the two positions = permanently “moving”; battle samples always combat=true). Process lesson: the CLI editor compile-check must run -t:Rebuild (incremental ran as a no-op and waved a real CS0103 through to Unity).
07-19 HAND PROPS on custom skeletons shipped + verified — the soldier carries a textured M60 (grip correct through idle/run/stance/fire). The donor (an APC) has no weapon slots and GetSlotIndex −1 silently drops attachments, so InjectHandProp constructs the FragmentEntry itself at repoint: Prop-Lab MeshCollection (registered-lookup → catalog → bundle-name fallback), borrowed weapon output layer, boneName by substring against our renamed b###_ bones. Three decompiled+field-verified engine discoveries: (1) the GPU pawn descriptor snapshots fragments at registration — and the game’s own full rebuild is UNSAFE mid-load (skips not-loaded definitions WITHOUT reserving slots; in the field this scattered the recon drones and the howitzer and gave it soldier-motion) → replaced with a surgical patch of only our definition (fragments copied to the buffer tail, descriptor[defId] repointed); (2) weapon materials are streamed 64×64 _Proxy placeholders that Amplitude resets — a one-shot paint flip-flopped between sessions → the prop’s own <name>_Atlas is painted on a private layer clone (unit-retexture isolation) and repainted per tick (ReferenceEquals fast-path); (3) baked FxMesh import angles don’t survive the mod bundle — in-game the class default (-90,0,0) silently tipped every prop vs. the preview → the plugin always stamps the angles pre-encoding (registry override handPropAngles, else zero), so in-game equals the baked vertices (orientation authored via Rotation offset; the Prop Lab’s import-angles field removed as a trap). Tooling: Prop Lab gained Edit existing / New / Remove per-prop recipes (haf_props.json); Animation Lab gained the Hand prop combobox (auto-resolves the collection GUID). Also: Universal Model Factory renamed to Model Factory (menu, title, docs; pre-existing docked tabs retitled via OnEnable — Unity caches titleContent). Fragment limit noted: no scale channel in the GPU fragment record (runtime pawn scale moves the glue point but can’t resize the mesh).
07-19 Per-pack asset folders + full framework-identity migration — VERIFIED in-game: (1) file-based assets resolve pack-relative first — subdirectory packs haf_packs/<mymod>/pack.json discovered (default modId = folder name; sounds//skins/ inside are the pack’s asset roots), flat packs get a same-named sibling folder, legacy shared haf_sounds/haf_skins remain the fallback — a third-party pack ships as ONE self-contained directory and stops “feeling like an ENC extension”. (2) Framework identity went fully neutral in one clean cut (user call: zero external installs = no compat period): assembly/DLL → HumankindAssetFramework.dll (old DLL deleted in the same deploy — both present would double-patch), BepInPlugin GUID → community.humankind.haf (cfg copied to the new name, settings carried), editor menu → Tools ▸ HAF (all authoring windows + Tests + Tech Tree + Database Browser consolidated under one root). Kept deliberately: csproj filename, C# namespace, and all haf_* PACK files (packs are branded; only the framework is neutral). First-session in-game verification clean — plugin loads under the new identity, settings intact, units/districts/audio normal. Closes the last three items of the 6-point external doc review (front-door page, four-axes README, umbrella name, pack resolution, per-pack assets, naming).
07-19 HAF pack resolution ENFORCED (was reserved-and-logged) — external doc review called the gap between the multi-mod ambition and behavior the framework’s most important discrepancy; closed: ResolvePacks now rejects duplicate modIds (first file keeps the id), validates dependsOn (missing dependency skips the pack, iterated to a fixpoint), topologically sorts load order over dependsOn + loadAfter with a STABLE seed-order Kahn (no declared constraints ⇒ byte-identical to the old base-first + filename order — today’s single-pack setup provably unaffected; cycles fall back to file order, loudly), and the merge honors declared overrides ({modId, pawnDescription} replaces the targeted entry in place, logged as an override; an undeclared clash stays first-loaded-wins, loud). haf_load_report.txt gains RESOLUTION / OVERRIDES APPLIED sections. A field-level patches concept (vs whole-entry overrides) is deliberately deferred until a real compatibility pack shapes it. Docs: Multi-Mod.md rewritten to the enforced semantics.
07-19 Post-review hardening batch + full guard sweep GREEN: (1) Fold-gating decision implemented (split gating) — the destructive rest-fold (rest rewrite + visual rebake) now runs on the CONVERSION path only (_loc0 and convert_rig); the location-strip stays on BOTH paths deliberately (every verified legacy bake went through it; un-stripping risked the drone’s unscaled-translation wobble). “Legacy byte-identical” now precisely means no rig manipulation, and a legacy model with location keys + shape keys no longer aborts. (2) deploy_convert.py recoil hardened for non-M114 rigs — the tube’s parent is sampled whatever its name (was a guaranteed KeyError); RecoilArm holds key an IDENTITY BASIS (true pass-through at any parent pose) with arc targets on a parent-aware baseline (a moving carriage no longer displaces the tube through the deploy); empty tube-match fails loudly; dead key_bone removed. Shipped m114_deploy.glb predates this and stays. (3) Feature Test Tier-2 routes through ConfigFor — the hand-built config had dropped convertRig/rotation, baking the soldier through the wrong pipeline. (4) Tests submenu — the six guard entries moved to Tools ▸ ENC ▸ Tests ▸. Verification sweep after all of it: smoke 14/14 (howitzer fresh-baked animated-legacy through the newly gated pipeline — bake-level proof of the gating decision; in-game check on the next real howitzer re-bake remains), Tier-2 4/4 (soldier now visibly on the conversion path: RIGANIM conversion path: ON), ConvGate litmus + registry PASS. (The “Can’t import normals — mesh ‘default’” warnings during Tier-2 are benign: the synthetic OBJ fixtures carry no normals and Unity recalculates.)
07-19 LAW 5 + THE KICK — the fire animation lands (late-night arc): the sandbox howitzer’s attack went through five hypotheses (aim layer — EXONERATED, the donor streams runaway 5000° angles but at the INVALID bone-index sentinel, diagnostic log kept in ClearAimLayer; RecoilArm rest — verified correct in the baked Skeleton asset; slot rebinding; strip list) before the Arc-R scaling experiment proved Law 5: bone POSITIONS are pinned at bind, only ROTATIONS animate — the far-pivot arc renders as a modest in-place tube PITCH, which is what the proven legacy “kickback” always was at map zoom. Shipped kick = slam-only recoil range + palindrome Return slow knob (user-designed: the slam played backward slowed, gliding home; the source’s own post-slam frames are reload choreography, deliberately excluded). Explored & parked in history (e280e91): the PRISTINE pre-retarget fire-window snapshot that can play the source’s full fire cycle (kick + barrel lowering + raise) — plus an unbuilt runtime ObjectSpace-nudge option for true whole-body lurch. Hardened along the way: conversion failure detection (Blender exits 0 on python crashes — the baker reused a stale converted GLB and recorded bad args as success; success now = the script’s own final marker), reversed recoil ranges rejected loudly, the Lab preserves unsaved form edits across reloads (banner + explicit choice instead of silent discard), raw-source Play clip button with single-frame stepping, and the clip player’s go-to-frame buttons. All on branch bake-only-stance.
07-19 BAKE-ONLY STANCE ARCHITECTURE + THE GATE — the state-driven howitzer lands, in-game verified (branch bake-only-stance): the sandbox migration’s remaining failures fell to a measurement protocol the user mandated (“it’s not good until it’s byte-identical to the proven TowedGunHowitzers”): byte-diff of _ClipsPoseData.bytes (identical ~100 frames then divergent = a locatable leak, not a mystery) + headless Blender pose-sweep gates on the slim FBXs (PASS = 0.0000 across all 250 frames vs the proven bake, idle stance == deployed pose bone-for-bone) — the whole Blender stage now gates without Unity or the game. Three root causes found+fixed in rig_anim.py: (1) role slicing leaked pose values into channels the primary doesn’t key (save/restore every pose bone); (2) export-time pose was arbitrary — it becomes the FBX default = the engine’s reference pose (every export now pins the clip’s first frame); (3) pacing knobs don’t belong at runtime (user ruling) — slice speed step clip[a..b/N] = every Nth frame = N× faster, baked. Architecture: Idle stance (override) role (animClipIdle/clipIdle end-to-end) — a stance baked as the PRIMARY encodes ~identity vs its own reference and renders as REST (“forgot to deploy”); the primary stays the FULL reference clip; the runtime idle-hold/deploySpeed overloads were reverted (strictly additive plugin change, gated on clipIdle data). Also: the legacy “instant fold” was demystified — it was the absence of a fold animation (empty Pre-move = the same snap; /12 = a fast fold, user’s pick). All four engine laws + the sandbox/gate method + a symptom index written up in the new Animation-Pitfalls.md.
07-19 DEPLOY CONVERSION AS RECIPE (user-designed) + the ROTATION-ONLY authoring law, field-proven on a sandbox unit: the rigid-parts conversion (deploy_convert.py) stopped being a hand-run Blender command (whose args lived in shell history — the M114 leg re-key was exactly such a lost step) and became registry data: deployConvert + knobs (trim start/end, strip, barrel ready-frame, legScale with empty = source legs verbatim, barrel scale, recoil range/step/slide/arc) on the entry; UniversalBaker.EnsureDeployConverted runs Blender automatically into FactorySource/<res>/deploy_converted.glb (cached on an args+source+tool fingerprint; a knob change reconverts AND re-slims via the mtime chain), and the converter’s 7c step cuts ready-made role clips (deployed/folded/unfold/fold/recoil) that the Lab’s roles reference by name. Verified end-to-end on a sandbox entry (SiegeHowitzersCar donor): raw Sketchfab file → recipe → 5-state artillery in-game. The law it proved: the engine bake keeps rotation and DISCARDS translation — legs that spread by rotation+slide play pivoting about the wrong point in-game (swept inward) even though the full-fidelity preview is perfect; legScale 1 re-keys them as pure rotation — the once-“voodoo” step is load-bearing, now a documented one-field knob (Animated-Models.md “rotation-only law”). Supporting fixes the same session: the clip range picker (▶) rebuilt on real-object rendering after two hand-rolled BakeMesh renderers each corrupted the view (un-mirrored legs, giant parts — the “crossed legs” that triggered the whole investigation were never in the data); picker inspection FBXs export the animation COMPLETE (its location-strip was the picker-side corruptor) with a source-keyed cache; the Lab re-syncs from the registry on every domain reload + explicit ↻ Reload (closing the stale-form clobber trap for good); Lab Model-file Browse button; importer-synthesized Icosphere junk confirmed handled by rig_anim’s material-less-mesh drop.
07-19 Two-round adversarial code review (both repos, 5 parallel reviewers per round) — all HIGHs fixed: Round 1 (~40 raw findings) surfaced and fixed the same day: a Substring(-1) crash in the baker’s reused-FBX fallback (stale pre-FactorySource path assumption); the Factory bake silently reverting Retexture/Sound-owned fields (ownership merge now covers all third-window fields); the convertRig Migrate() re-firing every Load (now one-shot, keyed on the file predating the flag); Unit Retexture’s Remove deleting FULL model entries listed as “overrides” (now clears overrides + confirmation dialogs); plugin session lifecycleregistered latched per-process while AnimationLoad rebuilds per-session (new RearmModelRegistration on the AnimationLoad postfix, mirroring the props axis) + the sim-thread entries race (publish-once + snapshot reads) + temp facing log removed; rig_anim.py failure posture (single-user-ize before modifier apply; hard-fail once the rest pose is rewritten — no more silent exit-0 half-normalized rigs). Round 2 adversarially verified all ten round-1 fixes correct, then found + fixed: SoundWindow’s pick-then-edit entry hijack (resourceName fallback retargeted another pawn’s full entry; fresh entries also inherited its name so Upsert replaced it); a fix-regression where pawns spawning in the transient-registry-retry window latched anyAnimated=false for the session (success publish now invalidates); incomplete re-arm (isolation layers/adjusted atlas/audio-listener latch now reset per session); a main-thread infinite loop on corrupt WAVs (negative RIFF chunk size). Also: Factory preview editor now torn down at beforeAssemblyReload (the per-recompile “SerializedObject has been Disposed” console error — Unity logs it inside DestroyImmediate, uncatchable at the call site). Verified-clean under two rounds: GUID nibble-swap math, keep-GUID re-bake, registry corrupt-guard/atomic-write/backup, patch exception discipline, cross-thread sample locking, deploy ramp math. Deferred findings tracked in Review-Backlog.md.
07-18 Conversion gate refactor — explicit “Convert raw rig” flag (Rotation defused): the raw-rig conversion pipeline is now selected by a dedicated Animation Lab checkbox (registry convertRig) instead of rotation != 0. The old trigger made Rotation a hidden pipeline switch — editing it on a working legacy model (the howitzer) silently rerouted the bake into the conversion (location-strip + rest-normalize could break a deploy clip), and a no-net-rotation conversion needed the 360,0,0 identity trick (how the soldier shipped). Now: flag OFF = byte-identical legacy always, regardless of Rotation; flag ON = the full conversion (rotation applied within it; rig_anim.py argv[8], absent = old inference so old callers keep exact behavior). Wired end-to-end: ModelDef + load-time auto-migration (animated + rotation≠0 → flag on), BakeConfig/ConfigFor, Lab checkbox + both windows’ ownership merges, slim-cache key, smoke-test path grouping (animated-conv now = flag), ConversionGateTest fixtures. Registry data cleaned: soldier = rotation 0,0,0 + convertRig true (same output — 360° ≡ identity). Verified in-editor: smoke test 14/14 (soldier fresh-baked animated-conv via the flag at rotation 0,0,0; legacy drone + howitzer byte-identical with the flag off; Retex entry SKIP), ConvGate full-conversion PASS on the real rig.
07-18 Animated position offset now pawn-frame (was world-axis): ApplyPositionOffset rotated the planar (x = sideways, y = fore/aft) registry offset by the pawn’s ObjectSpace.Rotation before adding — previously it added fixed WORLD axes, so the nudge pointed a constant compass direction and visibly drifted around the model as the unit turned (static models were fine: their offset is baked into the mesh = model frame). Motivating case: pushing the TowedGunHowitzers gun forward so the crew pawns don’t intersect it — the clearance now holds in every facing (verified in-game). TryQuaternion reads Unity or Amplitude-layout quaternions reflectively; falls back to world axes (logged) if unreadable. z (altitude) stays world-up.
07-21 ParseGuidCsv sign bug fixed + props “catalog gap” was a misdiagnosis + boot-tick silenced (all verified in-game): ParseGuidCsv included '-' in its split separators, silently stripping the sign off negative a,b,c,d GUID components — the sling prop’s MeshCollection (negative a) was requested with a corrupted GUID every session. With the sign fix, Amplitude’s catalog resolves mod-bundle MeshCollections by GUID fine once the bundle is mounted — the documented “type-specific catalog gap” (Pawn-Props.md trap 3) was our own corrupted input, and the by-name GetAllLoadedAssetBundles fallback never worked at all (Amplitude mounts community bundles through its own provider — dead code, removal candidate). Second fix: the props Update-tick safety net ran every frame from engine boot, guaranteed-missing before any bundle mounts — 64+ red Requested asset is not found lines per launch in the Amplitude diagnostics, noise that actively buried a real DB error during the 07-21 load-failure debugging. Now armed only by the first AnimationLoad postfix (the registration moment that works) and paced to ~1 attempt/s. Diagnostics verified clean (0 lines, was 64+); Sling_Collection registers first-try; M60 hand-prop pipeline unaffected.
08-01 Deploy part-strip made user-friendly + both howitzers de-wheeled: removing an extra part from a deploy-convert model used to mean re-typing the ENTIRE default crew/prop kill-list (deployStrip REPLACES the defaults — a footgun). New deployStripExtra ALWAYS appends to the effective list (deploy_convert argv[16]) plus a Pick parts… dialog (StripPartsDialog — a ticklist of the source model’s node names): add parts by clicking, defaults stay automatic, and the Dugout Canoe’s camera replace-override stays intact (its hull is all polySurface*, which the default polysurface kill would self-delete — proven by inspection, which is exactly why replace must remain a separate field). Backward-safe: old golden args have no argv[16] → read empty → byte-identical bake (regression stayed 4/5, canoe included). Applied to BOTH howitzers — SiegeHowitzersCar (the validating clone) and TowedGunHowitzers (the original): the mis-animated contrl_wheel/ctrl_wheel2 control hand-wheels the deploy motion dragged to the ground are stripped; idle → deploy[175..175]; deploy goldens re-blessed 29→27 bones. Gotcha recorded: staticParts (the freeze knob) is gated if convert_rig: in rig_anim.py → a NO-OP on deploy-convert bakes (convertRig off), which is why trying to freeze the wheel silently did nothing — removal via deployStripExtra is the tool. Also: Animation Lab label column widened 150 → responsive 210–245px so the descriptive field labels (“Also remove (adds to defaults)”, “Deploy conversion (rigid-parts source)”…) read in full. Also corrected: the idle-stance override role holds fine on a SINGLE frame (deploy[179..179]/[180..180] both verified) — the FrameCount-1 “renders rest” degeneracy is specific to stance-as-PRIMARY, not the override.
08-01 Unit facing persistence — a HAF side-file (the game save has no facing field), verified in-game: decompile confirmed the simulation Unit/Army carry NO orientation — facing lives only on PresentationUnit.FormationAngle and is recomputed from movement on load, so units reset heading on reload. New Patches/FacingPersistPatch.cs restores it WITHOUT touching the game save: a main-thread tick captures {SimulationEntityGUID → FormationAngle} for every loaded army; a Sandbox.Save postfix (Hk_SandboxSave) writes that snapshot to BepInEx/config/haf_state/facing/<StorageContainerInfo.Name>.facing (per-save file, keyed by the army’s serialized GUID which survives the load); a Sandbox.Load postfix (Hk_SandboxLoad) arms the matching file and the post-load poll re-applies each heading via PresentationUnit.FlipPawnsGrid(angle, Teleport). Flicker fix (same session): the first pass waited ~0.3s past the respawn rebuild → a visible neutral flash; now the restore runs EVERY frame while armed and re-applies on ANY heading DRIFT (turns the pawn the instant it exists → no flash; also re-corrects a respawnAfterLoad reset; no-op for already-correct units → no jitter). Config Factory/PersistUnitFacing (default on), all armies, fail-soft. Save/load choke points + the stable GUID key documented in Facing-Persistence.md for reuse.
08-01 Editor UX pass — the OrganGun (Ribauldequin) shipped as the first Vehicle-Lab WHEELED unit, and the friction it exposed fixed: (1) Animation Lab “Auto-detect settings” — one button reads the model’s clips + rig and fills the whole config (a Vehicle Lab Spin rig → State-driven, Idle=Spin[0..0], Movement=Spin, Convert-raw-rig/Auto-ground/Keep-translations ON, Fix100× OFF — the exact recipe that window prints; also character idle/run, single-clip loop, and a deploy-clip hint), instead of typing eight fields by hand. (2) Failed bake KEEPS settings (Animation Lab + Model Factory) — the form is snapshotted before the bake and restored on error (RebaseOnRegistry + trims mutate it in place otherwise), so a failure no longer reverts the config; status now reads “Bake FAILED (settings kept)”. (3) Vehicle Lab: recipe management folded into an Animation-Lab-style “Edit existing” combobox on the first row (<new model> + saved recipes, ↻ Reload/Remove), replacing the scattered New-model button + Recipes dropdown + Load-recipe dialog; and a “Enable wave rock” checkbox (off by default) so wheeled/tracked units don’t inherit the boat sway — no more zeroing two amplitude sliders. OrganGun recipe: 3 wheel meshes (.001 a coincident duplicate → Ignore), 2 rigged, wave off; verified in-game (wheels roll on move). All editor-only, compile-clean.
08-01 Animation Lab ↻ Reload no longer scrambles the model preview (user-diagnosed): on a tiling-UV rig (the Ribauldequin/Jagdpanzer — mesh UVs run −1.7…+4.2, mapped to tile a seamless source texture) the preview came up correct on open/bake but turned into an orange/gray checkerboard after a ↻ Reload. Long false trail (recorded so it isn’t repeated): assumed a DXT1 _Atlas.asset display bug and tried ForceUpdate reimport, then a mip-free RGBA32 blit copy, then a runtime-material instance — a draw_mats.txt dump proved the copy was clean and bound at 1:1 tiling, so the texture was never the cause; the mesh’s out-of-range UVs sampling the packed atlas were. Preview-only (the shipped unit uses atlas-remapped UVs baked into its GPU mesh — correct in-game). The fix, per the user: ↻ Reload passes rebuildPreview:false to OnSelectResource — it reloads the DATA only and leaves the existing (correct) preview alone; a different-entry select or a Bake still rebuilds. Real fix (a preview mesh carrying atlas UVs) deferred to backlog.
08-01 Part-selection + hand-off polish (Ozhiga Organ Gun = 2nd Vehicle-Lab wheeled unit, “VolleyGun”): (1) Vehicle Lab left/right slice — a horizontal companion to the height bracket: two sliders hide parts LEFT/RIGHT of a line on the width axis (center.y, where the two wheels mirror), so a wheel is isolated by bracketing height + side, then marked in one sweep. (2) Padded slider defaults (user finding) — the height + width brackets now pad their range a hair past the outermost part, so “fully open” hides NOTHING at rest (the default used to clip the edge part = “1 hidden by the sliders”). (3) “Edit in Animation Lab” hand-off fixOpenFor cloned the registry entry and ignored the model file open in the Factory, so a just-Browsed-but-unsaved _Spin.glb was replaced by the stale static file → Auto-detect saw no Spin clip → nothing happened. Now it carries the Factory’s current model file across (RebaseOnRegistry already preserves the Lab’s copy). Verified: the Ozhiga gun then auto-detected + baked animated. All editor-only, compile-clean.
08-01 Registry Save no longer wipes Resize/Era-Lab data or the pack header (critical-review finding A, 3 reviewers; verified in-game — bake → rebuild → load OK): ModelRegistry.Save rebuilt the whole file from session-static UnitScales/EraGrid/FormationThresholds (populated ONLY by Load, emptied on every domain reload) plus RegistryFile defaults for the header — so any Save reached after a recompile but before a fresh Load serialized empty arrays + a default header, silently erasing all Resize-Lab/Era-Lab rules and any hand-authored dependsOn/overrides. Fix: a loaded-this-session flag (set by Load, reset on domain reload); Save now MERGES onto the current on-disk file — always preserves the header, and preserves the scale/era/threshold arrays from disk whenever loaded is false (never overwrites with the empty statics). Strictly ≥ old behaviour: a read-lock during Save degrades to the original rebuild path, never worse. Also closes the finding’s second half — the whole-models-snapshot clobber: new SaveStatics()/LoadModelsOnly() re-read models fresh so a Lab’s stale snapshot can’t revert a concurrent bake; Global Era Lab now uses SaveStatics() (owns only the statics) and Resize Lab merges its per-model scale edits onto freshly-read models before saving. Editor-only, compile-clean.
08-01 Two review HIGHs: per-repaint window freeze + gate-test pipe deadlock (finding-order pass 2, editor-only, compile-clean): (1) Sound Studio + Unit Retexture called ModelRegistry.Load() inside OnGUI — every repaint re-read+parsed pack.json, and on a MISSING config file Load does a 250ms Thread.Sleep, so a new adopter’s window ran at ~4fps. Both now cache the list in a field, refreshed on OnEnable/OnFocus and after every save (so a bake/edit in another window is still picked up on focus), with a lazy ?? Load() fallback that survives domain reload — the same pattern the other Labs already use. (2) ConversionGateTest deadlock — it drained Blender’s stdout THEN stderr sequentially with the 180s WaitForExit AFTER (so it never armed); if Blender filled the stderr pipe buffer while we blocked on stdout, both sides wedged and the gate-test menu froze the whole editor. Both call sites now route through UniversalBaker.RunBounded (concurrent background-task drain + bounded wait + kill-on-hang), the same helper the main bakers and ClipRangeDialog already use.
08-01 Vehicle Lab recipe was lossy — saving/reloading changed the rig, and stale orientation/rock leaked between models (review finding #3, editor-only, compile-clean): the Recipe DTO stored only ~11 of the ~22 fields the Vehicleize bake command actually consumes — it omitted modelRot (straighten a crooked import), tracksStatic, and ALL 9 wave-rock fields (waveEnabled, rockDegrees/Frames/AxisChoice/Heading/PitchDeg/RollCycles/PitchCycles/PitchPhase). So saving a boat or a rotated import and reloading it baked a different rig (as-imported orientation, no rock, live tracks), and — worse — because LoadRecipeFromPath never touched those fields, whatever rock/orientation was on screen from the previous model leaked into the next one’s bake. Fix: the DTO now carries all 11 (defaults match the live fields, so a pre-2026-08-01 recipe missing the keys loads neutrally = wheeled / as-imported / no rock); SaveRecipe writes them and LoadRecipeFromPath fully restores them (a wheeled recipe now overwrites a boat’s rock and vice-versa — no cross-model leak), with > 0 guards on the counted fields mirroring the existing treadAdvCells handling. Also removed the now-dead LoadRecipe() OpenFilePanel wrapper (the Edit-existing combobox calls LoadRecipeFromPath directly).
08-01 Animated slim-FBX reuse now has an argument fingerprint — ‘Reuse extracted’ + a changed clip/rotation no longer bakes from a stale FBX (review finding #4, MED, editor-only, compile-clean): the animated bake cached its slimmed rig FBX and busted that cache only on a newer TOOL (rig_anim.py) or newer SOURCE-FILE mtime — it was never keyed on the rig_anim ARGUMENTS. So with Reuse extracted on, changing the animClip (bake “hover”, switch to “walk”), the rotationEuler, animateBones, convertRig, staticParts/localNodeAnim, or any state-role clip — none of which touch the source-file mtime — silently reused the slim baked with the OLD settings, baking the wrong clip or orientation. Fix mirrors the deploy path’s proven .args.txt sidecar: a <name>_anim.args.txt fingerprint of all slim-shaping args (source path, tris, animateBones, clip, material mode, rotation, convertRig, autoGround, socketBones, keepTranslations, staticParts, localNodeAnim, animStateDriven + all 8 state clips) is written after each successful slim and compared on reuse — re-slim when it differs, or is absent (a pre-fix FBX re-slims once, identical output when nothing changed). Also folds the source PATH into the key, closing the sibling gap where switching to a DIFFERENT source file whose mtime isn’t newer than the cached FBX slipped past srcNewer. Strictly safer: can only re-slim a stale cache, never alters a correct bake’s output; bakeLocked entries never reach the path.
08-01 Systemic risk B — the three LIVE drift bugs between paired windows, fixed (review finding B; editor-only, compile-clean). Rather than the full extraction (which touches the settled Factory/Lab ownership design), the concrete instances the duplication had already drifted into: (1) disabled-rebase drift — the Lab owns the “Disable override” toggle, but ModelFactoryWindow.RebaseLabOwnedOnRegistry (which reads back ~40 Lab/Sound/Skin-owned fields the Factory can’t display) omitted disabled, so disabling an override in the Lab then Baking from the Factory silently UN-disabled it. Added cur.disabled = regE.disabled next to its sibling bakeLocked. This is the third omission of this exact class (keepTranslations burned three T-62 bakes on 2026-07-26; animPhaseSpread followed) — see the structural note below. (2) Factory preview MissingReferenceException spam — the Lab’s fit-preview drops a cached mesh DESTROYED under it (mesh == null fake-null → clear the draw list); the Factory’s identical PRU loop lacked the guard, so a re-bake/cache-clear under an open Factory threw every repaint. Mirrored the guard. (3) ProjectileBaker parity — added PropBaker’s fxGuid null-guard (a null from an un-Built FxMesh made FieldInfo.SetValue(struct, null) write default(Guid) → a zero-GUID trail that reports success but shows an INVISIBLE munition; now fails loudly) and its cached FindType (the uncached per-repaint assembly scan was a per-mouse-move CPU/GC hit). Structural note: the rebase field-list is the recurring drift surface (3 omissions now) — a reflection/declared-list-driven rebase would prevent the next one, but it touches the ownership model, so it’s flagged for a decision rather than done unilaterally.
08-01 Killed the drift CLASS — the Factory’s ownership rebase is now fail-safe AND fully type-safe (systemic-risk B root cause; editor-only, compile-clean). RebaseLabOwnedOnRegistry was ~40 hand-written cur.X = regE.X lines enumerating every field the Factory does NOT own — a denylist you had to remember to extend for every new Lab/Sound/Skin field, which had already been forgotten FOUR times over (keepTranslations, animPhaseSpread, disabled, and ~13 Sound fields never listed at allbrightness, silenceDonorAudio, and every idle/attack/death/battle sound field, all silently reverting on a Factory bake). Re-structured to mirror the Lab’s already-fail-safe RebaseOnRegistry: START from the saved entry (JsonUtility.FromJsonOverwrite copies EVERY field of the saved entry onto cur in place — so a NEW field is preserved by default, fail-safe), then overlay only the ~27 Factory-owned fields from the form with plain compile-checked assignments. No reflection — a typo or a renamed field is now a BUILD error, not a silent revert. Proven behaviour-preserving: set-diff shows the new preserved-from-entry set = the old rebased set MINUS nothing PLUS exactly the 15 previously-missing Sound/Skin fields (the latent bug fixes). The Lab’s RebaseOnRegistry was already fail-safe by construction (starts from the registry entry, overlays only its owned fields), so only the Factory side needed inverting.
08-01 HAF plugin performance pass (runtime, in-game verified) — a 5-agent review of the ~8k-line BepInEx plugin, focused on per-frame cost. It found the hottest paths already well-tuned (the per-pawn-per-frame pose hooks are alloc-free + reflection-cached, texture atlases are built-once-and-cached behind a ReferenceEquals early-out, threading is sound, no per-tick file I/O, the F8 prober is fully gated) and fixed the real standing costs: (1) Formation re-instantiate scan ran ~12×/s for the WHOLE session (MaybeReinstantiate) with uncached reflection + a per-army LINQ closure + a per-scan HashSet alloc, and never terminated. It’s a one-time catch-up for units rendered before the formation override landed — now it settles (stops scanning) once a run of scans handles nothing new (a save-load re-arms it), caches its Mem lookups, and uses a plain foreach + one reused set. (2) The state-driven + deploy-on-stop movement polls recomputed Regex.Replace(pawnDescription,"_[0-9]+$","") per entry per unit at 20 Hz — cached once into ModelEntry.coreDesc at registry publish; ProcessDeployState’s per-frame entries.Any(closure) → manual foreach; the deploy poll’s per-unit FirstOrDefault(closure)foreach; the six per-poll dict.Keys.Where(…).ToList() despawn-cleanups → one reused PruneGone helper. All behaviour-preserving. Deferred (higher risk): merging the two polls’ redundant army walk into one pass; several unbounded-growth dicts + a save-reload layer leak (finding tiers 2–3).
08-01 Facing-persistence patch was fighting unit movement — units crab-walked SIDEWAYS for ~5 s after a load (found while perf-testing the two Vehicle-Lab organ guns; the earlier A/B missed it because it reverted the perf fixes but not this patch, which was added the same day). FacingPersistPatch re-applied each army’s saved heading via FlipPawnsGrid on ANY heading drift for a ~5 s window (to catch a respawnAfterLoad rebuild reset) — but it couldn’t tell a load/respawn reset from the player moving the unit, so it snapped the heading back every frame and the unit slid sideways (one hex was fine; a multi-hex move crabbed the whole way). Rewritten to single-shot: restore each saved unit’s heading exactly once when its pawn first loads, skip any unit already in motion (IsAnyPawnMoving — true from the move order, before the render position changes), then stop the instant every saved unit has been handled (one cycle — no fixed 5 s wait; a frame cap only backstops units that never load). Also reuses the per-tick capture dictionary (was a GC alloc every walk). Fixes the sideways bug, the residual first-5 s jerk, and the standing per-frame cost. Trade-off: a respawnAfterLoad unit that resets after the single pass isn’t re-corrected (a rare rotor-borrower, acceptable). In-game verified. See Facing-Persistence.md.
08-01 HAF plugin Tier-2 memory leaks fixed (runtime perf review, in-game verified — texture + sound intact across reloads, no crash). Several per-pawn/per-entry collections only ever GREW: (A) the engine-audio dicts (engineLastPos/engineMoving/customSources/loopHoldUntil/idleNextAt) gained an entry per sub-pawn instance id and were never pruned — every combat death and every zoom-LOD rebuild orphaned entries for the whole session. Now pruned against the live sub-pawn set on the ~2 s refresh (a PruneById helper, mirroring the state/deploy polls). (B) attackSoundNextAt + idleNextAt (per entry) and the static _silencedEmitterIds were never cleared even on reload, compounding across every save-load in one process run — now cleared in RearmModelRegistration. (C) the Object.Instantiate‘d retexture/prop layer clones (isolatedLayer/handPropLayer) were nulled but never Destroy‘d, leaking a native layer object per entry per reload — now destroyed on reload (fake-null guarded; the session-1 objects they were cloned from/injected into are torn down by then, so nothing live references them). Remaining review items (Tier 3, deferred): the GetMember doubled field/property lookup, unguarded (Vector3) pose-helper casts (a per-frame-exception cliff after a future game-update rename), two unguarded Update polls, and the two-poll double-walk merge.
08-01 HAF plugin Tier-3 fixes (runtime, in-game log-verified — no exceptions, injection intact). (1) GetMember/SetMember now use ONE combined member cache instead of a property-lookup-then-field-lookup pair: every Amplitude member we touch is a field, so the property probe was wasted on the per-pawn-per-frame hot path — now a single dict hit. (2) Three pose casts guarded (DeployPoseTime/FireOncePoseTime/ApplyPositionOffset): (Vector3)GetMember(...,"Translation") threw on a null (renamed member) — a per-pawn-per-frame exception cliff after a future game update; now they degrade gracefully. (3) ProcessBattleCries wrapped in try/catch (it does Camera.main + new GameObject — an unhandled throw killed the whole Update chain); ProcessFireQueues’ prune loop documented as intentionally unguarded (pure non-throwing List/ConcurrentQueue/Time ops). (4) anyMuzzle cached like anyAnimated — was an all-entries loop on every GetBoneTRS/VFX lookup during combat. Deferred: the two-poll double-walk merge (risky refactor of two verified state machines) + the HookedEntryFor scan (marginal).
08-01 State-driven pose: same-type units no longer swap movement state when stacked (general-review finding, in-game verified with adjacent moving volley guns). The state poll pools its movement samples per model TYPE (there is no per-unit id — a pawn entry’s array slot reshuffles on LOD), and the pose hook matched a pawn to the single NEAREST sample within 4u. So two same-type units within the radius in DIFFERENT states (one moving, one idle) could have a pawn grab the neighbour’s sample and play the wrong clip (a parked volley gun spinning its wheels because an identical one drove past). StatePose now takes a proximity-weighted majority of the in-radius samples (weight = R²−d²) so a pawn deep in its own formation is carried by its mates instead of flipped by a single closer neighbour. Byte-identical to the old nearest-sample pick whenever the in-radius samples agree (the common case: the winning side’s nearest sample IS the overall nearest), so it only changes the ambiguous mixed-state zone that was the bug. Deploy/fire pose hooks share the pattern (deferred — continuous pose-time needs a variant).
08-01 Attack sound no longer double-fires for a ranged attacker (general-review finding, in-game verified on a ranged organ gun given a custom attack sound). Two independent paths play the attack one-shot — the fight-hook path (OnPawnAttack, via the ranged/melee fight sequence) and the early FaceEnemy path — and they keyed their min-gap dedup by DISJOINT values (attacking-pawn hashcode vs resourceName^battleUnit), so a ranged attacker roared TWICE per shot (once at the camera, once at the pawn). Both now dedup on a shared per-(entry, attacking-unit-GUID) key (AttackSoundKey): the army-stack and battle-tile PresentationUnit objects share the sim GUID (the state poll relies on the same fact), so both paths compute the identical key and the second roar is suppressed within the min-gap. Also moved OnPawnAttack’s sound key from per-PAWN to per-UNIT, so a multi-pawn unit roars once per attack rather than a chorus. Fail-safe: a key mismatch degrades to today’s double, never to silence.
08-01 FacingPersist no longer mis-gated behind the model-injection toggle (general-review finding). FacingPersist.Tick() — the main-thread driver that captures each army’s facing AND applies the post-load restore — sat inside if (UniversalInjectOn.Value), but the facing feature has its OWN PersistUnitFacing config + its own Sandbox.Save/Load hooks. So turning model injection off silently killed facing capture/restore (the save side-file would never be written). Moved the call to its own if (PersistUnitFacing.Value) gate, matching how FormationOverride.Tick() is gated. Behaviour-identical in the default config (both toggles default on); only fixes the off-injection case. Compile-clean.
08-01 Stale baker/ModelDef schema-drift risk — documented, not deleted (general-review finding). The plugin repo’s baker/ folder holds a snapshot of the editor tooling whose ModelDef (ModelRegistry.cs) is 120 lines behind ENCReload’s authoritative copy, missing live runtime fields (scale, animPhaseSpread). Investigation found baker/ is NOT a simple dead copy: it also contains the LIVE glbconv/ converter (own .csproj + built publish/) and a Tools/ Blender-scripts copy, and the csproj notes it “ships in the mod” — so a blanket delete was unsafe. Since this repo isn’t a Unity project, the stale editor .cs can’t compile/run here anyway (the drift is only reachable if someone bakes from this copy). Rather than a risky delete, added a prominent STALE-COPY warning header to baker/ModelRegistry.cs + a baker/README.md spelling out what’s authoritative (ENCReload), what’s inert (the editor .cs snapshot), and what’s live (glbconv/, Tools/), with the rule that the snapshot must be re-synced from ENCReload before it’s ever distributed as tooling.
08-01 Pack wrapper header no longer silently dropped on a JSON typo (general-review finding; multi-pack, latent for the single-pack ENC base). ParsePack parsed the wrapper (modId/schemaVersion/dependsOn/loadAfter/overrides) with JObject.Parse inside a bare catch { }, while the MODELS parse had a primary + regex fallback. A JSON syntax error (the exact hand-edit that drops models to the regex path) therefore made the wrapper throw → swallowed with no log → the header was lost, yet the models still loaded — so a pack that declared a cross-pack overrides had it silently vanish and the merge downgraded it to a first-loaded-wins conflict. Fixed: the catch now (a) logs a loud warning naming the pack, and (b) recovers modId/schemaVersion/dependsOn/loadAfter/overrides by regex — the same resilience the models path has (wrapper-only keys, so whole-file matching is safe; overrides scoped to its own array block). Behaviour-identical for a valid pack (the try path is untouched); only the malformed-pack path changed.
08-01 Injection matching now prefers the MOST SPECIFIC entry, not first-in-registry-order (general-review finding; latent for ENC — no nested names). Three matchers picked a unit’s owning entry by substring with FirstOrDefault (first match in resourceName order): RepointMatch (skeleton bind), FindEntryForUnitDefinition (sim-thread combat hook), and ProcessEngineAudio (sub-pawn audio). If one entry’s pawnDescription were a substring of another’s (e.g. "…ManOWar" vs "…ManOWar_Elite"), a unit containing both silently got the earlier entry — the general one could claim the specialised unit and bind the wrong model, with no log; the full-pawnDescription matcher and the coreDesc matcher could even disagree on ownership. Replaced all three with one shared LongestMatch(list, name, key) helper that takes the longest matching key (most specific) and warns once per name when >1 entry matches. Result is identical to the old FirstOrDefault whenever only one entry matches (the common case), so ENC is unaffected; non-capturing key lambdas are compiler-cached (no per-call alloc); the log guard is locked for the two threads.
08-01 Retexture no longer bleeds onto the emblematic original when layer isolation fails (general-review finding; conditional/latent). A texture-only entry (reskin/desaturate that keeps the vanilla mesh) shares its output layer with the emblematic original unit, so GreyIsolate clones a private layer at inject and ApplyTexture paints only the clone. If the clone was never made — the fragment’s fxOutputLayer wasn’t ready yet at inject (host==null), or GreyIsolate’s exact meshName==layerHint didn’t match while ApplyTexture’s fallback matches by substringApplyTexture fell through to paint the shared host layer directly, bleeding the reskin/desaturate onto the vanilla original. (Agent’s “discovery returns empty → paints shared layer” was imprecise: an empty layerHint already early-returns; the real triggers are the two above, where layerHint is set but no clone exists.) Added a guard: an entry with an all-zero skeleton guid (i.e. texture-only, the same isModelEntry test used at inject) that reaches the fallback with no isolated clone now skips the shared-layer repaint (degrades to the vanilla skin + warns once per resource) instead of corrupting the original. A custom model (non-zero skeleton guid) owns its own layer and keeps the fallback. Byte-identical whenever isolation succeeds — the normal case for every ENC retexture entry today.
08-01 Documented the SkeletonBoneBudget default (general-review hygiene note — no behaviour change, value unchanged). The default 262,144 (4× vanilla 65,535) was an undocumented round number. Added a rationale comment + expanded the config description: it’s a deliberately generous SAFETY margin (~1,080 tread-instances of headroom), not an empirically-fitted worst case, chosen so the spike plague can’t recur; it costs only a few MB of VRAM and is applied unconditionally to all users. Both trade-offs are intentional given the tiny cost; the note records how the number was picked and how to re-verify if it’s ever shrunk (dense late-game map). Comment/description only — the default value and the enlarge logic are untouched.
08-01 Consolidated the three duplicated reflection readers onto one implementation (general-review hygiene). UniversalInject.GetMember (cached, property-first, finds non-public — 302 sites), FormationOverride.Mem (its own cache, property-first, public-only, 17 sites), and FireProbe.Member (uncached, field-first, no try/catch, 8 sites) were three subtly incompatible copies of one concept — they could resolve the same (object,name) to different members (field-vs-property order) or silently miss non-public members (Mem), and a fix to one never reached the others. Mem and Member are now thin forwarding aliases to GetMember, deleting the two duplicate caches + the FireProbe.BF flags. Behaviour audited as unchanged: Mem’s sites all read public members (identical resolution); Member’s field-first→property-first switch was checked against its call-sites (StrikerUnit/StrikerArmy/UnitDefinition/GUID/AttackerEmpireIndex/TargetTileIndex are properties, striker is a lowercase field with no competing property, and GUID already resolves property-first elsewhere). FireProbe.Int keeps its null→int.MinValue sentinel. Touches the combat fire hook, so worth a relaunch sanity-check that attack sounds still fire.
08-01 Removed the shipped hot-path diagnostics; kept only what’s functional (general-review hygiene; user directive: remove diagnostics unless they change functionality). Deleted the [Rot] adjacent-attack-rotation diagnostic (the Hk_RotDiag_FaceEnemy/LookAt/FsmStart patches + OnUnitActionDiag/OnRotationStartDiag/Tail), the [State] pose/move transition logs (+ the log-only lastStateAnim field and the nearest/sampleCount diagnostic locals in StatePose), the [State] OnPawnAttack + poll logs, and the [PawnDiag] spike-plague patch (Hk_PawnEntryDiag). The early attack sound was extracted first — it had been living inside OnUnitActionDiag (a method named as a diagnostic), so deleting the diagnostic would have silently killed the combat roar. It now lives in its own UniversalInject.TryEarlyAttackSound, hooked by a renamed Hk_EarlyAttackSound on UnitActionFaceEnemy.StartUnitAction — same gate (our attacker + Attacker scope + attack clip), same AttackSoundKey dedup, same camera-anchored PlayAttackOneShot. Two error-handler LogError lines kept (not diagnostics). Net −~90 lines, 4 fewer Harmony patches. NOTE: this removes the instrumentation for the still-open adjacent-attack-rotation bug — re-add from git history when that investigation resumes. Touches the combat audio path → relaunch sanity-check that attack sounds still fire.
08-01 Honest hook patched-count at startup (general-review hygiene; startup-log only, no gameplay change). The load line counted patched++ whenever Patch() didn’t throw — but a hook whose TargetMethod returns null (Amplitude renamed a type) patches nothing without throwing (the deliberate per-hook CreateClassProcessor design that keeps one dead hook from failing the whole plugin), so it was still counted. The summary could read “N/N hooks patched” while some silently patched no method — hiding real API drift. Now counts the methods Patch() actually returns (applied.Count > 0), warns per hook that resolved no target, and appends “, K skipped — see warnings above” to the summary. The individual hooks already logged their own “NOT found” warnings; this makes the summary agree with them.
08-02 Maintainability Phase 1 — split the UniversalInjectPatch.cs monolith (5,902 lines → 11 files, pure code motion, behaviour-identical). The single static UniversalInject class became internal static partial class UniversalInject spread across concern-named files: .Inject (register/repoint/upload/fragments/hand-prop), .Retexture, .Clips, .ScaleEra, .Pose, .Muzzle, .Combat (respawn/fire-queues/attack-sound/anim-states), .Audio, .Districts (also GetMember/props/projectiles), .Hooks (the 10 Harmony patch classes), and UniversalInjectPatch.cs keeps the registry load/parse/match + the standalone types. Contiguous line ranges moved verbatim (sed), cut only at blank lines between members so no method was split and every leading comment stayed with its method. Verified two ways: member-declaration set is byte-identical between the old monolith and the union of new files (set-diff empty both directions), and the build is 0 warnings / 0 errors. No ModelEntry change (the declined POCO split stays declined) — only file boundaries moved. Reflection helpers (GetMember/SetMember/CachedMember) are still split across .Audio/.Districts; Phase 2 co-locates them. In-game verified (plugin loads, custom units inject) 2026-08-02. New docs/Code-Map.md documents the file layout.
08-02 Adopted a unit-test suite for the pure registry layer (Tests/HumankindAssetFramework.Tests.csproj, xUnit/net471, 17 tests). Covers the half that touches no live-game reflection — ParseModels, ResolvePacks (dup-modId reject, dependsOn/loadAfter ordering, missing-dep skip, stable seed order), LongestMatch, CoreDesc, RegexStrArray — i.e. exactly the functions behind this session’s registry bugs (ParseGuidCsv sign, LongestMatch ambiguity, wrapper-parse overrides, substring pawn-match), so it’s a regression net rather than coverage theatre. Enabled by [InternalsVisibleTo] + a few private→internal bumps and a ManualLogSource("test") fixture for Plugin.Log; Tests\** excluded from the plugin globs; References\*.dll mirrored into the test bin. Run: dotnet test Tests/HumankindAssetFramework.Tests.csproj -c Release. The runtime/integration seam (inject/pose/audio) stays out of scope by design — deferred in-game smoke (Phase 5). Does not change the standing no-runtime-unit-tests stance; this is the pure-logic exception that was always carved out.
08-02 Extended the registry suite to 30 tests (test-only; helpers were already internal). Added ParseModels robustness — the third-party-registry case the testing note flagged: garbage/empty input returns empty without throwing, missing skel defaults to zero, signed GUID components survive, and the per-object parse keeps each field with its model (proven, not just asserted). Plus the regex-fallback recovery on a truncated document — which pinned a real behavioral fact now locked by test: the fallback keys entry count on Min(pawnDescription, skel, atlas), so a recoverable model needs all three. ResolvePacks now also covers the cycle→file-order-with-note path, the transitive dependency strand (fixpoint), and soft loadAfter to an absent modId; RegexStrArray covers empty-item filtering; GuidToLong covers null/non-numeric→0.
08-02 Registry suite to 44 tests — added the pure era-scaling logic (test-only + two private→internal bumps in .ScaleEra). EraFromName (extract EraN from a name, case-insensitive, multi-digit, -1 on none/null) and EraAnchorFor — the Global Era Lab anchor rule, guarding the user rule that a unit stays at 1.0 unless an authored grid cell says otherwise (own-age-or-earlier → 1.0, later-but-unauthored → 1.0, non-positive eras clamp cleanly). FindEntryForUnitDefinition was deliberately left untested: it just delegates to the already-covered LongestMatch+CoreDesc, so it’d mean exposing the entries global for ~no new coverage. This is the practical ceiling of the pure-data layer — further unit tests would be theatre; the next real coverage gain is the deferred in-game smoke seam (Phase 5).
08-02 Maintainability Phase 2 — co-located the reflection helpers into UniversalInject.Reflection.cs (pure code motion, behaviour-identical). GetMember/SetMember/MakeGuid + the (type,name) member cache (memberCache/fieldCache/CachedMember/CachedField) were split across .Audio (cache + SetMember) and .Districts (GetMember/MakeGuid) as a Phase-1 contiguous-range artifact; now they live in one file, with the cache’s doc-comment (which had dangled at the end of .Combat.cs after Phase 1) rescued alongside. BF stays a shared const in the home file; ParseGuidCsv stays in .Districts (it’s district-config parsing that merely calls the relocated MakeGuid). Verified: build 0/0, 44/44 tests green, and the member-declaration set across the touched files is identical before/after (pure move). FacingPersist’s own small CachedField is a separate class helper, left as-is.
08-02 Audio override — Hk_SilenceEvents + Audio/SilenceAudioEvents config (new capability; verified in-game). A Prefix on the service sink AudioManager.PostEvent (the same chokepoint the F8 Audio Trace watches) drops any posted Wwise event whose name contains a configured comma-separated case-insensitive substring. No-op while the config is empty; the substring list re-parses only when the config string changes (hot-path safe). Origin: an “out-of-place cart sound in a modern-era game” traced to a frozen ancient city-state whose village/cart ambience was actually era-correct — the game already plays per-city era-appropriate ambience natively, so a “era-linked city audio” feature would have duplicated working vanilla behaviour. Kept the tool anyway as a general “mute a lazy/reused vanilla sound” knob (valuable to total-overhaul mods) and step 1 of a future silence-then-substitute override.
08-02 Game Sound Lab — registry-backed audio overrides + F8 audition (new feature; verified in-game). Editor: Tools/HAF/Game Sound Lab (renamed from “Ambient Sounds Lab” — it spans units/ambient/music/UI) authors haf_sounds.json via SoundOverrideRegistry, with a searchable catalog pick list (from Dump Sound Cataloghaf_sound_catalog.txt) + category tabs. Plugin: reads the registry into ShouldSilenceEvent, and adds an F8 audition (Play Event/Stop) so a modder can HEAR a Wwise event by name before silencing it. The audition took real debugging: post the looked-up AudioEventHandle via the emitter’s own PostEvent (not the silent AkSoundEngine.PostEvent(string,gid)), on ALL AudioEmitters (units + cities; Camera.main is null in-game), with Stop = StopAll for looping _Start events. Full writeup + the granularity ceiling (event-level only; samples are hashed/unnamed) + the shelved city-ambience/enrichment findings in docs/Game-Sound-Lab.md.
08-02 Maintainability Phase 3 — quiet-by-default logging (General/VerboseLog config + Plugin.Diag() helper). The ~137 chatty per-model/per-pawn BRING-UP LogInfo calls (skeleton repoints, atlas/skin/prop injection, pose-hook dumps, per-unit state/facing/resize, buffer/bone-pool sizing) now route through Plugin.Diag(), which no-ops unless VerboseLog is on. A normal load stays quiet — the ~29 always-on lines are the summaries (“loaded N packs/models”, “parsed N via Newtonsoft”), registry counts ([District]/[Formation]/[Audio] registry: N), hook-registration confirms ([Fire]/[Audio]/… hooked), F8-triggered dumps ([Budget]/[AtlasDump]/[DistrictSub], Play Event/catalog), era changes, plus all warnings/errors (untouched). Behaviour-neutral (logging only); VerboseLog=true restores the full trace for investigating one model. Phase 3 was earlier flagged low-value (the per-frame spam was already removed); did the light version anyway for a clean distributable log.
08-02 Reflection-fragility Option A / phase A1 — startup compatibility report (GameBinding). HAF binds to game types/members by name via reflection; a game update can rename one and the feature silently misbehaves. GameBinding resolves a small, confident catalog of the core game types/members at load and logs [GameBinding] OK or a specific NOT FOUND warning naming exactly what’s missing — turning a silent runtime bug into a loud, localized startup warning (the reflection sibling of the honest patched-count + schema guard). Deliberately plain System.Reflection (no Harmony/MonoMod) so the resolver is unit-testable — 5 new tests (found type+members, missing member keeps the real one, missing type, method/property both count, null/empty safe); suite now 49. Behaviour-neutral (a log line); keeps the graceful per-hook degradation. It immediately earned its keep: on first run it flagged a mis-attributed catalog member (PresentationSubPawn.PresentationUnit — that member is on the army/battle-unit, not the sub-pawn), caught before it could ship. Verified in-game (OK — 6 type(s)). A2 (grow the catalog) / A3 (route reads through cached handles) are the deeper, optional follow-ons.
08-02 Reflection-fragility A2 — grew the GameBinding catalog to 15 game types with their hot-path members (audio: PresentationSubPawn/AudioEmitter/AudioEntityGUID/AudioManager/AkSoundEngine; pawn/pose/inject: PawnManager/AnimationManager/PresentationPawnDefinitionAddOn/ClipCollection/MeshCollection; unit/combat: PresentationUnit/PawnRangedFightSequence/UnitActionFaceEnemy; era: Sandbox; Guid). Each member attributed to the type it’s actually read off. Behaviour-neutral (report only); verified in-game OK — 15 type(s) clean on the first pass. Still just a broader “is it there?” check — no code reroute (that’s A3).
08-02 Reflection-fragility A3 (pilot) — route the AUDIO type resolutions through cached GameBinding accessors. The deeper de-fragilization: instead of a game type NAME scattered across many AccessTools.TypeByName("…") call sites, each name lives in ONE place (a cached GameBinding.<Type> accessor), so a rename is a one-line fix. Piloted on the audio subsystem (AudioEmitter, PresentationSubPawn, AudioManager, AkSoundEngine, AudioEventHandle) — 13 sites across .Audio/.Hooks/CombatEventPatch now use the accessors (with the game’s short-name fallback preserved for AkSoundEngine/AudioEventHandle). Behaviour-preserving (GameBinding.X resolves the same type); resolution now cached. 3 new Cached unit tests (resolve+cache same instance, fallback, missing→null); suite → 52. Verified in-game: 26/26 hooks patched (audio hooks resolve their targets via the accessors), OK — 15 type(s), sound plays. Remaining subsystems (pawn/pose/inject, unit/combat, era, districts) can adopt the same pattern incrementally, or A3 stays demonstrated-and-parked here.

Open findings — 2026-07-07 critical review

Editor pipeline (UniversalBaker / ModelFactoryWindow / ModelRegistry)

E1 🔴 Bake uses trimmed fields, registry stores the untrimmed original — silent no-inject — ✅ FIXED (2026-07-07)

ModelFactoryWindow.cs DoBake: cfg got cur.pawnDescription.Trim() but Upsert(cur) persisted the raw string. Paste a pawn description with a trailing space → bake succeeds, registry entry looks valid, but the plugin’s substring match (name.IndexOf(pawnDescription)) never fires. “Baked ✓”, model never appears, no error anywhere. (hideMeshes is safe — the plugin trims per-token; pawnDescription is not.)

Fixed: DoBake now trims every text field on cur itself before building the bake config, so what’s baked and what’s registered are identical.

E2 🟡 Remove button deletes by the edited name and always reports success — ✅ FIXED (2026-07-12)

ModelFactoryWindow.cs: removal keyed on the live cur.resourceName text field, not the selected entry (existing[selected]), and Remove()’s bool was discarded. Editing the name field then Remove deleted a different model — or nothing — while the status still said “Removed”.

Fixed: keys on existing[selected] and branches the status on Remove()’s actual bool (“Removed ‘…’” vs “‘…’ was not in the registry — nothing removed”).

E3 🟡 Static bake of a skinned-only model ships a 0-vertex (invisible) unit — ✅ FIXED (2026-07-12)

UniversalBaker.cs static combine iterates only MeshFilter; a rigged FBX that imports as pure SkinnedMeshRenderers yielded cVerts.Count == 0, yet the bake completed, the GUID check passed (the asset exists — it’s just empty), and a registry entry for an invisible unit was written. Only trace: verts=0 in a Debug.Log.

Fixed: a cVerts.Count == 0 guard after the combine returns Fail(...), pointing to the Animated path (where skinned meshes belong). Verified it does NOT false-trigger on valid models: 12/12 bake smoke test.

E4 🟡 RunBounded isn’t fully bounded: unbounded pipe-drain after WaitForExit — ✅ FIXED (2026-07-12)

If the child exited but a grandchild (Blender helper) inherited the stdout handle, WaitForExit(timeout) returned true and the ReadToEnd tasks never saw EOF — GetAwaiter().GetResult() then hung the editor main thread forever, the exact freeze the cap exists to prevent.

Fixed: the drain is bounded too — Task.WaitAll(new[]{outTask, errTask}, remaining); on timeout it takes whatever completed (TaskStatus.RanToCompletion), observes any late fault, logs a warning, and continues (the process already exited cleanly). Normal bakes drain in ms — unchanged (12/12 smoke test).

E5 🟡 Delete-first re-bake destroys the last-good assets with no rollback — ✅ FIXED (2026-07-12)

Static and animated paths delete _Skeleton/_Atlas/_ModelMesh/_Mat/_Model.prefab before the fallible steps run. If the re-bake then failed, the registry still pointed at now-deleted assets: “re-bake failed, old model still works” became “re-bake failed, old model destroyed”. (Delete-first is the deliberate stale-skeleton fix — the gap was only the missing rollback.)

Fixed: Build/BuildAnimated now snapshot the existing outputs (each .asset + its .meta, so the GUIDs survive) to a temp dir outside the project before the bake, and on any failure (exception or ok:false) restore them — wiping any partial new outputs first. Fail-safe by construction: the success path is unchanged (backup → bake → discard) and restore runs only on an already-failed bake, so it can’t harm a good bake. Backing up outside Assets/ avoids a duplicate-GUID import. Runtime-verified (2026-07-13): a dedicated Bake Feature Test case now bakes a cube, captures the _Skeleton/ _Atlas Unity GUIDs + mesh content, forces a failed re-bake of the same resource (missing model file), and asserts the assets return with their original GUIDs and content — closing the earlier “restore path never triggered” gap and catching a non-atomic/GUID-losing restore. Passed (skelGuidKept=True, atlasGuidKept=True, verts 24→24).

E6 🟢 Corrupt project backup + missing registry = permanent Save lockout — ✅ FIXED (2026-07-12)

ModelRegistry.Load parsed the backup restore inside the same try whose catch set lastLoadCorrupt and named RegistryPath — a file that doesn’t exist in this scenario — so Save refused forever with instructions that couldn’t be followed.

Fixed: the backup parse is now in its own try/catch; a corrupt backup is treated as “no backup” (a warning that names the backup path, lastLoadCorrupt stays clear, Save is not locked — there’s no live registry to protect, and the next Save rewrites the backup).

E7 🟢 Stale _Preview.prefab shadows a static re-bake in the window preview — ✅ FIXED (2026-07-13)

Bake animated → re-bake static: the static delete-first list omitted <name>/<name>_Preview.prefab, and LoadPreview prefers the anim path whenever it exists — so the preview kept showing the old animated model while the game got the new static one.

Fixed: the static path’s delete-first now also removes the stale animated-preview assets (_Preview.prefab / _PreviewMesh.asset / _PreviewMat.mat) from FactorySource, so LoadPreview falls through to the fresh _Model.prefab. (The reverse, static→animated, was already fine: the animated bake regenerates _Preview, which LoadPreview then correctly prefers.)

E8 🟢 Texture leak per multi-material bake — ✅ FIXED (2026-07-12)

The per-material albedos loaded for PackTextures (up-to-4096² RGBA32 each) were never DestroyImmediated; Unity objects don’t GC, so an iterating modder stranded tens of MB per bake until the next domain reload.

Fixed: both PackTextures sites (static BuildInner + animated BuildMultiAtlasAndRemap) now DestroyImmediate the source albedos right after packing (they’re copied into the atlas; the animated path keeps using only the orderedAlb keys afterward, not the textures).

Tools — Blender scripts + glbconv (key items verified on Blender 5.1.2)

T1 🔴 prep_model.py reduce hard-fails on instanced (multi-user mesh) models — ✅ FIXED (2026-07-07)

modifier_apply on linked-duplicate data raises RuntimeError: Modifiers cannot be applied to multi-user data (reproduced on 5.1.2). glTF/FBX importers create linked duplicates whenever nodes share a mesh — common in Sketchfab models (wheels, missiles, rotor blades). Any such model with Reduce on failed the whole bake, and the surfaced error was the misleading “Blender prep produced no GLB (exit 0)”.

Fixed: each object gets o.data = o.data.copy() when its data is multi-user, before the decimate modifier is applied — also the correct semantics (applying through each user would re-decimate the shared datablock). Verified end-to-end: a GLB whose import produces two objects sharing one mesh (users=2) now reduces cleanly.

T2 🟡 Reduce ratio counts polygons, but COLLAPSE ratio operates on triangles — ✅ FIXED (2026-07-08)

A quad-heavy model under-reduced by up to 2×: 20k quads (40k tris) with target 24000 computed ratio 1.0 → no reduction at all → 40k tris shipped into the ~25k-ceiling engine buffer this feature exists to protect. GLB inputs were immune (glTF is triangle-only); OBJ/FBX/.blend sources were exposed. The post-reduce log also under-reported (counted polys, not tris).

Fixed: ratio and the before/after report now count real triangles (len(p.vertices) - 2 per polygon, like rig_anim.py). Verified end-to-end: a 10,000-quad OBJ (20,000 real tris) with target 5000 now computes ratio 0.25 and lands at 4,999 output triangles (independently re-imported and counted); the old code would have produced ~10,000. Triangle inputs are byte-identical in behaviour (each tri counts 1).

T3 🟡 rig_anim.py albedo grab takes the first TEX_IMAGE node, not Base Color — ✅ FIXED (2026-07-12)

Node order is creation order; a PBR material with normal/roughness maps could hand the Factory a normal map as the atlas albedo — purple/garbled skin, no error.

Fixed: base_color_image() now traces the Principled BSDF’s Base Color input upstream to the nearest image node (through a mix/gamma node if present); falls back to any TEX_IMAGE only when there’s no Principled or the input is unlinked. Blender-syntax-checked.

T4 🟡 rig_anim.py join() keeps only the active object’s modifiers — ✅ FIXED (2026-07-12)

Active was meshes[0] (scene order); if that was a bone-parented prop without an Armature modifier, the joined mesh exported with no skin binding — the whole model rigid/frozen, all green logs.

Fixed two ways: the join target is now chosen as a mesh that has an armature modifier (fallback meshes[0]), and — the real guarantee — after the join the code re-adds an armature modifier bound to arm if none survived. The joined mesh keeps every source mesh’s vertex groups regardless, so re-binding fully restores skinning.

T5 🟡 glbconv: negative-scale (mirrored) nodes don’t flip triangle winding — ✅ FIXED, source + exe (2026-07-12)

Mirroring half a symmetric vehicle via scale (−1,1,1) is routine; those halves came out inside-out — invisible under backface culling, silently “fixed” by users reaching for Double-sided without knowing why.

Fixed: Program.cs.src now emits the triangle as (A,C,B) when node.WorldMatrix.GetDeterminant() < 0, and glbconv.exe was rebuilt (pinned SharpGLTF.Core 1.0.6, untrimmed + single-file-compressed so it stays ~35 MB; trimming was rejected — it changed OBJ/MTL output on 4 of 11 models). The rebuild was verified geometry-identical across all 11 registry models; its only other effect is a SharpGLTF UV-decode change (raw tiled UVs → pre-folded), which the baker’s per-vertex fold makes equivalent except at tile seams (3 of 208,198 Cobra verts, 0.0014%). The prior exe is backed up + in git. Build recipe + reproducibility note now in Tools/glbconv/BUILD.md (previously there was no build documentation at all — that gap is closed). Recommend a one-time in-game Cobra glance.

T6 🟢 Silent-empty outcomes in the Blender scripts — ✅ FIXED (2026-07-13)

T7 🟢 Tool lows — reviewed 2026-07-13, consciously deferred

Each was re-examined and left as-is: fixing any needs a glbconv rebuild and/or changes output on current models (regression risk), for negligible benefit — none is reachable or impactful in the current pipeline. Revisit only if one actually bites.

Verified clean (checked and explicitly cleared): glbconv’s mat_none handling, TriMat/outTri bookkeeping, MatName collision-proofing (mat{i}_ prefix), the UV V-flip (including repeat-wrapped UVs), 0-material/null-name paths, Sanitize’s filename safety; prep_model.py’s victim-set materialization (no iterate-while-removing) and case handling; Blender 5.x slotted-action fallback in rig_anim.py; window probe caching, GLB chunk parsing, and log scanning; Plugin.cs wholesale; Prober.cs (diagnostics, defensively guarded throughout).


Architectural findings — 2026-07-12

Where 07-07 hunted line-bugs, this pass treats system structure as the risk. The framework has the signature of software that grew one feature-flag at a time; at its current size that accretion — not any single bug — is the dominant risk, especially given the goal of many more models and an eventual shippable package.

A1 🔴 OnPawnAdded was a ~190-line per-pawn-per-frame god-method — ✅ FIXED (2026-07-12, 9b956a7)

The hottest hook (runs per pawn-add per frame once any animated/freeze model is registered) inlined five behaviors — skeleton rescue, freeze, loop, fire-once, deploy + recoil overlay. Each feature bolted on another branch, and the branches interact (deployOnStop && fireOnAttack). Untestable as a unit; the interactions lived only in the author’s head.

Fixed: decomposed into a thin dispatcher + one named handler per concern — TryReadLastPawn, ForceOurSkeleton, ApplyFreeze, ApplyAnimatedPose, with the pose-time logic split into a strategy dispatch (ComputePoseTimeDeployPoseTime/RecoilOverlay, FireOncePoseTime, loop) and the tail steps (ClearAimLayer/ApplyPositionOffset/ApplyScale/LogPoseHookOnce). Strictly behavior-preserving: same reflection reads, write-backs, ordering, thresholds, and log-gate fields; the boxed PawnEntry is threaded through a small PawnCtx and shared by reference so every SetMember lands on the same box. Adding a new model behavior is now a new method, not another branch on the hot path. Verified in-game across all three behaviors (drones loop, howitzer deploy+recoil, zeppelins freeze), each with multiple instances, plus a 12/12 bake smoke test.

A2 🟡 ModelEntry is a ~35-field bag mixing four concerns

Registry data, runtime-resolved handles (skeleton, animId, skeletonId), per-instance concurrent state (fireGuidQueue, deployProgress, deploySamples), and behavior config all live in one class — so every new feature touches the parser, the POCO, and the (now-decomposed) hot loop in lockstep. Natural next split, pairing with A3: a plain serialized config record vs. a runtime-state object.

A3 🟡 The cross-repo registry schema is defined in FOUR places — now guarded by a real check (2026-07-12)

ModelDef (editor writer, JsonUtility) · ModelEntry (runtime) · the Newtonsoft parse · the regex-fallback parse — a new field is edited in all four. Mitigated (Option A — verify, don’t merge): check_schema_parity.sh was rewritten to make drift loud. It now asserts (1) the Newtonsoft and regex read paths read the same key set, (2) every read key is a ModelDef field the baker writes (minus a runtime-only allowlist), and (3) each read cast’s type matches ModelDef’s declared type; bake-time-only fields are listed as INFO. Proven to fail on all three drift types (dropped regex key, unwritten key, type mismatch). The duplication still exists — this monitors it rather than removing it. The deeper Option B (auto-deserialize into one POCO, drop the manual mapping, retire the regex fallback) was deliberately not taken, per the standing “keep the two sides separate, just verify compatible” preference; revisit B only if the manual mapping starts costing more than the guard saves.

A4 ✅ RESOLVED (2026-07-12) — one dismissed after verification, one fixed

A5 🟢 Package-readiness (the stated goal) — not close yet

ENC branding hardcoded (haf_models.json, ENC.* EditorPrefs); Blender discovery Windows/Program-Files-only; machine-specific paths (D: backup, C:\GameData junction); the ENCReload README sends a package consumer to a different repo for all docs. None are bugs — just the gap between “works on my machine” and “shippable to strangers.” Overlaps the deferred list’s ENC-branding, Blender-PATH-discovery, and Blender-free-static items.


Still deferred (from earlier passes — unchanged)


Architectural (2026-07-12): A1 ✅ · A3 ✅ · A4 ✅ (SimplifyMat collision fixed; MeasureLongestAxis verified-and-dismissed). Next: A2 (ModelEntry config/state split), when Option B is tackled.

  1. E1 ✅ done · T1 ✅ done · T2 ✅ done — tier 1 complete.
  2. E2 ✅ · E4 ✅ done (Remove key + honest status; bounded pipe drain).
  3. T3 / T4 / T5 ✅ done — the silent-corruption class (wrong albedo, lost skinning, inside-out mirror halves).
  4. E5 ✅ · E3 ✅ done · T6 — honest failures for empty/destroyed outputs.
  5. Deferred list + lows as the package push approaches (Blender PATH discovery first among them). | 08-02 | Reflection-fragility A3 — COMPLETE (rolled out). Every game type resolution now goes through a cached GameBinding.<Type> accessor — ~55 AccessTools.TypeByName("…") sites across 7 files replaced (incl. 4 multi-fallback chains + a namespace-collision chain for PresentationFormationDefinition); the only TypeByName left is the dynamically-parameterised one. The validation catalog now references the accessors (nameof), so every game-type NAME lives in exactly one place; the report validates all 31. Cached gained params fallbacks. Verified in-game: 26/26 hooks patched, OK — 31 type(s), features work. The rollout’s own instrumentation caught two real issues pre-ship: (1) AudioEventHandle is a genuine late-loader (Wwise type loads post-menu) → kept out of the startup catalog with a note (accessor re-resolves on use); (2) the honest patched-count flagged Hk_FormationPrefabExtend skipping because ResolveType couldn’t match the game’s SIMPLE-name-only types (GameObjectPoolController/EntityFactoryControllerSettings) the way AccessTools.TypeByName does — fixed with a Type.Name scan (now unit-tested). Suite → 53. | | 08-02 | Reflection-fragility A4 — game-version awareness. GameBinding.ValidateAndLog now appends the running game version to the report (UnityEngine.Application.version), and a VerifiedGameVersion const (pinned to 1.30) gives it context: on the verified build the report reads [game 1.30, verified]; on any other it reads [game X — UNTESTED; catalog verified against 1.30, so warnings are likely this update]. So a NOT FOUND can be pinned to a specific game build and distinguished from a real regression. Behaviour-neutral (a log line). Completes the Option-A reflection-fragility arc (A1 report → A2 catalog → A3 accessors → A4 version context). |