gotcha #28: hand-composing an engine-derived transform reads a stale cache (replicant-only)

The #141 bug class, written up so it is not re-introduced.  GetSegmentToEntity
recomputes ONLY when segmentModified is set; JointedMover::GetSegmentToWorld is
what sets it -- and the binary's own GetMuzzlePoint @004b9948 goes through it
(FUN_00424da8), so every muzzle query in the 1995 image performs the
joints->segments refresh.  Four port sites hand-composed instead, one of them
commented "the faithful FUN_004b9948".

Records the four rules the investigation actually cost:
 (a) never hand-compose; call GetSegmentToWorld
 (b) never force the dirty flag to fix a stale read -- that stand-in scored
     IDENTICALLY to the faithful fix while patching only one consumer
 (c) "peer POV only" geometry bugs = suspect a cache the local render pass
     refreshes for free, before suspecting replication (it was provably fine)
 (d) a partial-looking score: check PREFIX vs interleaved before calling it
     partial -- these were a clean prefix ending when the peer first had a
     twist to carry, so the fix was complete and "64% fixed" was wrong
 (e) the probe trap: one shared static sampled every Nth call hides one of two
     alternating instances entirely

#141 closed with the full write-up; #148 filed for the torso replication
cadence (13 records in a 5-minute run) which is a separate, real problem and
likely bears on #37 and #70.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
This commit is contained in:
Joe DiPrima
2026-08-08 09:29:47 -05:00
co-authored by Claude Opus 5
parent e6c5ac951e
commit eebe7e61a4
2 changed files with 200 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
"""Night 13: report #141 (fixed) and file the torso-cadence follow-up. ASCII only."""
import sys
sys.path.insert(0, r"C:\git\bt411\scratchpad\night7")
import gitea
BODY_141 = """**ROOT CAUSE FOUND AND FIXED** (`f01de8c` + sweep `e6c5ac9`), reproduced and measured on a
2-node bench (`scratchpad/night13/missileframe.sh`).
## Reproduced
Only A sweeps its torso and only A fires, so every REPLICANT line in B's log mirrors one A salvo.
New `[launchframe]` receipt (`BT_PROJ_LOG`) prints the yaw of the launch forward vs the BODY forward:
| | n | max abs(twistDelta) | mean | >0.1 rad |
|---|---|---|---|---|
| master | 165 | 2.2962 | 1.2283 | **100%** |
| REPLICANT | 165 | **0.0000** | 0.0000 | **0%** |
`segResolved=1` on both, and `segYaw == bodyYaw` EXACTLY on the peer -- the launch frame was the
bind pose.
## What it was NOT
Everything upstream was already correct, which is why it looked like netcode. Both sides pass the
mount segment (`GetSegmentIndex()`, task #67 -- master `mislanch.cpp:363`, replicant mirror `:478`).
The peer's torso data is fine end to end: records arrive (`atUpd=2.44/-2.39`, `rate=0.305`), the
copy extrapolates correctly (`cur=-2.13987 target=-2.13987 copy=1`), and the copy torso
demonstrably writes its joint (`PushTwist COPY twist=-1.49601`). Hierarchy identical on both nodes
-- same seg 18, same `parentIdx=4`, non-null parent and joint subsystem.
The twist reached the joint and died at the **segment cache**.
## Root cause
`MechWeapon::GetMuzzlePoint` `@004b9948` ends in `FUN_00424da8(owner, segment, out)`, which is
`JointedMover::GetSegmentToWorld` instruction-for-instruction:
```c
iVar1 = FUN_00417ab4(param_1 + 0x31c); // GetJointSubsystem()
if (*(int *)(iVar1 + 0xfc) != 0) { // AreJointsModified() <- TESTED, never set
... walk owner+0x300, seg+0xc = 1 ... // ModifySegment() on every segment
*(int *)(iVar1 + 0xfc) = 0; // ModifyJoints(False)
}
FUN_0040b104(out, FUN_004244dc(seg), owner+0xd0); // x localToWorld
```
**In the 1995 image every muzzle query performs the joints->segments refresh.** Our
`BTResolveWeaponMuzzle` -- labelled "the faithful FUN_004b9948" -- hand-composed
`GetSegmentToEntity() x localToWorld` and skipped it. `GetSegmentToEntity` only recomputes when
`segmentModified` is already set (`SEGMENT.cpp:262`), so it returned a stale cache. On the MASTER
that was invisible (the render pass refreshes the local mech every frame, after its torso pushes
the joint); a REPLICANT gets no such refresh, so peer muzzles sat at the bind pose.
## Fix
Route the muzzle path through the engine accessor, where the binary puts it. **No forced dirty
flag** -- an earlier attempt set `ModifyJoints(True)` and scored the same, so it bought nothing and
was removed; the binary only ever tests that flag.
Swept the same unfaithful pattern at three more sites: the generic segment->world bridge, the
damage-effect anchor, and **the energy-beam gun port** -- a peer's BEAM had the identical exposure
and would also have originated from the untwisted gun port. Repo-wide there is now exactly one
`GetSegmentToEntity` call outside `SEGMENT.cpp`: inside `GetSegmentToWorld` itself, after the
refresh.
## Result
| | n | max | mean | >0.1 rad |
|---|---|---|---|---|
| master | 165 | 2.1719 | 1.2781 | 100% |
| REPLICANT | 165 | **2.0907** | 0.8201 | **64%** |
**The 64% is not a partial fix.** The failures are a contiguous PREFIX with zero interleaved cases:
```
ZZZZ...(60)...ZZZZXXXX...(105)...XXXX
```
and they end exactly when the peer acquires a twist to carry:
```
first torso RECORD received : line 206
first copy currentTwist != 0 : line 1016
first CORRECT launch frame : line 1054 (38 lines = probe sampling granularity)
```
Those 60 salvos fired while the replicated twist was genuinely 0, so launching along the body
facing was CORRECT. Once the peer has a twist, 100% of launches carry it.
## Field-verify
Unreleased. Next playtest: have a peer watch a twisted mech fire missiles -- rounds should leave
along the torso, not the feet. Also worth checking beams for the same reason (same fix).
Follow-up filed separately: the peer's torso takes far too long to FIRST acquire the master's
twist."""
TITLE_NEW = "Peer torso twist takes far too long to first sync -- only 13 update records across a 5-minute run"
BODY_NEW = """Split out of #141, whose launch-frame defect is fixed. This is a separate, measured
problem in the torso REPLICATION CADENCE.
## Measurement
From the #141 bench (`scratchpad/night13/missileframe.sh` / `missileframe2.sh`, 2 nodes, node A
sweeping its torso continuously at 0.35 rad/s for the whole run):
```
first torso RECORD received on the peer : line 206
first copy currentTwist != 0 : line 1016
```
and across the entire ~5 minute run the peer received only **13** `[torso-rec-rx]` records, despite
the master's twist changing continuously the whole time.
So the master was twisted from very early on, while the peer's copy torso reported `currentTwist`
of exactly 0 for a long stretch afterwards. The extrapolator itself is fine once fed --
`ComputeTargetTwist` predicts `twistAtUpdate + twistRate * elapsed` and the copy tracks its target
exactly (`cur=-2.13987 target=-2.13987 copy=1`). The problem is how rarely it is fed, and how late
the first useful feed arrives.
## Why it matters
* It is the entire reason #141's fix reads 64% instead of 100% on the bench -- 60 salvos fired
before the peer had any twist to carry.
* A peer's torso will visibly LAG or sit straight while the mech is actually twisted. That is
plausibly relevant to **#37** (MadCat torso is BACKWARDS) and **#70** (torso twist stops working
after respawn) -- worth re-testing both against this once it is understood.
## Not yet investigated
Whether 13 records is the authentic cadence (the binary may deliberately send torso updates rarely
and lean on `twistRate` extrapolation to cover the gaps -- in which case the bug is that our
extrapolation is not running or not seeded until late), or whether our send-side gate is simply too
conservative. `Torso::WriteUpdateRecord` snapshots `twistAtUpdate = currentTwist` at send, so the
send trigger is the thing to read first.
Diagnostics already in place: `BT_TORSO_LOG` gives `[torso-rec-rx]` (receive), `[torso-copy]`
(the copy's cur/target/atUpd/rate), and `[torso] PushTwist master|COPY` (per instance-kind -- note
that probe previously sampled one shared static every 30th call, which with two torsos ticking 1:1
always reported the SAME instance and hid the copy entirely; fixed in `05d7b58`)."""
gitea.comment(141, BODY_141)
gitea.call("/issues/141", method="PATCH", payload={"state": "closed"})
print("commented + closed #141")
num = gitea.create(TITLE_NEW, BODY_NEW)
print("created #%d" % num["number"])