#!/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"