Skip to content

Audit 2026 07 31

sswelm edited this page Aug 20, 2026 · 2 revisions

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

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

📁 ARCHIVED NOTE — frozen 2026-08-01, not maintained. A point-in-time audit, kept for its reasoning and its findings-class taxonomy. Every finding here was triaged into Review-Backlog.md or Framework-Review.md — read those for current status. Nothing below is current instructions.

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:

  • No NaN guard — a NaN position makes every 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.
  • Claim marker is float equality on 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.
  • Phase derived from 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.

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

  • 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.

FIXED in this pass

  • The unseeded-descriptor warning fired 12× per launch on a healthy state — gated on 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.

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.

Clone this wiki locally