diff --git a/tools/re-capture/bin/screenshot b/tools/re-capture/bin/screenshot new file mode 100755 index 0000000..c1eeb1b --- /dev/null +++ b/tools/re-capture/bin/screenshot @@ -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" diff --git a/tools/re-capture/fly_stage.sh b/tools/re-capture/fly_stage.sh index 925dd7f..73ca7de 100755 --- a/tools/re-capture/fly_stage.sh +++ b/tools/re-capture/fly_stage.sh @@ -1,133 +1,37 @@ #!/usr/bin/env bash # 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 -# GAME, TAKE OFF): poking ONE word marks every story stage cleared, and MISSION -# SELECT will then launch any of them — -# 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. +# This is the LAUNCH half; the navigation lives in nav_to_flight.sh so a Canary +# that is already up (a boot to the title costs minutes under lavapipe) can be +# driven without paying for a second one. # # Usage: fly_stage.sh [boot_timeout_s] set -u STAGE="${1:?usage: fly_stage.sh }" 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 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)] $*"; } -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 rm -f /dev/shm/xenia_* 2>/dev/null : > "$XENIA_PAD_FILE" 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 \ --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. 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" -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" +exec "$HERE/nav_to_flight.sh" "$STAGE" "$BOOT_TIMEOUT" --snapshot diff --git a/tools/re-capture/frame_burst.py b/tools/re-capture/frame_burst.py new file mode 100755 index 0000000..246c891 --- /dev/null +++ b/tools/re-capture/frame_burst.py @@ -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 [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()) diff --git a/tools/re-capture/frame_session.sh b/tools/re-capture/frame_session.sh new file mode 100755 index 0000000..c579360 --- /dev/null +++ b/tools/re-capture/frame_session.sh @@ -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 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 /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 diff --git a/tools/re-capture/nav_to_flight.sh b/tools/re-capture/nav_to_flight.sh new file mode 100755 index 0000000..e9216c2 --- /dev/null +++ b/tools/re-capture/nav_to_flight.sh @@ -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 [menu_timeout_s] [--snapshot] +set -u +STAGE="${1:?usage: nav_to_flight.sh }" +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" diff --git a/tools/re-capture/rebuild_canary.sh b/tools/re-capture/rebuild_canary.sh new file mode 100755 index 0000000..aa417dc --- /dev/null +++ b/tools/re-capture/rebuild_canary.sh @@ -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 [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 [...]" >&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" diff --git a/tools/re-capture/screen_id.py b/tools/re-capture/screen_id.py new file mode 100755 index 0000000..8d2c7c0 --- /dev/null +++ b/tools/re-capture/screen_id.py @@ -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 [--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())