diff --git a/context/reconstruction-gotchas.md b/context/reconstruction-gotchas.md index 0b350b1..ce1237e 100644 --- a/context/reconstruction-gotchas.md +++ b/context/reconstruction-gotchas.md @@ -863,3 +863,56 @@ warning"), list what STARTS DRAWING at that event -- alarm-driven redraws, state-change repaints -- before suspecting the event's logic; (d) the operator's screenshot is worth ten theories: the red-faces capture identified in one frame what three log-side hypotheses missed. + +## 28. HAND-COMPOSING an engine-derived transform reads a STALE CACHE — and it only bites REPLICANTS (#141, 2026-08-08) + +`EntitySegment::GetSegmentToEntity()` (`SEGMENT.cpp:262`) **recomputes only when +`segmentModified` is already set** — otherwise it hands back the cached matrix, +and if the segment has no parent it can never recompute at all. The thing that +sets that flag after a joint moves is `JointedMover::GetSegmentToWorld` +(`JMOVER.cpp:136-146`): it tests `AreJointsModified()` and, when set, walks the +whole segment table marking every entry dirty, then clears the joint flag. + +The binary agrees exactly. `MechWeapon::GetMuzzlePoint @004b9948` ends in +`FUN_00424da8(owner, segment, out)`, which IS `GetSegmentToWorld` +instruction-for-instruction (`GetJointSubsystem` → `if (AreJointsModified())` → +mark all → `ModifyJoints(False)` → `× localToWorld`). **So in the 1995 image +every muzzle query performs the joints→segments refresh.** [T1] + +Four port sites had replaced that with `mw.Multiply(seg->GetSegmentToEntity(), +mech->localToWorld)` — including one commented "the faithful FUN_004b9948". +They skip the refresh and read whatever cache is present. + +**Why it hid for a year:** the local mech is refreshed every frame anyway — the +renderer and cockpit camera call `GetSegmentToWorld` on it, *after* its torso +pushes the joint. So master-side output is correct and solo testing is clean. +A **replicant** gets no such pass: its cache stays at the BIND POSE. Measured on +a 2-node bench, peer missiles left along the LEG facing with `segYaw == bodyYaw` +EXACTLY (`twistDelta` 0.0000 over 165 salvos) while that same peer's copy torso +was demonstrably writing its joint (`PushTwist COPY twist=-1.49601`) from +correctly replicated records. Twist arrived, joint moved, segment never +re-derived. + +Rules: +(a) **Never hand-compose `GetSegmentToEntity() × localToWorld`.** Call + `GetSegmentToWorld` — it is the binary's own path and it does the refresh. +(b) **Do NOT "fix" a stale transform by forcing the dirty flag.** Setting + `ModifyJoints(True)` at the read site made the symptom go away and scored + identically to the faithful fix — it was a stand-in that patched ONE + consumer and left every other peer segment reader stale. The binary only + ever *tests* that flag. +(c) A cached-transform bug is **master/replicant asymmetric by construction**. + If a geometry symptom is reported "peer POV only", suspect a cache that the + local render pass refreshes for free — before suspecting replication. Here + the replication was provably fine. +(d) When a fix lands at a partial percentage, **check whether the failures are + interleaved or a PREFIX** before calling it partial. These were a clean + prefix that ended the moment the peer first had a non-zero twist to carry — + i.e. the fix was complete and the remainder was correct behaviour. Reporting + it as "64% fixed" was wrong. +(e) Related probe trap: `Torso::PushTwist` sampled ONE shared static every 30th + call. With a master torso and a copy torso ticking 1:1, every 30th call is + always the SAME instance — the probe showed only the local untwisted torso + and hid the copy's writes entirely. Sample **per instance-kind** whenever + master and replicant objects share a diagnostic. (See also §gotcha on + process-wide statics serving the player's data as the replicant's.) diff --git a/scratchpad/night13/close141.py b/scratchpad/night13/close141.py new file mode 100644 index 0000000..a5c6f2c --- /dev/null +++ b/scratchpad/night13/close141.py @@ -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"])