#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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SgmXGNMXavXiafKXf9MDC
This commit is contained in:
Joe DiPrima
2026-08-08 08:14:25 -05:00
co-authored by Claude Opus 5
parent 1ae57398f1
commit 05d7b5890a
4 changed files with 312 additions and 7 deletions
+90 -2
View File
@@ -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;
+12 -5
View File
@@ -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
{
+105
View File
@@ -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
+105
View File
@@ -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