#!/usr/bin/env bash # Boot Canary and drive it into the Stage 02 mission from save slot 01, then # LEAVE IT RUNNING (unlike grab_tutorial.sh, which snapshots and exits) so a # live control loop can attach to /dev/shm. # # Nav is the verified route: title -> LOAD GAME -> slot 01 -> YES -> READY ROOM # -> TAKE OFF. With `--hangar` it stops in the HANGAR instead so a loadout can # be picked first. set -u MODE="${1:-fly}" export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98 SD="$(cd "$(dirname "$0")" && pwd)" SHOTS=/sylph-home/re/shots alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $1}'; } # DO NOT `setsid` THE DISPLAY OR THE EMULATOR. They used to be detached into # their own sessions so they would outlive the shell that started them. What # that actually bought was the opposite: a process nothing owns is a process # nothing keeps alive, and both were being reaped a couple of minutes in — the # long-standing "Xvfb and the emulator die on their own every few minutes" note. # MEASURED WRONG 2026-08-10: a harness-tracked BACKGROUND task does not protect # them either — the display was lost 11 s in, at the turn boundary. Run this # whole script as ONE BLOCKING FOREGROUND call and leave Xvfb, openbox # and xenia as its children: they then live exactly as long as the session does. # `nohup` still shields them from a stray HUP; the exit-status wrapper means a # death is reported with the server's own account instead of being inferred. 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/xvfb98.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/openbox98.log 2>&1 & sleep 1 fi xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 || { echo "DISPLAY UNAVAILABLE"; exit 1; } } # pad.py, NOT `vgamepad`: that command no longer exists (the uinput pad was # replaced by the --hid=file driver) and every call here failed silently, so the # whole scripted route pressed nothing. See canary-scripted-input-traps.md. step(){ python3 "$SD/pad.py" dpad "$1" 0.08; sleep 0.9; } tap(){ python3 "$SD/pad.py" tap "$1" 0.3 || { echo "PAD PRESS FAILED"; exit 6; }; } shot(){ screenshot "$SHOTS/$1" >/dev/null 2>&1; } pkill -x xenia_canary 2>/dev/null; sleep 2 [ -n "$(alive)" ] && { kill -9 $(alive) 2>/dev/null; sleep 2; } rm -f /dev/shm/xenia_memory_* /dev/shm/xenia_code_cache_* 2>/dev/null ensure_display cd /sylph-home/re # Sign in a profile that EXISTS. A XUID with nothing behind it opens a sign-in # dialog and IsUIActive() then swallows every keystroke for the whole run — 8.4 # million of them, measured. And KEEP the log: /dev/null is what hid that. XUID="${SYLPH_XUID:-$(ls "${XENIA_CONTENT:-$HOME/.local/share/Xenia/content}" 2>/dev/null | head -1)}" [ -n "$XUID" ] || { echo "NO PROFILE on disc"; exit 2; } LOG="${LAUNCH_MISSION_LOG:-$SHOTS/launch_mission-canary.stdout}" echo "signing in profile $XUID; emulator log: $LOG" # EXTRA_FLAGS is for one-off diagnostics that should not become defaults -- # e.g. --log_high_frequency_kernel_calls=true, which is the only way to see # KeWaitForSingleObject at all (it is declared kHighFrequency and is therefore # silent even at LOG_LEVEL=3). # shellcheck disable=SC2086 nohup run-canary --apu=sdl --log_mask="${LOG_MASK:-13}" --log_level="${LOG_LEVEL:-2}" \ --logged_profile_slot_0_xuid="$XUID" ${EXTRA_FLAGS:-} "$LOG" 2>&1 & sleep 5 # The boot budget is a knob because one diagnostic blows straight through the # default: --log_high_frequency_kernel_calls=true writes ~157 MB in ten minutes # and the title had still not appeared, so skip_intro timed out and the run was # scored a BOOT FAILED when it was only slow. "$SD/skip_intro.sh" "${SKIP_INTRO_TIMEOUT:-600}" \ || { echo "BOOT FAILED (skip_intro exit $?)"; exit 1; } sleep 14 # main menu is not input-ready before this step down # NEW GAME -> LOAD GAME tap A; sleep 8 # save list, slot 01 preselected # Open "Load game?" and answer YES, VERIFYING the dialog is actually up first. # A fixed sleep here desynchronised the whole route: if the dialog had not # opened yet, `step up` moved the SAVE CURSOR instead of selecting YES, and the # blind `--tap A` below then oscillated the dialog for 300 s. See dialog_up.py. opened=0 for _ in 1 2 3; do tap A; sleep 4 shot "lm-loaddialog.png" if python3 "$SD/dialog_up.py" "$SHOTS/lm-loaddialog.png" >/dev/null 2>&1; then step up # cursor starts on NO tap A; opened=1; break fi echo "--- load dialog not up yet, retrying" done [ $opened -eq 1 ] || { echo "LOAD DIALOG NEVER OPENED"; exit 5; } # NOT a fixed sleep. LOAD -> READY ROOM took longer than 28 s in both runs on # 2026-08-23, so the next press was eaten by the transition and the run ended up # in OPTIONS (once) and BRIEFINGS (once); and a freshly restored profile inserts # an extra "Auto-Save is active. OK?" dialog here, which the `--tap A` clears # without the script having to know whether it is there. # KNOWN LIMIT, stated rather than hidden: those blind A taps are safe only # because everything before this point still lands. If the save-list presses # ever drift and the run is still sitting on the MAIN MENU here, a blind A would # start a NEW GAME. The real fix is to wait for each screen on the route rather # than only for this one; only this transition has actually been measured to # overrun, so only this one is waited for. "$SD/wait_screen.sh" readyroom 300 --tap-if-dialog A \ || { echo "NEVER REACHED READY ROOM"; exit 1; } # ...and the READY ROOM is DRAWN before it is usable: it comes up with a # "Preparing to Sortie" spinner and TAKE OFF greyed out, for tens of seconds. # Pressing during that does nothing, and the press after it then lands on the # wrong item. The two states are the same image to within 1.7 units of blue, so # they need the label test rather than screen_id.py. armed=0 for _ in $(seq 1 60); do shot "lm-readyroom.png" if python3 "$SD/take_off_armed.py" "$SHOTS/lm-readyroom.png" >/dev/null; then armed=1; break fi sleep 3 done [ $armed -eq 1 ] || { echo "TAKE OFF NEVER ARMED"; exit 1; } if [ "$MODE" = "--hangar" ]; then step down; step down # BRIEFINGS -> HANGAR tap A; sleep 12 shot "lm-hangar.png" echo "STOPPED IN HANGAR"; exit 0 fi step up # BRIEFINGS -> TAKE OFF tap A # The launch cinematic + stage load + objective card is NOT a fixed 75 s under # lavapipe; wait for the flight HUD itself. "$SD/wait_flight.sh" 300 || { echo "NEVER REACHED FLIGHT"; exit 1; } shot "lm-flight.png" echo "IN FLIGHT (emulator left running)"