#!/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 [--json] Prints one of: title | menu | briefing | readyroom | 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 frame_size(path): """(w, h) of the capture, or None if it cannot be read.""" out = subprocess.run(["identify", "-format", "%w %h", path], capture_output=True, text=True).stdout.split() try: return int(out[0]), int(out[1]) except (IndexError, ValueError): return None # A capture that is not a plausible game frame must be REJECTED, not classified. # MEASURED 2026-08-26: a guard shot came back 10x710 -- a sliver -- and every # statistic below is an area fraction, so the sliver classified cleanly as # `menu`. The guard passed, the fixed key sequence was issued anyway, and the run # loaded a TUTORIAL instead of the save's Stage 02. # # This is the SECOND time this has been paid for. `bin/screenshot`'s own header # records the first (2026-08-18): a second window of class "xenia_canary" meant # grabs came back as slivers and "a whole session's screen ids were noise". That # fix hardened the CAPTURE side only, so the same failure still reaches the # oracles by any other path. Reject it here too, where the answer is consumed. MIN_W, MIN_H = 640, 360 def features(path): size = frame_size(path) if size is None or size[0] < MIN_W or size[1] < MIN_H: return None 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" # unreadable OR not a plausible frame -- never a screen # Flight first: the HUD paints far more green than any menu. # (READY ROOM is checked before the menu rule because it is also dark-ish # and strongly blue, and would otherwise be swallowed by it.) if f["green"] > 0.004: return "flight" # The READY ROOM is far the brightest blue of any 2D screen: measured # (12.7, 36.5, 133.5) on two independent captures, byte-identical because # the screen is static. The next-bluest thing on the load path is the save # list at (16.7, 41.7, 81.0), and the mission briefing is cyan rather than # blue at (4.6, 47.7, 54.5), so a floor of b > 110 separates all three with # room to spare. Worth having as its own class: `launch_mission.sh` used a # fixed `sleep 28` for this transition and its next press was swallowed, # which put one run in OPTIONS and the next in BRIEFINGS. if f["b"] > 110 and f["r"] < 40 and f["b"] - f["g"] > 60: return "readyroom" # The mission BRIEFING map is cyan, not blue, and without this rule it falls # straight through to the menu test below (dark, b - r > 30, r < 45, little # white -- all true of it). MEASURED 2026-08-26: that mislabel made # `wait_screen.sh readyroom` report NEVER REACHED READY ROOM on a run that # had in fact navigated LOAD GAME -> slot 01 -> YES and was sitting on the # briefing three screens further on, which is the worst kind of failure -- # a successful route scored as a failed one. # # What separates them is that cyan has blue and green nearly equal, while # the menu's blue runs far ahead of its green: # # briefing (1.4, 45.2, 49.6) (5.5, 49.1, 54.3) (4.6, 47.7, 54.5) b-g ~ 5 # main menu (25, 38, 70) (13, 26, 59) b-g ~ 32 # # The r < 20 floor keeps the title screen (52.8, 66.1, 74.5, b-g 8.4) out; # the ready room (b-g ~ 97) and flight (green rule) are already claimed above. if f["r"] < 20 and f["g"] > 30 and f["b"] - f["g"] < 20: return "briefing" # The main menu is dark and strongly blue. TWO measured signatures, not one: # # white 3.5% mean (25, 38, 70) the 2026-07 reference at the top # white 0.03% mean (13, 26, 59) measured 2026-08-18, twice, on the menu # reached from the boot title # # The second one classified as "other" under the original rule's # `0.015 < white` floor, which is worse than it sounds: a script that waits # for "menu" then never sees it reports the navigation as FAILED while the # menu is plainly on screen. Both variants are dark, strongly blue and # essentially green-free, so key on that and let the near-white fraction be # anything below the title's 3.8%. if f["b"] - f["r"] > 30 and f["r"] < 45 and 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())