#!/usr/bin/env bash # Probe the challenge-mission gate on the running game. # # The gate (docs/re/challenge-mission-gate.md): GamePart_ChallengeMission tests a # CLEARED-STAGE bitmask on a static singleton at guest 0x828F4070 — # word A 0x828F40C0 bit = stage id, for ids < 24 (story 1-16, tutorial 18-23) # word B 0x828F4814 bit = stage id - 24 (challenge 24-29) # so setting every bit should make all six challenge missions available without # playing the campaign. # # POKE=1 (default) sets both words; POKE=0 runs the identical navigation without # touching them. Run it BOTH ways: the first poked run hit a Xenia heap failure on # the way into MISSION SELECT, and only the control says whether that failure has # anything to do with the poke. # # Runs as ONE blocking foreground call on purpose: setsid'd processes are reaped # at turn boundaries, so a session split across calls loses its emulator. # # Usage: [POKE=0|1] [TAG=name] challenge_probe.sh [boot_timeout_s] set -u export HOME=/sylph-home/re export DISPLAY=:99 export 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" BOOT_TIMEOUT="${1:-400}" POKE="${POKE:-1}" POKE_A="${POKE_A:-0x0001FFFE}" # stages 1-16 cleared POKE_B="${POKE_B:-0x0000003F}" # challenge stages 24-29 cleared TAG="${TAG:-$([ "$POKE" = 1 ] && echo poked || echo control)}" mkdir -p "$SHOTS" say() { echo "[$(date +%H:%M:%S)] $*"; } shot() { screenshot "$SHOTS/chal-$TAG-$1.png" >/dev/null 2>&1; } # --- clean slate ------------------------------------------------------------- pkill -9 -x xenia_canary 2>/dev/null sleep 1 rm -f /dev/shm/xenia_* 2>/dev/null : > "$XENIA_PAD_FILE" LOG="$HOME/canary.stdout" # --- launch ------------------------------------------------------------------ say "launching canary (lavapipe, file pad) — POKE=$POKE tag=$TAG" run-canary --audio --apu=sdl --log_mask=13 \ --logged_profile_slot_0_xuid=E0300000EFBEA3D4 \ --hid=file --pad_file="$XENIA_PAD_FILE" & # Xvfb keeps the LAST instance's framebuffer until the new one draws, so a # screenshot taken seconds after launch shows the PREVIOUS run's screen. That is # how a control run once reported "MAIN MENU reached after 1s" against a menu # belonging to a process that no longer existed. Blank the root, and refuse to # believe any screen oracle until the emulator has had time to draw its own. xsetroot -solid black 2>/dev/null || true LAUNCH_GRACE=40 # --- reach the MAIN MENU, verifying instead of pressing blind ---------------- # Two oracles, both sampled from real screenshots: # title screen : green "PRESS (A) BUTTON" glyph at (625,618) # main menu : the "NEW GAME" text at (648,221) is pure white (254,254,254), # where the title has the yellow planet (208,189,88) # The first control run pressed A while the ATTRACT MOVIE happened to show a # greenish pixel at the title-glyph spot, then navigated a menu that was never # open and reported "0 failures" for a screen it never reached. Verify the menu. 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 sampled points, never by one pixel. A # single "is (648,221) white?" test matched a white loading flash, and the run # then navigated a menu that was not on screen -- the same class of mistake as # trusting the stale framebuffer. Require the menu's contrast: white "NEW GAME" # text AND the dark blue panel behind it. at_menu() { screenshot /tmp/nav-probe.png >/dev/null 2>&1 || return 1 read -r r g b < <(px "648,221") # NEW GAME text: white [ -n "${r:-}" ] || return 1 [ "$r" -gt 230 ] && [ "$g" -gt 230 ] && [ "$b" -gt 230 ] || return 1 read -r r2 g2 b2 < <(px "560,300") # panel left of LOAD GAME: dark blue [ -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:-}" ] && [ "$g" -gt 130 ] && [ $((g - r)) -gt 45 ] && [ $((g - b)) -gt 45 ] } say "waiting for the main menu (up to ${BOOT_TIMEOUT}s)" MENU=0 for i in $(seq 1 "$BOOT_TIMEOUT"); do if [ "$i" -lt "$LAUNCH_GRACE" ]; then sleep 1; continue; fi if at_menu && sleep 1 && at_menu; then say "MAIN MENU reached after ${i}s"; MENU=1; break; fi if at_title; then say " title visible — tapping A"; pad tap A 0.25; sleep 2; fi sleep 1 done [ "$MENU" = 1 ] || { say "TIMEOUT: never reached the main menu"; shot 00-timeout; exit 1; } shot 01-mainmenu say "gate words:" poke r32 0x828F40C0 1 poke r32 0x828F4814 1 if [ "$POKE" = 1 ]; then # Default to REAL stage ids only. 0xFFFFFFFF claims stages that do not exist # (0, 17, and 24-31 in word A), and that run blew the guest heap: # MmAllocatePhysicalMemoryEx could not satisfy 128 MB and the guest threw. # Word A bits 1..16 = the story campaign; 18..23 would be the tutorials. say "poking word A = $POKE_A, word B = $POKE_B" poke w32 0x828F40C0 "$POKE_A" poke w32 0x828F4814 "$POKE_B" else say "control run — leaving the words untouched" fi # --- main menu -> EXTRAS ----------------------------------------------------- # NEW GAME / LOAD GAME / TUTORIAL / OPTIONS / EXTRAS 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 02-extras # --- EXTRAS -> MISSION SELECT ------------------------------------------------ ALLOC_BEFORE=$(grep -c 'MmAllocatePhysicalMemoryEx: Allocation failed' "$LOG" 2>/dev/null | head -1) pad tap A 0.15 sleep 4 shot 03-missionselect ALLOC_AFTER=$(grep -c 'MmAllocatePhysicalMemoryEx: Allocation failed' "$LOG" 2>/dev/null | head -1) say "MmAllocatePhysicalMemoryEx failures: before=$ALLOC_BEFORE after=$ALLOC_AFTER" say "guest C++ exceptions: $(grep -c 'Guest attempted to throw a C++ exception' "$LOG" 2>/dev/null | head -1)" say "shots: $SHOTS/chal-$TAG-*.png" say "done — emulator left running"