HumankindAssetFramework

Audit 2026-07-31 — latent bugs of the “silently disarmed” class

Triggered by a real incident: Abominations rendering as exploded spikes on some saves. The root cause was a safety net that could never arm itself — descId was learned one-directionally (see Animated-Runtime §7; fixed in 0c0b12f). This pass hunted the same class across the animation/injection runtime: state that can only be populated from a success, silent early returns on the hot path, order/race dependence, unstable cache keys, swallowed exceptions.

Findings were verified against the live LogOutput.log and registry. Ranked by likelihood × visible severity. All OPEN unless marked. Line numbers as of the audit; see also Review-Backlog.

1. FIXED (c6154a6, NOT yet verified in game) · HIGH — static models had NO wrong-skeleton rescue

UniversalInjectPatch.cs:2591Hooked(x) => x.animId >= 0 || x.freezeDonorAnim, consumed by HookedEntryFor (:2592) and the descriptor fallback (:2637).

Both match paths are gated on Hooked, so a model that is repointed but neither animated nor freezing has no rescue path, and the diagnostic (also gated) cannot report it. Eight of twenty shipped entries qualify: AttackHelicopter, HandCrankedSubmarines, Hovercraft, OrganGun, ReconHelicopter, StealthCruiser, StealthHelicopter, VolleyGun — all anim -1, freezeDonorAnim: false, all with registered skeletons.

This is exactly the failure 0c0b12f fixed, still live for these. The log proves the game hands pawns of a repointed unit a donor skeleton (rescued wrong-skeleton pawn: skelId 1 -> 70), and ForceOurSkeleton’s own comment (:2686) says such a pawn “would otherwise draw mis-skinned”. Trigger: the same load-order race — a save that puts a hovercraft or cruiser on screen early. Symptom: spikes, silently, for the whole session.

Second-order: an animated entry whose clip fails to resolve (ResolveAnimId returns -1, e.g. a stale clip GUID after a rebake) drops out of Hooked too, so a clip-load error silently costs the skeleton force as well.

Fix: make the rescue independent of pose behaviour — gate on “repointed onto our own skeleton” (x.skeletonId >= 0 && x.repointed), keep the pose decision separate. The descriptor seed already runs for these entries, so the net would be armed from frame 0 for free.

DONE c6154a6: new predicate Rescuable(x) = x.skeletonId >= 0 && x.repointed replaces Hooked on both match paths and the warning; the pose decision stays at the dispatch, which gained a third branch (neither animated nor freeze = force the skeleton, persist the entry, leave the pose alone — the animated path would write Pose0 with animId -1, and ForceOurSkeleton only mutates the boxed struct so the write-back must be explicit). Also closed the matching hole in the early-out at :2627: a purely static pack has both cached flags false and returned before reaching the rescue, so the fix would have been dead for the very case it was written for — anyRescuable is recomputed when an entry is repointed and on session reset. Needs in-game verification: load a save with a cruiser or hovercraft on screen.

2. FIXED (see below, NOT yet verified in game) · MEDIUM-HIGH — a rules-only pack permanently disabled the mesh-scale engine

:844 if (entries.Count == 0) { registered = loaded; return; } versus :869 animMgrRef = animMgr;

animMgrRef is assigned after that early return, and nowhere else. Scale rules, era grid and formation thresholds parse independently of the models array, so a rules-only pack (the planned R.E.D. Patch shape) is a supported configuration — but with zero model entries EnsureRegistered latches registered = loaded = true and never runs again, so animMgrRef stays null forever and ScaleDescriptorMeshes bails at :2403. The comment there (“the per-frame path retries”) is false in this case.

Symptom: scale rules half-work — per-pawn ObjectSpace.Scale placement still runs, so a multi-part unit’s parts spread apart while nothing resizes. Nothing in the log names the cause. Fix: hoist animMgrRef = animMgr; above the early return — it is only a handle capture.

DONE: the capture now happens immediately before the entries.Count == 0 return, so it runs on every path. It is a plain handle assignment with no side effects, and it remains the only write to animMgrRef. Verify with a rules-only pack: unit-scale rules should actually resize rather than only spreading a multi-part unit apart.

3. OPEN · MEDIUM — formation-by-size caches survive a session change

sizeFormUnitName (:2267), sizeFormApplied (:2276); session reset at :796-832.

