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.
UniversalInjectPatch.cs:2591 — Hooked(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.
: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.
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).
:2630 — e.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.
TryReadLastPawn disables the whole pose hook silently:2668-2684 — return 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.
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.
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”.
PhaseFor edges:2878-2918. The tracker is verified sound in the field (tracks=14 live=14, stable positions). Residual:
sqrMagnitude comparison false, so each frame allocates a new track
and logs [Phase] NEW track at Info: an unthrottled log inside the per-pawn-per-frame hook.Time.time (:2903) — correct today (one frame, one value), but silently
degrades to no collision protection if the hook is ever called off-frame. A frame counter is unambiguous.phaseTracks.Count (:2913) — after churn the count can return to a value already in use,
giving two live pawns the same phase (the lockstep the feature exists to prevent, for that pair). A monotonic
per-entry counter avoids it.: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).
e.deploySamples (:121) is not cleared at :796-832 while stateSamples is (:810) — session-1 data stays
published until the first poll of session 2.attackSoundNextAt (:152), idleNextAt (:154), idleRecent (:155) and _silencedEmitterIds (:4332) are
never cleared or pruned. Keys are GetHashCode() / GetInstanceID(), which can be recycled — a recycled key
inherits a future min-gap (an attack roar or idle growl silently skipped), or for _silencedEmitterIds silences a
vanilla unit’s emitter. Also unbounded growth over a long session.aggregateEra (:2054) is not reset per poll (unlike techEra / domainEra at :2070) and is only assigned
when the Timeline read succeeds, while eraApiLogged (:2120) hides repeated failures. A second session can
inherit the previous game’s aggregate era as the anchor floor — ships sized for the wrong era. With lastEraPoll
also unreset, there is a ≤2 s window at session start where domainEra still holds the old frontier: long enough
for a real formation repoint plus a visible live re-form, then an undo.repointed (3325964).
The seed runs in RepointMatch, which only fires when a unit’s AddOn loads, so an entry whose unit has never
appeared this session legitimately has no descriptor. Once repointed, the state really is unreachable as the
docs claim.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.