#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;