Compare commits
1 Commits
auto/re-ch
...
auto/re-fr
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c9380ff13 |
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