#124 bench: the ZONE-WALK MATRIX -- systematic per-panel targeting, operator-watchable

The precision rig the operator specified: two nodes, target visible,
every zone aimed at deliberately through the REAL reticle/pick path.

* BT_SPIN_SELF=<deg/s> (target node): rotates the node's own viewpoint
  mech in place (the BT_SPAWN_AT write pattern per frame) so every
  aspect passes the shooter's boresight; the operator watches this
  node's paper doll take the hits.
* BT_ZONE_WALK=<secs/zone> (shooter node): walks the target's damage
  zones in order, resolves each zone's carrier segment's live world
  position (BTResolveSegmentWorld + the new BTMechZoneSegAndName
  bridge), SERVOS the torso twist + aim elevation until the centered
  reticle ray (BTGetAimRay) points at the segment, fires 3 laser
  pulses, advances. [walk] ZONE/FIRE/HOLD on A pairs with [dmghit]
  zone/level lines on B.
* scratchpad/night10/zonewalk.sh: launch both + relay, map sed'd to
  grass/day, NO kill timer -- the session stays up for observation.

Hard-won servo constraints (documented in test-harness.md so they are
never relearned): the ENGAGE GATE (ray live + range<150 + bearing<1.1;
outside it RELAX the twist -- servoing at the twist limit while the
goto marches is a limit-clamp fight that visibly shakes the mech, and
a 0.55 gate deadlocks against the goto's ~0.55 resting bearing); YAW
POLARITY -1 (the twist cell's angular sense is opposite atan2(x,-z)
world yaw -- operator-observed live, the SECOND witness for the #124
SelectSlice twist-sign flip) with a divergence watchdog that
self-flips; damped correction (gain .40, cap .025/frame -- the aim ray
lags the twist write a frame); and a one-shot approach PORT to 100u
off the target instead of a cross-map march.

First live run: walker cycles zones, settles, fires; the target's
luleg climbed 0.458->0.523 under its own aimed pulses -- aimed shot,
correct panel, damage consumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Joe DiPrima
2026-08-03 10:04:03 -05:00
co-authored by Claude Fable 5
parent b0b7adc4ea
commit 16e75d6e1a
4 changed files with 341 additions and 1 deletions
+37
View File
@@ -90,6 +90,43 @@ own1 snd1 thr1 vul1 (own1 has NO arm zones — rack zones instead).
against cavern rock and they shoot terrain for the whole window. Always sed the
map/time on the COPY, single-node and two-node alike.
### THE ZONE-WALK MATRIX (`scratchpad/night10/zonewalk.sh`) — systematic
### per-panel hit-location testing, operator-watchable (built for #124)
The precision-targeting rig: **two nodes**. Node B (launched first, back
window) is the TARGET — `BT_SPIN_SELF=15` rotates its own viewpoint mech in
place (deg/s; the BT_SPAWN_AT write pattern, applied per frame) so every
aspect passes the shooter's boresight; its window shows its own paper doll
taking the hits (`BT_DMG_LOG=1` prints every `[dmghit]` with zone + level).
Node A (foreground — it owns the pick focus) runs `BT_ZONE_WALK=<secs/zone>`:
walks the target's damage zones in index order, resolves each zone's CARRIER
SEGMENT's live world position (`BTResolveSegmentWorld` — the same resolver the
attached smoke rides; zone→segment via the `BTMechZoneSegAndName` bridge),
SERVOS the torso twist + aim elevation until the centered reticle's pick ray
(`BTGetAimRay(0,0)`) points at the segment, then pulses the laser trigger 3×
and advances. `[walk] ZONE/FIRE/HOLD` lines on A pair with `[dmghit]` on B.
Launch: `bash scratchpad/night10/zonewalk.sh` (relay included; NO kill timer —
the session stays up for observation; teardown = taskkill btl4).
Companion scalpels: `BT_ASPECT_TEST=1` (the zero-premise frame probe — 4
world-cardinal self-impacts + one at each weapon's physical MUZZLE, the
left/right anchors that caught the #124 reflection-vs-rotation error);
`BT_AIM_SWEEP=<period>` (blind elevation sweep — superseded by the walker).
Hard-won constraints baked into the walker (do not relearn these):
- **Engage gate**: servo only when the aim ray is live AND range < 150 AND
|bearing| < 1.1 rad; otherwise RELAX the twist toward 0 and hold the zone
clock. Without it the servo winds the twist to its limit while the goto
marches (limit-clamp fight = the mech visibly SHAKES) — and the gate must
sit well above the goto's resting bearing error (~0.55 rad at STOP=90) or
it deadlocks in HOLD.
- **Yaw polarity**: the twist cell's angular sense is OPPOSITE atan2(x,z)
world yaw (operator-observed live: `+=` turned the torso AWAY — the second
witness for the #124 SelectSlice twist-sign flip). The servo starts at 1
and carries a growth WATCHDOG that self-flips if the error diverges.
- **Damped servo**: gain 0.40, step cap 0.025 rad/frame, deadband — the aim
ray lags the twist write by a frame; full-error correction oscillates.
- **Approach port**: dropzones spawn cross-map; the walker one-shot teleports
A to 100 u off the target (BT_SPAWN_AT pattern incl. the +500 y ground-snap
lift) instead of minutes of marching.
### Two-node bench skeleton (the MP pattern)
```bash
bt_assert_player_env
+253 -1
View File
@@ -707,6 +707,7 @@ static int gEnemyDestroyed = 0;
extern Entity *gEnemyMech;
// gauge scoring wave: producers (btplayer.cpp) -- feed the scoreboard from combat.
extern void BTPostDamageScore(Entity *victim, Scalar damage, Entity *shooter); // per-hit SCORE (ScoreInflicted)
float gBTWalkElev = 0.0f; // #124 zone walker: servo-driven aim elevation (rad)
extern void BTPostKillScore(Entity *victim, Scalar damage); // KILL (+ MP death)
// Mech target slots (verified vs the binary's weapon/fire path, part_013.c):
@@ -3807,6 +3808,7 @@ void
if (s_spawnAt == 1)
{
s_spawnAt = 2;
// (BT_SPIN_SELF lives right below -- same proven code path)
localOrigin.linearPosition.x = s_sx;
localOrigin.linearPosition.z = s_sz;
// FIX (user-reported: teleport embeds the mech in sloped terrain):
@@ -3830,6 +3832,33 @@ void
}
}
// #124 MATRIX BENCH (BT_SPIN_SELF=<deg/s>): rotate OUR OWN mech in place
// -- run on the TARGET node of a two-node zone-walk session, so the
// shooter's walker sweeps every aspect while the operator watches this
// node's paper doll take the hits. Same write pattern as BT_SPAWN_AT
// (heading scalar + quaternion + localToWorld), applied every frame so
// nothing fights it.
{
static float s_ssRate = -2.0f;
if (s_ssRate < -1.0f)
{
const char *e = getenv("BT_SPIN_SELF");
s_ssRate = (e && *e) ? (float)atof(e) : -1.0f;
if (s_ssRate >= 0.0f)
DEBUG_STREAM << "[spin] SELF rotation armed: " << s_ssRate
<< " deg/s\n" << std::flush;
}
if (s_ssRate > 0.0f)
{
static float s_ssYaw = 0.0f;
s_ssYaw += s_ssRate * 0.0174533f * dt;
if (s_ssYaw >= 6.2831853f) s_ssYaw -= 6.2831853f;
gDriveHeading = s_ssYaw;
localOrigin.angularPosition = EulerAngles(0.0f, s_ssYaw, 0.0f);
localToWorld = localOrigin;
}
}
// BRING-UP repro harness: BT_FORCE_SECONDS=<n> releases the forced throttle
// after n sim-seconds (headless stand-in for an interactive tap->release,
// exercising the walk->stop gait transition the constant-throttle soaks
@@ -5937,7 +5966,27 @@ void
{
extern float gBTLookPitch, gBTLookYaw;
extern Scalar BTGetTorsoElevation(Subsystem *);
const float elev = (float)BTGetTorsoElevation(GetTorsoSubsystem());
float elev = (float)BTGetTorsoElevation(GetTorsoSubsystem());
// #124 ZONE WALKER: the servo owns the aim elevation.
if (getenv("BT_ZONE_WALK"))
{
extern float gBTWalkElev;
elev = gBTWalkElev;
}
// #124 MATRIX BENCH (BT_AIM_SWEEP=<period_s>): triangle-sweep the
// aim elevation +-0.20 rad so the pick ray paints the target's
// full height through the REAL boresight/pick path.
else if (getenv("BT_AIM_SWEEP"))
{
static float s_swT = 0.0f;
float period = (float)atof(getenv("BT_AIM_SWEEP"));
if (period < 1.0f) period = 8.0f;
s_swT += dt;
float ph = fmodf(s_swT, period) / period; // 0..1
float tri = (ph < 0.5f) ? (ph * 4.0f - 1.0f)
: (3.0f - ph * 4.0f); // -1..1..-1
elev = 0.20f * tri;
}
eyepointRotation = EulerAngles(
Radian(Radian::Normalize(gBTLookPitch + elev)),
Radian(Radian::Normalize(gBTLookYaw)),
@@ -5951,6 +6000,209 @@ void
}
}
// #124 ZONE WALKER (BT_ZONE_WALK=<secs-per-zone>): systematic per-panel
// targeting through the REAL boresight/pick path. Each period: advance
// to the target mech's next damage zone, resolve its CARRIER SEGMENT's
// live world position (BTResolveSegmentWorld -- the same resolver the
// attached smoke rides), then SERVO the torso twist + aim elevation
// until the centered reticle's pick ray (BTGetAimRay(0,0)) points at
// the segment, and pulse the laser trigger. Pairs with BT_SPIN_SELF on
// the target node: over one revolution every zone is aimed at from
// every aspect. [walk] logs each zone/aim/shot; the TARGET node's
// [dmghit] answers where the damage actually landed.
if ((Entity *)this == application->GetViewpointEntity()
&& getenv("BT_ZONE_WALK"))
{
extern int BTGetTargetCandidates(Entity *shooter, Entity **out, int maxOut);
extern int BTIsRegisteredMech(Entity *e);
extern int BTResolveSegmentWorld(void *entity, int seg_index, float *pos3, float *rows9);
extern int BTGetAimRay(float rx, float ry, float outStart[3], float outDir[3]);
extern int BTMechZoneSegAndName(void *mech_v, int zone_idx, int *seg_out, const char **name_out);
extern Scalar *BTGetTorsoTwistAddr(Subsystem *torso);
extern float gBTWalkElev;
static float s_zwPeriod = 0.0f;
static float s_zwT = 1.0e9f;
static int s_zwZone = -1;
static int s_zwShots = 0;
if (s_zwPeriod <= 0.0f)
{
s_zwPeriod = (float)atof(getenv("BT_ZONE_WALK"));
if (s_zwPeriod < 2.0f) s_zwPeriod = 6.0f;
}
Entity *cand[8];
int nc = BTGetTargetCandidates((Entity *)this, cand, 8);
Mech *target = 0;
for (int ci = 0; ci < nc && target == 0; ++ci)
if (cand[ci] != 0 && BTIsRegisteredMech(cand[ci]))
target = (Mech *)cand[ci];
if (target != 0)
{
// ENGAGE GATE: the servo owns the twist only when the aim ray
// is live, the target is inside working range, AND the bearing
// sits inside a comfortable twist arc -- the BODY (the goto)
// does the coarse facing. Outside the gate: relax the twist
// and elevation toward neutral (no limit-clamp fights = no
// shaking) and HOLD the zone clock.
float sp[3], rows[9], rs[3], rd[3];
int rayLive = BTGetAimRay(0.0f, 0.0f, rs, rd);
Point3D tp = target->localOrigin.linearPosition;
float pdx = (float)(tp.x - localOrigin.linearPosition.x);
float pdz = (float)(tp.z - localOrigin.linearPosition.z);
float pdist = sqrtf(pdx*pdx + pdz*pdz);
// ONE-SHOT APPROACH PORT: cross-map dropzones waste minutes of
// march -- teleport to 100 u out, facing the target (the
// BT_SPAWN_AT pattern incl. the above-terrain lift), and let
// the goto/servo close the rest.
static int s_zwPorted = 0;
if (!s_zwPorted && pdist > 250.0f)
{
s_zwPorted = 1;
localOrigin.linearPosition.x = tp.x;
localOrigin.linearPosition.z = tp.z + 100.0f;
localOrigin.linearPosition.y += 500.0f; // ground-snap lift
float fy = atan2f((float)(tp.x - localOrigin.linearPosition.x),
(float)(tp.z - localOrigin.linearPosition.z));
gDriveHeading = fy;
localOrigin.angularPosition = EulerAngles(0.0f, fy, 0.0f);
localToWorld = localOrigin;
DEBUG_STREAM << "[walk] PORTED to 100u off target (was "
<< pdist << "u away)\n" << std::flush;
}
float dx = 0, dy = 0, dz2 = 0, dist = 1.0e9f;
if (rayLive)
{
dx = (float)tp.x - rs[0];
dy = ((float)tp.y + 7.0f) - rs[1];
dz2 = (float)tp.z - rs[2];
dist = sqrtf(dx*dx + dy*dy + dz2*dz2);
}
float brgErr = 0.0f;
if (rayLive && dist > 1.0f)
brgErr = atan2f(dx/dist, -dz2/dist) - atan2f(rd[0], -rd[2]);
while (brgErr > 3.14159f) brgErr -= 6.2831853f;
while (brgErr < -3.14159f) brgErr += 6.2831853f;
int engaged = rayLive && dist < 150.0f && fabsf(brgErr) < 1.10f;
Scalar *tw = BTGetTorsoTwistAddr(GetTorsoSubsystem());
if (!engaged)
{
if (tw != 0) *tw *= 0.96f; // relax, no snapping
gBTWalkElev *= 0.96f;
s_zwT = 0.0f; // hold the zone clock
static float s_zwHold = 0.0f;
s_zwHold += dt;
if (s_zwHold > 5.0f)
{
s_zwHold = 0.0f;
DEBUG_STREAM << "[walk] HOLD (ray=" << rayLive
<< " dist=" << dist << " brgErr=" << brgErr
<< ") -- approaching\n" << std::flush;
}
}
else
{
s_zwT += dt;
int segIdx = -1;
const char *zname = "?";
if (s_zwT >= s_zwPeriod || s_zwZone < 0)
{
// advance to the next zone with a real carrier segment
s_zwT = 0.0f;
s_zwShots = 0;
for (int tries = 0; tries < 64; ++tries)
{
++s_zwZone;
if (!BTMechZoneSegAndName(target, s_zwZone, &segIdx, &zname))
{
s_zwZone = -1; // wrap
continue;
}
if (segIdx >= 0)
break;
}
DEBUG_STREAM << "[walk] ZONE " << s_zwZone << " '" << zname
<< "' seg=" << segIdx << " target="
<< target->GetEntityID() << "\n" << std::flush;
}
if (BTMechZoneSegAndName(target, s_zwZone, &segIdx, &zname)
&& segIdx >= 0
&& BTResolveSegmentWorld((void *)target, segIdx, sp, rows))
{
float wx = sp[0]-rs[0], wy = sp[1]-rs[1], wz = sp[2]-rs[2];
float wl = sqrtf(wx*wx + wy*wy + wz*wz);
if (wl > 1.0f)
{
wx /= wl; wy /= wl; wz /= wl;
float yawErr = atan2f(wx, -wz) - atan2f(rd[0], -rd[2]);
while (yawErr > 3.14159f) yawErr -= 6.2831853f;
while (yawErr < -3.14159f) yawErr += 6.2831853f;
float pitchErr = asinf(wy) - asinf(rd[1]);
// DAMPED servo: gain + step cap + deadband (the aim
// ray lags the twist write by a frame; full-error
// correction under lag oscillates). YAW POLARITY:
// the twist cell's angular sense is OPPOSITE the
// atan2(x,-z) world-yaw convention (operator-
// observed: += turned the torso AWAY and wound the
// limit -- the same reversed sense the #124 adapter
// flags for SelectSlice's twist term). Start -1;
// the growth WATCHDOG flips the latch if the error
// diverges ~25 straight frames, so a wrong polarity
// self-corrects instead of limit-shaking.
static float s_zwYawPol = -1.0f;
static float s_zwPrevYaw = -1.0f;
static int s_zwGrow = 0;
const float kGain = 0.40f, kCap = 0.025f;
if (tw != 0 && fabsf(yawErr) > 0.004f)
{
float st = kGain * yawErr;
if (st > kCap) st = kCap;
if (st < -kCap) st = -kCap;
*tw += s_zwYawPol * st;
if (s_zwPrevYaw >= 0.0f
&& fabsf(yawErr) > s_zwPrevYaw + 1.0e-4f)
{
if (++s_zwGrow > 25)
{
s_zwYawPol = -s_zwYawPol;
s_zwGrow = 0;
DEBUG_STREAM << "[walk] yaw polarity flipped to "
<< s_zwYawPol << "\n" << std::flush;
}
}
else
{
s_zwGrow = 0;
}
s_zwPrevYaw = fabsf(yawErr);
}
if (fabsf(pitchErr) > 0.003f)
{
float es = kGain * pitchErr;
if (es > 0.015f) es = 0.015f;
if (es < -0.015f) es = -0.015f;
gBTWalkElev += es;
if (gBTWalkElev > 0.40f) gBTWalkElev = 0.40f;
if (gBTWalkElev < -0.40f) gBTWalkElev = -0.40f;
}
int aligned = (fabsf(yawErr) < 0.012f
&& fabsf(pitchErr) < 0.010f);
if (aligned && s_zwShots < 3
&& s_zwT > 0.30f * s_zwPeriod + s_zwShots * 1.2f)
{
gBTLaserKey = 1;
++s_zwShots;
DEBUG_STREAM << "[walk] FIRE " << s_zwShots
<< " zone " << s_zwZone << " '" << zname
<< "' range=" << wl << "\n" << std::flush;
}
}
}
}
}
}
// task #6 ORDER FIX: the scripted block must run BEFORE the fire-push
// block below -- it writes gBTLaserKey/gBTConfigKey, which the earlier
// keyboard poll zeroes each frame while the window is unfocused; the
+19
View File
@@ -1438,3 +1438,22 @@ void BTAmmoExplosionFanOut(
((Mech *)owner_mech)->AmmoExplosionFanOut(
(::Subsystem *)inflicting_subsystem, inflicting_subsystem_id, total_damage);
}
//
// #124 ZONE WALKER bridge: a zone's carrier SEGMENT index + name, complete-
// type TU (Mech__DamageZone). Replicant-safe -- the zone table streams on
// every instance, so the SHOOTER can enumerate its target's zones locally.
// Returns 0 past the end (the walker wraps on that).
//
int BTMechZoneSegAndName(void *mech_v, int zone_idx, int *seg_out, const char **name_out)
{
Mech *m = (Mech *)mech_v;
if (m == 0 || zone_idx < 0 || zone_idx >= m->damageZoneCount)
return 0;
Mech__DamageZone *dz = m->Zone(zone_idx);
if (dz == 0)
return 0;
*seg_out = dz->EffectSegmentIndex(); // public accessor (@0x194)
*name_out = (const char *)dz->damageZoneName; // engine base, public
return 1;
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# #124 ZONE-WALK MATRIX -- the systematic per-panel targeting session (2-node,
# operator-watchable). Node B (right window) = the TARGET: spins in place
# (BT_SPIN_SELF), its paper doll shows every hit landing. Node A = the
# SHOOTER: BT_ZONE_WALK servo-aims the real boresight at each of B's zones'
# carrier segments in turn, settles, fires 3 laser pulses, advances. One
# B-revolution per ~24 s x 6 s/zone => every zone gets aimed shots from many
# aspects over a few minutes.
# READ: A's log [walk] ZONE/FIRE lines vs B's log [dmghit] zone= lines.
# The session STAYS UP for observation -- close the windows (or taskkill
# btl4.exe) when done; the relay dies with this script's 60-min tail.
set -x
. /c/git/bt411/scratchpad/night6/bench_common.sh
cd /c/git/bt411/content || exit 1
bt_assert_player_env
taskkill //F //IM btl4.exe > /dev/null 2>&1
sleep 2
rm -f zw_a.log zw_b.log
bt_expert_egg MP.EGG ZW.EGG
sed -i "s/^map=.*/map=grass/; s/^time=.*/time=day/; 0,/^vehicle=.*/s//vehicle=madcat/" ZW.EGG
# TARGET first (right/back window): spins, logs every damage application.
BT_SPIN_SELF=15 BT_DMG_LOG=1 BT_DEATH_LOG=1 BT_MP_LOG=1 \
bt_launch zw_b.log ZW.EGG 0x0C -net 1601
sleep 2
# SHOOTER second (foreground -- owns the pick focus): the walker.
BT_ZONE_WALK=6 BT_GOTO=enemy BT_GOTO_STOP=90 BT_KEY_NOFOCUS=1 \
BT_DMG_LOG=1 BT_RANGE_LOG=1 BT_MP_LOG=1 \
bt_launch zw_a.log ZW.EGG 0x03 -net 1501
sleep 5
python ../tools/btconsole.py ZW.EGG 127.0.0.1:1501 127.0.0.1:1601 > zw_relay.log 2>&1 &
sleep 3600 # keep the relay's parent alive for the observation hour