Files
Syplheed-Reborn/tools/re-capture/screen_id.py
Fabian Hamm 6c9380ff13 re(flight): a per-frame sampler, and nav oracles that a menu bar cannot break
WIP toward the residual flight-speed-law question (does a 1 s burst reach the
steady angular rate, or is there a per-axis multiplier?). The write-up already
concluded that host-side polling cannot answer it and named a Canary-side hook
as the tool required; that hook now exists (--frame_probe_log, committed as
auto/re-frame-probe in xenia-canary-native) and this is the harness for it.

- `rebuild_canary.sh` -- the surgical rebuild the box can actually do, kept in
  the repo this time instead of in /tmp: compile only the changed objects, `ar`
  them into their archive, and re-run the link command lifted out of the
  generated ninja. A full `ninja` is impossible here (several TUs need dev
  headers the image lacks) and the build cache cannot be re-configured. 31 s.
- `frame_burst.py` -- points the probe at the player craft's transform block
  (pos-112, the three 16-byte-strided rows plus the position) and drives full
  stick holds, recording each hold's start and end in the same clock the probe
  stamps its lines with.
- `frame_session.sh` -- the whole run as ONE blocking foreground call, per the
  session-lifetime rule; REUSE=1 drives a Canary that is already up.
- `nav_to_flight.sh` -- fly_stage.sh's navigation, split out 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 pay for it twice.

The navigation change is the one worth reading. Every screen oracle here tested
named pixels ("648,221 is white"), which is only valid 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 this display the image is ~25 px lower and every constant reads
the wrong row. Nothing errors. One run sat 300 s in front of a plainly visible
MAIN MENU reporting "no main menu"; the next missed the title screen entirely
and let the attract movie loop for ten minutes.

So `screen_id.py` identifies screens by WHOLE-IMAGE statistics instead -- the
fraction of green UI-text pixels, the fraction of near-white pixels, and the
per-channel means -- which no vertical shift, scale or letterbox can move. It is
calibrated against known-good captures and classifies all of them correctly:
title, three different menu screens, in-flight, and four movie frames as
"other". `bin/screenshot` additionally crops the menu bar off saved evidence
shots, deriving the offset from the window's own height rather than a constant.

Not yet a finding: the run has not reached flight, so no rate has been measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 06:03:44 +00:00

89 lines
3.1 KiB
Python
Executable File

#!/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())