re(flight): a per-frame sampler, and nav oracles that a menu bar cannot break
WIP toward the residual flight-speed-law question (does a 1 s burst reach the
steady angular rate, or is there a per-axis multiplier?). The write-up already
concluded that host-side polling cannot answer it and named a Canary-side hook
as the tool required; that hook now exists (--frame_probe_log, committed as
auto/re-frame-probe in xenia-canary-native) and this is the harness for it.
- `rebuild_canary.sh` -- the surgical rebuild the box can actually do, kept in
the repo this time instead of in /tmp: compile only the changed objects, `ar`
them into their archive, and re-run the link command lifted out of the
generated ninja. A full `ninja` is impossible here (several TUs need dev
headers the image lacks) and the build cache cannot be re-configured. 31 s.
- `frame_burst.py` -- points the probe at the player craft's transform block
(pos-112, the three 16-byte-strided rows plus the position) and drives full
stick holds, recording each hold's start and end in the same clock the probe
stamps its lines with.
- `frame_session.sh` -- the whole run as ONE blocking foreground call, per the
session-lifetime rule; REUSE=1 drives a Canary that is already up.
- `nav_to_flight.sh` -- fly_stage.sh's navigation, split out so a live emulator
can be re-used. A boot to the title costs minutes under lavapipe and a run
that only failed to NAVIGATE should not pay for it twice.
The navigation change is the one worth reading. Every screen oracle here tested
named pixels ("648,221 is white"), which is only valid while the game image sits
at a known place on the root window -- and it does not: xenia's GTK window has a
menu bar, so on this display the image is ~25 px lower and every constant reads
the wrong row. Nothing errors. One run sat 300 s in front of a plainly visible
MAIN MENU reporting "no main menu"; the next missed the title screen entirely
and let the attract movie loop for ten minutes.
So `screen_id.py` identifies screens by WHOLE-IMAGE statistics instead -- the
fraction of green UI-text pixels, the fraction of near-white pixels, and the
per-channel means -- which no vertical shift, scale or letterbox can move. It is
calibrated against known-good captures and classifies all of them correctly:
title, three different menu screens, in-flight, and four movie frames as
"other". `bin/screenshot` additionally crops the menu bar off saved evidence
shots, deriving the offset from the window's own height rather than a constant.
Not yet a finding: the run has not reached flight, so no rate has been measured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
45
tools/re-capture/bin/screenshot
Executable file
45
tools/re-capture/bin/screenshot
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Grab the GAME's image, not the emulator window.
|
||||||
|
#
|
||||||
|
# Every pixel oracle in this toolkit (at_menu, at_title, wait_not_black, the
|
||||||
|
# HUD readers) was measured against a bare 1280x720 game image. xenia's window
|
||||||
|
# is a GTK window with a menu bar, so the game surface actually starts ~25 px
|
||||||
|
# down — and when it does, all of those constants read the wrong pixels. That
|
||||||
|
# is not a hypothetical: a run sat on a plainly visible MAIN MENU for 300 s
|
||||||
|
# reporting "no main menu", and a later one missed the title screen entirely and
|
||||||
|
# let the attract movie loop for ten minutes.
|
||||||
|
#
|
||||||
|
# The offset is not guessed: it is the xenia window's height minus 720. So this
|
||||||
|
# works whether or not the menu bar is there, and needs no per-display constant.
|
||||||
|
# Put this directory first on PATH and existing scripts keep working unchanged.
|
||||||
|
set -u
|
||||||
|
REAL=/usr/local/bin/screenshot
|
||||||
|
OUT="${1:-}"
|
||||||
|
if [ -z "$OUT" ]; then
|
||||||
|
dir="${HOME:-/tmp}/shots"; mkdir -p "$dir"
|
||||||
|
n_file="$dir/.counter"; n=$(( $(cat "$n_file" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$n_file"
|
||||||
|
OUT="$dir/shot-$(printf '%04d' "$n").png"
|
||||||
|
fi
|
||||||
|
|
||||||
|
RAW="$(mktemp /tmp/shot-raw-XXXXXX.png)"
|
||||||
|
trap 'rm -f "$RAW"' EXIT
|
||||||
|
"$REAL" "$RAW" >/dev/null || exit 1
|
||||||
|
|
||||||
|
# "…": ("xenia_canary" "Xenia_canary") 1280x745+0+0 +0+0
|
||||||
|
geo=$(xwininfo -root -children 2>/dev/null \
|
||||||
|
| grep '"xenia_canary"' | grep -oE '[0-9]+x[0-9]+\+-?[0-9]+\+-?[0-9]+' | head -1)
|
||||||
|
if [ -z "$geo" ]; then # no window found — hand back the raw grab
|
||||||
|
cp "$RAW" "$OUT"; echo "$OUT"; exit 0
|
||||||
|
fi
|
||||||
|
W=${geo%%x*}; rest=${geo#*x}; H=${rest%%+*}; rest=${rest#*+}; X=${rest%%+*}; Y=${rest#*+}
|
||||||
|
OFF=$(( H - 720 )); [ "$OFF" -lt 0 ] && OFF=0
|
||||||
|
TOP=$(( Y + OFF ))
|
||||||
|
[ "$TOP" -lt 0 ] && TOP=0
|
||||||
|
if [ "$OFF" -eq 0 ] && [ "$TOP" -eq 0 ] && [ "$X" -eq 0 ]; then
|
||||||
|
cp "$RAW" "$OUT"; echo "$OUT"; exit 0
|
||||||
|
fi
|
||||||
|
# Height is whatever is still on screen: a 1280x745 window on a 720-high root
|
||||||
|
# loses its last rows, and every oracle point in use sits well above them.
|
||||||
|
convert "$RAW" -crop "${W}x720+${X}+${TOP}" +repage "$OUT" 2>/dev/null \
|
||||||
|
|| cp "$RAW" "$OUT"
|
||||||
|
echo "$OUT"
|
||||||
@@ -1,133 +1,37 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Launch any STORY stage from MISSION SELECT and snapshot guest RAM in flight.
|
# Launch any STORY stage from MISSION SELECT and snapshot guest RAM in flight.
|
||||||
#
|
#
|
||||||
# Supersedes the save-editing route (patch GHAD +52 in every slot, boot, LOAD
|
# This is the LAUNCH half; the navigation lives in nav_to_flight.sh so a Canary
|
||||||
# GAME, TAKE OFF): poking ONE word marks every story stage cleared, and MISSION
|
# that is already up (a boot to the title costs minutes under lavapipe) can be
|
||||||
# SELECT will then launch any of them —
|
# driven without paying for a second one.
|
||||||
# 0x828F40C0 = 0x0001FFFE cleared-stage mask, bits 1..16
|
|
||||||
# See docs/re/challenge-mission-gate.md. Nothing is written to disc, so there is
|
|
||||||
# no save to back up and restore afterwards.
|
|
||||||
#
|
#
|
||||||
# Usage: fly_stage.sh <stage 1..16> [boot_timeout_s]
|
# Usage: fly_stage.sh <stage 1..16> [boot_timeout_s]
|
||||||
set -u
|
set -u
|
||||||
STAGE="${1:?usage: fly_stage.sh <stage 1..16>}"
|
STAGE="${1:?usage: fly_stage.sh <stage 1..16>}"
|
||||||
BOOT_TIMEOUT="${2:-400}"
|
BOOT_TIMEOUT="${2:-400}"
|
||||||
export HOME=/sylph-home/re DISPLAY=:99 SDL_AUDIODRIVER=dummy
|
# DISPLAY is overridable: the container entrypoint's own Xvfb owns :99's lock as
|
||||||
|
# root, and once that server dies an unprivileged relaunch cannot clear
|
||||||
|
# /tmp/.X99-lock — so a session may have to bring its display up elsewhere.
|
||||||
|
export HOME=/sylph-home/re DISPLAY="${DISPLAY:-:99}" SDL_AUDIODRIVER=dummy
|
||||||
export XENIA_PAD_FILE=/tmp/xenia_pad.txt
|
export XENIA_PAD_FILE=/tmp/xenia_pad.txt
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
pad() { python3 "$HERE/pad.py" "$@"; }
|
|
||||||
poke() { python3 "$HERE/gpoke.py" "$@"; }
|
|
||||||
SHOTS="$HOME/shots"; mkdir -p "$SHOTS"
|
|
||||||
say() { echo "[$(date +%H:%M:%S)] $*"; }
|
say() { echo "[$(date +%H:%M:%S)] $*"; }
|
||||||
shot() { screenshot "$SHOTS/fly$STAGE-$1.png" >/dev/null 2>&1; }
|
|
||||||
px() { convert /tmp/nav-probe.png -format \
|
|
||||||
"%[fx:int(255*p{$1}.r)] %[fx:int(255*p{$1}.g)] %[fx:int(255*p{$1}.b)]" info: 2>/dev/null; }
|
|
||||||
|
|
||||||
# A screen is identified by a PATTERN of points, never one pixel — a single
|
|
||||||
# bright-pixel test once matched a white loading flash and the run navigated a
|
|
||||||
# menu that was not on screen.
|
|
||||||
at_menu() {
|
|
||||||
screenshot /tmp/nav-probe.png >/dev/null 2>&1 || return 1
|
|
||||||
read -r r g b < <(px "648,221"); [ -n "${r:-}" ] || return 1
|
|
||||||
[ "$r" -gt 230 ] && [ "$g" -gt 230 ] && [ "$b" -gt 230 ] || return 1
|
|
||||||
read -r r2 g2 b2 < <(px "560,300"); [ -n "${r2:-}" ] || return 1
|
|
||||||
[ "$r2" -lt 120 ] && [ "$b2" -gt "$r2" ]
|
|
||||||
}
|
|
||||||
at_title() {
|
|
||||||
screenshot /tmp/nav-probe.png >/dev/null 2>&1 || return 1
|
|
||||||
read -r r g b < <(px "625,618"); [ -n "${g:-}" ] || return 1
|
|
||||||
[ "$g" -gt 130 ] && [ $((g - r)) -gt 45 ] && [ $((g - b)) -gt 45 ]
|
|
||||||
}
|
|
||||||
|
|
||||||
pkill -9 -x xenia_canary 2>/dev/null; sleep 1
|
pkill -9 -x xenia_canary 2>/dev/null; sleep 1
|
||||||
rm -f /dev/shm/xenia_* 2>/dev/null
|
rm -f /dev/shm/xenia_* 2>/dev/null
|
||||||
: > "$XENIA_PAD_FILE"
|
: > "$XENIA_PAD_FILE"
|
||||||
|
|
||||||
say "launching canary for stage $STAGE"
|
say "launching canary for stage $STAGE"
|
||||||
|
# CANARY_EXTRA lets a caller add cvars (e.g. --frame_probe_log=...) without
|
||||||
|
# forking this script; unquoted on purpose so it can carry several.
|
||||||
|
# shellcheck disable=SC2086
|
||||||
run-canary --audio --apu=sdl --log_mask=13 \
|
run-canary --audio --apu=sdl --log_mask=13 \
|
||||||
--logged_profile_slot_0_xuid=E0300000EFBEA3D4 \
|
--logged_profile_slot_0_xuid=E0300000EFBEA3D4 \
|
||||||
--hid=file --pad_file="$XENIA_PAD_FILE" &
|
--hid=file --pad_file="$XENIA_PAD_FILE" ${CANARY_EXTRA:-} &
|
||||||
# Xvfb keeps the previous instance's framebuffer until the new one draws.
|
# Xvfb keeps the previous instance's framebuffer until the new one draws.
|
||||||
xsetroot -solid black 2>/dev/null || true
|
xsetroot -solid black 2>/dev/null || true
|
||||||
|
# Nothing is on screen for the first ~40 s, so do not start polling into it.
|
||||||
|
sleep 40
|
||||||
|
|
||||||
say "waiting for the main menu"
|
exec "$HERE/nav_to_flight.sh" "$STAGE" "$BOOT_TIMEOUT" --snapshot
|
||||||
MENU=0
|
|
||||||
for i in $(seq 1 "$BOOT_TIMEOUT"); do
|
|
||||||
[ "$i" -lt 40 ] && { sleep 1; continue; }
|
|
||||||
if at_menu && sleep 1 && at_menu; then say "MAIN MENU after ${i}s"; MENU=1; break; fi
|
|
||||||
at_title && { say " title — tapping A"; pad tap A 0.25; sleep 2; }
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
[ "$MENU" = 1 ] || { say "TIMEOUT: no main menu"; shot 00-timeout; exit 1; }
|
|
||||||
|
|
||||||
say "poking the cleared-stage mask so every story stage is selectable"
|
|
||||||
poke w32 0x828F40C0 0x0001FFFE
|
|
||||||
|
|
||||||
# main menu -> EXTRAS -> MISSION SELECT
|
|
||||||
for _ in 1 2 3 4; do pad dpad down 0.06; sleep 0.35; done
|
|
||||||
sleep 0.5; pad tap A 0.15; sleep 3; shot 01-extras
|
|
||||||
pad tap A 0.15; sleep 4; shot 02-missionselect
|
|
||||||
|
|
||||||
# the list starts on Stage01
|
|
||||||
say "stepping to Stage$STAGE"
|
|
||||||
for _ in $(seq 2 "$STAGE"); do pad dpad down 0.06; sleep 0.30; done
|
|
||||||
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
|
|
||||||
# instantiated at stage load proper — so the run has to reach flight.
|
|
||||||
# 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
|
|
||||||
say "READY ROOM -> TAKE OFF (one up from the pre-highlighted BRIEFINGS)"
|
|
||||||
pad dpad up 0.06; sleep 1; shot 07-takeoff-hl
|
|
||||||
pad tap A 0.20; sleep 8
|
|
||||||
# TAKE OFF starts its own load, and it hangs black just as the stage load can --
|
|
||||||
# guarding only the first transition left a run pressing A into a black screen and
|
|
||||||
# then reporting "player entity not found" from a game that never reached flight.
|
|
||||||
say "waiting for the take-off load"
|
|
||||||
if ! wait_not_black 180; then
|
|
||||||
say "STILL BLACK after take-off — load hung, aborting"
|
|
||||||
shot 08-stuck-black
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
for _ in $(seq 1 6); do pad tap A 0.20; sleep 4; done
|
|
||||||
say "waiting for flight to settle"
|
|
||||||
sleep 25
|
|
||||||
shot 08-flight
|
|
||||||
SHM=$(ls /dev/shm/xenia_memory_* 2>/dev/null | head -1)
|
|
||||||
if [ -n "$SHM" ]; then
|
|
||||||
OUT="$HOME/snap-stage$STAGE.bin"
|
|
||||||
cp --sparse=always "$SHM" "$OUT" && say "snapshot -> $OUT ($(du -h "$OUT" | cut -f1) actual)"
|
|
||||||
fi
|
|
||||||
say "shots: $SHOTS/fly$STAGE-*.png"
|
|
||||||
|
|||||||
109
tools/re-capture/frame_burst.py
Executable file
109
tools/re-capture/frame_burst.py
Executable file
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Drive stick bursts while Canary samples the craft's transform ONCE PER FRAME.
|
||||||
|
|
||||||
|
Why this exists. Every earlier rate measurement polled guest RAM from the host
|
||||||
|
through /dev/shm. That read is unsynchronised with the guest: adjacent samples
|
||||||
|
are separated by an unknown number of guest updates, so short windows alias --
|
||||||
|
`ramp_probe.py` got 3x swings between neighbouring 0.25 s windows and could not
|
||||||
|
tell "the rate ramps up after the stick goes over" from "the sampler is lying".
|
||||||
|
See docs/re/flight-speed-law.md, which names a Canary-side hook as the tool the
|
||||||
|
residual needs.
|
||||||
|
|
||||||
|
That hook now exists (`--frame_probe_log`, sampled in VdSwap, one line per guest
|
||||||
|
frame). This script only has to point it at the player craft and drive the pad,
|
||||||
|
recording when each hold starts and ends in the SAME clock the probe stamps its
|
||||||
|
lines with, so the analysis can cut the log at the exact frame the stick moved.
|
||||||
|
|
||||||
|
Usage: frame_burst.py <events.csv> [hold_s] [repeats]
|
||||||
|
Env: XENIA_FRAME_PROBE control file (default /tmp/xenia_frame_probe.txt)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import gmem # noqa: E402
|
||||||
|
import speed_law # noqa: E402
|
||||||
|
from axis_probe import pad_state # noqa: E402
|
||||||
|
|
||||||
|
PROBE = os.environ.get("XENIA_FRAME_PROBE", "/tmp/xenia_frame_probe.txt")
|
||||||
|
|
||||||
|
# The transform block sits BELOW the position triple: three 16-byte-strided rows
|
||||||
|
# starting at pos-112, position at pos+0 (nav-live.json: rot_delta -112,
|
||||||
|
# rot_stride 16). One region covers both.
|
||||||
|
ROT_DELTA = -112
|
||||||
|
REGION_LEN = 128
|
||||||
|
|
||||||
|
# Full deflection on each axis, plus the throttle settings the burst design uses.
|
||||||
|
BURSTS = [
|
||||||
|
("roll_cruise", {"lx": 32767}),
|
||||||
|
("pitch_cruise", {"ly": 32767}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def write_regions(regions):
|
||||||
|
tmp = PROBE + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
f.write("# written by frame_burst.py\n")
|
||||||
|
for va, ln in regions:
|
||||||
|
f.write(f"{va:08x} {ln}\n")
|
||||||
|
os.replace(tmp, PROBE)
|
||||||
|
|
||||||
|
|
||||||
|
def pos_at(fd, off):
|
||||||
|
return struct.unpack(">3f", os.pread(fd, 12, off))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
out_csv = sys.argv[1]
|
||||||
|
hold = float(sys.argv[2]) if len(sys.argv) > 2 else 3.0
|
||||||
|
reps = int(sys.argv[3]) if len(sys.argv) > 3 else 2
|
||||||
|
|
||||||
|
w, off, nm = speed_law.find_player()
|
||||||
|
if not w:
|
||||||
|
sys.exit("player entity not found")
|
||||||
|
base_off = off + ROT_DELTA
|
||||||
|
va = gmem.primary_va(base_off)
|
||||||
|
if va is None:
|
||||||
|
sys.exit(f"file offset {base_off:#x} has no guest VA")
|
||||||
|
print(f"# locked on {nm}: transform block at VA {va:#010x} (+{REGION_LEN})")
|
||||||
|
write_regions([(va, REGION_LEN)])
|
||||||
|
|
||||||
|
# The probe only starts emitting once the emulator notices the control file,
|
||||||
|
# which is at most one frame. Give it a moment, then prove the craft is
|
||||||
|
# actually flying before spending the run on it.
|
||||||
|
time.sleep(1.0)
|
||||||
|
p0 = pos_at(w.fd, off)
|
||||||
|
time.sleep(1.0)
|
||||||
|
p1 = pos_at(w.fd, off)
|
||||||
|
moved = sum((p1[i] - p0[i]) ** 2 for i in range(3)) ** 0.5
|
||||||
|
if moved < 1.0:
|
||||||
|
sys.exit(f"craft is not moving ({moved:.3f}) — not in flight, or dead")
|
||||||
|
print(f"# craft is flying ({moved:.1f} units/s)")
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for rep in range(reps):
|
||||||
|
for label, state in BURSTS:
|
||||||
|
pad_state()
|
||||||
|
time.sleep(5.0)
|
||||||
|
t_pre = time.time()
|
||||||
|
pad_state(**state)
|
||||||
|
t_on = time.time()
|
||||||
|
time.sleep(hold)
|
||||||
|
pad_state()
|
||||||
|
t_off = time.time()
|
||||||
|
rows.append((rep, label, f"{t_pre:.6f}", f"{t_on:.6f}", f"{t_off:.6f}"))
|
||||||
|
print(f"# rep{rep} {label}: held {t_on:.3f} -> {t_off:.3f}", flush=True)
|
||||||
|
time.sleep(2.0)
|
||||||
|
pad_state()
|
||||||
|
|
||||||
|
with open(out_csv, "w") as f:
|
||||||
|
f.write("rep,label,t_pre,t_on,t_off\n")
|
||||||
|
for r in rows:
|
||||||
|
f.write(",".join(str(x) for x in r) + "\n")
|
||||||
|
print(f"# wrote {out_csv}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
59
tools/re-capture/frame_session.sh
Executable file
59
tools/re-capture/frame_session.sh
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One blocking foreground session: boot to flight, then measure angular rate with
|
||||||
|
# the per-frame probe instead of host polling.
|
||||||
|
#
|
||||||
|
# Everything (Xvfb, openbox, xenia) is a plain child of THIS script. Anything
|
||||||
|
# `setsid`'d is reaped at the agent's turn boundary — see the session-lifetime
|
||||||
|
# note in launch_mission.sh — so the whole run has to fit in one call.
|
||||||
|
#
|
||||||
|
# Usage: frame_session.sh [stage] [hold_s] [repeats]
|
||||||
|
set -u
|
||||||
|
STAGE="${1:-10}"; HOLD="${2:-3.0}"; REPS="${3:-2}"
|
||||||
|
export HOME=/sylph-home/re DISPLAY="${DISPLAY:-:99}" SDL_AUDIODRIVER=dummy
|
||||||
|
export XENIA_PAD_FILE=/tmp/xenia_pad.txt
|
||||||
|
export XENIA_FRAME_PROBE=/tmp/xenia_frame_probe.txt
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
OUT="$HOME/frameprobe"; mkdir -p "$OUT"
|
||||||
|
LOG="$OUT/frame-stage$STAGE.log"
|
||||||
|
say() { echo "[$(date +%H:%M:%S)] $*"; }
|
||||||
|
|
||||||
|
ensure_display() {
|
||||||
|
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
|
||||||
|
rm -f "/tmp/.X${DISPLAY#:}-lock" 2>/dev/null || true
|
||||||
|
nohup bash -c 'Xvfb "$0" -screen 0 1280x720x24 -ac -nolisten tcp \
|
||||||
|
+extension GLX +extension RANDR >/tmp/xvfb${DISPLAY#:}.log 2>&1
|
||||||
|
echo "$(date +%T) XVFB EXIT $? (128+N means signal N)" >>/tmp/xvfb-exit.log' \
|
||||||
|
"$DISPLAY" </dev/null >/dev/null 2>&1 &
|
||||||
|
for _ in $(seq 1 50); do xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break; sleep 0.2; done
|
||||||
|
nohup env DISPLAY="$DISPLAY" HOME=/sylph-home openbox </dev/null >/tmp/openbox${DISPLAY#:}.log 2>&1 &
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 || { say "DISPLAY UNAVAILABLE"; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_display
|
||||||
|
# A stale control file would make the probe log the PREVIOUS run's addresses from
|
||||||
|
# the first frame, which land in whatever occupies them now.
|
||||||
|
rm -f "$XENIA_FRAME_PROBE"
|
||||||
|
|
||||||
|
# REUSE=1 drives a Canary that is already up (it must have been launched with the
|
||||||
|
# same --frame_probe_log). Booting to the title costs minutes under lavapipe and
|
||||||
|
# a run that only failed to navigate should not pay for it again.
|
||||||
|
if [ "${REUSE:-0}" = 1 ] && pgrep -x xenia_canary >/dev/null; then
|
||||||
|
say "re-using the running emulator; navigating to stage $STAGE"
|
||||||
|
"$HERE/nav_to_flight.sh" "$STAGE" 300 || { say "navigation failed ($?)"; exit 2; }
|
||||||
|
else
|
||||||
|
rm -f "$LOG"
|
||||||
|
say "booting to flight on stage $STAGE with the per-frame probe armed"
|
||||||
|
CANARY_EXTRA="--frame_probe_log=$LOG --frame_probe=$XENIA_FRAME_PROBE" \
|
||||||
|
"$HERE/fly_stage.sh" "$STAGE" 300 || { say "boot failed ($?)"; exit 2; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "arming the probe on the player craft and driving the bursts"
|
||||||
|
python3 "$HERE/frame_burst.py" "$OUT/events-stage$STAGE.csv" "$HOLD" "$REPS"
|
||||||
|
rc=$?
|
||||||
|
# Stop sampling before the emulator is torn down, so the log ends cleanly.
|
||||||
|
rm -f "$XENIA_FRAME_PROBE"
|
||||||
|
say "frame log: $LOG ($(wc -l < "$LOG" 2>/dev/null || echo 0) lines)"
|
||||||
|
exit $rc
|
||||||
134
tools/re-capture/nav_to_flight.sh
Executable file
134
tools/re-capture/nav_to_flight.sh
Executable file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Drive an ALREADY-RUNNING Canary from the title/attract movie to flight on any
|
||||||
|
# story stage. Split out of fly_stage.sh so a live emulator can be re-used: a
|
||||||
|
# boot to the title costs minutes under lavapipe, and a run that only failed to
|
||||||
|
# navigate should not have to pay for it twice.
|
||||||
|
#
|
||||||
|
# Poking ONE word marks every story stage cleared, so MISSION SELECT will launch
|
||||||
|
# any of them — 0x828F40C0 = 0x0001FFFE (cleared-stage mask, bits 1..16); see
|
||||||
|
# docs/re/challenge-mission-gate.md. Nothing is written to disc.
|
||||||
|
#
|
||||||
|
# Usage: nav_to_flight.sh <stage 1..16> [menu_timeout_s] [--snapshot]
|
||||||
|
set -u
|
||||||
|
STAGE="${1:?usage: nav_to_flight.sh <stage 1..16>}"
|
||||||
|
MENU_TIMEOUT="${2:-400}"
|
||||||
|
SNAPSHOT="${3:-}"
|
||||||
|
# DISPLAY is overridable: the container entrypoint's own Xvfb owns :99's lock as
|
||||||
|
# root, and once that server dies an unprivileged relaunch cannot clear
|
||||||
|
# /tmp/.X99-lock — so a session may have to bring its display up elsewhere.
|
||||||
|
export HOME=/sylph-home/re DISPLAY="${DISPLAY:-:99}" SDL_AUDIODRIVER=dummy
|
||||||
|
export XENIA_PAD_FILE=/tmp/xenia_pad.txt
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# bin/screenshot crops the emulator's menu bar away so a saved shot looks like
|
||||||
|
# the bare game image; the oracles below no longer depend on that, but the shots
|
||||||
|
# kept for evidence are easier to compare against older ones.
|
||||||
|
export PATH="$HERE/bin:$PATH"
|
||||||
|
pad() { python3 "$HERE/pad.py" "$@"; }
|
||||||
|
poke() { python3 "$HERE/gpoke.py" "$@"; }
|
||||||
|
SHOTS="$HOME/shots"; mkdir -p "$SHOTS"
|
||||||
|
say() { echo "[$(date +%H:%M:%S)] $*"; }
|
||||||
|
shot() { screenshot "$SHOTS/fly$STAGE-$1.png" >/dev/null 2>&1; }
|
||||||
|
screen() { python3 "$HERE/screen_id.py" /tmp/nav-probe.png --json 2>/dev/null; }
|
||||||
|
|
||||||
|
# Screens are identified by WHOLE-IMAGE statistics (screen_id.py), never by named
|
||||||
|
# pixels. The pixel oracles this replaces were measured against a bare 1280x720
|
||||||
|
# game image, and xenia's window puts a menu bar above it on some displays -- so
|
||||||
|
# every constant read ~25 px too high, silently. That cost one run 300 s staring
|
||||||
|
# at a visible MAIN MENU and another ten minutes of attract movie.
|
||||||
|
# One grab per poll, classified once: a screenshot costs seconds while lavapipe
|
||||||
|
# has every core, so asking three separate questions per second does not work.
|
||||||
|
which_screen() {
|
||||||
|
screenshot /tmp/nav-probe.png >/dev/null 2>&1 || { echo none; return; }
|
||||||
|
screen | python3 -c 'import json,sys; print(json.load(sys.stdin)["screen"])' 2>/dev/null || echo none
|
||||||
|
}
|
||||||
|
|
||||||
|
pgrep -x xenia_canary >/dev/null || { say "no xenia_canary running"; exit 1; }
|
||||||
|
|
||||||
|
say "waiting for the main menu"
|
||||||
|
MENU=0
|
||||||
|
LAST=
|
||||||
|
for i in $(seq 1 "$MENU_TIMEOUT"); do
|
||||||
|
NOW=$(which_screen)
|
||||||
|
[ "$NOW" != "$LAST" ] && { say " screen: $NOW"; LAST=$NOW; }
|
||||||
|
# Two consecutive reads, because a movie frame can momentarily look like a menu.
|
||||||
|
if [ "$NOW" = menu ] && sleep 1 && [ "$(which_screen)" = menu ]; then
|
||||||
|
say "MAIN MENU after ${i}s"; MENU=1; break
|
||||||
|
fi
|
||||||
|
[ "$NOW" = title ] && { say " title — tapping A"; pad tap A 0.25; sleep 2; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[ "$MENU" = 1 ] || { say "TIMEOUT: no main menu"; shot 00-timeout; exit 1; }
|
||||||
|
|
||||||
|
say "poking the cleared-stage mask so every story stage is selectable"
|
||||||
|
poke w32 0x828F40C0 0x0001FFFE
|
||||||
|
|
||||||
|
# main menu -> EXTRAS -> MISSION SELECT
|
||||||
|
for _ in 1 2 3 4; do pad dpad down 0.06; sleep 0.35; done
|
||||||
|
sleep 0.5; pad tap A 0.15; sleep 3; shot 01-extras
|
||||||
|
pad tap A 0.15; sleep 4; shot 02-missionselect
|
||||||
|
|
||||||
|
# the list starts on Stage01
|
||||||
|
say "stepping to Stage$STAGE"
|
||||||
|
for _ in $(seq 2 "$STAGE"); do pad dpad down 0.06; sleep 0.30; done
|
||||||
|
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
|
||||||
|
if screenshot /tmp/nav-probe.png >/dev/null 2>&1; then
|
||||||
|
lit=$(screen | python3 -c 'import json,sys; d=json.load(sys.stdin); print(1 if d.get("r",0)+d.get("g",0)+d.get("b",0) > 20 else 0)' 2>/dev/null)
|
||||||
|
[ "$lit" = 1 ] && 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
|
||||||
|
# instantiated at stage load proper — so the run has to reach flight.
|
||||||
|
# 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
|
||||||
|
say "READY ROOM -> TAKE OFF (one up from the pre-highlighted BRIEFINGS)"
|
||||||
|
pad dpad up 0.06; sleep 1; shot 07-takeoff-hl
|
||||||
|
pad tap A 0.20; sleep 8
|
||||||
|
# TAKE OFF starts its own load, and it hangs black just as the stage load can --
|
||||||
|
# guarding only the first transition left a run pressing A into a black screen and
|
||||||
|
# then reporting "player entity not found" from a game that never reached flight.
|
||||||
|
say "waiting for the take-off load"
|
||||||
|
if ! wait_not_black 180; then
|
||||||
|
say "STILL BLACK after take-off — load hung, aborting"
|
||||||
|
shot 08-stuck-black
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
for _ in $(seq 1 6); do pad tap A 0.20; sleep 4; done
|
||||||
|
say "waiting for flight to settle"
|
||||||
|
sleep 25
|
||||||
|
shot 08-flight
|
||||||
|
if [ "$SNAPSHOT" = "--snapshot" ]; then
|
||||||
|
SHM=$(ls /dev/shm/xenia_memory_* 2>/dev/null | head -1)
|
||||||
|
if [ -n "$SHM" ]; then
|
||||||
|
OUT="$HOME/snap-stage$STAGE.bin"
|
||||||
|
cp --sparse=always "$SHM" "$OUT" && say "snapshot -> $OUT ($(du -h "$OUT" | cut -f1) actual)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
say "shots: $SHOTS/fly$STAGE-*.png"
|
||||||
63
tools/re-capture/rebuild_canary.sh
Executable file
63
tools/re-capture/rebuild_canary.sh
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Surgical rebuild of xenia_canary inside the sylph box.
|
||||||
|
#
|
||||||
|
# The image has no cmake/ninja/clang of its own and the build/ cache cannot be
|
||||||
|
# re-configured (see project_sylph_canary_patch_toolchain memory), so a full
|
||||||
|
# `ninja` is impossible: several TUs need dev headers this image lacks. What DOES
|
||||||
|
# work is compiling only the objects that changed, refreshing their archive, and
|
||||||
|
# re-running the link command lifted out of the generated ninja file.
|
||||||
|
#
|
||||||
|
# Usage: rebuild.sh <ninja object path> [more objects...]
|
||||||
|
# e.g. rebuild.sh src/xenia/kernel/CMakeFiles/xenia-kernel.dir/Release/xboxkrnl/xboxkrnl_video.cc.o
|
||||||
|
# Objects are paths relative to build/. The archive each belongs to is derived
|
||||||
|
# from the .dir name (xenia-kernel.dir -> obj/Linux/Release/libxenia-kernel.a).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PROJ="${PROJECT_DIR:-/home/fabi/RE - Project Sylpheed}"
|
||||||
|
BUILD="$PROJ/xenia-canary-native/build"
|
||||||
|
PFX=/sylph-home/re/toolchain
|
||||||
|
export PATH="$PFX/usr/lib/llvm-18/bin:/sylph-home/.local/bin:$PATH"
|
||||||
|
export LD_LIBRARY_PATH="$PFX/usr/lib/x86_64-linux-gnu:$PFX/usr/lib/llvm-18/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||||
|
|
||||||
|
[ $# -ge 1 ] || { echo "usage: rebuild.sh <object.o> [...]" >&2; exit 2; }
|
||||||
|
cd "$BUILD"
|
||||||
|
|
||||||
|
echo "[rebuild] compiling $# object(s)"
|
||||||
|
ninja -f build-Release.ninja "$@"
|
||||||
|
|
||||||
|
declare -A LIBS=()
|
||||||
|
for o in "$@"; do
|
||||||
|
d="${o#*CMakeFiles/}"; d="${d%%.dir/*}"
|
||||||
|
lib="obj/Linux/Release/lib$d.a"
|
||||||
|
[ -f "$lib" ] || { echo "[rebuild] no archive $lib for $o" >&2; exit 3; }
|
||||||
|
ar r "$lib" "$o"
|
||||||
|
LIBS["$lib"]=1
|
||||||
|
done
|
||||||
|
for lib in "${!LIBS[@]}"; do ranlib "$lib"; echo "[rebuild] refreshed $lib"; done
|
||||||
|
|
||||||
|
# Lift the link command out of the generated ninja rather than hard-coding it, so
|
||||||
|
# it stays correct if the object or library list ever changes.
|
||||||
|
python3 - <<'PY' > /tmp/xenia_link.sh
|
||||||
|
import re, shlex
|
||||||
|
NJ = "CMakeFiles/impl-Release.ninja"
|
||||||
|
txt = open(NJ).read()
|
||||||
|
m = re.search(r"^build bin/Linux/Release/xenia_canary: (\S+) (.*?)(?=\n\S|\n\n)",
|
||||||
|
txt, re.M | re.S)
|
||||||
|
assert m, "link build statement not found"
|
||||||
|
head = m.group(2)
|
||||||
|
# $in = the explicit inputs, up to the first | (implicit deps)
|
||||||
|
ins = head.split("\n")[0].split(" | ")[0].split(" || ")[0].strip()
|
||||||
|
body = m.group(0)
|
||||||
|
var = dict(re.findall(r"^ (\w+) = (.*)$", body, re.M))
|
||||||
|
cmd = (f'{shlex.quote("/sylph-home/re/toolchain/usr/lib/llvm-18/bin/clang++")} '
|
||||||
|
f'{var["FLAGS"]} {var["LINK_FLAGS"]} {ins} -o {var["TARGET_FILE"]} '
|
||||||
|
f'{var.get("LINK_PATH","")} {var["LINK_LIBRARIES"]}')
|
||||||
|
print(cmd)
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "[rebuild] linking bin/Linux/Release/xenia_canary"
|
||||||
|
# The image ships runtime sonames only (libgtk-3.so.0, not libgtk-3.so) and no
|
||||||
|
# libstdc++fs.a; linkshim/ supplies both as symlinks + an empty archive.
|
||||||
|
LIBRARY_PATH="$PFX/linkshim${LIBRARY_PATH:+:$LIBRARY_PATH}" bash /tmp/xenia_link.sh
|
||||||
|
ls -la bin/Linux/Release/xenia_canary
|
||||||
|
echo "[rebuild] done"
|
||||||
88
tools/re-capture/screen_id.py
Executable file
88
tools/re-capture/screen_id.py
Executable file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Identify which game screen a screenshot shows, without fixed pixel positions.
|
||||||
|
|
||||||
|
The nav scripts used to test named pixels ("648,221 is white"). That works only
|
||||||
|
while the game image sits at a known place on the root window, and it does not:
|
||||||
|
xenia's GTK window has a menu bar, so on some displays the image is ~25 px lower
|
||||||
|
and every constant reads the wrong row. The failure is silent and expensive — a
|
||||||
|
run sat on a plainly visible MAIN MENU for 300 s reporting "no main menu", and a
|
||||||
|
later one missed the title screen so the attract movie looped for ten minutes.
|
||||||
|
|
||||||
|
So identify screens by WHOLE-IMAGE statistics instead, which no vertical shift
|
||||||
|
(or scale, or letterbox) can move:
|
||||||
|
|
||||||
|
green fraction of pixels that are the game's green UI text/HUD colour
|
||||||
|
white fraction of near-white pixels
|
||||||
|
mean per-channel mean
|
||||||
|
|
||||||
|
Measured on known-good captures of each screen (1280x720, no menu bar):
|
||||||
|
|
||||||
|
title green 0.11% white 7.4% mean (62, 75, 84) blue-ish, bright
|
||||||
|
main menu green 0.04% white 3.5% mean (25, 38, 70) dark, strongly blue
|
||||||
|
in flight green 1.2% white 3.4% mean (54, 39, 37) green HUD everywhere
|
||||||
|
movie green 0% white varies no green at all
|
||||||
|
|
||||||
|
Usage: screen_id.py <png> [--json]
|
||||||
|
Prints one of: title | menu | flight | other, plus the features.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Downscaled first: the features are area fractions, so 320x180 gives the same
|
||||||
|
# answer for a fraction of the work (a full-size pure-python pass costs seconds,
|
||||||
|
# and these oracles are polled once a second).
|
||||||
|
W, H = 320, 180
|
||||||
|
|
||||||
|
|
||||||
|
def features(path):
|
||||||
|
raw = subprocess.run(
|
||||||
|
["convert", path, "-alpha", "off", "-resize", f"{W}x{H}!", "-depth", "8",
|
||||||
|
"rgb:-"], capture_output=True).stdout
|
||||||
|
n = len(raw) // 3
|
||||||
|
if not n:
|
||||||
|
return None
|
||||||
|
green = white = 0
|
||||||
|
sr = sg = sb = 0
|
||||||
|
for i in range(0, n * 3, 3):
|
||||||
|
r, g, b = raw[i], raw[i + 1], raw[i + 2]
|
||||||
|
sr += r; sg += g; sb += b
|
||||||
|
if g > 130 and g - r > 45 and g - b > 45:
|
||||||
|
green += 1
|
||||||
|
if r > 230 and g > 230 and b > 230:
|
||||||
|
white += 1
|
||||||
|
return {"green": green / n, "white": white / n,
|
||||||
|
"r": sr / n, "g": sg / n, "b": sb / n}
|
||||||
|
|
||||||
|
|
||||||
|
def classify(f):
|
||||||
|
if f is None:
|
||||||
|
return "none"
|
||||||
|
# Flight first: the HUD paints far more green than any menu.
|
||||||
|
if f["green"] > 0.004:
|
||||||
|
return "flight"
|
||||||
|
# The main menu is dark and strongly blue, with the five white labels.
|
||||||
|
if f["b"] - f["r"] > 30 and f["r"] < 45 and 0.015 < f["white"] < 0.075:
|
||||||
|
return "menu"
|
||||||
|
# The title is brighter, still blue-ish, and carries the green PRESS A text.
|
||||||
|
if f["green"] > 0.0004 and f["b"] > 60 and f["white"] > 0.03:
|
||||||
|
return "title"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
path = sys.argv[1]
|
||||||
|
f = features(path)
|
||||||
|
name = classify(f)
|
||||||
|
if "--json" in sys.argv:
|
||||||
|
print(json.dumps({"screen": name, **(f or {})}))
|
||||||
|
elif f:
|
||||||
|
print(f"{name} green={f['green']:.4f} white={f['white']:.4f} "
|
||||||
|
f"mean=({f['r']:.1f},{f['g']:.1f},{f['b']:.1f})")
|
||||||
|
else:
|
||||||
|
print(name)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user