RearmModelRegistration clears every other descriptor-keyed map (:800, comment: “descriptor ids are session-scoped”) but misses these. Load a second game in one app run and descriptor ids re-resolve: sizeFormUnitName[descId] returns the previous game’s unit name, so MaybeSwapFormationBySize repoints and re-forms that unit using this unit’s scale; a stale sizeFormApplied can also suppress a needed swap.

Symptom: in the second game of a session a unit type renders with the wrong pawn count, or visibly re-forms once. Gated on having authored a formation-by-size table — the feature shipped in 1c65e90. Fix: clear sizeFormApplied / sizeFormUnitName / sizeFormWarned at :800. Keep sizeFormOriginal (keyed by unit-definition name — stable, and needed for restore).

4. OPEN · MEDIUM (latent) — the learn line overwrites the seeded descriptor, silently

:2630e.descId = ctx.descId; still runs after the injection-time seed, unconditionally and unlogged.

A no-op only while exactly one pawn definition maps to an entry. RepointMatch matches by substring (:960), so a second definition containing the same string (a variant, a modded ..._01_Something) repoints onto the same entry with a different PawnDefinitionId; the seed thrashes, and :2630 then flips descId between the two on alternate pawns. Whichever descriptor is not currently stored has no net — intermittent mis-skinning with the “cured by re-summoning” signature that made the original bug so hard to pin. Today’s registry (all _01) is clear.

Fix: learn only when unset (if (e.descId < 0)), and warn once if a different descriptor ever arrives on our skeleton — that is the signal that one entry is claiming two pawn definitions, which the injector cannot represent.

5. OPEN · MEDIUM — TryReadLastPawn disables the whole pose hook silently

:2668-2684return false on a null pawnEntries or an out-of-range pawnCount, with no log, ever.

This is the same failure the catch at :2664 was explicitly hardened against (“a bare catch here hid member renames after a game update — models just stopped animating, no clue why”), left open on the null path. A game update renaming pawnEntries / pawnCount gives: every custom model renders, none animates, no wrong-skeleton rescue, nothing in the log. Fix: one-shot LogError on each bail, in the style of :848.

6. OPEN · MEDIUM — one error latch shared by two unrelated domains

poseErrLogged is set and tested by both the Resize catch (:2395) and the pose-hook catch (:2664). A single Resize exception permanently silences pose-hook error reporting, and vice versa — and both then throw every frame for every pawn in silence, so “models stopped animating” arrives with no evidence. Fix: split into poseErrLogged / resizeErrLogged.

7. OPEN · LOW-MEDIUM — the skeleton rescue is persisted only as a side effect

ForceOurSkeleton (:2689) mutates the boxed entry but never calls ctx.pawnEntries.SetValue; only ApplyFreeze (:2708) and ApplyAnimatedPose (:2870) write back. Anything throwing in between discards the rescue as well as the pose — e.g. ApplyPositionOffset (:3408) casts Translation with no null guard. Fix: write back inside ForceOurSkeleton; the later write-back is idempotent.

Related: SetMember (:4168) swallows every SetValue exception, so a field type change (e.g. SkeletonId int → uint) would make the rescue a silent no-op while rescueLogged still prints “rescued”.

8. OPEN · LOW (latent) — PhaseFor edges

:2878-2918. The tracker is verified sound in the field (tracks=14 live=14, stable positions). Residual:

9. OPEN · LOW — the unmatched-pawn warning loop is on the hottest path

:2646-2653 walks the entries list for every vanilla pawn, every frame. Cheap per iteration, but this is the loop from which closure allocations and string concatenation were already stripped for exactly this reason (:2633, :2599). Fix: hoist behind a cached anyUnseeded, or fold into the repointed gate (normally empty).

10. OPEN · LOW — session-scope cleanup gaps

FIXED in this pass

Verified GUARDED — checked, not findings

anyAnimated / anyFreeze latching against empty entries (invalidated at :377 and :799 — the obvious twin of the fixed bug, explicitly handled); transient registry-load failure (loaded unlatched until 3 attempts, :380); meshApplied across sessions (self-verifying via the stored first-vertex probe, :2456); domainEra (rebuilt each poll); customClip* not reset per session (correct — runtime AudioClips survive scene loads); turretBoneIdx / muzzleBoneName latching (unreachable given registration ordering; the one reachable path is already loudly logged at :1050); the activeFires / stateSamples / deploySamples locking discipline and the battle-walk key salt.