re: in-flight HUD — independent confirmation of the ammo mapping

The tutorials need no save and are ~1 min from a cold boot, so they are the
cheapest route into a live flight scene. The HUD prints the equipped weapons as
NOSE BM 06000 / MAIN MPM 00300 — matching wep_01's LoadingCount 6000 and wep_02's
300, with the weapons identified from the HANGAR loadout rather than from the
ammo number. That makes it a genuinely independent confirmation of
Ammo Capacity == LoadingCount, from a different renderer in a different mode.

Also inventories the HUD elements (heat gauges, shield/armor pools, target armor
gauge, per-weapon RANGE markers) and notes that the RANGE gauge draws each
weapon's MaximumRange on a scale — a route to a real number where the Arsenal
panel only gives a letter class.

Adds autopilot.py (waypoint-arrow chaser). It detects reliably but oscillates;
recorded as such rather than as a working tool.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-28 18:50:46 +00:00
parent c3b5f3f401
commit 07eff85819
6 changed files with 159 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

View File

@@ -170,3 +170,63 @@ Screenshots for every row above: `/sylph-home/re/caps/` in the container.
Evidence PNGs are committed under [`captures/weapon-datasheet/`](captures/weapon-datasheet/)
(64-colour quantized for size; the numbers stay legible).
---
# Addendum — the in-flight HUD (2026-07-28)
Reached via **TUTORIAL → BASIC CONTROLS / HEADS-UP DISPLAY** from the title menu (no
save needed). Evidence: [`captures/hud-runtime/`](captures/hud-runtime/).
## The HUD corroborates the ammo mapping, independently
The Delta Saber's HUD prints its two equipped weapons as `NOSE <TYPE> <AMMO>` and
`MAIN <TYPE> <AMMO>`. With the loadout known from the HANGAR (nose = STILETTO BG I, main =
FALCON 9AM) the HUD reads **`NOSE BM 06000`** and **`MAIN MPM 00300`** — exactly
`wep_01_Beam`'s `LoadingCount = 6000` and `wep_02_Missile`'s `300`.
This matters because it is **not** the Arsenal panel: the weapons were identified from the
HANGAR loadout, and the numbers come from a different renderer in a different game mode. It
is a genuinely independent confirmation of `Ammo Capacity == LoadingCount`.
The type tags (`BM`, `MPM`) are the same short codes as the Arsenal type tabs.
## HUD element inventory
| HUD element | Backing data | Conf. |
|---|---|---|
| `NOSE` / `MAIN` + type tag + 5-digit ammo | equipped weapon, `LoadingCount` | ✅ |
| `HEAT` / `L.HEAT` bar under each weapon | the `Heating` / `Cooling` pair | 🟡 |
| Speed readout + throttle scale (`0`, `100`, `A/B`) | craft velocity | ✅ |
| `SHIELD` and `ARMOR` bars (two separate pools) | craft `HP` + a shield pool | 🟡 |
| Target reticle: name / type / distance + ring **Armor Gauge** | target unit record | 🟡 |
| `RANGE` gauge with `MAIN` and `NOSE` tick markers | each equipped weapon's `MaximumRange`, plotted against target distance | 🟡 |
| `YOU KILLED: WARSHIPS nnnn` + a second counter | score / `ScorePoint` | ❔ |
The `RANGE` gauge is the interesting one for future work: it draws a **per-weapon marker on
a distance scale**, so a weapon whose `MaximumRange` is defaulted on disc (`wep_25`,
`wep_58`, `wep_82`) would have its value *drawn* rather than bucketed into a letter — if the
scale can be calibrated against two weapons with known ranges, that recovers a real number
where the Arsenal panel only gives a class.
## Craft velocity
Full afterburner (RT) peaked at **1193** against `UN_f001_TCAF_DeltaSaber_T`'s on-disc
`MaximumVelocity = 1200`. 🟡 PROBABLE — one observation, and the readout may have been
still climbing. Cruise sat at 350, which is *not* the record's `CruisingVelocity` (700), so
the number is the current throttle setting, not a named constant; don't read more into it.
## Notes for the next session
- The tutorials need **no save game** and are reachable in ~1 min from a cold boot, which
makes them the cheapest way back into a live flight scene.
- `tools/re-capture/autopilot.py` chases the yellow off-screen waypoint arrow (colour +
shape, `-sample` not `-resize`, ~0.65 s per detection). It **finds the arrow reliably but
oscillates** — the proportional gain is too high for the craft's turn rate. It needs a
damping/derivative term before it can actually fly a waypoint.
- The HEADS-UP DISPLAY tutorial reaches a scripted targeting segment where the ship stops
moving (target distance pinned) and the on-screen controller highlights **A**; tapping and
holding A did not advance it. **NEEDS-HUMAN**: what input that segment wants.
- Canary is unstable here: it died twice mid-session (once loading BEAM `Resource3D`, once
hanging on tutorial teardown) with no crash dump. Re-launch is cheap; just don't assume a
long session survives.

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Fly the Delta Saber toward the tutorial waypoint by chasing the yellow
off-screen direction arrow.
Detection: no PIL/numpy in the box, so the frame comes from `convert ... txt:-`.
Two things make it fast AND correct:
* `-sample` (point sampling, not `-resize`/`-scale` averaging) — a 1-px-thin
glyph keeps its true colour, so the exact colour predicate still fires while
the dump shrinks ~9x (3.5s -> 0.65s).
* shape, not just colour — the instruction-frame bars and the throttle marker
are the same dim yellow, so the arrow is the largest blob that is roughly as
tall as it is wide, with the fixed throttle marker blacklisted by position.
A ~1.1 s control loop is fast enough to converge; the earlier ~6 s loop was not.
"""
import re, subprocess, sys, time
X0, Y0, W, H = 80, 60, 820, 640 # flight view (the arrow also rides the bottom edge)
K = 3.03 # 1/0.33 sample factor
CX, CY = 640, 405 # crosshair
THROTTLE = (382, 456) # fixed yellow decoy on the speed gauge
PX = re.compile(r"^(\d+),(\d+):.*?srgb\((\d+),(\d+),(\d+)\)")
def pad(*a):
subprocess.run(["/opt/sylph/vgamepad.py", *a], check=False)
def arrow(png="/tmp/ap.png"):
subprocess.run(["/opt/sylph/screenshot.sh", png], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
out = subprocess.run(["convert", png, "-crop", f"{W}x{H}+{X0}+{Y0}", "+repage",
"-sample", "33%", "txt:-"], capture_output=True, text=True).stdout
pts = set()
for l in out.splitlines()[1:]:
m = PX.match(l)
if not m:
continue
x, y, r, g, b = (int(v) for v in m.groups())
if r >= 60 and g >= 48 and b <= 0.35 * g and (r - g) <= 0.45 * r:
pts.add((x, y))
seen, best = set(), None
for p in pts:
if p in seen:
continue
st, c = [p], []
seen.add(p)
while st:
x, y = st.pop(); c.append((x, y))
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
q = (x + dx, y + dy)
if q in pts and q not in seen:
seen.add(q); st.append(q)
xs = [q[0] for q in c]; ys = [q[1] for q in c]
cx, cy = X0 + sum(xs) / len(c) * K, Y0 + sum(ys) / len(c) * K
if abs(cx - THROTTLE[0]) < 30 and abs(cy - THROTTLE[1]) < 30:
continue
if len(c) < 8:
continue
if best is None or len(c) > best[2]:
best = (cx, cy, len(c))
return best
def main():
steps = int(sys.argv[1]) if len(sys.argv) > 1 else 25
centred = 0
for i in range(steps):
a = arrow()
if a is None:
print(f"{i:2d}: no arrow -> boost (target ahead)")
pad("trig", "RT", "0.55"); time.sleep(1.2); pad("trig", "RT", "0")
centred += 1
if centred >= 6:
print(" arrival likely — stopping"); return 0
continue
x, y, n = a
dx, dy = x - CX, y - CY
if abs(dx) < 70 and abs(dy) < 70:
centred += 1
print(f"{i:2d}: arrow ({x:.0f},{y:.0f}) centred -> boost")
pad("trig", "RT", "0.55"); time.sleep(1.2); pad("trig", "RT", "0")
continue
centred = 0
lx = max(-1.0, min(1.0, dx / 200))
ly = max(-1.0, min(1.0, dy / 160))
print(f"{i:2d}: arrow ({x:.0f},{y:.0f}) n={n} -> LX {lx:+.2f} LY {ly:+.2f}")
pad("axis", "LX", f"{lx:.2f}"); pad("axis", "LY", f"{ly:.2f}")
time.sleep(0.45)
pad("axis", "LX", "0"); pad("axis", "LY", "0")
print("steps exhausted")
return 1
sys.exit(main())

View File

@@ -5,6 +5,10 @@
# Static logo screens are left alone, so a stray tap can never land on NEW GAME.
set -u
export HOME=/sylph-home/re
# The X root keeps the DEAD session's last frame, so a fresh launch would be
# detected as "already at the title". Blank it, and wait for the new window.
xsetroot -solid black 2>/dev/null || true
until xdotool search --name "Xenia-canary" >/dev/null 2>&1; do sleep 1; done
deadline=$(( SECONDS + ${1:-900} ))
while [ $SECONDS -lt $deadline ]; do
screenshot /tmp/f1.png >/dev/null 2>&1; sleep 0.6