From 05d7b5890ae221a5377e64924238801614e891d9 Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Sat, 8 Aug 2026 08:14:25 -0500 Subject: [PATCH] #141 peer missiles: the launch frame read a STALE segment cache on replicants Oracle: "missiles are firing in the direction the mech feet are facing ... and then coming around to track the target", peer POV only -- the shooter's own view is correct. REPRODUCED AND MEASURED (scratchpad/night13/missileframe.sh, 2-node: only A sweeps its torso and only A fires, so every REPLICANT line in B's log is the mirror of one A salvo). New [launchframe] receipt prints the yaw of the launch forward vs the BODY forward on both nodes: master n=165 |twistDelta| max=2.2962 mean=1.2283 >0.1rad: 100% REPLICANT n=165 |twistDelta| max=0.0000 mean=0.0000 >0.1rad: 0% segResolved=1 on BOTH, and segYaw == bodyYaw EXACTLY on the peer. WHAT IT IS NOT. Both sides already pass the mount segment (mislanch.cpp:363 master, :478 replicant mirror, both `GetSegmentIndex()` from task #67), and 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 is identical too: same seg 18, same parentIdx 4, non-null parent + joint subsystem on both. ROOT CAUSE. BTPushProjectile composed the frame BY HAND -- `mw.Multiply(seg->GetSegmentToEntity(), localToWorld)`. But EntitySegment::GetSegmentToEntity (SEGMENT.cpp:262) recomputes ONLY when `segmentModified` is set, and the thing that sets it after a joint moves is JointedMover::GetSegmentToWorld (JMOVER.cpp:136-146), which tests AreJointsModified() and then marks every segment dirty. Hand-composing skips that, so you read whatever cache is sitting there. On the MASTER that was invisible -- the renderer/cockpit camera call GetSegmentToWorld for the local mech every frame, AFTER the local torso pushes its joint, so the cache was already correct. A REPLICANT gets no such refresh: its cache stayed at the BIND POSE, and the twist never reached the launch direction. FIX. Use the engine accessor, and set the joints-dirty flag first so it actually refreshes (by fire time the frame's render pass has already consumed and cleared it -- measured jointsDirty=0 on BOTH nodes). RESULT (same bench): REPLICANT max 0.0000 -> 2.1145 mean 0.0000 -> 0.8252 0% -> 64% PARTIAL, and I am not claiming otherwise. 36% of peer salvos still read the exact-zero stale signature while the master is 100%. Forcing every per-joint `jointModified` flag as well (GetSegmentToParent's own gate, SEGMENT.cpp:196) was tried and moved the number by NOTHING -- 64% either way -- so the residual is a different cause, most likely frame ORDER (the salvo mirror running before the copy torso has posed that frame). Cheap form kept. Also fixes a SAMPLING TRAP in the torso probe: PushTwist sampled one shared static every 30th call, and with a master torso and a copy torso ticking 1:1 every 30th call is always the SAME instance -- so the probe showed only the local untwisted torso and hid the copy's writes entirely. Now sampled per instance-kind, which is what made the copy's correct joint writes visible and moved the search downstream to the segment cache. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC --- game/reconstructed/mech4.cpp | 92 +++++++++++++++++++++++- game/reconstructed/torso.cpp | 17 +++-- scratchpad/night13/missileframe.sh | 105 ++++++++++++++++++++++++++++ scratchpad/night13/missileframe2.sh | 105 ++++++++++++++++++++++++++++ 4 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 scratchpad/night13/missileframe.sh create mode 100644 scratchpad/night13/missileframe2.sh diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index cbfae47..91df319 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -1493,8 +1493,57 @@ void EntitySegment *seg = sm->GetSegment(muzzle_seg); if (seg != 0) { - AffineMatrix mw; - mw.Multiply(seg->GetSegmentToEntity(), sm->localToWorld); + // #141 -- USE THE ENGINE ACCESSOR, not a hand-rolled + // GetSegmentToEntity() x localToWorld. + // + // EntitySegment::GetSegmentToEntity (SEGMENT.cpp:262) + // recomputes ONLY when `segmentModified` is set; otherwise it + // returns the CACHED matrix. The thing that sets that flag + // after a joint moves is JointedMover::GetSegmentToWorld + // (JMOVER.cpp:136-146): it tests AreJointsModified() and, when + // set, marks EVERY segment dirty and clears the flag. Compose + // the matrix by hand and you skip that entirely -- you read + // whatever cache happens to be sitting there. + // + // On the MASTER that was invisible: the renderer / cockpit + // camera call GetSegmentToWorld for the LOCAL mech every frame, + // so the cache was already fresh when we fired. A REPLICANT + // gets no such refresh, so its cache stayed at the BIND POSE + // and the torso twist never reached the launch frame. + // + // Measured (scratchpad/night13/missileframe.sh, 165 salvos + // mirrored 1:1): master |twistDelta| max 2.2962 / mean 1.2283, + // 100% > 0.1 rad -- REPLICANT max 0.0000, mean 0.0000, 0%, + // with segResolved=1 and segYaw == bodyYaw EXACTLY, while that + // same peer's copy torso was demonstrably writing its joint + // (`PushTwist COPY ... twist=-1.49601`) off correctly + // replicated records (`cur=-2.13987 target=-2.13987 copy=1`). + // Twist arrived, joint moved, segment cache never refreshed. + // That is #141: "missiles launch along the LEG/FOOT facing, + // then curve to the target -- peer POV only". + // FORCE the recompute. GetSegmentToWorld only refreshes when + // AreJointsModified() is set, and by fire time the frame's + // renderer/camera pass has already consumed and cleared that + // flag on BOTH nodes (measured: jointsDirty=0 master AND peer). + // On the master the cache it left behind is correct, because + // that pass ran AFTER the local torso pushed its joint. On a + // replicant the cache is stale, so seg 18 returned its + // bind-pose matrix (segYaw == bodyYaw EXACTLY) even though the + // hierarchy is identical -- same parentIdx 4, same non-null + // parent + joint subsystem. Setting the flag makes + // GetSegmentToWorld mark every segment dirty so the whole + // chain re-derives from the CURRENT joint angles. Costs one + // segment-table walk per salvo. + // MEASURED: this alone takes the replicant from 0% to 64% of + // salvos carrying the twist. Also forcing every per-joint + // `jointModified` flag (GetSegmentToParent's own gate, + // SEGMENT.cpp:196) was tried and moved the number by NOTHING + // -- 64% either way -- so the residual 36% is a different + // cause, not per-joint cache staleness. Kept the cheap form. + if (JointSubsystem *jsf = sm->GetJointSubsystem()) + jsf->ModifyJoints(True); + LinearMatrix mw; + sm->GetSegmentToWorld(*seg, &mw); mw.GetFromAxis(X_Axis, &ax); mw.GetFromAxis(Y_Axis, &ay); mw.GetFromAxis(Z_Axis, &az); @@ -1507,6 +1556,45 @@ void sm->localToWorld.GetFromAxis(Y_Axis, &ay); sm->localToWorld.GetFromAxis(Z_Axis, &az); } + // #141 DIAGNOSTIC (BT_PROJ_LOG). The peer-POV report is that the + // round leaves along the LEG facing, ignoring torso twist, while the + // shooter's own view is correct. Both nodes pass GetSegmentIndex() + // as the mount frame, so if this is real the difference is whether + // the SEGMENT actually carries the twist on a replicant. Print the + // frame we launched through on BOTH sides: twistDelta is the yaw of + // the launch forward vs the BODY forward, so it should equal the + // torso twist on the master and MUST match on the replicant. A + // replicant reading ~0 while the master reads non-zero IS the bug. + if (getenv("BT_PROJ_LOG")) + { + UnitVector bz; + sm->localToWorld.GetFromAxis(Z_Axis, &bz); + const float kPi = 3.14159265f; + float segYaw = atan2f(-(float)az.x, -(float)az.z); + float bodyYaw = atan2f(-(float)bz.x, -(float)bz.z); + float dYaw = segYaw - bodyYaw; + while (dYaw > kPi) dYaw -= 2.0f * kPi; + while (dYaw < -kPi) dYaw += 2.0f * kPi; + // #141 probe 2: GetSegmentToEntity only RECOMPUTES when + // (segmentModified && parentSegment). A null parent means it can + // never recompute -- it returns the bind-pose baseOffset forever, + // which would read as segYaw == bodyYaw exactly. Print the + // hierarchy + joint-dirty state so master and peer can be diffed. + EntitySegment *pseg = (muzzle_seg >= 0) ? sm->GetSegment(muzzle_seg) : 0; + const void *parent = (pseg != 0) ? (const void *)pseg->GetParent() : 0; + int parentIdx = (pseg != 0) ? pseg->GetParentIndex() : -99; + JointSubsystem *js = sm->GetJointSubsystem(); + DEBUG_STREAM << "[launchframe] " + << (sm->GetInstance() == Entity::ReplicantInstance + ? "REPLICANT" : "master ") + << " seg=" << muzzle_seg << " segResolved=" << haveFrame + << " segYaw=" << segYaw << " bodyYaw=" << bodyYaw + << " twistDelta=" << dYaw + << " parent=" << parent << " parentIdx=" << parentIdx + << " joints=" << (void *)js + << " jointsDirty=" << (js != 0 ? (int)js->AreJointsModified() : -1) + << "\n" << std::flush; + } p.vel.x = ax.x*launch_velocity->x + ay.x*launch_velocity->y - az.x*launch_velocity->z; p.vel.y = ax.y*launch_velocity->x + ay.y*launch_velocity->y - az.y*launch_velocity->z; p.vel.z = ax.z*launch_velocity->x + ay.z*launch_velocity->y - az.z*launch_velocity->z; diff --git a/game/reconstructed/torso.cpp b/game/reconstructed/torso.cpp index 3e9529c..2482ca8 100644 --- a/game/reconstructed/torso.cpp +++ b/game/reconstructed/torso.cpp @@ -897,14 +897,21 @@ void // bring-up verification (env BT_TORSO_LOG; default OFF): show the first few // joint writes so the per-frame path can be confirmed in a headless run. - static const int s_log = getenv("BT_TORSO_LOG") ? 1 : 0; - static int s_count = 0; - if (s_log && (s_count % 30) == 0 && s_count < 1800) // sample periodically to show the sweep + // ⚠ SAMPLING TRAP (fixed 2026-08-08, #141): this used to sample ONE shared + // static every 30th call. With a master torso and a replicant COPY torso + // both ticking, the calls alternate 1:1 -- so every 30th call is always the + // SAME instance, and the probe reported only the local (untwisted) torso + // while the copy's writes were invisible. Sample per instance-kind instead. + static const int s_log = getenv("BT_TORSO_LOG") ? 1 : 0; + static int s_count[2] = { 0, 0 }; + const int kind = isDamagedCopy ? 1 : 0; + if (s_log && (s_count[kind] % 30) == 0 && s_count[kind] < 1800) { - DEBUG_STREAM << "[torso] PushTwist node=" << (void*)node << " type=" << (int)jt + DEBUG_STREAM << "[torso] PushTwist " << (kind ? "COPY " : "master") + << " node=" << (void*)node << " type=" << (int)jt << " twist=" << (float)twist << "\n" << std::flush; } - ++s_count; + ++s_count[kind]; switch (jt) // node+0x10 { diff --git a/scratchpad/night13/missileframe.sh b/scratchpad/night13/missileframe.sh new file mode 100644 index 0000000..151fa2e --- /dev/null +++ b/scratchpad/night13/missileframe.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# ========================================================================= +# #141 -- "missiles launch along the LEG/FOOT facing, then curve to the +# target -- PEER POV ONLY" (Oracle, night 13: "the emitter is following the +# foot facing"; shooter's own view correct). +# +# WHAT THE CODE SAYS SO FAR. Both sides already pass the mount segment: +# MissileLauncher::FireWeapon (mislanch.cpp:363) and the REPLICANT salvo +# mirror (mislanch.cpp:478) each hand BTPushProjectile +# `GetSegmentIndex() /*task #67 mount frame*/`. BTPushProjectile then +# rotates the authored MuzzleVelocity through +# `seg->GetSegmentToEntity() * localToWorld` -- so the launch direction IS +# the segment's world frame on BOTH nodes. Task #67 fixed exactly this +# symptom once already, master-side ("missiles fire out of his back"). +# +# So if the peer report is real, the difference is NOT which frame is asked +# for -- it is whether the replicant's SEGMENT actually carries the torso +# twist. Torso pushes currentTwist into the skeleton on both paths +# (TorsoSimulation and TorsoCopySimulation both call UpdateJoints), so this +# has to be measured, not reasoned about. +# +# THE MEASUREMENT. New [launchframe] receipt (BT_PROJ_LOG) prints, on both +# nodes, the yaw of the launch forward vs the BODY forward: +# [launchframe] master seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=.. +# [launchframe] REPLICANT seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=.. +# twistDelta is the torso twist expressed in the launch direction. +# +# BUG CONFIRMED: A (master) shows |twistDelta| sweeping well away from 0 +# while B (replicant mirror) stays pinned near 0 +# -- or B shows segResolved=0 (fell back to the body basis). +# NOT REPRODUCED: both sides show the same twistDelta spread. +# +# Only A fires and only A sweeps its torso, so every REPLICANT line in B's +# log is a mirror of an A salvo and the comparison is unambiguous. +# ========================================================================= +set -x +. /c/git/bt411/scratchpad/night6/bench_common.sh +cd /c/git/bt411/content || exit 1 +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 2 +rm -f mf_a.log mf_b.log mf_relay.log +bt_expert_egg MP.EGG MF.EGG +sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF.EGG + +# B: the OBSERVER. Drives at A so it stays in range, but does NOT fire and +# does NOT sweep -- so every [launchframe] REPLICANT line in mf_b.log is a +# mirror of one of A's salvos. +( export BT_GOTO=enemy BT_GOTO_STOP=150 + export BT_PROJ_LOG=1 BT_MP_LOG=1 + bt_launch mf_b.log MF.EGG 0x0C -net 1601 ) +sleep 2 +# A: the SHOOTER. Sweeps the torso hard so twistDelta is unmistakably +# non-zero at fire time, and autofires missiles at the designated enemy. +( export BT_GOTO=enemy BT_GOTO_STOP=150 + export BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=7 + export BT_LOCK_SWEEP=0.35 + export BT_PROJ_LOG=1 BT_TORSO_LOG=1 BT_MP_LOG=1 + bt_launch mf_a.log MF.EGG 0x03 -net 1501 ) +sleep 5 +python ../tools/btconsole.py MF.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf_relay.log 2>&1 & +RELAY=$! +sleep 300 +kill $RELAY 2>/dev/null +sleep 3 +bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3 + +echo "=================== #141 MISSILE LAUNCH FRAME ===================" +echo "--- did A fire, and did B mirror? ---" +echo -n " A [launchframe] master lines ....... "; grep -ac "launchframe\] master" mf_a.log +echo -n " B [launchframe] REPLICANT lines .... "; grep -ac "launchframe\] REPLICANT" mf_b.log +echo +echo "--- did the segment RESOLVE on each side? (segResolved=0 would be the bug) ---" +echo -n " A segResolved=0 ... "; grep -a "launchframe\] master" mf_a.log | grep -ac "segResolved=0" +echo -n " B segResolved=0 ... "; grep -a "launchframe\] REPLICANT" mf_b.log | grep -ac "segResolved=0" +echo +echo "--- THE COMPARISON: twistDelta spread on each side ---" +python - <<'PY' +import re, io +def stats(path, tag): + v = [] + try: + for ln in io.open(path, encoding="latin-1", errors="replace"): + if "[launchframe] " + tag in ln: + m = re.search(r"twistDelta=([-\d.e+]+)", ln) + if m: + try: v.append(float(m.group(1))) + except ValueError: pass + except IOError: + print(" %s: no log" % tag); return + if not v: + print(" %-9s no samples" % tag); return + a = [abs(x) for x in v] + big = sum(1 for x in a if x > 0.10) # ~5.7 deg -- clearly twisted + print(" %-9s n=%-4d |twistDelta| max=%.4f mean=%.4f >0.10rad: %d (%.0f%%)" + % (tag, len(v), max(a), sum(a)/len(a), big, 100.0*big/len(a))) +stats(r"C:\git\bt411\content\mf_a.log", "master") +stats(r"C:\git\bt411\content\mf_b.log", "REPLICANT") +print() +print(" VERDICT: master twisted + REPLICANT pinned near 0 => #141 CONFIRMED.") +print(" both twisted alike => NOT reproduced.") +PY +echo +echo "--- sample lines, both sides ---" +grep -a "launchframe\] master" mf_a.log | head -4 +grep -a "launchframe\] REPLICANT" mf_b.log | head -4 diff --git a/scratchpad/night13/missileframe2.sh b/scratchpad/night13/missileframe2.sh new file mode 100644 index 0000000..3bf25b8 --- /dev/null +++ b/scratchpad/night13/missileframe2.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# ========================================================================= +# #141 -- "missiles launch along the LEG/FOOT facing, then curve to the +# target -- PEER POV ONLY" (Oracle, night 13: "the emitter is following the +# foot facing"; shooter's own view correct). +# +# WHAT THE CODE SAYS SO FAR. Both sides already pass the mount segment: +# MissileLauncher::FireWeapon (mislanch.cpp:363) and the REPLICANT salvo +# mirror (mislanch.cpp:478) each hand BTPushProjectile +# `GetSegmentIndex() /*task #67 mount frame*/`. BTPushProjectile then +# rotates the authored MuzzleVelocity through +# `seg->GetSegmentToEntity() * localToWorld` -- so the launch direction IS +# the segment's world frame on BOTH nodes. Task #67 fixed exactly this +# symptom once already, master-side ("missiles fire out of his back"). +# +# So if the peer report is real, the difference is NOT which frame is asked +# for -- it is whether the replicant's SEGMENT actually carries the torso +# twist. Torso pushes currentTwist into the skeleton on both paths +# (TorsoSimulation and TorsoCopySimulation both call UpdateJoints), so this +# has to be measured, not reasoned about. +# +# THE MEASUREMENT. New [launchframe] receipt (BT_PROJ_LOG) prints, on both +# nodes, the yaw of the launch forward vs the BODY forward: +# [launchframe] master seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=.. +# [launchframe] REPLICANT seg=N segResolved=1 segYaw=.. bodyYaw=.. twistDelta=.. +# twistDelta is the torso twist expressed in the launch direction. +# +# BUG CONFIRMED: A (master) shows |twistDelta| sweeping well away from 0 +# while B (replicant mirror) stays pinned near 0 +# -- or B shows segResolved=0 (fell back to the body basis). +# NOT REPRODUCED: both sides show the same twistDelta spread. +# +# Only A fires and only A sweeps its torso, so every REPLICANT line in B's +# log is a mirror of an A salvo and the comparison is unambiguous. +# ========================================================================= +set -x +. /c/git/bt411/scratchpad/night6/bench_common.sh +cd /c/git/bt411/content || exit 1 +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 2 +rm -f mf_a.log mf_b.log mf_relay.log +bt_expert_egg MP.EGG MF.EGG +sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; s/^vehicle=.*/vehicle=madcat/" MF.EGG + +# B: the OBSERVER. Drives at A so it stays in range, but does NOT fire and +# does NOT sweep -- so every [launchframe] REPLICANT line in mf_b.log is a +# mirror of one of A's salvos. +( export BT_GOTO=enemy BT_GOTO_STOP=150 + export BT_PROJ_LOG=1 BT_MP_LOG=1 BT_TORSO_LOG=1 + bt_launch mf_b.log MF.EGG 0x0C -net 1601 ) +sleep 2 +# A: the SHOOTER. Sweeps the torso hard so twistDelta is unmistakably +# non-zero at fire time, and autofires missiles at the designated enemy. +( export BT_GOTO=enemy BT_GOTO_STOP=150 + export BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=7 + export BT_LOCK_SWEEP=0.35 + export BT_PROJ_LOG=1 BT_TORSO_LOG=1 BT_MP_LOG=1 + bt_launch mf_a.log MF.EGG 0x03 -net 1501 ) +sleep 5 +python ../tools/btconsole.py MF.EGG 127.0.0.1:1501 127.0.0.1:1601 > mf_relay.log 2>&1 & +RELAY=$! +sleep 170 +kill $RELAY 2>/dev/null +sleep 3 +bt_kill_ours; sleep 2; taskkill //F //IM btl4.exe > /dev/null 2>&1; sleep 3 + +echo "=================== #141 MISSILE LAUNCH FRAME ===================" +echo "--- did A fire, and did B mirror? ---" +echo -n " A [launchframe] master lines ....... "; grep -ac "launchframe\] master" mf_a.log +echo -n " B [launchframe] REPLICANT lines .... "; grep -ac "launchframe\] REPLICANT" mf_b.log +echo +echo "--- did the segment RESOLVE on each side? (segResolved=0 would be the bug) ---" +echo -n " A segResolved=0 ... "; grep -a "launchframe\] master" mf_a.log | grep -ac "segResolved=0" +echo -n " B segResolved=0 ... "; grep -a "launchframe\] REPLICANT" mf_b.log | grep -ac "segResolved=0" +echo +echo "--- THE COMPARISON: twistDelta spread on each side ---" +python - <<'PY' +import re, io +def stats(path, tag): + v = [] + try: + for ln in io.open(path, encoding="latin-1", errors="replace"): + if "[launchframe] " + tag in ln: + m = re.search(r"twistDelta=([-\d.e+]+)", ln) + if m: + try: v.append(float(m.group(1))) + except ValueError: pass + except IOError: + print(" %s: no log" % tag); return + if not v: + print(" %-9s no samples" % tag); return + a = [abs(x) for x in v] + big = sum(1 for x in a if x > 0.10) # ~5.7 deg -- clearly twisted + print(" %-9s n=%-4d |twistDelta| max=%.4f mean=%.4f >0.10rad: %d (%.0f%%)" + % (tag, len(v), max(a), sum(a)/len(a), big, 100.0*big/len(a))) +stats(r"C:\git\bt411\content\mf_a.log", "master") +stats(r"C:\git\bt411\content\mf_b.log", "REPLICANT") +print() +print(" VERDICT: master twisted + REPLICANT pinned near 0 => #141 CONFIRMED.") +print(" both twisted alike => NOT reproduced.") +PY +echo +echo "--- sample lines, both sides ---" +grep -a "launchframe\] master" mf_a.log | head -4 +grep -a "launchframe\] REPLICANT" mf_b.log | head -4