From 16e75d6e1ad1ad2491778f1acc29abb2f18c50d4 Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Mon, 3 Aug 2026 10:04:03 -0500 Subject: [PATCH] #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= (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= (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 --- context/test-harness.md | 37 +++++ game/reconstructed/mech4.cpp | 254 ++++++++++++++++++++++++++++++++- game/reconstructed/mechdmg.cpp | 19 +++ scratchpad/night10/zonewalk.sh | 32 +++++ 4 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 scratchpad/night10/zonewalk.sh diff --git a/context/test-harness.md b/context/test-harness.md index e5a528f..cc1b432 100644 --- a/context/test-harness.md +++ b/context/test-harness.md @@ -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=`: +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=` (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 diff --git a/game/reconstructed/mech4.cpp b/game/reconstructed/mech4.cpp index 4e62b97..0478276 100644 --- a/game/reconstructed/mech4.cpp +++ b/game/reconstructed/mech4.cpp @@ -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=): 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= 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=): 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=): 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 diff --git a/game/reconstructed/mechdmg.cpp b/game/reconstructed/mechdmg.cpp index 8b12682..0a296e8 100644 --- a/game/reconstructed/mechdmg.cpp +++ b/game/reconstructed/mechdmg.cpp @@ -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; +} diff --git a/scratchpad/night10/zonewalk.sh b/scratchpad/night10/zonewalk.sh new file mode 100644 index 0000000..86e4b70 --- /dev/null +++ b/scratchpad/night10/zonewalk.sh @@ -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