re(flight): axis probe pins the rows and checks liveness; fly_stage waits, not sleeps

Both fixes the previous run's caveats asked for, plus one the run itself forced.

axis_probe.py now:
 - PINS which non-forward row is up and which is right, by comparing world-Y
   across the rows in level flight, and says CONFIDENT or WEAK. entities2
   measures row 2 = forward against velocity, but the other two were labelled by
   the D3D convention, and yaw/pitch SWAP if that is wrong -- so the previous
   run's last two columns were named on an assumption.
 - checks the craft is ALIVE between inputs, and ABORTS with a message instead of
   reporting the clean zeros a destroyed craft produces. The first run ended on
   GAME OVER and only said so afterwards.
 - measures the UNKNOWN inputs (rx, ry, LB, RB) first while the craft is healthy,
   keeping the established lx/ly as controls at the end.

fly_stage.sh now WAITS for the stage load instead of sleeping a fixed guess. The
fixed sleeps worked until they didn't: one load ran long, the script pressed START
into a black screen, and every later step went to nothing while the screenshots
recorded a plausible-looking sequence. It now polls for a non-black frame and
aborts with a pointer to the log if the load hangs (PhysicalHeap::Release
failures) rather than continuing blind.

The probe itself did not run this iteration -- the stage load hung -- so there is
no new axis data, and none is claimed.
This commit is contained in:
2026-08-13 22:22:19 +00:00
parent 5f3c618b51
commit dfa769420d
2 changed files with 75 additions and 3 deletions

View File

@@ -52,13 +52,16 @@ PAD_FILE = os.environ.get("XENIA_PAD_FILE", "/tmp/xenia_pad.txt")
# Candidates. Sticks at full deflection, shoulders as buttons. Triggers are the
# throttle (already settled) and are left out.
# Order matters: the craft can be shot down mid-run, so the UNKNOWN inputs go
# first while it is healthy and the already-established ones (lx = roll) go last
# as controls. A row measured after death looks like a clean zero.
CANDIDATES = [
("lx+", {"lx": 32767}),
("ly+", {"ly": 32767}),
("rx+", {"rx": 32767}),
("ry+", {"ry": 32767}),
("LB", {"press": "LB"}),
("RB", {"press": "RB"}),
("lx+", {"lx": 32767}),
("ly+", {"ly": 32767}),
]
@@ -84,6 +87,46 @@ def dot(a, b):
return sum(a[i] * b[i] for i in range(3))
def alive(w, off, cfg, secs=1.0):
"""Is the craft still flying? A destroyed craft stops moving, and every
subsequent input then measures a clean, meaningless zero — the failure mode
that invalidated this probe's first run (it ended on GAME OVER and only said
so afterwards)."""
p0 = struct.unpack(">3f", os.pread(w.fd, 12, off))
time.sleep(secs)
p1 = struct.unpack(">3f", os.pread(w.fd, 12, off))
return math.dist(p0, p1) > 1.0
def pin_rows(w, off, cfg):
"""Which non-forward row is UP and which is RIGHT?
`entities2` measures row 2 = forward against the velocity vector, but the other
two are assigned by the D3D convention rather than evidence — and yaw and pitch
SWAP if that is wrong, so naming them without this test is a guess.
Discriminator: in level flight the craft's up-vector points along world +Y and
its right-vector lies near the horizontal plane. Sample at neutral and compare
|world-Y| across the rows."""
f_i = cfg["fwd_row"]
acc = [0.0, 0.0, 0.0]
n = 0
for _ in range(20):
time.sleep(0.05)
rs = rows_at(w.fd, off, cfg)
for r in range(3):
acc[r] += rs[r][1] # world Y component
n += 1
ys = [a / n for a in acc]
cand = [r for r in (0, 1, 2) if r != f_i]
up_i = max(cand, key=lambda r: abs(ys[r]))
right_i = [r for r in cand if r != up_i][0]
print(f"# row world-Y means: {[round(y,3) for y in ys]} (forward = row {f_i})")
print(f"# -> up = row {up_i}, right = row {right_i}"
f" {'CONFIDENT' if abs(ys[up_i]) - abs(ys[right_i]) > 0.3 else 'WEAK — craft may not be level'}")
return up_i, right_i
def main():
out_csv = sys.argv[1]
dwell = float(sys.argv[2]) if len(sys.argv) > 2 else 5.0
@@ -93,7 +136,9 @@ def main():
sys.exit("player entity not found")
print(f"# locked on {nm}")
f_i = cfg["fwd_row"]
u_i, w_i = [r for r in (0, 1, 2) if r != f_i]
if not alive(w, off, cfg):
sys.exit("craft is not moving at the start — not in flight, or already dead")
u_i, w_i = pin_rows(w, off, cfg)
rows = []
print(f"# {'input':<6} {'roll':>9} {'yaw':>9} {'pitch':>9} (deg over the dwell)")
@@ -115,6 +160,10 @@ def main():
prev = cur
pad_state()
wall = time.time() - t0
if not alive(w, off, cfg):
print(f"# CRAFT STOPPED MOVING after {label} — everything from here is "
f"meaningless, aborting rather than reporting zeros")
break
rate = [v / wall for v in sw]
rows.append((label, *[round(v, 2) for v in rate]))
print(f"# {label:<6} {rate[0]:9.1f} {rate[1]:9.1f} {rate[2]:9.1f} deg/wall-s")

View File

@@ -76,6 +76,21 @@ sleep 1; shot 03-selected
pad tap A 0.20; sleep 3; shot 04-after-A
pad tap A 0.20; sleep 3; shot 05-after-A2
# Wait for a screen instead of sleeping a guess. The fixed sleeps below used to be
# enough and then were not: one run's stage load ran long, the script pressed START
# into a black loading screen, and every later step went to nothing while the shots
# recorded a plausible-looking sequence. Same lesson as the menu oracles.
wait_not_black() { # $1 = timeout s
for _ in $(seq 1 "$1"); do
screenshot /tmp/nav-probe.png >/dev/null 2>&1 || { sleep 1; continue; }
read -r r g b < <(px "640,360")
read -r r2 g2 b2 < <(px "200,200")
if [ -n "${r:-}" ] && [ $((r + g + b + r2 + g2 + b2)) -gt 60 ]; then return 0; fi
sleep 1
done
return 1
}
# Selecting a stage lands on the mission BRIEFING (A: Continue), which leads to the
# READY ROOM (TAKE OFF / BRIEFINGS / HANGAR / ...) with BRIEFINGS pre-highlighted.
# A snapshot taken at the briefing yields ZERO unit-definition objects — they are
@@ -83,6 +98,14 @@ pad tap A 0.20; sleep 3; shot 05-after-A2
# START (Skip) clears the briefing. `A` does NOT: it pages through the brief and
# ten taps still left the run sitting on a briefing screen, twice. One START press
# lands on the READY ROOM.
say "waiting for the stage to finish loading"
if ! wait_not_black 180; then
say "STILL BLACK after 180s — the load hung (this happens; check the log for"
say " 'PhysicalHeap::Release failed'), aborting instead of pressing into nothing"
shot 06-stuck-black
exit 2
fi
sleep 3
say "skipping the briefing (START, not A)"
pad tap START 0.30; sleep 6
shot 06-readyroom