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
|
||||
# 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 <stage 1..16> [boot_timeout_s]
|
||||
set -u
|
||||
STAGE="${1:?usage: fly_stage.sh <stage 1..16>}"
|
||||
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
|
||||
|
||||
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