15 Commits

Author SHA1 Message Date
claude-re
695351dfc4 docs/re: mark the 'targeting is automatic' section superseded
It concluded no target-select input exists because no single press cycled a
target; select is A pressed twice. Kept with a correction banner rather than
deleted — a probe that never performs the action will 'prove' the action does
not exist, which is the reusable warning.
2026-07-30 20:48:15 +00:00
claude-re
4821ba7fea pilot: target select is A pressed TWICE — from the HUD tutorial, not from probing
HEADS-UP DISPLAY tutorial, verbatim: 'Press A twice to target the enemy closest
to the center of the screen.' A double tap, which is exactly why every button
sweep in flight-controls-runtime.md found nothing and why I concluded targeting
was automatic — each sweep tapped once. It also explains the missiles:
GuidanceType 5 guides to the GAME's selection and the loop had never made one,
so 98 launches guided to nothing.

Wired in: double-tap A when the committed contact is already within 14 deg of
the nose, so the game's choice and ours are the same object. One run: 8 kills
from 66 missiles (12% per missile) against the previous 9 from 101 (8.9%). The
absolute count is inside run variance and the efficiency gain is one sample, so
neither is claimed as decisive — it needs repeat runs.

Also documents that expository tutorials self-advance while interactive ones
stall (BASIC CONTROLS waits forever on 'Go to the box'), and that captions need
cropping across many frames because they type out.
2026-07-30 20:47:54 +00:00
claude-re
58f421d896 re-capture: read the ADVANCED CONTROLS tutorial; its moves regress the pilot, so they ship off
tutorial_capture.sh plays a tutorial and photographs what it teaches. ADVANCED
CONTROLS states three mechanics the key-config screen only named:
  B + LS            Side Roll / 180 Degree Turn / Level Off
  B + A together    face the target (snap turn)
  LT + RT together  'sets your fighter's speed to that of the target ... works
                    well when you are trying to get behind an enemy. Once
                    behind an enemy, this also helps you attack them.'

Wired both usable ones in and measured, one run each, everything else equal:
  commitment only ............ 101 missiles, 364 fire frames,  9 kills
  + match(4500) + snap-face ....  9 missiles,  28 fire frames,  0 kills
  + match(1200) + snap-face ...  57 missiles, 225 fire frames,  2 kills

So both are a net regression as applied, and both now default to OFF. Matching a
target's speed while still 5 km behind means never closing (the pilot sat at
272 u/s all run) — the tutorial scopes it to being already in the saddle. The
B+A snap turn reorients mid-pursuit and destroys the dwell commitment buys.

The code and thresholds stay so a future session can re-enable and A/B them over
SEVERAL runs; one run per config is inside this stage's spawn variance.
2026-07-30 19:50:25 +00:00
claude-re
9f41fe08e9 pilot: target commitment takes kills from 2 to 9; and the target pointer does not exist
The loop re-scored every contact every tick, so the nose chased whichever
fighter scored best that instant and aim error wandered 10-40 deg through a
pass. A missile lock is time-on-target, so constant switching is the one thing
guaranteed to prevent a kill. Commit to a contact until it dies, passes 6000,
sits >90 deg off the nose for 2.5 s, or 14 s elapse.

Same guns, same ballistics, same escort weighting, same missile cadence:
kills 0000 (five gun-only runs) -> 0002 (missiles) -> 0009 (commitment), with
101 missiles vs 98, and the largest hostile-population fall of any run
(134->97). Own hull untouched. The ACROPOLIS still ended at 76.6%, so this is
lethality, not the mission outcome.

Also records a negative result so it is not re-attempted: the selected target is
NOT a raw entity pointer. Three searches came up empty — a +-0x1400 window of
the player object, a full-RAM sweep of every entity-pointer word tapped through
each button (only thread-stack slots churn, which is frame noise), and a delta
tally over all 150 entities of the kind that found the definition pointer at
+0x130. The selection must be a handle, an index, or in a subsystem outside the
entity object.
2026-07-30 19:19:23 +00:00
claude-re
95ac545b2b docs/re: the game's own in-flight action list, off the disc
Decoded dat/GP_OPTIONS.pak (po_keys_btn* sprites): the OPTIONS key-config screen
lists every bindable in-flight action. Two of them change the plan.

'Change Target' exists — so target selection IS an input, and the earlier probe
that swept LB/X/B/A/LS/RS found nothing only because it watched the ammo
counters, which those actions do not touch.

'Padlock Mode Toggle' is a view/aim lock onto the selected target: the aim-dwell
problem solved by a game mechanic rather than by tuning a PD controller, and
presumably how a human holds a contact long enough to lock a missile.

Confirms our measured bindings (Use Nose Weapon = RB, Use Main Weapon = Y,
Accelerate/Decelerate = RT/LT, Radar Map Toggle = d-pad) and adds Special Move,
Maneuver, Resupply and Change Main Weapon (which would reach ASMissile, Power
5000). Also records that CONTROL SETTINGS carries a Control Type preset plus
yaw/pitch/roll sensitivity, so the mapping is not fixed and any stick
calibration is only valid for the save's current profile.

Tutorial menu (GP_TUTORIAL.pak) names the six lessons: BASIC CONTROLS,
HEADS-UP DISPLAY, RADAR, SUPPLY AND SPECIAL MOVES, RADIO ORDERS, ADVANCED
CONTROLS.
2026-07-30 18:54:16 +00:00
claude-re
ab8f5307ff docs/re: in-flight control mapping, and why lethality is an aim-dwell problem
Probed rather than assumed: hold each pad input and photograph the HUD ammo
counters. RB = nose gun (NOSE BM 6000->5956 in 4 s, ~11 rounds/s, HEAT rises),
Y = main mount (MAIN MPM 300->299), d-pad = tactical map overlay, and
LB/X/B/A/LS/RS move neither counter.

No target-cycle input exists. The green TARGET marker is already up with nothing
pressed, so the game selects for us and a guided missile's lock is a
time-on-target mechanic, not a button. That closes the lethality question: it is
not target choice (escort weighting), not ballistics (now from the confirmed
Shell records), and not the mapping — the steering loop simply never holds the
nose on one contact long enough to lock. Aim dwell is the next lever.

Also records two things the reimplementation needs: two weapons with separate
ammo pools and HUD counters, and a HEAT bar that fills while the gun fires
(cap and cool-down not yet measured).
2026-07-30 18:49:16 +00:00
claude-re
76f463b611 pilot: RB fires, Y is the main mount, and guided missiles produce the first kills
fire_probe.sh holds each pad input in flight and photographs the HUD ammo
counters. RB moves NOSE BM 06000 -> 05956 in 4 s (~11 rounds/s, HEAT rises);
Y moves MAIN MPM 00300 -> 00299; nothing else moves either counter. So the
control mapping is measured rather than assumed, and 'we never shoot' is dead:
we shoot and miss.

The disc data says to stop shooting: Shell_TCAF_DeltaSaber_Missile_P is Power
200, GuidanceType 5 (guided), MaximumRange 5000, versus the nose gun's Power 15
unguided — one missile is worth ~14 gun hits on a 500 HP fighter and it steers
itself. Launching them (press Y, release a tick later, >=2 s apart) produced
YOU KILLED: WARPLANES 0002 — the first non-zero kill counter of the series,
against 0000 in all five gun-only runs, with hostiles down 134 -> 104.

Still only 2 kills per 98 missiles (~2%). Likely cause: the game expects a lock
before launch and an unlocked missile is wasted. Reading the lock state out of
RAM is the next step.
2026-07-30 18:40:39 +00:00
claude-re
3f6efadf9e pilot: real ballistics from the solved Shell records — and the metric that says it did not help
Shell_TCAF_DeltaSaber_*_P: Velocity 8000, LifeTime 0.5 s, MaximumRange 4000
(self-consistent: 8000 x 0.5 = 4000), all confirmed. Two things were wrong:
lead computed flight time as d / OUR speed (400-2000 u/s, so every shot was led
4-16x too far), and FIRE_RANGE was 5000 — past where the shells expire.

Both fixed. But the HUD's own kill counters read 0000/0000 at the end of EVERY
run including the nearest-fighter baseline, so the pilot kills nothing in any
configuration and 'fraction of frames firing' was never measuring lethality.
No improvement is claimed.

One clean negative result kept: gating on the target's angular half-size alone
(2.7 deg at 2584 units) is far tighter than the steering loop can hold the nose
— firing collapsed to 1 frame in 2639. Angular size is a floor on the firing
cone, never a cap.

Next: the HUD carries a live ammo count, so holding fire and watching it settles
'we never shoot' vs 'we shoot and miss' in a single run.
2026-07-30 18:16:26 +00:00
claude-re
402985adbf pilot: escort-weighted targeting (DEFEND), plus the capital-ship keep-out it needed
While the asset is losing hull, target what is pressing IT — ranked by distance
to the asset minus credit for closing on it — instead of what is nearest to us.
Trigger and ranking both read the live hull (pos+0x154), so nothing is inferred.
DEFEND engaged 1.9 s after the asset's first hit and held 54% of a 330 s run.

It did NOT measurably save the asset: over the window two runs share, the
policies are equal to within noise (t=239: 23218 vs 23038). Two reasons, both
recorded rather than papered over: the runs are not comparable past that window
(spawn timing differs and the hostile count GREW 134->166 in one, fell 147->118
in the other), and the real bottleneck is lethality — the guns are on for 12% of
combat frames because the target is outside the 9 deg cone the rest of the time.

Also corrects a single-run claim in the previous commit: the asset is NOT
reliably safe for the first ~170 s. A second run had first damage at t=70 s. The
stage does not replay identically; only 'the loss is slow' survives.

Fixes a fatal bug the new mode exposed: DEFEND flies at the asset, which sits
inside the friendly formation, and the first escort run went hull 1500 -> DEAD in
one tick at 2026 units/s, 0.6 s from a friendly destroyer that avoidance thought
it would clear by 365 units — the ship's radius is 2000. Keep-out applied only to
hostile turrets. Every entity above BIG_RADIUS now gets its own radius + 800 of
physical keep-out with braking inside it, whatever its faction.
2026-07-30 17:49:38 +00:00
claude-re
c277e42c92 docs/re: the hull anchor is class-wide — the escort objective is scoreable live
own_state.py found hull = position + 0x154 for the PLAYER. Stage 02 is an
escort and is lost when the ACROPOLIS sinks, so scoring it needs someone
else's hull. Measured over 240 s of Stage 02: at t=0 pos+0x154 equals each
entity's own definition HP across 7 classes and 5 distinct HP values (turret
100, fighter 500, destroyer 10000, cruiser 30000, Acropolis 25000). Nothing
read above its HP; the five that read slightly below were already under fire
when the player launched. It falls with damage (780 events), goes negative at
death, and the object then leaves the heap.

UN_f101_TCAF_Acropolis: HP 25000, radius 1400, measured 25000 -> 23038 over
240 s with the attack starting only at t~170 s (~600 HP/min) — so the earlier
GAME OVER was not a fast loss, it was an undefended one.

Also: REMAINING OB reads 012 while 118 ADAN entities are alive, so it counts
objectives, not hostiles; its address is still unknown.
2026-07-30 17:21:05 +00:00
claude-re
8ee3bfbcef re-capture: stop detaching the display and the emulator
ensure_display() and the run-canary launch used 'setsid nohup', to make them
outlive the shell that started them. That is what has been killing every long
run: a setsid'd process belongs to no supervised tree, and both Xvfb and xenia
were reaped a couple of minutes in — the 'they die on their own every few
minutes' note in the project memory. Measured: a bare Xvfb with no emulator
running exited 0 (a clean shutdown, not a crash and not the OOM killer) at the
exact moment a turn ended, and a session whose display was setsid'd from inside
a tracked task died the same way 12 s after launch.

Run launch_mission.sh as one tracked background task and keep Xvfb, openbox and
xenia as its children. Adds an exit-status wrapper so a future death reports the
server's own exit code (128+N for signal N) instead of being inferred, and
clears a stale X lock before starting.
2026-07-30 17:12:00 +00:00
claude-re
91fb022caf re-capture: a dead display must not look like a playing movie
skip_intro.sh compared two screenshots to detect the intro movie. When the X
server died 3 min into a run, `screenshot` failed silently and left both PNGs
at their previous contents — two stale files, whose RMSE is a constant non-zero
number, i.e. exactly the signature of a changing screen. The loop then reported
"movie -> skip A" every 5 s for the whole 600 s timeout with no emulator and no
display alive, and the session wasted 10 minutes before saying BOOT FAILED.

Both waiters now verify, every iteration, that the screenshot was actually
written, that the display answers xdpyinfo, and that a non-zombie xenia_canary
exists — with distinct exit codes (3 display, 4 emulator, 5 capture) so the
session log names the cause instead of timing out.

Also adds mission_state.py + escort_session.sh: read pos+0x154 against each
definition's HP for EVERY entity, to test whether the hull anchor is a property
of the entity class rather than of the player object (the escort question).
2026-07-30 17:06:08 +00:00
claude-re
93d6534efe docs/re: open a backlog, first item = capital ships assemble wrong in the viewer
The runtime-capture write-up declares static ship assembly exact, but its test
covers exactly one ship (e106). Records what is suspect (engine-cluster rig,
the X-reflect twin heuristic, cross-id turrets), and that the F10 capture is
already the oracle to settle it on a second class.
2026-07-30 17:01:32 +00:00
41c34dc3ca re: surviving is not winning -- Stage 02 is an escort
The second 240 s flight ended on GAME OVER with our hull untouched at
1500/1500. Nothing shot us down; the ACROPOLIS was sunk while the
pilot pursued an attacker two kilometres away. "Nearest hostile
fighter" is the wrong objective function for this stage.

The fix is available with what is already solved: the protected ship's
hull is readable with the same anchor as ours (position+0x154, maximum
= its own definition HP), so target priority can be "closing on the
asset" and the escort can be scored live.

Also records that a frozen log tail is what mission-end looks like
from outside the emulator, not a wedge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:43:42 +00:00
2e9903d0fc re: the autopilot now survives, and kills
Three measurements, then a pilot built on them.

* Hull is position+0x154. Found by anchoring on a field the definition
  already had solved (HP = 1500) rather than scanning for a value that
  falls: an undamaged craft must contain its own definition's number.
  Confirmed by the trace across a death -- 30/60/90 per hit, negative
  at 0, GAME OVER on screen.
* RT accelerates, LT brakes, and the throttle is a persistent setting
  (488 -> 1510 -> 174 units/s, measured as displacement per second of
  the craft's own position, so no speed field was needed). This
  overturns the earlier "RT is not the throttle", which came from
  assuming the control and hunting for a field.
* Shield is probably position+0x430 (== definition MaxValue 400), not
  yet confirmed live -- nothing had damaged it.

pilot.py is a state machine on damage (ENGAGE / EVADE / RETIRE) that
treats turrets as keep-out zones instead of targets. It flew Stage 02
for 300 s with the hull untouched at 1500/1500 and took the first
confirmed kill (WARPLANES 0001); every run the day before was dead
inside 35 s.

Two bugs the live run exposed and this fixes: gating the guns on the
commanded direction keeps them cold whenever avoidance is steering
(gate on the target instead), and an orbit-plus-brake rule made it
circle one attacker for 40 s outside its own firing cone.

Also: boot to in-flight is now ~100 s unattended, because
wait_flight.sh waits for the HUD's own shield bar instead of a fixed
75 s sleep that lavapipe does not honour; entities2.py picks the
attitude block by matching the measured flight path (taking the first
orthonormal block gave a bone/camera frame); and the entity-heap scan
is numpy instead of a per-word Python loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:38:00 +00:00
38 changed files with 5167 additions and 30 deletions

46
docs/re/BACKLOG.md Normal file
View File

@@ -0,0 +1,46 @@
# RE backlog
Open items that are *not* being worked right now. Each entry says what is wrong or
unknown, what evidence exists, and what the first step would be. Move an item into
`INDEX.md` (with a `structures/…md` or a parser + test) once it is actually settled.
---
## Capital ships assemble wrong in the viewer
**Reported:** 2026-07-30, by the user. **Status:** ❔ open, not investigated.
The reborn viewer builds capital ships from the split XBG7 parts via
`sylpheed-formats::ship::assemble_ship`, and they come out **wrong** — parts in the
wrong place / wrong orientation.
**Why this is a real finding and not a known limitation:** the RE write-up
[`ship-placement-runtime-capture.md`](ship-placement-runtime-capture.md) declares
static assembly ✅ **exact** as of 2026-07-26 — 9-channel joint tables
`[TX TY TZ RY RX RZ SX SY SZ]`, Euler `Ry·Rx·Rz`, with
`ship::tests::static_assembly_matches_runtime_capture` asserting static == runtime
capture (T < 1.0, R < 0.02). So either the viewer is not using that path, or the
claim generalises worse than the test suggests.
**The likely gap:** that test is **one ship** — the `e106` destroyer, 8 parts plus
two nacelles, two turrets and the hull mirror. Nothing pins the other classes.
Rules that were derived from `e106` and could easily be `e106`-specific:
- the engine cluster rig mounted at `GN_Engine_01` (two mirrored nacelles + centre);
- "X-reflect the shared-geometry twin whose lateral offset opposes the geometry's
dominant side" — a heuristic, not a decoded flag;
- cross-id turret instancing (×2).
**First step (the oracle already exists):** re-run the runtime capture on a *different*
capital ship and diff static vs captured, exactly as `e106` was done — F10 in the
`capture-ship-placement` build of `xenia-canary-native` dumps the ship shader's
`c0..c2` WorldViewProjection rows per part; `WV_ref⁻¹ · WV_p` is the ship-space rigid
transform, which is ground truth. Pick a class whose rig differs from `e106`
(different engine count, a ship with no `sld`, a carrier). Then extend
`static_assembly_matches_runtime_capture` into a per-ship table so a regression in one
class cannot hide behind `e106` passing.
**Also worth ruling out first, cheaply:** that the viewer's own transform stack (scale,
handedness, node-instance recursion) is not re-breaking a correct assembly — compare
the viewer's placement against `assemble_ship`'s output directly before blaming the
format layer.

View File

@@ -31,6 +31,10 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|-----------|-------|------|-------|
| Live guest-memory read | ✅ | [`tools/re-capture/gmem.py`](../../tools/re-capture/gmem.py) | Canary backs the guest address space with `/dev/shm/xenia_memory_*`; guest VAs map in through Xenia's fixed table. Full-RAM search ~0.2 s (sparse, `SEEK_DATA`). No debugger, no emulator patch, game keeps running |
| IDXD object layout solver | ✅ | [`tools/re-capture/weapon_runtime.py`](../../tools/re-capture/weapon_runtime.py) | Scan RAM for a class's vtable → enumerate its objects → brute-force `(field, offset, encoding)` against the disc records. Accepts a binding only on **zero** contradictions. Generalizes to any IDXD-backed definition |
| Live entity state, anchored on the definition | ✅ | [`tools/re-capture/own_state.py`](../../tools/re-capture/own_state.py) · [autopilot](autopilot-memory-driven.md) | An undamaged craft holds its definition's own numbers, so a *solved definition field* locates the matching live field without a value scan: definition `HP` (1500) → **hull at `position+0x154`**, confirmed by a trace across a death (30/60/90 per hit, negative at 0). Reusable for any live counter whose maximum the definition carries |
| Mission / escort state, every entity's hull | ✅ | [`tools/re-capture/mission_state.py`](../../tools/re-capture/mission_state.py) · [escort state](mission-escort-state.md) | `hull = position + 0x154` is a property of the **entity class**, not of the player object: at t=0 it equals each entity's own definition `HP` across 7 classes and 5 distinct HP values (turret 100, fighter 500, destroyer 10000, cruiser 30000, **ACROPOLIS 25000**), falls under fire (780 damage events in 240 s), goes negative at death, and the object then leaves the heap. So an escort objective is scoreable live — `UN_f101_TCAF_Acropolis` measured at 25000 → 23038 over 240 s, attack starting only at t≈170 s. `REMAINING OB` counts objectives, not hostiles (012 on the HUD vs 118 live ADAN); its address is still ❔ |
| In-flight control mapping | ✅/🟡 | [`tools/re-capture/fire_probe.sh`](../../tools/re-capture/fire_probe.sh) · [controls](flight-controls-runtime.md) | Measured by holding each pad input and photographing the HUD ammo counters: **`RB` = nose gun** (6000→5956 in 4 s, ~11 rounds/s, HEAT rises), **`Y` = main mount** (missiles, 300→299), d-pad = **tactical map** overlay, nothing else moves a counter. No target-cycle input exists — the `TARGET` marker is present with nothing pressed, so targeting is automatic and a missile lock is **time-on-target**. That, not target choice or ballistics, is what caps lethality at 2 kills per 98 missiles |
| Input → dynamics calibration | ✅ | [`tools/re-capture/ctrl_probe.py`](../../tools/re-capture/ctrl_probe.py) · [`binq.py`](../../tools/re-capture/binq.py) | Hold each pad input in turn and measure the craft's speed as displacement/s of its own position triple — no speed field needed first. Settled the throttle: **`RT` accelerates, `LT` brakes, and the setting persists** (488 → 1510 → 174 units/s), overturning an earlier field-scan conclusion |
## Functions / code paths

View File

@@ -1,12 +1,101 @@
# Memory-driven autopilot — build log and current state
**Status: 🟢 IT FLIES AND SHOOTS — it does not yet survive.**
Updated 2026-07-29 (second pass). The autopilot reads the live world, picks
hostile targets, pursues them and opens fire. What it cannot do is stay alive:
there is no evasion or shield management, so it dies before a mission ends.
This is the honest state, not a plan.
**Status: 🟢 IT FLIES, KILLS AND SURVIVES — but it loses the mission anyway.**
Updated 2026-07-30. `pilot.py` flew Stage 02 for **300 s with the hull untouched
at 1500/1500** and scored the first confirmed autopilot kill (`YOU KILLED
WARPLANES 0001` on the HUD, screenshots `shots/pilot1-*.png`); the scene's
hostile count fell from 134 to 111 over the run. The day before, every run was
dead inside 35 s. What is still missing is the *end* of a mission: the objective
counter (`REMAINING OB`) rises as new waves spawn, and nothing yet tracks which
targets actually close it out — and the second run proved the point the hard
way: `GAME OVER` with the hull at 1500/1500, because Stage 02 is an **escort**
and the ACROPOLIS was sunk while the pilot chased fighters two kilometres away.
## What the loop does now, observed
## 2026-07-30 — the numbers survival needs
Three things the loop was missing were measured this session, each by
consequence rather than by reading a field and hoping.
### Hull is `position + 0x154` ✅ CONFIRMED
The unit definition already had `HP` solved at `+0x054`
([unit-struct-runtime](structures/unit-struct-runtime.md)); the Delta Saber's is
**1500**. A craft that has taken no damage must therefore *contain that number*,
which turns "find the HP field" into a two-float lookup rather than a value scan
(`own_state.py`). It appears once in the entity object, at `pos+0x154`, and the
trace across a death settles it (`ctrl_probe.py` capture, `binq.py trace`):
```
t phase hull
0.00 base 1500.00 <- == definition HP
23.25 rest_A 1380.00 <- first hit, -120
26.68 … 27.18 B 1320 … 930 <- seven hits in 0.5 s
31.93 X 150.00
35.21 Y -30.00 <- goes negative
35.26 Y -180.00 -> GAME OVER on screen
```
Damage arrives in 30/60/90-point steps and the field goes *negative* at death,
so it is the raw hull counter, not a clamped display value. **1500 hull lost in
12 s** of sitting in a turret's line of fire is the whole reason every earlier
run died.
### Shield is `position + 0x430` 🟡 PROBABLE — not yet confirmed live
Same anchor trick: the definition's shield `MaxValue` is **400** and
`ChargeSpeed` **25**, and the entity object holds `400.0` at `+0x430`, `+0x434`
and `+0x438`, with `25.0` at `+0x448`. Which of the three is the *current* value
is unproven — the capture that spanned the death used a ±0x400 window and
cropped them out. `ctrl_probe.py` now samples ±0x800.
### `RT` accelerates, `LT` brakes, and the throttle is a *setting* ✅ CONFIRMED
`ctrl_probe.py` holds each input in turn and measures the craft's own speed as
displacement per second from the position triple, so no speed field is needed.
Distance flown / phase duration, one 3 s hold each, sticks neutral:
| phase | speed (units/s) | | phase | speed (units/s) |
|---|---|---|---|---|
| base (no input) | 488 | | A | 287 |
| **RT** | **1510** | | B | 139 |
| rest after RT | 1056 | | X | 125 |
| **LT** | **174** | | Y | (dying) |
| rest after LT | 428 | | LB / LS / RS / RY / RX / dpad | no effect |
RT triples the speed, LT cuts it to a third, and **the braked state persists**:
after the LT phase the craft sat at 125140 units/s with the sticks and triggers
neutral for the remaining 40 s, and nothing but RT brought it back. So these are
a throttle setting, not a momentary boost — which also means a control loop must
send only the *changes*.
This **corrects** the earlier note in this file ("`RT` is *not* the throttle,
and no button tested is"). That conclusion came from `findspeed.py`, which
assumed the control and went looking for a *field* that rose; measuring the
speed directly reverses it.
### Two method corrections
* **Pick the attitude block by the flight path, not by address order.** The
player object contains **20** orthonormal 3×3 blocks (identity frames, bone
or camera frames), and `pos-0x70` and `pos-0x30` hold the *same* matrix.
Taking `found[0]` wrote a config with `rot_delta = -0x764` and a nonsense
forward axis; `entities2.py self` now scores every block against the measured
direction of travel and picks the best (`cos = +1.000`, row 2, sign +1).
* **The entity-heap scan has to be numpy.** A per-word Python loop over the
16 MB entity region costs seconds per scan, which is the whole budget of a
10 Hz control loop; `np.isin` over a `>u4` view is milliseconds.
### Stage 02 as an autopilot testbed (from the in-flight HUD)
`OBJECTIVE: shoot down all invading enemy fighters while watching out for
attacks on the ACROPOLIS` · `DEFEAT: your fighter is shot down, or the ACROPOLIS
is sunk` · `HINT: you can resupply at the ACROPOLIS`. The HUD shows
**`REMAINING OB 004`** — only four objective targets — so this mission is
winnable by an autopilot that survives. It also shows separate **SHIELD** and
**ARMOR** bars (matching a 400-point shield over 1500 hull), `A/B 7,635`
afterburner, and `NOSE BM 06000` / `MAIN MPM 00300` ammo.
## What the loop did before that, observed
```
[ 82.5] tgt=e007_ADAN_Turret d=3384 yaw= -7.3 pit=+14.6 stick=(-0.13,-0.34) fire=0
@@ -33,8 +122,8 @@ rescan reports the scene as e.g. `136 entities {'TCAF': 16, 'ADAN': 120}`.
direction of travel with **cos = +1.000**.
3. **The fire button is RB** — established by consequence, not by guessing:
of RB/LB/A/B/X/Y/RT/LT, pressing RB is the only one that makes the nose-ammo
counter in RAM fall (5958 → 5940). `RT` is *not* the throttle, and no button
tested is.
counter in RAM fall (5958 → 5940). (This entry also claimed `RT` is *not* the
throttle — **wrong**, see the 2026-07-30 measurement above.)
4. **Control.** PD on the aiming error with the derivative taken from the
craft's own body angular velocity (from two consecutive rotation matrices),
and target selection weighted by off-boresight angle
@@ -113,7 +202,60 @@ statements about `0x820af030`, which is *not* the live entity —
(6 Hz) and re-check orthonormality on every read — blocks found by a scan get
overwritten between the scan and the read.
## The next step that unblocks the most
## The second run lost the mission **without being hit** (2026-07-30)
A second 240 s flight, with the two fixes above, ended on the `GAME OVER`
screen — while the hull read **1500/1500 on the last live tick**. Nothing shot
us down. The other defeat condition fired: *the ACROPOLIS is sunk*. The HUD had
been showing a red `WARNING` banner for a while, and the pilot spent the whole
run pursuing an `e010_ADAN_Attacker_S` two kilometres away.
So surviving is necessary and not sufficient, and "nearest hostile fighter" is
the wrong objective function for this stage. **The mission is an escort.** What
follows:
* **Prioritise hostiles by their distance to the protected asset, not to us.**
The attackers worth killing are the ones closing on the ACROPOLIS.
* **The protected asset's health is readable with the same anchor as ours** —
hull at `position + 0x154`, its maximum being its own definition's `HP`. That
gives a live "are we winning" signal for the escort, and it should drive the
target choice directly.
* A frozen tail in the log (identical position, speed and target for the last
five seconds) is what mission-end looks like from the outside, **not** an
emulator wedge. Worth knowing before diagnosing the wrong thing.
* Practical: do **not** pipe a long run's log through `tail` — that discards
everything but the end, and the interesting part of this run is gone.
## After survival, the blocker is lethality (2026-07-30)
The 300 s run took **no damage at all** and killed **one** warplane, spending
~800 rounds of nose ammo (`06000``05193`) to do it, while `REMAINING OB` rose
from `004` to `011` as fresh waves spawned. So attrition at this rate never
finishes the mission, and the ranking of open problems has changed:
1. **Hit rate.** It opens fire at 25 km with a 9° cone and a crude lead
(`p + v·d/speed`, no projectile speed). The `Shell` records in
[weapon-struct-runtime](structures/weapon-struct-runtime.md) carry the real
projectile speed and `MaximumRange` per weapon — the lead and the firing
range should come from *those*, not from constants.
2. **Which targets count.** `REMAINING OB` is the mission's own objective
counter and it is on screen, so it is in RAM; finding it turns "shoot
whatever is nearest" into "shoot what closes the mission". Objective-marked
entities also draw an `OB` badge in the HUD, so the flag is likely a word in
the entity object.
3. **Confirming the shield word** — needs a run that actually takes damage; the
pilot is now good enough at avoiding that to make it awkward, so drive
straight at a turret on purpose with `--dry` steering disabled.
4. **Does the ACROPOLIS repair?** RETIRE mode has never triggered (the hull
never fell), so the resupply hint is still untested.
## The next step that unblocks the most (superseded — kept for the reasoning)
**Update 2026-07-30: this is no longer the blocker.** Entity typing via the
definition pointer already solved target selection, so the game's own target
pointer is now a convenience rather than a prerequisite. It would still be the
cheapest route to problem 2 above (objective targets), because whatever the HUD
locks on to is what the game itself considers a target.
**Find the game's own target pointer instead of typing entities ourselves.**
The HUD has a lock-on system (a `TARGET` marker and a target-cycle button), so
@@ -143,6 +285,12 @@ with the pad and watch which pointer-shaped global changes in step.
## Files
`pilot.py` (**the survival loop**) · `ctrl_probe.py` (input → speed calibration,
plus a per-tick window of the player object) · `binq.py` (query that capture) ·
`own_state.py` (definition-anchored hull/shield lookup) · `fly_session.sh`
(boot → mission → bind → fly, one task) · `wait_flight.sh` (wait for the real
HUD instead of a fixed sleep) · `navigator.py` (drift-aware steering + CPA
avoidance, reused by the pilot) ·
`gworld.py` (live reader + entity list) · `flight_probe.py` (scripted inputs +
sampling, and the `Pad` FIFO client) · `flight_analyze.py` · `whatchanges.py`
(encoding-agnostic "which words are live") · `findplayer.py` · `findself.py` ·

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 KiB

View File

@@ -0,0 +1,104 @@
# In-flight control mapping — measured, not assumed
**Status:** ✅ for the weapon bindings (ammo counters move), 🟡 for the rest (HUD
observation only). Probes: `tools/re-capture/fire_probe.sh` (hold each input, photograph
the ammo counters) and `lock_probe.sh` (tap each, watch the reticle). Stage 02, in flight.
Evidence: [`captures/fire-probe-ammo-counters.png`](captures/fire-probe-ammo-counters.png).
| input | effect | confidence |
|---|---|---|
| **`RB`** | **Nose gun.** `NOSE BM` 06000 → 05956 in a 4 s hold ≈ **11 rounds/s**; `HEAT` bar rises | ✅ |
| **`Y`** | **Main mount** (missiles). `MAIN MPM` 00300 → 00299 per tap | ✅ |
| **`RT` / `LT`** | Throttle up / brake, a *persistent* setting (488 → 1510 → 174 u/s) | ✅ (earlier session) |
| **d-pad** | **Tactical map** overlay (grid with contact blips) — not target cycling | 🟡 |
| `LB`, `X`, `B`, `A`, `LS`, `RS` | No change to either ammo counter | ✅ (as "not a weapon") |
## ~~Targeting appears to be automatic~~ — WRONG, corrected below
> **Superseded.** This section concluded targeting was automatic because no input
> cycled a target. It is wrong: the HUD tutorial states target select is **Ⓐ pressed
> twice**, and every sweep here tapped once. Kept because the reasoning is a useful
> warning — a probe that never performs the action will "prove" the action does not
> exist. The rest of the section's measurements stand.
No *single* press cycled a target. The green `TARGET` marker is already present in
idle frames with nothing pressed, which I read as the game selecting for us.
That fits the measurements end to end:
- the guns fire fine (11 rounds/s) but the kill counters read `0000` after five gun-only
runs → **we shoot and miss**;
- guided missiles (`Missile_P`, Power 200, `GuidanceType` 5) got the first kills,
`WARPLANES 0002`, but only **2 per 98 launches**;
- the pilot's own log shows aim error wandering between ~10° and ~40° for most of a
pass.
At the time I concluded the bottleneck was aim dwell. Partly right — target
**commitment** did take kills 2 → 9 — but the larger cause was simply that no target was
ever selected, so the guided missiles had nothing to guide to.
## The game's own action list (from the OPTIONS key-config screen)
Decoded from `dat/GP_OPTIONS.pak` (`po_keys_btn*` sprites) — this is the authoritative
set of bindable in-flight actions, straight off the disc, no probing required:
| # | Action | Our mapping |
|---|---|---|
| 1 | Aircraft Control | LX/LY ✅ |
| 2 | View Point Control | RX/RY (unused by the pilot) |
| 3 / 4 | Left / Right Yaw Control | — (separate from pitch/roll!) |
| 5 / 6 | Accelerate / Decelerate | `RT` / `LT` ✅ |
| 7 | **Use Main Weapon** | `Y` ✅ |
| 8 | **Use Nose Weapon** | `RB` ✅ |
| 9 | Special Move | ❔ |
| 10 | Maneuver | ❔ |
| 11 | Resupply | ❔ |
| 12 | **Change Target** | ❔ — **this is the target-select the loop needs** |
| 13 | Change Main Weapon | ❔ (would reach `ASMissile`, Power 5000) |
| 14 | **Padlock Mode Toggle** | ❔ — **the aim-dwell mechanism** |
| 15 | Radar Map Toggle | d-pad 🟡 (matches the observed map overlay) |
Two entries change the plan outright:
- **`Change Target` exists**, so target selection *is* an input after all. The earlier
probe swept `LB/X/B/A/LS/RS` and found no ammo change — consistent with those being
exactly these non-weapon actions. The probe simply watched the wrong indicator.
- **`Padlock Mode Toggle`** is a view/aim lock onto the selected target. That is the
aim-dwell problem solved *by a game mechanic* rather than by tuning a PD controller —
and it is why a human player can hold a contact long enough to lock a missile.
Also note `CONTROL SETTINGS` carries a **`Control Type`** preset plus **Yaw / Pitch /
Roll Sensitivity** and a separate **`Throttle`** option: the mapping is not fixed, and
the craft's response to a given stick deflection is configurable. Any calibration done
against one profile (e.g. the `ctrl_probe.py` throttle numbers) is only valid for the
save's current settings.
## What the tutorials state outright
`tutorial_capture.sh <index> <secs> <tag>` plays one lesson and photographs it. Captions
use a typewriter effect, so crop `900x125+160+40` from many frames to read a full
sentence. Lessons that require the player to *do* something stall (BASIC CONTROLS sits
on "Go to the box on your screen" forever with nobody flying); the expository ones run
on their own.
- **HEADS-UP DISPLAY (index 1):** *"Enemies are displayed with **red markers** and allies
with **blue markers**." · "Targeting an enemy displays an Armor Gauge…" ·* **"Press Ⓐ
twice to target the enemy closest to the center of the screen."**
- **ADVANCED CONTROLS (index 5):** `B`+`LS` = Side Roll / 180 Degree Turn / Level Off ·
`B`+`A` together = face the target · `LT`+`RT` together = *"sets your fighter's speed
to that of the target… works well when you are trying to get behind an enemy. Once
behind an enemy, this also helps you attack them."*
**`Change Target` is Ⓐ pressed TWICE** — a double tap. That is why every button sweep in
this document found nothing and why I wrongly concluded targeting was automatic: each
sweep tapped once. It also explains the missiles — `GuidanceType 5` needs the *game's*
selection, and the loop had never made one, so 98 launches guided to nothing.
## Notes for the reimplementation
- Two independent weapons with separate ammo pools and separate HUD counters:
`NOSE BM` (gun, 6000) and `MAIN MPM` (missiles, 300).
- The gun has a **HEAT** bar that fills while firing — a sustained-fire limit the
reimplementation needs; its cap and cool-down rate are not measured yet.
- The tactical map is a full-screen overlay bound to the d-pad and does not pause flight
(the craft kept taking fire with it open).

View File

@@ -0,0 +1,225 @@
# Escort / mission state from guest RAM — every entity's hull
**Status:** ✅ CONFIRMED (2026-07-30). Capture: `tools/re-capture/mission_state.py`,
session `tools/re-capture/escort_session.sh`, Stage 02 from save slot 01, 240 s of
flight, 240 samples at 1 Hz → [`captures/mission-state-stage02.jsonl`](captures/mission-state-stage02.jsonl).
Screenshot evidence: [`captures/escort-stage02-hud.png`](captures/escort-stage02-hud.png).
## The question
`own_state.py` found the **player's** hull by anchoring on a solved definition
field — an undamaged craft carries its definition's `HP` (+0x054), so the live
counter is the copy of that number that falls. Result: `hull = position + 0x154`
([autopilot](autopilot-memory-driven.md)).
Stage 02 is an **escort**, and it is lost when the ACROPOLIS sinks, not when the
player dies: a 240 s run hit `GAME OVER` with our own hull at 1500/1500. Scoring
that objective needs *someone else's* hull. So: is `+0x154` a property of the
**entity class**, or of the player object?
## Finding — it is class-wide
At the first sample of the run, before this session's fighting had touched them,
`pos+0x154` equals the entity's own definition `HP` across **seven classes and
five distinct HP values**:
| Class | radius | definition `HP` | `pos+0x154` at t=0 |
|---|---|---|---|
| `UN_e007_ADAN_Turret` | 22 | 100 | 100.0 (all 60 instances) |
| `UN_e010_ADAN_Attacker_S` | 100 | 500 | 500.0 (all 19) |
| `UN_f106_TCAF_Destroyer` | 2000 | 10000 | 10000.0 |
| `UN_e106_ADAN_Destroyer` | 2100 | 10000 | 10000.0 |
| `UN_f105_TCAF_Cruiser` | 3800 | 30000 | 30000.0 |
| `UN_e105_ADAN_Cruiser` | 3800 | 30000 | 30000.0 |
| **`UN_f101_TCAF_Acropolis`** | 1400 | **25000** | **25000.0** |
Measured directly by `mission_state.py scan` at the start of three separate runs:
**146/150, 147/150 and 147/150 entities** hold exactly their definition's `HP` at
`pos+0x154`. The handful that do not sit *slightly below* it (9800/10000,
29933.3/30000, 9725/10000, …) — the battle is already in progress when the player
launches, so those ships have already been shot at. **Nothing read above its `HP`,
and nothing read an unrelated number**, which is what a coincidental offset would
produce.
The value behaves like a live counter, not a copy of the definition:
- it **falls under fire** — 780 distinct damage events were logged across the run;
- it **goes negative at death** and the entity then disappears from the heap
(`UN_f106_TCAF_Destroyer``-30.0` of 10000, another → `-0.0`, a third GONE);
- the drops match what the HUD draws — the screenshot shows the ACROPOLIS and the
destroyer *CHARON* each with their own health bar, CHARON's already red.
So **`hull = position + 0x154` for every entity**, and the escort objective is
directly scoreable: read the protected ship's hull, normalise by its definition's
`HP`, done. No new anchor, no value scan.
## The escort asset, measured
`UN_f101_TCAF_Acropolis`, one instance, `HP` 25000, collision radius 1400.
Its hull over the 240 s run (pilot chasing the nearest hostile fighter, the
current `pilot.py` behaviour):
```
t= 0..150s 25000.0 untouched
t= 180.1s 24779.5
t= 210.1s 24149.5
t= 239.1s 23038.2 -1961.8 total, ≈ -600 HP/min once it starts
```
**⚠️ Onset is NOT a fixed schedule — corrected by a later run.** From this run alone
it looked like the asset is safe for the first ~170 s. A second run put the first
damage at **t = 70 s**, and its hostile population *grew* (134 → 166 ADAN) where this
one's shrank (147 → 118). So the stage is not replaying identically, and "the asset
is untouched early" is a property of one run, not of Stage 02. What survives the
second run is the weaker, still useful claim: **the loss is slow** — a few hundred to
~1400 HP/min against 25000, so tens of minutes to sink. The earlier `GAME OVER`
therefore was not a fast loss; it was an undefended one.
## Also captured
- Hostile population fell 147 → 118 over the run (the pilot fired on 435 of 1913
engage frames; most of the remainder it was manoeuvring with the target outside
the 9° firing cone).
- Two friendly destroyers were lost while the pilot was elsewhere.
- The HUD's `REMAINING OB` read **012** at t≈240 s while 118 ADAN entities were
alive, so that counter is **objectives, not hostiles** — its RAM address is still
unknown (❔ open).
## Escort-weighted targeting — implemented, and what it did NOT fix
`pilot.py` gained a **DEFEND** mode (2026-07-30): while the asset is losing hull,
target the hostiles pressing *it* — ranked by distance to the asset minus credit for
closing on it — instead of the ones nearest to us. Trigger and ranking both read the
live hull, so nothing is inferred.
It works mechanically: DEFEND engaged **1.9 s after the asset's first hit** in one run
(t=167.0), and held for 54 % of a 330 s run. **But it did not measurably save the
asset.** Over the window the two policies share, they are the same to within noise:
| t (s) | nearest-fighter | escort-weighted |
|---|---|---|
| 120 | 25000.0 | 24910.0 |
| 180 | 24779.5 | 24460.0 |
| 239 | 23038.2 | 23218.0 |
Two honest reasons it cannot yet be scored better than "no worse":
1. **The runs are not comparable past that window** — different spawn timing and, in
the escort-weighted run, a hostile population that *grew* 134 → 166 while the
baseline's fell 147 → 118.
2. **Lethality is the real bottleneck, not target choice.** The guns are on for only
**12 % of combat frames** (320 of 2630); the rest of the time the target is outside
the 9° firing cone while the loop manoeuvres. Choosing a better target does little
when most passes do not shoot.
**One bug found and fixed by the first escort run** (worth keeping as a pattern): the
new mode flies *at* the asset, which sits inside the friendly formation, and the run
ended `hull 1500 -> DEAD` in a single tick at 2026 units/s, 0.6 s from a friendly
destroyer the avoidance expected to clear by 365 units — against a hull of radius
2000. Keep-out had been applied only to hostile turrets. Every entity above
`BIG_RADIUS` now gets a physical keep-out of **its own radius + 800**, with braking
inside it, whatever its faction; the next run survived its full 330 s untouched.
## Ballistics from the disc data — and the measurement that invalidates the metric
The solved `Shell` records give the player's guns exactly
(`Shell_TCAF_DeltaSaber_{NoseGun,Gun,Beam}_P`, all ✅ CONFIRMED):
**`Velocity` 8000**, **`LifeTime` 0.5 s**, **`MaximumRange` 4000** — self-consistent,
since 8000 × 0.5 = 4000 — plus shell `Radius` 2030 and `Power` 15/30/40.
Two things in `pilot.py` were plainly wrong against those numbers, and both are fixed:
- **Lead used our own speed as the shell speed.** Flight time was `d / max(our_speed,
300)`, i.e. 4002000 u/s instead of 8000 — every shot led **416× too far ahead**.
- **`FIRE_RANGE` was 5000**, past the range at which the shells expire.
**But the outcome metric says none of this has been shown to help.** The HUD's own
counters — `YOU KILLED: WARSHIPS` / `WARPLANES` — read **0000 / 0000 at the end of
every run**, including the nearest-fighter baseline. The pilot is not killing
anything in any configuration, so "fraction of frames with the guns on" (12 % → 5 % →
1 frame in 2639 as the firing gate was varied) was never measuring lethality. The
corrections above are right on the physics and fix demonstrably wrong code; **they are
not evidence of improvement**, and none is claimed.
The firing gate itself produced one clean result worth keeping: gating on the target's
angular half-size **alone** (2.7° at 2584 units for a fighter) is far tighter than the
steering loop can hold the nose, and firing collapsed to 1 frame in 2639. Angular size
belongs in the gate as a **floor** that opens it up close, never as a cap.
### Why nothing died — settled by probe, then fixed
`fire_probe.sh` holds each pad input in turn in flight and photographs the HUD ammo
counters. Result:
| input | `NOSE BM` | `MAIN MPM` |
|---|---|---|
| idle | 06000 | 00300 |
| **RB** | **05956** (44 in 4 s, HEAT rises) | 00300 |
| **Y** | 05951 | **00299** (1) |
| LB / X / B / A / RT / LT | no change | no change |
So **`RB` is the nose gun (~11 rounds/s) and `Y` is the main mount** — measured, not
assumed — and the "we never shoot" hypothesis is dead: **we shoot and miss.**
Which is what the disc data says to stop doing. `Shell_TCAF_DeltaSaber_Missile_P` is
**Power 200, `GuidanceType` 5 (guided), `MaximumRange` 5000**, against the nose gun's
**Power 15, unguided**. One missile is worth ~14 gun hits on a 500 HP fighter *and it
steers itself* — the accuracy problem solved rather than tuned. (`ASMissile_P` is
Power **5000**, the anti-ship option.)
Adding missile launches to the pilot (press `Y`, release a tick later, ≥2 s apart)
produced **the first kills of the whole series: `YOU KILLED: WARPLANES 0002`**, versus
`0000` in all five gun-only runs, with hostiles down 134 → 104 (the largest fall yet).
**Still poor, and stated as such: 98 missiles for 2 kills (~2 %).** The likely cause is
that the game expects a *lock* — holding the target in the reticle before launch — and
an unlocked launch is wasted. Reading the lock state (or the lock timer) out of RAM is
the next step, and it is the same anchoring trick as everything else here.
## Target commitment — the change that actually moved kills
The pilot re-scored every contact every tick, so the nose chased whichever fighter was
momentarily best-scoring and the aim error wandered 1040° through a pass. Since a
missile lock is time-on-target, constant switching is the one thing guaranteed to
prevent a kill. **Commitment**: stay on the chosen contact until it dies, gets beyond
6000, sits >90° off the nose for 2.5 s, or 14 s elapse.
Nothing else changed — same guns, same ballistics, same escort weighting, same missile
cadence:
| run | kills (`WARPLANES`) | missiles | hostiles |
|---|---|---|---|
| gun-only × 5 | **0000** | 0 | 147→118 … 134→166 |
| + guided missiles | **0002** | 98 | 134→104 |
| + **target commitment** | **0009** | 101 | **134→97** |
4.5× the kills for the same ammunition, and the largest fall in hostile population of
any run. Our own hull finished untouched at 1500/1500.
**The escort is still not saved** — the ACROPOLIS finished at 76.6 % — so this improves
lethality, not the mission outcome, and the two should not be conflated.
### Negative result: the selected target is not a raw entity pointer
Worth recording so it is not re-attempted. `target_probe.py` looked for the selection
three ways: (1) every word in a ±0x1400 window of the player object that points at a
live entity — **none**; (2) every word in *all* of RAM holding an entity pointer, tapped
through each button — only thread-stack slots (`0x70xx_xxxx`) churned, which is frame
noise, not selection; (3) a delta tally over all 150 entities looking for a repeated
offset holding a pointer to *another* entity, the same trick that found the definition
pointer at `+0x130` — **zero candidates**.
So neither the player nor the AI ships keep a raw pointer to their target near their
transform. The selection is a handle, an index, or lives in a targeting subsystem
outside the entity object.
## Reimplementation notes
- Defeat conditions for an escort stage are readable as: protected-asset
`hull ≤ 0`, or player `hull ≤ 0`.
- Every unit's effective HP is the definition's `HP`, confirmed live for 7 classes —
the same field the [unit struct](structures/unit-struct-runtime.md) already solves
statically, so disc data and runtime agree.
- Entity removal on death is observable (the object leaves the heap), which gives a
clean lifetime signal for anything modelling spawn/despawn.

144
tools/re-capture/binq.py Normal file
View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Ask questions of a ctrl_probe.py capture: which words answer to which input?
The capture is a window of the player entity object sampled every tick, tagged
with the pad state that produced it. That makes the interesting question
mechanical: for each 4-byte offset, does its value during phase X differ from
its value during the rest phases either side? A word that only moves while `RT`
is held is that input's state — a throttle setting, an afterburner tank, a heat
gauge — and one that moves in *every* phase is just live physics.
Sub-commands
phases per-phase mean of every offset that moves at all
respond <phase> offsets that move during <phase> and not at rest
near <value> [tol] offsets whose first sample is ~= value (HUD anchor)
trace <off> [off...] full time series of specific offsets (pos-relative hex)
"""
import os
import struct
import sys
import numpy as np
HDR = b"SYLPHCTR"
REC = struct.Struct("<d32sff3f")
def load(path):
with open(path, "rb") as f:
blob = f.read()
assert blob[:8] == HDR, "not a ctrl_probe capture"
n, win, back = struct.unpack_from("<III", blob, 8)
stride = REC.size + win
base = 8 + 12
ts, phase, sp, dodge, pos, wins = [], [], [], [], [], []
for i in range(n):
o = base + i * stride
t, ph, s, dg, x, y, z = REC.unpack_from(blob, o)
ts.append(t)
phase.append(ph.split(b"\0")[0].decode())
sp.append(s)
dodge.append(dg)
pos.append((x, y, z))
wins.append(blob[o + REC.size:o + REC.size + win])
# A run that ended in GAME OVER keeps sampling a dead object, and those
# frames dominate every statistic. $BINQ_TMAX truncates the capture to the
# part that was still flying.
tmax = float(os.environ.get("BINQ_TMAX", "inf"))
if tmax < float("inf"):
keep = [i for i, t in enumerate(ts) if t <= tmax]
n = len(keep)
ts = [ts[i] for i in keep]
phase = [phase[i] for i in keep]
sp = [sp[i] for i in keep]
dodge = [dodge[i] for i in keep]
pos = [pos[i] for i in keep]
wins = [wins[i] for i in keep]
A = np.frombuffer(b"".join(wins), dtype=">f4").reshape(n, win // 4).astype(np.float64)
U = np.frombuffer(b"".join(wins), dtype=">u4").reshape(n, win // 4)
return dict(n=n, win=win, back=back, t=np.array(ts), phase=phase,
speed=np.array(sp), dodge=np.array(dodge),
pos=np.array(pos), F=A, U=U)
def label(d, i):
return f"pos{i * 4 - d['back']:+#07x}"
def finite(d):
F = d["F"]
return np.all(np.isfinite(F), axis=0) & (np.max(np.abs(F), axis=0) < 1e12)
def cmd_phases(d, args):
ok = finite(d)
phases = []
for p in d["phase"]:
if p not in phases:
phases.append(p)
F = d["F"]
mv = np.zeros(F.shape[1])
means = {}
for p in phases:
m = np.array([x == p for x in d["phase"]])
means[p] = F[m].mean(axis=0)
for p in phases:
mv = np.maximum(mv, np.abs(means[p] - means[phases[0]]))
idx = np.flatnonzero(ok & (mv > 1e-3))
order = idx[np.argsort(-mv[idx])][:int(args[0]) if args else 25]
print("offset " + "".join(f"{p[:8]:>10}" for p in phases))
for i in order:
print(f"{label(d, i):<10}" + "".join(f"{means[p][i]:10.2f}" for p in phases))
def cmd_respond(d, args):
want = args[0]
F, ok = d["F"], finite(d)
inp = np.array([p == want for p in d["phase"]])
rest = np.array([p.startswith("rest") or p == "base" for p in d["phase"]])
if not inp.any():
sys.exit(f"no phase {want!r}")
# A word answering to this input must move *while it is held* and be quiet
# at rest; a word that also moves at rest is live physics, not the input.
a = F[inp]
r = F[rest]
d_in = a.max(axis=0) - a.min(axis=0)
d_rest = r.max(axis=0) - r.min(axis=0)
score = d_in - 2.0 * d_rest
idx = np.flatnonzero(ok & (d_in > 1e-3) & (score > 0))
for i in idx[np.argsort(-score[idx])][:20]:
print(f"{label(d, i):<10} in-phase {a[:, i].min():12.3f}..{a[:, i].max():12.3f}"
f" at-rest {r[:, i].min():12.3f}..{r[:, i].max():12.3f}")
if not len(idx):
print("(nothing moves under this input that is quiet at rest)")
def cmd_near(d, args):
v = float(args[0])
tol = float(args[1]) if len(args) > 1 else max(1e-3, abs(v) * 1e-3)
F, U = d["F"], d["U"]
for i in np.flatnonzero(np.abs(F[0] - v) <= tol):
print(f"{label(d, i):<10} f32 {F[0, i]:.4f} -> {F[-1, i]:.4f}")
for i in np.flatnonzero(np.abs(U[0].astype(np.float64) - v) <= tol):
print(f"{label(d, i):<10} u32 {U[0, i]} -> {U[-1, i]}")
def cmd_trace(d, args):
offs = [int(a, 0) for a in args]
idx = [(o + d["back"]) // 4 for o in offs]
print("t phase speed " + " ".join(f"{o:+#07x}" for o in offs))
for k in range(d["n"]):
print(f"{d['t'][k]:6.2f} {d['phase'][k]:<12} {d['speed'][k]:6.0f} "
+ " ".join(f"{d['F'][k, i]:8.2f}" for i in idx))
def main():
d = load(sys.argv[1])
print(f"# {d['n']} ticks, window {d['win']:#x} bytes, back {d['back']:#x}")
cmd = sys.argv[2] if len(sys.argv) > 2 else "phases"
{"phases": cmd_phases, "respond": cmd_respond, "near": cmd_near,
"trace": cmd_trace}[cmd](d, sys.argv[3:])
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""Which control is the throttle, and where does the craft keep its own state?
Two questions, one flight. Both are answered by *consequence* rather than by
reading a field we hope is the right one:
* **Throttle.** The craft's speed is measured from its own position — a finite
difference on the position triple in guest RAM — so no speed field has to be
found first. The probe then holds each candidate input in turn and asks which
one changes that measured speed. (`findspeed.py` failed the other way round:
it assumed `RT` was the throttle and went looking for a field that rose.)
* **Own state.** Every tick also copies a window of the player entity object.
Afterwards, offsets whose float value tracks the measured speed are candidate
speed/throttle fields, and offsets that only ever *fall* are candidate
hull/shield/ammo — the numbers survival needs.
Flying straight into a firefight for 90 s is how earlier runs died, so the loop
keeps the collision avoidance from navigator.py armed the whole time and marks
any sample where it had to intervene: a phase that had to dodge is not a clean
speed measurement, and says so rather than being quietly averaged in.
Usage: ctrl_probe.py <config.json> <out-prefix> [hold_s] [settle_s]
"""
import json
import math
import os
import struct
import sys
import time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gmem # noqa: E402
import navigator # noqa: E402
from flight_probe import Pad # noqa: E402
# 0x800 each way: the shield lives at pos+0x430 (own_state.py), which a
# 0x400 window silently cropped out of the first capture.
WIN_BACK = 0x800 # bytes of the player object kept before the position
WIN_FWD = 0x800 # ...and after
WIN = WIN_BACK + WIN_FWD
HZ = 20.0
# (label, [pad commands]) — everything the pad can do that might be a throttle.
# RB is left out: it is the fire button (autopilot-memory-driven.md) and firing
# during a speed measurement only invites return fire.
CANDIDATES = [
("RT", [("trig", "RT", 1.0)]),
("LT", [("trig", "LT", 1.0)]),
("RT+LT", [("trig", "RT", 1.0), ("trig", "LT", 1.0)]),
("A", [("press", "A")]),
("B", [("press", "B")]),
("X", [("press", "X")]),
("Y", [("press", "Y")]),
("LB", [("press", "LB")]),
("LS", [("press", "LS")]),
("RS", [("press", "RS")]),
("RY_up", [("axis", "RY", -1.0)]),
("RY_down", [("axis", "RY", 1.0)]),
("RX_right", [("axis", "RX", 1.0)]),
("dpad_up", [("dpad", "up")]),
("dpad_down", [("dpad", "down")]),
("dpad_left", [("dpad", "left")]),
("dpad_right", [("dpad", "right")]),
]
def apply(pad, cmds):
for c in cmds:
if c[0] == "trig":
pad.trig(c[1], c[2])
elif c[0] == "axis":
pad.axis(c[1], c[2])
elif c[0] == "press":
pad.press(c[1])
elif c[0] == "dpad":
pad.f.write(f"dpad {c[1]}\n")
def clear(pad):
pad.reset()
pad.f.write("dpad center\n")
class Run:
def __init__(self, cfg, prefix, hold, settle):
self.W = navigator.World(cfg)
self.nav = navigator.Navigator(self.W, None, dry=True)
self.prefix = prefix
self.hold = hold
self.settle = settle
self.pad = Pad()
self.rows = [] # (t, phase, pos, speed, dodged)
self.win = [] # raw window bytes per tick
ents = self.W.scan()
me = [(off, va) for off, va in ents if "Player" in self.W.defs[va]]
if not me:
sys.exit("player entity not in the scan — not in flight?")
self.me_off = me[0][0]
self.me_name = self.W.defs[me[0][1]]
print(f"# player {self.me_name} pos off {self.me_off:#x} "
f"va {gmem.primary_va(self.me_off):#x} | {len(ents)} entities",
flush=True)
# ------------------------------------------------------------- helpers
def pos(self):
return self.W.pos(self.me_off)
def sticks_for(self, vec):
"""Stick deflections that point the nose along `vec` (escape steering)."""
M = self.W.rot(self.me_off)
if M is None:
return 0.0, 0.0
fwd = M[self.W.fwd_row] * self.W.fwd_sign
right = M[(self.W.fwd_row + 1) % 3]
up = np.cross(fwd, right)
ez = float(np.dot(vec, fwd))
yaw = math.atan2(float(np.dot(vec, right)), ez if abs(ez) > 1e-3 else 1e-3)
pitch = math.atan2(float(np.dot(vec, up)), ez if abs(ez) > 1e-3 else 1e-3)
return (max(-1.0, min(1.0, 2.0 * yaw)), max(-1.0, min(1.0, -2.0 * pitch)))
# ---------------------------------------------------------------- phase
def phase(self, label, cmds, secs):
clear(self.pad)
apply(self.pad, cmds)
t_end = time.time() + secs
dodged = False
while time.time() < t_end:
t = time.time()
p = self.pos()
if p is None:
break
self.rows.append([t, label, p, 0.0, 0])
self.win.append(os.pread(self.W.fd, WIN, self.me_off - WIN_BACK))
# avoidance runs at 5 Hz; a real threat overrides the phase and the
# samples from here on are flagged
if len(self.rows) % 4 == 0:
ents = self.W.sample(t)
me = [e for e in ents if e[0] == self.me_off]
if me:
_, _, mp, mv, mr = me[0]
push, worst = self.nav.avoidance(mp, mv, mr, ents, self.me_off)
if float(np.linalg.norm(push)) > 0.6:
sx, sy = self.sticks_for(navigator.norm(push))
self.pad.axis("LX", sx)
self.pad.axis("LY", sy)
dodged = True
self.rows[-1][4] = 1
elif dodged:
self.pad.axis("LX", 0.0)
self.pad.axis("LY", 0.0)
time.sleep(max(0.0, 1.0 / HZ - (time.time() - t)))
return dodged
def run(self):
t0 = time.time()
self.phase("base", [], self.settle * 2)
for label, cmds in CANDIDATES:
d = self.phase(label, cmds, self.hold)
self.phase(f"rest_{label}", [], self.settle)
sp = self.phase_speed(label)
print(f"[{time.time()-t0:6.1f}] {label:<10} "
f"v0={sp[0]:7.1f} v1={sp[1]:7.1f} d={sp[1]-sp[0]:+7.1f}"
f"{' (dodged)' if d else ''}", flush=True)
clear(self.pad)
self.speeds()
self.dump()
# ------------------------------------------------------------ analysis
def speeds(self):
"""Fill in per-tick speed by central difference on position."""
for i, r in enumerate(self.rows):
j, k = max(0, i - 2), min(len(self.rows) - 1, i + 2)
dt = self.rows[k][0] - self.rows[j][0]
if dt > 1e-3:
r[3] = float(np.linalg.norm(self.rows[k][2] - self.rows[j][2])) / dt
def phase_speed(self, label):
"""(speed early, speed late) within a phase — needs speeds() first."""
self.speeds()
v = [r[3] for r in self.rows if r[1] == label]
if len(v) < 6:
return (0.0, 0.0)
n = max(2, len(v) // 4)
return (float(np.mean(v[:n])), float(np.mean(v[-n:])))
def dump(self):
with open(self.prefix + ".csv", "w") as f:
f.write("t,phase,x,y,z,speed,dodged\n")
t0 = self.rows[0][0]
for t, ph, p, sp, dg in self.rows:
f.write(f"{t-t0:.3f},{ph},{p[0]:.3f},{p[1]:.3f},{p[2]:.3f},"
f"{sp:.3f},{dg}\n")
with open(self.prefix + ".bin", "wb") as f:
f.write(b"SYLPHCTR")
f.write(struct.pack("<III", len(self.rows), WIN, WIN_BACK))
t0 = self.rows[0][0]
for (t, ph, p, sp, dg), w in zip(self.rows, self.win):
f.write(struct.pack("<d32sff3f", t - t0, ph.encode()[:32], sp,
float(dg), *[float(c) for c in p]))
f.write(w.ljust(WIN, b"\0"))
print(f"# wrote {self.prefix}.csv and {self.prefix}.bin "
f"({len(self.rows)} ticks)", flush=True)
# ---- per-phase summary
print("\n# phase n v_early v_late delta dodged")
order, seen = [], set()
for r in self.rows:
if r[1] not in seen:
seen.add(r[1])
order.append(r[1])
for ph in order:
v = [r[3] for r in self.rows if r[1] == ph]
dg = sum(r[4] for r in self.rows if r[1] == ph)
if len(v) < 6:
continue
n = max(2, len(v) // 4)
a, b = float(np.mean(v[:n])), float(np.mean(v[-n:]))
print(f" {ph:<12} {len(v):4d} {a:9.1f} {b:9.1f} {b-a:+9.1f} {dg:5d}")
# ---- which words in the object track speed, and which only fall
W_ = np.frombuffer(b"".join(x.ljust(WIN, b"\0") for x in self.win),
dtype=">f4").reshape(len(self.win), WIN // 4)
sp = np.array([r[3] for r in self.rows], dtype=np.float64)
with np.errstate(invalid="ignore", over="ignore"):
A = W_.astype(np.float64)
ok = np.all(np.isfinite(A), axis=0) & (np.max(np.abs(A), axis=0) < 1e9)
var = np.std(A, axis=0)
cand = np.flatnonzero(ok & (var > 1e-6))
cor = []
for i in cand:
c = np.corrcoef(A[:, i], sp)[0, 1]
if np.isfinite(c):
cor.append((abs(c), c, i))
cor.sort(reverse=True)
print("\n# object words correlating with measured speed "
"(offset relative to the position triple)")
for ac, c, i in cor[:12]:
off = i * 4 - WIN_BACK
print(f" pos{off:+#07x} r={c:+.3f} "
f"range {A[:, i].min():.3f} .. {A[:, i].max():.3f}")
print("\n# words that never rise (candidate hull / shield / ammo)")
shown = 0
for i in cand:
col = A[:, i]
if col[-1] >= col[0] - 1e-6:
continue
if np.max(np.diff(col)) > 1e-6:
continue
off = i * 4 - WIN_BACK
print(f" pos{off:+#07x} {col[0]:.3f} -> {col[-1]:.3f}")
shown += 1
if shown >= 12:
break
if not shown:
print(" (none — nothing in the window decreased monotonically)")
def main():
cfg = json.load(open(sys.argv[1]))
prefix = sys.argv[2]
hold = float(sys.argv[3]) if len(sys.argv) > 3 else 4.0
settle = float(sys.argv[4]) if len(sys.argv) > 4 else 2.5
Run(cfg, prefix, hold, settle).run()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# One background task: bind the player's transform, then run the control probe.
#
# Everything that must not be interrupted lives in a single task here, because
# the display and the emulator both die on their own every few minutes in this
# container and nothing may depend on surviving between tool calls.
#
# Assumes the game is ALREADY in flight (launch_mission.sh). Pass --boot to
# have it get there itself.
set -u
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
export PYTHONPATH=/sylph-home/.local/lib/python3.12/site-packages
SD="$(cd "$(dirname "$0")" && pwd)"
CFG=/tmp/nav-live.json
PRE=/tmp/ctrl
HOLD=3.0
SETTLE=2.0
if [ "${1:-}" = "--boot" ]; then
shift
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
fi
[ $# -ge 1 ] && CFG="$1"
[ $# -ge 2 ] && PRE="$2"
[ $# -ge 3 ] && HOLD="$3"
[ $# -ge 4 ] && SETTLE="$4"
python3 "$SD/entities2.py" self 0x130 "$CFG" || { echo "TRANSFORM BIND FAILED"; exit 1; }
echo "--- config: $(cat "$CFG")"
python3 "$SD/ctrl_probe.py" "$CFG" "$PRE" "$HOLD" "$SETTLE"
echo "PROBE DONE"

View File

@@ -180,24 +180,35 @@ def main():
"(" + ",".join(f"{v:+.3f}" for v in row) + ")" for row in M))
if len(sys.argv) > 3 and found:
import json
# forward axis = the row closest to our direction of travel
vdir = None
# Which of the orthonormal blocks is the CRAFT's attitude? The one
# with a row along the direction of travel. Taking found[0] is what
# produced a config with rot_delta -0x764 and a nonsense forward
# axis: several blocks inside the object are orthonormal (bone or
# camera frames), and only the craft's own has a row that tracks
# where the craft is going.
p0 = np.array(pos)
time.sleep(0.35)
p1 = np.array(struct.unpack(">3f", os.pread(fd, 12, off)))
if np.linalg.norm(p1 - p0) > 1e-3:
vdir = (p1 - p0) / np.linalg.norm(p1 - p0)
rot_delta, M, rot_stride = found[0]
row, sign = 2, 1
if vdir is not None:
step = p1 - p0
if np.linalg.norm(step) < 1e-3:
sys.exit("craft is not moving — cannot bind the forward axis")
vdir = step / np.linalg.norm(step)
best = None
for d, M, st in found:
cs = [float(M[r] @ vdir) for r in range(3)]
row = int(np.argmax([abs(c) for c in cs]))
sign = 1 if cs[row] > 0 else -1
print(f"# forward axis = row {row} (sign {sign:+d}), "
f"cos={cs[row]:+.3f}")
r = int(np.argmax([abs(c) for c in cs]))
if best is None or abs(cs[r]) > abs(best[3]):
best = (d, M, st, cs[r], r)
rot_delta, M, rot_stride, cos, row = best
sign = 1 if cos > 0 else -1
print(f"# attitude block pos{rot_delta:+#07x} stride {rot_stride}: "
f"forward = row {row} (sign {sign:+d}), cos={cos:+.3f}")
if abs(cos) < 0.9:
print("# WARNING: no block tracks the flight path (|cos| < 0.9)"
" — the craft may be drifting hard; re-run while flying straight")
cfg = {"def_delta": delta, "rot_delta": rot_delta,
"rot_stride": rot_stride,
"fwd_row": row, "fwd_sign": sign,
"fwd_row": row, "fwd_sign": sign, "fwd_cos": round(cos, 4),
"va_lo": ENT_VA_LO, "va_hi": ENT_VA_HI}
json.dump(cfg, open(sys.argv[3], "w"), indent=1)
print("# wrote " + sys.argv[3] + ": " + json.dumps(cfg))

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# One background task: boot -> Stage 02 -> fly the pilot while a second reader
# records EVERY entity's hull, so the escort question ("who is losing, and how
# fast") is answered from memory instead of from the HUD.
#
# Same one-task rule as fly_session.sh: the display and the emulator both die on
# their own in this container, so nothing may depend on surviving between tool
# calls.
set -u
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
export PYTHONPATH=/sylph-home/.local/lib/python3.12/site-packages
SD="$(cd "$(dirname "$0")" && pwd)"
SECS="${1:-300}"
TAG="${2:-escort}"
SHOTS=/sylph-home/re/shots
CFG=/tmp/nav-live.json
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
python3 "$SD/entities2.py" self 0x130 "$CFG" || { echo "BIND FAILED"; exit 1; }
echo "--- config: $(cat "$CFG")"
echo "=== initial entity table ==="
python3 "$SD/mission_state.py" scan "$CFG"
# The HUD's REMAINING OB counter has no known address yet, so the correlation
# material is a screenshot every 20 s stamped against the same clock as the
# memory samples.
( for i in $(seq 1 20); do
printf '%s SHOT %02d\n' "$(date +%s.%N)" "$i" >> "/tmp/$TAG-shots.log"
screenshot "$SHOTS/$TAG-$i.png" >/dev/null 2>&1
sleep 20
done ) &
SHOTTER=$!
date +%s.%N > "/tmp/$TAG-t0"
python3 "$SD/mission_state.py" watch "$CFG" "$SECS" 1 "/tmp/$TAG-mission.jsonl" \
> "/tmp/$TAG-mission.log" 2>&1 &
WATCHER=$!
python3 "$SD/pilot.py" "$CFG" "$SECS" > "/tmp/$TAG-pilot.log" 2>&1
wait $WATCHER 2>/dev/null
kill $SHOTTER 2>/dev/null
screenshot "$SHOTS/$TAG-end.png" >/dev/null 2>&1
echo "SESSION DONE"

42
tools/re-capture/fire_probe.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Does the pad actually DISCHARGE a weapon? Hold each candidate input in turn
# and photograph the HUD's ammo counters.
#
# Why this exists: the autopilot's HUD kill counters read WARSHIPS 0000 /
# WARPLANES 0000 at the end of every run, in every targeting configuration, so
# "fraction of frames with the guns commanded on" was never measuring anything.
# Before tuning aim any further, settle the prior question — whether the fire
# command reaches the gun at all. The HUD carries a live ammo count (`MAIN MPM
# 00300`, matched to `LoadingCount` by the weapon RE), so the counter falling
# during a hold is direct evidence of a discharge, and the counter sitting still
# through every button is direct evidence that we have never fired a shot.
#
# `RB fires` came from an earlier session; this re-tests it rather than assuming
# it, and sweeps the other buttons so a wrong mapping cannot hide.
set -u
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
export PYTHONPATH=/sylph-home/.local/lib/python3.12/site-packages
SD="$(cd "$(dirname "$0")" && pwd)"
SHOTS=/sylph-home/re/shots
HOLD="${1:-4}"
shot(){ screenshot "$SHOTS/fire-$1.png" >/dev/null 2>&1; echo " shot $1"; }
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
sleep 3
echo "=== probing (hold ${HOLD}s each) ==="
shot "00-idle"
# RB first: it is the incumbent claim. Then the rest, so a wrong mapping cannot
# hide behind it. A/B/X/Y may also switch weapon or open something — that is
# fine for a probe, and the final frame records wherever it ended up.
for b in RB LB Y X B A LS RS; do
vgamepad press "$b"; sleep "$HOLD"; shot "hold-$b"; vgamepad release "$b"; sleep 1.5
done
# triggers are analogue, not buttons
vgamepad trig RT 1.0; sleep "$HOLD"; shot "hold-RT"; vgamepad trig RT 0.0; sleep 1.5
vgamepad trig LT 1.0; sleep "$HOLD"; shot "hold-LT"; vgamepad trig LT 0.0; sleep 1.5
vgamepad reset
shot "99-final"
echo "PROBE DONE"

27
tools/re-capture/fly_session.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# One background task: boot -> mission -> bind the transform -> fly the pilot,
# with periodic screenshots so the outcome has visual evidence and not just a log.
#
# It is one task on purpose: the display and the emulator both die on their own
# every few minutes in this container, so nothing may depend on surviving
# between tool calls.
set -u
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
export PYTHONPATH=/sylph-home/.local/lib/python3.12/site-packages
SD="$(cd "$(dirname "$0")" && pwd)"
SECS="${1:-240}"
TAG="${2:-pilot}"
SHOTS=/sylph-home/re/shots
CFG=/tmp/nav-live.json
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
python3 "$SD/entities2.py" self 0x130 "$CFG" || { echo "BIND FAILED"; exit 1; }
echo "--- config: $(cat "$CFG")"
python3 "$SD/own_state.py" "$CFG" 3 "/tmp/$TAG-own.json"
( for i in $(seq 1 12); do sleep 25; screenshot "$SHOTS/$TAG-$i.png" >/dev/null 2>&1; done ) &
SHOTTER=$!
python3 "$SD/pilot.py" "$CFG" "$SECS"
kill $SHOTTER 2>/dev/null
screenshot "$SHOTS/$TAG-end.png" >/dev/null 2>&1
echo "SESSION DONE"

View File

@@ -13,12 +13,25 @@ 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.
# Run this whole script as ONE tracked background task 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
setsid nohup Xvfb "$DISPLAY" -screen 0 1280x720x24 -ac -nolisten tcp \
+extension GLX +extension RANDR </dev/null >/tmp/xvfb98.log 2>&1 &
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 >/dev/null 2>&1 &
for _ in $(seq 1 50); do xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break; sleep 0.2; done
setsid nohup env DISPLAY="$DISPLAY" HOME=/sylph-home openbox </dev/null >/tmp/openbox98.log 2>&1 &
nohup env DISPLAY="$DISPLAY" HOME=/sylph-home openbox </dev/null >/tmp/openbox98.log 2>&1 &
sleep 1
fi
xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 || { echo "DISPLAY UNAVAILABLE"; exit 1; }
@@ -32,10 +45,10 @@ rm -f /dev/shm/xenia_memory_* /dev/shm/xenia_code_cache_* 2>/dev/null
ensure_display
cd /sylph-home/re
setsid nohup run-canary --audio --apu=sdl --log_mask=13 \
nohup run-canary --audio --apu=sdl --log_mask=13 \
--logged_profile_slot_0_xuid=E0300000EFBEA3D4 </dev/null >/dev/null 2>&1 &
sleep 5
"$SD/skip_intro.sh" 600 || { echo "BOOT FAILED"; exit 1; }
"$SD/skip_intro.sh" 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
@@ -55,8 +68,8 @@ fi
step up # BRIEFINGS -> TAKE OFF
vgamepad tap A 250
sleep 75 # launch cinematic + stage load + objective card
shot "lm-objective.png"
vgamepad tap A 250; sleep 6 # dismiss the OBJECTIVE panel
# 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)"

34
tools/re-capture/lock_probe.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Which input SELECTS a target, and does a lock then build?
#
# 98 guided missiles produced 2 kills. A guided missile with nothing to guide to
# flies straight, so the suspicion is that the loop has never selected a target
# at all: the HUD carries a `TARGET` marker and a lock reticle, and no button in
# pilot.py has ever touched them. fire_probe.sh already showed LB/X/B/A/LS/RS do
# not discharge a weapon — but "does not fire" says nothing about "does not
# select", so sweep them again watching the RETICLE instead of the ammo.
#
# Captures the centre of the screen (reticle + lock brackets) and the right-hand
# target panel, so a selection or a building lock is visible either way.
set -u
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
SD="$(cd "$(dirname "$0")" && pwd)"
SHOTS=/sylph-home/re/shots
HOLD="${1:-3}"
shot(){ screenshot "$SHOTS/lock-$1.png" >/dev/null 2>&1; echo " shot $1"; }
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
sleep 3
shot "00-idle"
# tap, not hold: a select is an edge, and holding a cycle button would just spin
# through every contact. Two taps each, so a cycle that lands on nothing the
# first time still shows on the second.
for b in RS LS LB B X A Y; do
vgamepad tap "$b" 200; sleep 0.4; vgamepad tap "$b" 200; sleep "$HOLD"
shot "tap-$b"
done
vgamepad reset
shot "99-final"
echo "PROBE DONE"

167
tools/re-capture/mission_state.py Executable file
View File

@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Mission state from guest RAM: every entity's hull, not just the player's.
`own_state.py` found the player's hull by anchoring on a solved definition
field: an undamaged craft carries its definition's own `HP` (+0x054), so the
live counter is the copy of that number that *falls*. The result was
`hull = position + 0x154`.
The escort question needs the same number for **someone else's** ship. Stage 02
is lost when the ACROPOLIS sinks, not when the player dies, and the 240 s run in
autopilot-memory-driven.md hit GAME OVER with our own hull untouched. So the
claim to test here is that `+0x154` is a property of the *entity class*, not of
the player object: every entity, hostile or friendly, fighter or capital ship,
should hold its own definition's `HP` there at spawn and lose it when hit.
That is falsifiable in one run: read `pos+0x154` and the definition `HP` for
every entity in the scene and compare. If the anchor is class-wide, the ratio
is 1.0 for everything undamaged and nothing else lines up by accident; if it is
player-specific, most entities hold something unrelated.
Sub-commands:
scan [cfg] one table of every entity: hull vs definition HP
watch <cfg> <secs> [hz] [out] sample over time; report what took damage
"""
import json
import math
import os
import struct
import sys
import time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gmem # noqa: E402
import navigator # noqa: E402
HULL_OFF = 0x154 # own_state.py, player craft
DEF_HP = 0x054 # unit-struct-runtime.md, ✅ CONFIRMED
DEF_SIZE_R = 0x50
def f32(fd, off):
b = os.pread(fd, 4, off)
if len(b) < 4:
return float("nan")
(v,) = struct.unpack(">f", b)
return v
class Mission:
def __init__(self, cfg):
self.W = navigator.World(cfg)
self.def_hp = {}
for va in self.W.defs:
self.def_hp[va] = f32(self.W.fd, gmem.va_to_off(va) + DEF_HP)
def snapshot(self):
"""[(off, name, faction, radius, pos, hull, hp_max)] for every entity."""
out = []
for off, va in self.W.ents:
p = self.W.pos(off)
if p is None:
continue
hull = f32(self.W.fd, off + HULL_OFF)
nm = self.W.defs[va]
out.append((off, nm, navigator.faction(nm), self.W.radius[va],
p, hull, self.def_hp.get(va, float("nan"))))
return out
def fmt(e):
off, nm, fac, r, p, hull, hp0 = e
frac = hull / hp0 if hp0 and math.isfinite(hp0) and hp0 > 0 else float("nan")
return (f"{gmem.primary_va(off):#010x} {nm[:34]:<34} {fac:<4} r={r:6.0f} "
f"({p[0]:+8.0f},{p[1]:+8.0f},{p[2]:+8.0f}) hull={hull:9.1f} "
f"HP={hp0:9.1f} frac={frac:6.3f}")
def cmd_scan(cfg):
m = Mission(cfg)
m.W.scan()
ents = m.snapshot()
print(f"# {len(ents)} entities")
ok = sum(1 for e in ents
if math.isfinite(e[6]) and e[6] > 0 and abs(e[5] / e[6] - 1.0) < 1e-3)
fin = sum(1 for e in ents if math.isfinite(e[6]) and e[6] > 0)
print(f"# hull(+{HULL_OFF:#x}) == definition HP for {ok}/{fin} entities "
f"whose definition has an HP")
for e in sorted(ents, key=lambda e: -e[3]):
print(" " + fmt(e))
def cmd_watch(cfg, secs, hz, out):
m = Mission(cfg)
m.W.scan()
base = {e[0]: e for e in m.snapshot()}
print(f"# watching {len(base)} entities for {secs:g}s at {hz:g}Hz", flush=True)
fh = open(out, "w") if out else None
t0 = time.time()
last_scan = t0
last_seen = {}
while time.time() - t0 < secs:
t = time.time()
if t - last_scan > 15.0: # new waves spawn; re-enumerate rarely
m.W.scan()
last_scan = t
for e in m.snapshot():
base.setdefault(e[0], e)
ents = m.snapshot()
rec = {"t": round(t - t0, 2),
"n": len(ents),
"adan": sum(1 for e in ents if e[2] == "ADAN"),
"tcaf": sum(1 for e in ents if e[2] == "TCAF"),
"ents": []}
for e in ents:
off, nm, fac, r, p, hull, hp0 = e
b = base.get(off)
drop = (b[5] - hull) if b and math.isfinite(b[5]) else 0.0
big = r >= navigator.Navigator.BIG_RADIUS
if big or drop > 0.5:
rec["ents"].append({"va": gmem.primary_va(off), "nm": nm,
"fac": fac, "r": round(r, 1),
"hull": round(hull, 1), "hp0": round(hp0, 1),
"drop": round(drop, 1),
"pos": [round(float(c), 1) for c in p]})
if drop > 0.5 and abs(last_seen.get(off, 0.0) - hull) > 0.5:
last_seen[off] = hull
print(f"[{t-t0:6.1f}] HIT {nm[:30]:<30} {fac} "
f"hull {hull:9.1f}/{hp0:9.1f} (-{drop:.0f})", flush=True)
if fh:
fh.write(json.dumps(rec) + "\n")
fh.flush()
time.sleep(max(0.0, 1.0 / hz - (time.time() - t)))
if fh:
fh.close()
print("\n# damage summary")
ents = {e[0]: e for e in m.snapshot()}
for off, b in sorted(base.items(), key=lambda kv: -kv[1][3]):
cur = ents.get(off)
gone = cur is None
hull = cur[5] if cur else float("nan")
if not gone and abs(hull - b[5]) < 0.5 and b[3] < navigator.Navigator.BIG_RADIUS:
continue
print(f" {b[1][:34]:<34} {b[2]:<4} r={b[3]:6.0f} "
f"{b[5]:9.1f} -> {hull:9.1f}" + (" GONE" if gone else ""))
def main():
if len(sys.argv) < 3:
sys.exit(__doc__)
cmd = sys.argv[1]
cfg = json.load(open(sys.argv[2]))
if cmd == "scan":
cmd_scan(cfg)
elif cmd == "watch":
cmd_watch(cfg,
float(sys.argv[3]) if len(sys.argv) > 3 else 120.0,
float(sys.argv[4]) if len(sys.argv) > 4 else 1.0,
sys.argv[5] if len(sys.argv) > 5 else None)
else:
sys.exit(__doc__)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,397 @@
#!/usr/bin/env python3
"""Drift-aware navigation with collision avoidance, driven from guest memory.
Three things the pursuit loop in autopilot3.py did not do:
* **See everything.** It enumerated entities by looking for things that *move*,
so stations, hulls and parked structures were invisible — exactly the objects
you crash into. This scans the entity heap for words that equal a known unit
definition address and takes `position = hit - 0x130`, which finds every
entity whether it is moving or not.
* **Know how big they are.** Each entity's definition carries `Size_Radius`
(+0x50) and `Size_X/Y/Z` (+0x30/34/38) — fields already solved in
docs/re/structures/unit-struct-runtime.md — so the avoidance radius is the
game's own number, not a guess.
* **Account for drift.** The craft does not turn where it points: velocity lags
the nose like an aircraft with sideslip. Steering the *nose* at a target
therefore steers the *flight path* somewhere else, wide and late. This
measures the lag online (the angle between nose and velocity, and how fast
the velocity vector is actually swinging) and commands the nose *ahead* of
where the flight path should go, by that lag.
Avoidance is closest-point-of-approach, not distance: what matters is whether
the two paths will intersect within a horizon, which is why a fast crossing
target is dangerous at 2 km and a station drifting away is not at 300 m.
Usage: navigator.py <config.json> [seconds] [--dry]
"""
import json
import math
import os
import struct
import sys
import time
from collections import Counter
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gmem # noqa: E402
import gworld # noqa: E402
import entities2 # noqa: E402
from flight_probe import Pad # noqa: E402
# confirmed fields of the parsed unit definition (unit-struct-runtime.md)
DEF_SIZE_X, DEF_SIZE_Y, DEF_SIZE_Z, DEF_SIZE_R = 0x30, 0x34, 0x38, 0x50
def norm(v):
n = float(np.linalg.norm(v))
return v / n if n > 1e-9 else v * 0.0
def ang(a, b):
return math.acos(max(-1.0, min(1.0, float(np.dot(norm(a), norm(b))))))
class World:
def __init__(self, cfg):
self.cfg = cfg
self.w = gworld.World()
self.fd, self.size = self.w.fd, self.w.size
self.delta = cfg["def_delta"]
self.rot_delta = cfg["rot_delta"]
self.rot_stride = cfg.get("rot_stride", 12)
self.fwd_row = cfg["fwd_row"]
self.fwd_sign = cfg["fwd_sign"]
self.defs = {} # def_va -> name
self.def_word = {} # 4-byte BE -> def_va
self.radius = {} # def_va -> collision radius
self.prev = {} # pos_off -> (t, pos) for velocity
self.vel = {} # pos_off -> smoothed velocity
self.ents = []
self._load_defs()
def _load_defs(self):
for off in self.w.scan_vtable(gworld.DEF_VTABLE):
nm = self.w.name_of(off)
if not (nm and nm.startswith("UN_")):
continue
va = gmem.primary_va(off)
if va is None:
continue
self.defs[va] = nm
self.def_word[struct.pack(">I", va)] = va
b = os.pread(self.fd, 0x60, off)
try:
sx, sy, sz = (struct.unpack_from(">f", b, o)[0]
for o in (DEF_SIZE_X, DEF_SIZE_Y, DEF_SIZE_Z))
sr = struct.unpack_from(">f", b, DEF_SIZE_R)[0]
except struct.error:
sx = sy = sz = sr = 0.0
vals = [v for v in (sr, sx, sy, sz) if math.isfinite(v) and 0 < v < 1e5]
self.radius[va] = max(vals) if vals else 50.0
# ------------------------------------------------------------- entities
def scan(self):
"""Every entity in the heap — moving or not — by its definition pointer."""
lo = gmem.va_to_off(self.cfg["va_lo"])
hi = gmem.va_to_off(self.cfg["va_hi"])
found = []
# numpy, not a per-word python loop: the entity heap is 16 MB, so
# stepping it 4 bytes at a time costs seconds per scan and the control
# loop spends its whole budget scanning instead of flying.
want = np.array(sorted(self.defs.keys()), dtype=np.uint32)
for a, b in gmem.extents(self.fd, self.size):
a, b = max(a, lo), min(b, hi)
n = (b - a) // 4 * 4
if n < 64:
continue
arr = np.frombuffer(os.pread(self.fd, n, a), dtype=">u4").astype(np.uint32)
for k4 in np.flatnonzero(np.isin(arr, want)):
k = int(k4) * 4
va = int(arr[k4])
poff = a + k - self.delta
if poff < 0:
continue
pb = os.pread(self.fd, 12, poff)
if len(pb) < 12:
continue
p = np.array(struct.unpack(">3f", pb))
if not np.all(np.isfinite(p)) or np.max(np.abs(p)) > 1e7:
continue
found.append((poff, va))
# De-duplicate by POSITION: one entity is mirrored at several
# addresses, so keying on the address keeps every copy and the scene
# looks several times more crowded than it is.
seen, uniq = {}, {}
for poff, va in found:
p = self.pos(poff)
if p is None:
continue
key = (va, tuple(np.round(p, 0)))
if key in seen:
continue
seen[key] = poff
uniq[poff] = va
self.ents = list(uniq.items())
return self.ents
def pos(self, off):
b = os.pread(self.fd, 12, off)
if len(b) < 12:
return None
p = np.array(struct.unpack(">3f", b))
return p if np.all(np.isfinite(p)) else None
def rot(self, off):
n = self.rot_stride * 2 + 12
b = os.pread(self.fd, n, off + self.rot_delta)
if len(b) < n:
return None
M = np.array([struct.unpack_from(">3f", b, self.rot_stride * r) for r in range(3)])
if not np.all(np.isfinite(M)) or np.max(np.abs(M @ M.T - np.eye(3))) > 5e-3:
return None
return M
def sample(self, t):
"""[(off, name, pos, vel, radius)] with velocity by finite difference."""
out = []
for off, va in self.ents:
p = self.pos(off)
if p is None:
continue
# A frame the emulator did not advance gives an identical position
# and a bogus zero velocity, which then reads as "stopped" and
# wrecks both the drift estimate and every closing-rate. Keep the
# last good velocity instead, and smooth it.
prev = self.prev.get(off)
v = self.vel.get(off, np.zeros(3))
if prev is not None:
dt = t - prev[0]
moved = float(np.linalg.norm(p - prev[1]))
if dt > 0.02 and moved > 1e-4:
inst = (p - prev[1]) / dt
if float(np.linalg.norm(inst)) < 5000.0:
v = 0.5 * v + 0.5 * inst if np.any(v) else inst
self.prev[off] = (t, p)
else:
self.prev[off] = (t, p)
self.vel[off] = v
out.append((off, self.defs[va], p, v, self.radius[va]))
return out
def faction(nm):
b = nm[3:]
return "ADAN" if b.startswith("be") or b.startswith("e") else "TCAF"
class Navigator:
KP, KD = 2.2, 0.45
FIRE_CONE = math.radians(9)
FIRE_RANGE = 6000.0
HORIZON = 6.0 # s of look-ahead for collision checks
MARGIN_BIG = 220.0 # clearance around hulls and structures
MARGIN_SMALL = 45.0 # ...around fighters, which manoeuvre themselves
BIG_RADIUS = 120.0 # above this an entity counts as a structure
SELF_MIRROR = 25.0 # the same object is mirrored at several addresses
def __init__(self, W, pad, dry=False, log=sys.stdout):
self.W = W
self.pad = pad
self.dry = dry
self.log = log
self.prevM = None
self.firing = False
self.tau = 0.8 # velocity-lag time constant, refined online
# ------------------------------------------------------------ drift
def update_tau(self, fwd, vel, prev_vhat, dt):
"""How long the flight path takes to catch the nose.
The velocity vector swings toward the nose; the angle between them
divided by the rate the velocity is actually swinging is that lag, and
it is what the nose has to be commanded ahead by.
"""
if prev_vhat is None or dt <= 1e-3:
return
vh = norm(vel)
if np.linalg.norm(vh) < 1e-6:
return
swing = ang(prev_vhat, vh) / dt # rad/s the path is turning
lag = ang(fwd, vh) # rad the path is behind
if swing > 0.02 and lag > 0.02:
tau = lag / swing
if 0.05 < tau < 5.0:
self.tau = 0.9 * self.tau + 0.1 * tau
# ------------------------------------------------- collision avoidance
def avoidance(self, me_p, me_v, me_r, ents, me_off):
"""Sum of escape directions, weighted by how soon and how close."""
push = np.zeros(3)
worst = None
for off, nm, p, v, r in ents:
if off == me_off:
continue
rel_p = p - me_p
rel_v = v - me_v
d = float(np.linalg.norm(rel_p))
# The same entity exists at several mirrored addresses, so our own
# copy shows up as an obstacle at zero distance and pins the sticks
# at full deflection forever. Anything this close is us.
if d < self.SELF_MIRROR:
continue
# A wingman flying formation is metres away by design and steers
# itself; giving it a hull-sized margin makes the loop thrash.
big = r >= self.BIG_RADIUS
margin = self.MARGIN_BIG if big else self.MARGIN_SMALL
if not big and faction(nm) == "TCAF":
margin *= 0.5
safe = me_r + r + margin
if d > 1e4:
continue
vv = float(np.dot(rel_v, rel_v))
t_cpa = 0.0 if vv < 1e-6 else -float(np.dot(rel_p, rel_v)) / vv
t_cpa = max(0.0, min(self.HORIZON, t_cpa))
cpa = rel_p + rel_v * t_cpa
miss = float(np.linalg.norm(cpa))
if miss >= safe:
continue
urgency = (1.0 - miss / safe) * (1.0 - t_cpa / self.HORIZON)
if urgency <= 0:
continue
esc = -norm(cpa) if miss > 1e-3 else norm(np.cross(rel_p, me_v))
if np.linalg.norm(esc) < 1e-6:
esc = norm(np.cross(rel_p, np.array([0.0, 1.0, 0.0])))
push += esc * urgency
if worst is None or urgency > worst[0]:
worst = (urgency, nm, d, miss, t_cpa)
return push, worst
# -------------------------------------------------------------- target
def pick(self, me_p, me_v, fwd, ents, me_off):
speed = max(float(np.linalg.norm(me_v)), 1.0)
best, bestscore = None, 1e18
for off, nm, p, v, r in ents:
if off == me_off or faction(nm) != "ADAN":
continue
rel = p - me_p
d = float(np.linalg.norm(rel))
if d < 1e-3:
continue
# lead: where it will be when a shot gets there
lead = p + v * (d / max(speed, 200.0))
rel_l = lead - me_p
theta = ang(rel_l, fwd)
score = d * (1.0 + 3.0 * (theta / math.pi) ** 2)
if score < bestscore:
best, bestscore = (off, nm, lead, rel_l, d), score
return best
# ---------------------------------------------------------------- step
def step(self, t, dt, prev_vhat):
ents = self.W.sample(t)
me = None
for e in ents:
if "Player" in e[1]:
me = e
break
if me is None:
return "no-player", prev_vhat
me_off, me_nm, me_p, me_v, me_r = me
M = self.W.rot(me_off)
if M is None:
return "no-orientation", prev_vhat
fwd = M[self.W.fwd_row] * self.W.fwd_sign
right = M[(self.W.fwd_row + 1) % 3]
up = np.cross(fwd, right)
speed = float(np.linalg.norm(me_v))
vhat = norm(me_v) if speed > 1.0 else fwd
self.update_tau(fwd, me_v, prev_vhat, dt)
# body angular velocity for the damping term
w = np.zeros(3)
if self.prevM is not None and dt > 1e-3:
D = self.prevM @ M.T
w = np.array([D[2, 1] - D[1, 2], D[0, 2] - D[2, 0], D[1, 0] - D[0, 1]]) / (2 * dt)
self.prevM = M
tgt = self.pick(me_p, me_v, fwd, ents, me_off)
goal = norm(tgt[3]) if tgt else fwd
push, worst = self.avoidance(me_p, me_v, me_r, ents, me_off)
pn = float(np.linalg.norm(push))
# avoidance outranks the target when it is urgent
want = norm(goal + push * (3.0 if pn > 0.6 else 1.5)) if pn > 1e-6 else goal
# Command the NOSE ahead of where the flight path must go, by the
# measured lag -- steering the nose straight at the target makes the
# path arrive wide and late.
nose_cmd = norm(want + (want - vhat) * min(self.tau * 1.6, 2.5))
ex = float(np.dot(nose_cmd, right))
ey = float(np.dot(nose_cmd, up))
ez = float(np.dot(nose_cmd, fwd))
yaw = math.atan2(ex, ez if abs(ez) > 1e-3 else 1e-3)
pitch = math.atan2(ey, ez if abs(ez) > 1e-3 else 1e-3)
if ez < 0:
yaw = math.copysign(math.pi / 2, ex if ex else 1.0)
sx = max(-1.0, min(1.0, self.KP * yaw - self.KD * float(np.dot(w, up))))
sy = max(-1.0, min(1.0, -(self.KP * pitch - self.KD * float(np.dot(w, right)))))
# fire only when the *flight path* is clear and the nose is on target
aim_ok = tgt and abs(yaw) < self.FIRE_CONE and abs(pitch) < self.FIRE_CONE
fire = bool(aim_ok and tgt[4] < self.FIRE_RANGE and pn < 1.2)
if not self.dry:
self.pad.axis("LX", sx)
self.pad.axis("LY", sy)
if fire != self.firing:
(self.pad.press if fire else self.pad.release)("RB")
self.firing = fire
drift = math.degrees(ang(fwd, vhat))
msg = (f"spd={speed:6.0f} drift={drift:5.1f}d tau={self.tau:4.2f} "
f"yaw={math.degrees(yaw):+6.1f} pit={math.degrees(pitch):+6.1f} "
f"stick=({sx:+.2f},{sy:+.2f}) fire={int(fire)}")
if tgt:
msg += f" tgt={tgt[1][3:22]:<20} d={tgt[4]:7.0f}"
if worst:
msg += (f" | AVOID {worst[1][3:20]} miss={worst[3]:6.0f} "
f"t={worst[4]:4.1f}s u={worst[0]:.2f}")
return msg, vhat
def run(self, secs, hz=10.0):
self.W.scan()
t0 = time.time()
last, last_scan, prev_vhat = t0, 0.0, None
while time.time() - t0 < secs:
t = time.time()
if t - last_scan > 4.0:
ents = self.W.scan()
last_scan = t
c = Counter(faction(self.W.defs[va]) for _, va in ents)
print(f"[{t-t0:6.1f}] scan: {len(ents)} entities {dict(c)}",
file=self.log, flush=True)
msg, prev_vhat = self.step(t, t - last, prev_vhat)
last = t
print(f"[{t-t0:6.1f}] {msg}", file=self.log, flush=True)
time.sleep(max(0, 1.0 / hz - (time.time() - t)))
if not self.dry:
self.pad.reset()
def main():
cfg = json.load(open(sys.argv[1]))
secs = float(sys.argv[2]) if len(sys.argv) > 2 else 90.0
W = World(cfg)
Navigator(W, Pad(), dry="--dry" in sys.argv).run(secs)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Where does the craft keep its hull and shield? Anchor on the definition.
The parsed unit definition already has solved fields (unit-struct-runtime.md):
`HP` at `+0x054`, shield `MaxValue` at `+0x238`, shield `ChargeSpeed` at
`+0x244`. A craft that has taken no damage is at full hull and full shield, so
its live entity object must *contain those very numbers*. That turns "find the
HP field" from a value-scan over 4 GB into: read two floats from the definition,
then look for them inside the entity object.
Then watch the candidates while the craft is under fire. The live field is the
one that falls; a copy of the definition value that never moves is not it.
Usage: own_state.py <config.json> [watch_seconds] [out.json]
"""
import json
import os
import struct
import sys
import time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gmem # noqa: E402
import navigator # noqa: E402
DEF_HP, DEF_SHIELD_MAX, DEF_SHIELD_CHG = 0x054, 0x238, 0x244
BACK, FWD = 0x800, 0x800
def near(a, v):
return np.abs(a - v) <= max(1e-3, abs(v) * 1e-4)
def main():
cfg = json.load(open(sys.argv[1]))
secs = float(sys.argv[2]) if len(sys.argv) > 2 else 40.0
out = sys.argv[3] if len(sys.argv) > 3 else None
W = navigator.World(cfg)
ents = W.scan()
me = [(off, va) for off, va in ents if "Player" in W.defs[va]]
if not me:
sys.exit("player entity not found — not in flight?")
me_off, def_va = me[0]
print(f"# player {W.defs[def_va]} pos off {me_off:#x} def {def_va:#010x}")
d = os.pread(W.fd, 0x300, gmem.va_to_off(def_va))
want = {}
for nm, o in (("HP", DEF_HP), ("Shield_MaxValue", DEF_SHIELD_MAX),
("Shield_ChargeSpeed", DEF_SHIELD_CHG)):
(v,) = struct.unpack_from(">f", d, o)
want[nm] = v
print(f"# definition {nm:<18} = {v:g}")
blob = os.pread(W.fd, BACK + FWD, me_off - BACK)
arr = np.frombuffer(blob, dtype=">f4").astype(np.float64)
cands = [] # (label, delta_from_position)
for nm, v in want.items():
if not np.isfinite(v) or v == 0.0:
continue
for i in np.flatnonzero(near(arr, v)):
cands.append((nm, int(i) * 4 - BACK))
print(f"# {len(cands)} candidate offsets inside the entity object:")
for nm, dlt in cands:
print(f" pos{dlt:+#07x} == definition {nm}")
if not cands:
print("# none — the object does not carry the definition's own numbers"
" at this offset window; widen BACK/FWD or the craft is damaged")
# ---- watch them; the live field is the one that moves
hist = {c: [] for c in cands}
t0 = time.time()
last_print = 0.0
while time.time() - t0 < secs:
p = W.pos(me_off)
if p is None:
print("# player object gone (death / stage change?)")
break
w = os.pread(W.fd, BACK + FWD, me_off - BACK)
a = np.frombuffer(w, dtype=">f4").astype(np.float64)
for c in cands:
i = (c[1] + BACK) // 4
hist[c].append(float(a[i]))
t = time.time() - t0
if t - last_print > 4.0:
last_print = t
cur = " ".join(f"{c[0][:4]}{c[1]:+#x}={hist[c][-1]:.1f}" for c in cands[:6])
print(f"[{t:6.1f}] {cur}", flush=True)
time.sleep(0.2)
print("\n# offset anchor first last min moved")
moving = []
for c in cands:
h = np.array(hist[c])
if not len(h):
continue
mv = float(h.max() - h.min())
print(f" pos{c[1]:+#07x} {c[0]:<18} {h[0]:8.1f} {h[-1]:8.1f} "
f"{h.min():8.1f} {mv:7.3f}")
if mv > 1e-3:
moving.append({"anchor": c[0], "delta": c[1],
"first": h[0], "last": float(h[-1]),
"min": float(h.min())})
if not moving:
print("# nothing moved — the craft took no damage during the window")
if out:
json.dump({"player": W.defs[def_va], "def_va": def_va,
"definition": want,
"candidates": [{"anchor": a, "delta": b} for a, b in cands],
"moved": moving}, open(out, "w"), indent=1)
print(f"# wrote {out}")
if __name__ == "__main__":
main()

657
tools/re-capture/pilot.py Normal file
View File

@@ -0,0 +1,657 @@
#!/usr/bin/env python3
"""A pilot that tries to stay alive, not just to shoot.
Every earlier loop flew a straight pursuit and was shot down; the trace from
ctrl_probe.py shows why — 1500 hull points gone in twelve seconds while sitting
in a turret's line of fire, with no reaction of any kind. Three measured facts
make a reaction possible:
* **Hull is `position + 0x154`** — at spawn it equals the unit definition's own
`HP` (1500 for the Delta Saber), it steps down 30/60/90 per hit, and it goes
negative at death. So damage is observable *as it happens*, not inferred.
* **`RT` accelerates and `LT` brakes**, and the setting persists: measured
ground speed went 488 → 1510 under RT, 488 → 174 under LT and then *stayed*
near 130 with the sticks neutral. (The older note "RT is not the throttle" was
drawn from a value-scan for a speed field, not from measuring the speed.)
* **`RB` fires** (autopilot-memory-driven.md).
So the loop is a state machine on damage rather than a pure pursuit:
ENGAGE chase and shoot the nearest hostile fighter
DEFEND the escorted asset is being attacked — go kill what is attacking IT
EVADE entered the moment the hull drops — turn away from the threats,
full throttle, jink; leave only after several quiet seconds
RETIRE hull below a floor: break for the friendly capital ship, which the
mission's own hint says is where you resupply
Turrets are treated as threats to be *kept at a distance*, not as targets: the
objective is the invading fighters, and the turret is what killed every previous
run.
**Why DEFEND exists, and why it is not simply "always guard the asset".** Stage
02 is an escort: a 240 s run ended in GAME OVER with our own hull at 1500/1500
because the ACROPOLIS sank while the pilot chased the nearest fighter 2 km away.
But the measurement in docs/re/mission-escort-state.md says the loss is *slow* —
a few hundred to ~1400 HP/min against 25000, i.e. tens of minutes to sink. (When
it starts varies: t≈170 s in one run, t≈70 s in another, so do not schedule on
it — react to the hull.) So permanently orbiting it would throw away most of the
mission for nothing. The policy that fits the
measurement is: **fight freely until the asset is actually being hurt, then
switch to killing its attackers specifically.** Both the trigger and the target
choice are read live — every entity's hull is `position + 0x154`, confirmed for
seven classes, so "is the asset losing hull" and "which hostiles are closing on
it" are both observable rather than inferred.
Usage: pilot.py <config.json> [seconds] [--dry]
"""
import json
import math
import os
import struct
import sys
import time
from collections import deque
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gmem # noqa: E402
import navigator # noqa: E402
from navigator import ang, norm # noqa: E402
from flight_probe import Pad # noqa: E402
HULL_OFF = 0x154 # confirmed: == definition HP at spawn, falls when hit
SHIELD_OFF = 0x430 # candidate: == definition Shield MaxValue at spawn
DEF_HP = 0x054 # unit-struct-runtime.md
# The player Delta Saber's guns, from the solved Shell records
# (docs/re/captures/weapon-runtime-fields.csv, all ✅ CONFIRMED):
# Shell_TCAF_DeltaSaber_{NoseGun,Gun,Beam}_P Velocity 8000, LifeTime 0.5 s,
# MaximumRange 4000 (= 8000 × 0.5, self-consistent), shell Radius 2030.
# Both numbers were previously wrong in this loop, and both mattered:
# * flight time was computed as d / OUR speed (4002000 u/s), so every shot
# was led 416× too far ahead of the target;
# * FIRE_RANGE was 5000, i.e. a quarter of the shots were fired at targets
# the shells expire before reaching.
SHELL_VELOCITY = 8000.0
SHELL_MAX_RANGE = 4000.0
SHELL_RADIUS = 20.0
# The MAIN weapon, fired with Y — measured, not assumed: a hold-each-input probe
# (fire_probe.sh) moved NOSE BM 06000 -> 05956 under RB and MAIN MPM 00300 ->
# 00299 under Y, so RB is the nose gun (~11 rounds/s) and Y is the main mount.
# That probe also settled the lethality question the other way round: we DO
# shoot, so the kill counters reading 0000 mean we shoot and MISS.
#
# Which is exactly what the disc data says to stop doing. Shell_TCAF_DeltaSaber_
# Missile_P is Power 200 with GuidanceType 5 (guided) and MaximumRange 5000,
# against the nose gun's Power 15 unguided — one missile is worth ~14 gun hits
# on a 500 HP fighter, and it steers itself, which is the accuracy problem
# solved rather than tuned. Range is held under the confirmed 5000 because which
# main weapon is actually loaded is not read from RAM yet.
MISSILE_RANGE = 4000.0
MISSILE_CONE = math.radians(20.0)
MISSILE_PERIOD = 2.0 # s between launches; 300 rounds is not unlimited
# Stage 02's protected asset. Named rather than derived: "the biggest friendly"
# picks the f105 cruiser (30000 HP > the Acropolis's 25000), and "the friendly
# with the most HP" picks it too, so neither rule finds the right ship. The
# per-stage asset is mission script, not a property of the entity, so it is
# configuration here — override with $SYLPH_ASSET for another stage.
ASSET_NAME = os.environ.get("SYLPH_ASSET", "Acropolis")
class Pilot:
KP, KD = 2.2, 0.45
FIRE_CONE = math.radians(9) # fallback only; the real gate is angular size
FIRE_RANGE = SHELL_MAX_RANGE # the shells simply do not arrive past this
CONE_MIN = math.radians(2.0)
CONE_MAX = math.radians(25.0) # close-in the target subtends a lot; let it
TURRET_KEEPOUT = 2500.0 # ...and stay this far from things that shoot back
EVADE_QUIET = 5.0 # seconds without damage before re-engaging
RETIRE_FRAC = 0.30 # hull fraction that sends us home
HZ = 8.0
# --- escort ---
ASSET_GUARD = 9000.0 # hostiles this close to the asset count as its attackers
ASSET_QUIET = 20.0 # s of no asset damage before dropping out of DEFEND
ASSET_ALERT = 0.5 # HP of asset damage that counts as "under attack"
ASSET_STANDOFF = 3500.0 # loiter this far out when guarding with no target
HULL_CLEARANCE = 800.0 # clearance ON TOP of a capital ship's own radius
CLOSING_WEIGHT = 4.0 # s of closing-rate credit when ranking attackers
MY_RANGE_WEIGHT = 0.35 # how much our own distance discounts a target
# --- target commitment ---
# The loop re-scored every contact every tick, so the nose chased whichever
# fighter was momentarily best and the aim error wandered 10-40 deg through
# a pass. A missile lock is time-on-target (the OPTIONS screen calls it
# Padlock), so switching targets constantly is the one thing guaranteed to
# prevent a kill. Stay on the chosen contact until it dies, leaves range, or
# sits behind us long enough that chasing it is pointless.
COMMIT_MAX = 14.0 # s before we are allowed to reconsider anyway
COMMIT_DROP = 6000.0 # ...or it gets this far away
COMMIT_BEHIND = 2.5 # ...or stays >90 deg off the nose this long
# --- moves the ADVANCED CONTROLS tutorial teaches (tutorial_capture.sh) ---
# "Target an enemy and pull LT and RT [together]. This sets your fighter's
# speed to that of the target. This works well when you are trying to get
# behind an enemy. Once behind an enemy, this also helps you attack them."
# That is the overshoot problem solved by the game itself: matching speed
# holds us in the target's rear hemisphere instead of flying through it,
# which is the only way a time-on-target lock ever completes.
# Scope matters, and a measured regression proved it: applying the match at
# 4500 with a 70 deg cone dropped kills 9 -> 0. Matching a target's speed
# while still 5 km behind it means never closing — the pilot sat at 272 u/s
# and fired 28 frames all run. The tutorial's own wording scopes it: "when
# you are trying to get BEHIND an enemy... ONCE BEHIND an enemy, this also
# helps you attack them". So it is station-keeping in the saddle, not an
# approach throttle. Only match when we are already there.
# MEASURED: both tutorial moves are a NET REGRESSION as applied here, so
# both ship DISABLED. One run each, same everything else:
# commitment only ............ 101 missiles, 364 fire frames, 9 kills
# + match(4500) + snap-face .... 9 missiles, 28 fire frames, 0 kills
# + match(1200) + snap-face ... 57 missiles, 225 fire frames, 2 kills
# The moves are real and the tutorial is right about them; the loop just
# cannot use them yet. Snap-face (B+A) reorients the craft mid-pursuit and
# destroys the very dwell that commitment buys, and speed-match needs to be
# entered from the saddle rather than commanded at range. Set MATCH_RANGE
# and lower FACE_MIN to re-enable, and A/B them over SEVERAL runs — one run
# per config is inside this stage's spawn variance.
MATCH_RANGE = 0.0 # 1200.0 to re-enable
MATCH_CONE = math.radians(25)
# "Press B and A together to face [the target]" — a snap turn, far quicker
# than winding the PD controller around for a contact behind us.
FACE_MIN = math.radians(999) # 50 deg to re-enable the B+A snap turn
# HEADS-UP DISPLAY tutorial, verbatim: "Press A twice to target the enemy
# closest to the center of the screen." A DOUBLE tap — which is why every
# single-tap button sweep found nothing and concluded targeting was
# automatic. It also explains the missiles: GuidanceType 5 needs the GAME's
# selection, and we had never made one, so 98 launches guided to nothing.
# Select only when our committed contact is already near screen centre, so
# the game's choice and ours are the same object.
SELECT_CONE = math.radians(14)
SELECT_PERIOD = 3.0
FACE_PERIOD = 4.0
def __init__(self, W, pad, dry=False, log=sys.stdout):
self.W = W
self.pad = pad
self.dry = dry
self.log = log
# closest-point-of-approach avoidance is navigator.py's, reused as-is
self.av = navigator.Navigator(W, pad, dry=True, log=log)
self.prevM = None
self.firing = False
self.throttle = 0 # -1 brake, 0 coast, +1 accelerate
self.mode = "ENGAGE"
self.hp_hist = deque(maxlen=64)
self.hp0 = None
self.last_hit = -1e9
self.threat_dir = None
# escort bookkeeping
self.def_hp = {} # def_va -> definition HP
for va in W.defs:
self.def_hp[va] = self.f32(gmem.va_to_off(va) + DEF_HP)
self.asset_hist = deque(maxlen=64)
self.asset_hp0 = None
self.asset_last_hit = -1e9
self.missile_down = False
self.missile_t = -1e9
self.missiles = 0
self.commit_off = None # entity we are committed to
self.commit_t = -1e9
self.behind_since = None
self.matching = False
self.face_t = -1e9
self.face_down = None
self.faces = 0
self.select_t = -1e9
self.selects = 0
def f32(self, off):
b = os.pread(self.W.fd, 4, off)
if len(b) < 4:
return float("nan")
return struct.unpack(">f", b)[0]
# ---------------------------------------------------------------- escort
def asset(self, ents):
"""The protected ship, and its live hull — same anchor as everyone's."""
for off, nm, p, v, r in ents:
if ASSET_NAME in nm:
hull = self.f32(off + HULL_OFF)
return off, nm, p, v, r, hull
return None
def asset_attackers(self, hos, a_p):
"""Hostile fighters near the asset, ranked by how hard they press it.
Ranking is distance to the asset *minus* credit for closing on it, so a
fighter 4 km out and running in outranks one sitting at 2 km drifting
away. Turrets and hulls are excluded for the same reason as everywhere
else: they are not killable objectives, they are keep-out zones.
"""
out = []
for off, nm, p, v, r, hard in hos:
if hard:
continue
rel = a_p - p
d = float(np.linalg.norm(rel))
if d > self.ASSET_GUARD:
continue
closing = float(np.dot(norm(rel), v)) # +ve = moving at the asset
out.append((d - self.CLOSING_WEIGHT * max(closing, 0.0), off, nm, p, v, d, r))
out.sort(key=lambda e: e[0])
return out
# ------------------------------------------------------------ own state
def own(self, off):
b = os.pread(self.W.fd, 8, off + HULL_OFF)
hull = struct.unpack_from(">f", b, 0)[0] if len(b) >= 4 else float("nan")
b2 = os.pread(self.W.fd, 4, off + SHIELD_OFF)
shield = struct.unpack(">f", b2)[0] if len(b2) == 4 else float("nan")
return hull, shield
def set_throttle(self, want):
"""RT / LT are a persistent setting, so only send the change.
`want` is +1 accelerate, -1 brake, 0 coast, or the string "match" for
the tutorial's both-triggers speed-match onto the current target.
"""
if want == self.throttle or self.dry:
return
if want == "match":
self.pad.trig("RT", 1.0)
self.pad.trig("LT", 1.0)
else:
self.pad.trig("RT", 1.0 if want > 0 else 0.0)
self.pad.trig("LT", 1.0 if want < 0 else 0.0)
self.throttle = want
def select_target(self, t):
"""A, twice: make the GAME target what we are already pointing at."""
if self.dry or t - self.select_t < self.SELECT_PERIOD:
return
self.pad.f.write("tap A 90\n")
self.pad.f.write("tap A 90\n")
self.select_t = t
self.selects += 1
def face_target(self, t):
"""B + A: snap the nose onto the selected target."""
if self.dry or t - self.face_t < self.FACE_PERIOD:
return
self.pad.press("B")
self.pad.press("A")
self.face_down = t
self.face_t = t
self.faces += 1
# -------------------------------------------------------------- targets
def hostiles(self, ents, me_off):
out = []
for off, nm, p, v, r in ents:
if off == me_off or navigator.faction(nm) != "ADAN":
continue
out.append((off, nm, p, v, r, "Turret" in nm or r >= navigator.Navigator.BIG_RADIUS))
return out
def lead_point(self, p, v, d):
"""Where to aim: the target moved on by the shell's real flight time."""
return p + v * (d / SHELL_VELOCITY)
def fire_cone(self, d, r):
"""How far off the nose we will still pull the trigger.
The target's angular half-size, atan((r_target + r_shell) / range), is
the angle that can actually *hit* — but gating on it alone was measured
to be much worse than the old fixed 9°: at 2584 units a fighter subtends
2.7°, the steering loop holds the nose to ~1030°, and firing collapsed
to 1 frame in 2639. Ammunition is free and the guns are continuous, so
the angular size belongs here as a **floor** that opens the gate wider
up close, never as a cap that closes it far out.
"""
if d < 1.0:
return self.CONE_MAX
return min(self.CONE_MAX, max(self.FIRE_CONE, math.atan2(r + SHELL_RADIUS, d)))
def pick_committed(self, t, me_p, me_v, fwd, hos):
"""pick(), but stay on the same contact long enough to actually kill it."""
cur = None
for off, nm, p, v, r, hard in hos:
if off == self.commit_off and not hard:
cur = (off, nm, p, v, r)
break
if cur is not None:
off, nm, p, v, r = cur
rel = p - me_p
d = float(np.linalg.norm(rel))
behind = ang(rel, fwd) > math.pi / 2
self.behind_since = (self.behind_since if behind else None) or (t if behind else None)
stale = (t - self.commit_t > self.COMMIT_MAX
or d > self.COMMIT_DROP
or (self.behind_since is not None
and t - self.behind_since > self.COMMIT_BEHIND))
if not stale:
lead = self.lead_point(p, v, d)
return (off, nm, lead, lead - me_p, d, r)
# commit to a fresh one
tgt = self.pick(me_p, me_v, fwd, hos)
self.commit_off = tgt[0] if tgt else None
self.commit_t = t
self.behind_since = None
return tgt
def pick(self, me_p, me_v, fwd, hos):
"""Nearest *fighter*, weighted by how far off the nose it is."""
best, bestscore = None, 1e18
for off, nm, p, v, r, hard in hos:
if hard:
continue # turrets and hulls are not the objective
rel = p - me_p
d = float(np.linalg.norm(rel))
if d < 1e-3:
continue
lead = self.lead_point(p, v, d)
theta = ang(lead - me_p, fwd)
score = d * (1.0 + 3.0 * (theta / math.pi) ** 2)
if score < bestscore:
best, bestscore = (off, nm, lead, lead - me_p, d, r), score
return best
def threat_vector(self, me_p, hos):
"""Where the danger is: inverse-square weighted direction to shooters."""
acc = np.zeros(3)
for off, nm, p, v, r, hard in hos:
rel = p - me_p
d = float(np.linalg.norm(rel))
if d < 1.0 or d > 8000.0:
continue
w = (1500.0 / d) ** 2 * (3.0 if hard else 1.0)
acc += norm(rel) * w
return norm(acc) if np.linalg.norm(acc) > 1e-6 else None
def friendly_base(self, ents, me_off):
"""The biggest friendly — the carrier the briefing says to resupply at."""
best = None
for off, nm, p, v, r in ents:
if off == me_off or navigator.faction(nm) != "TCAF":
continue
if best is None or r > best[4]:
best = (off, nm, p, v, r)
return best
# ------------------------------------------------------------ steering
def sticks(self, want, M, w):
fwd = M[self.W.fwd_row] * self.W.fwd_sign
right = M[(self.W.fwd_row + 1) % 3]
up = np.cross(fwd, right)
ex, ey, ez = (float(np.dot(want, right)), float(np.dot(want, up)),
float(np.dot(want, fwd)))
yaw = math.atan2(ex, ez if abs(ez) > 1e-3 else 1e-3)
pitch = math.atan2(ey, ez if abs(ez) > 1e-3 else 1e-3)
if ez < 0: # target behind: commit to a full turn
yaw = math.copysign(math.pi / 2, ex if ex else 1.0)
sx = max(-1.0, min(1.0, self.KP * yaw - self.KD * float(np.dot(w, up))))
sy = max(-1.0, min(1.0, -(self.KP * pitch - self.KD * float(np.dot(w, right)))))
return sx, sy, yaw, pitch
# ---------------------------------------------------------------- step
def step(self, t, dt):
ents = self.W.sample(t)
me = next((e for e in ents if "Player" in e[1]), None)
if me is None:
return None
me_off, me_nm, me_p, me_v, me_r = me
M = self.W.rot(me_off)
if M is None:
return "no-orientation"
fwd = M[self.W.fwd_row] * self.W.fwd_sign
right = M[(self.W.fwd_row + 1) % 3]
up = np.cross(fwd, right)
speed = float(np.linalg.norm(me_v))
hull, shield = self.own(me_off)
if self.hp0 is None and math.isfinite(hull) and hull > 0:
self.hp0 = hull
self.hp_hist.append((t, hull))
# damage over the last ~2 s; the hull only ever falls, so any drop is a hit
recent = [h for (ts, h) in self.hp_hist if t - ts <= 2.0]
dmg = (max(recent) - hull) if recent else 0.0
if dmg > 0.5:
self.last_hit = t
if hull <= 0:
return "DEAD"
# body angular velocity, for the damping term
w = np.zeros(3)
if self.prevM is not None and dt > 1e-3:
D = self.prevM @ M.T
w = np.array([D[2, 1] - D[1, 2], D[0, 2] - D[2, 0], D[1, 0] - D[0, 1]]) / (2 * dt)
self.prevM = M
hos = self.hostiles(ents, me_off)
self.threat_dir = self.threat_vector(me_p, hos)
frac = hull / self.hp0 if self.hp0 else 1.0
# ---- the escorted asset, read exactly like our own hull
ast = self.asset(ents)
a_frac, a_dmg = 1.0, 0.0
if ast is not None:
a_hull = ast[5]
if self.asset_hp0 is None and math.isfinite(a_hull) and a_hull > 0:
self.asset_hp0 = a_hull
self.asset_hist.append((t, a_hull))
recent = [h for (ts, h) in self.asset_hist if t - ts <= 4.0]
a_dmg = (max(recent) - a_hull) if recent else 0.0
if a_dmg > self.ASSET_ALERT:
self.asset_last_hit = t
a_frac = a_hull / self.asset_hp0 if self.asset_hp0 else 1.0
# ---- mode. Our own survival still outranks the escort: a dead pilot
# defends nothing, and RETIRE/EVADE are what stopped us being shot down.
if frac <= self.RETIRE_FRAC:
self.mode = "RETIRE"
elif t - self.last_hit < self.EVADE_QUIET:
self.mode = "EVADE"
elif ast is not None and t - self.asset_last_hit < self.ASSET_QUIET:
self.mode = "DEFEND"
else:
self.mode = "ENGAGE"
tgt = self.pick_committed(t, me_p, me_v, fwd, hos)
push, worst = self.av.avoidance(me_p, me_v, me_r, ents, me_off)
if self.mode == "EVADE":
# Away from the guns, plus a jink so a straight escape line is not
# itself an easy solution for whatever is shooting.
away = -self.threat_dir if self.threat_dir is not None else fwd
jink = right * math.sin(t * 1.7) * 0.5 + up * math.cos(t * 2.3) * 0.35
want = norm(away + jink)
self.set_throttle(+1)
fire = False
elif self.mode == "DEFEND":
# Kill what is hitting the ship, not what is nearest to us. Among
# the asset's attackers prefer the one pressing it hardest, with a
# modest discount for being closer to us so the loop does not fly
# past three targets to reach a marginally worse fourth.
atk = self.asset_attackers(hos, ast[2])
best = None
for score, off, nm, p, v, d_a, r in atk:
d_me = float(np.linalg.norm(p - me_p))
total = score + self.MY_RANGE_WEIGHT * d_me
if best is None or total < best[0]:
best = (total, off, nm, p, v, d_me, r)
if best is not None:
_, off, nm, p, v, d_me, r = best
lead = self.lead_point(p, v, d_me)
tgt = (off, nm, lead, lead - me_p, d_me, r)
want = norm(tgt[3])
self.set_throttle(+1 if d_me > 2500.0 else 0)
else:
# Nothing on it right now: hold station near the ship instead of
# wandering off, so the next wave is met at the asset.
rel = ast[2] - me_p
d = float(np.linalg.norm(rel))
want = (norm(rel) if d > ast[4] + self.ASSET_STANDOFF
else norm(np.cross(rel, up)))
self.set_throttle(+1 if d > ast[4] + self.ASSET_STANDOFF else 0)
fire = True
elif self.mode == "RETIRE":
base = self.friendly_base(ents, me_off)
if base is not None:
rel = base[2] - me_p
d = float(np.linalg.norm(rel))
want = norm(rel) if d > base[4] + 400.0 else norm(np.cross(rel, up))
else:
want = -self.threat_dir if self.threat_dir is not None else fwd
self.set_throttle(+1)
fire = False
else:
# Straight lead pursuit and fly *through*. An earlier version orbited
# once inside a standoff radius and braked while doing it: it then
# circled one attacker for 40 s at ~700 m, at 60-100 units/s, never
# inside the firing cone. Overshooting and re-acquiring is better
# than a stall in the middle of a battle; collision avoidance already
# keeps a fighter-sized margin.
want = norm(tgt[3]) if tgt else fwd
if tgt and tgt[4] < self.MATCH_RANGE and ang(tgt[3], fwd) < self.MATCH_CONE:
self.set_throttle("match") # sit in its rear hemisphere
elif tgt and tgt[4] > 2500.0:
self.set_throttle(+1)
else:
self.set_throttle(0)
fire = True
# a turret inside its keep-out radius outranks the target
for off, nm, p, v, r, hard in hos:
if not hard:
continue
d = float(np.linalg.norm(p - me_p))
if d < self.TURRET_KEEPOUT:
want = norm(want + norm(me_p - p) * (2.0 * (1.0 - d / self.TURRET_KEEPOUT)))
break
# A capital ship is a wall, whatever its faction. DEFEND flies at the
# asset — which sits in the middle of the friendly formation — and the
# first escort run ended with hull 1500 -> DEAD in a single tick at
# 2026 units/s, 0.6 s from a friendly destroyer that the avoidance
# thought it would clear by 365 units. A destroyer's own radius is
# 2000. Closest-point-of-approach with a fighter-sized margin cannot
# keep us out of something that big, so give every large entity a hard
# physical keep-out scaled by ITS radius and brake inside it.
for off, nm, p, v, r in ents:
if off == me_off or r < navigator.Navigator.BIG_RADIUS:
continue
rel = me_p - p
d = float(np.linalg.norm(rel))
keep = r + self.HULL_CLEARANCE
if d < keep:
want = norm(want + norm(rel) * (2.5 * (1.0 - d / keep)))
if speed > 900.0:
self.set_throttle(-1)
break
pn = float(np.linalg.norm(push))
if pn > 1e-6:
want = norm(want + push * (3.0 if pn > 0.6 else 1.5))
sx, sy, yaw, pitch = self.sticks(want, M, w)
# The firing gate has to be measured against the TARGET, not against the
# commanded direction: `want` carries the avoidance and keep-out terms,
# so gating on it means the guns stay cold exactly when the loop is
# manoeuvring — which is most of a dogfight.
aim = self.sticks(norm(tgt[3]), M, w)[2:] if tgt else (math.pi, math.pi)
cone = self.fire_cone(tgt[4], tgt[5]) if tgt else self.FIRE_CONE
aim_ok = abs(aim[0]) < cone and abs(aim[1]) < cone
fire = bool(fire and tgt and aim_ok and tgt[4] < self.FIRE_RANGE and pn < 1.2)
# The main mount is a discrete launch, not a continuous stream: press Y
# and let go a tick later, then wait out MISSILE_PERIOD. Holding it
# would empty 300 rounds in half a minute.
msl = bool(tgt and self.mode in ("ENGAGE", "DEFEND")
and tgt[4] < MISSILE_RANGE
and abs(aim[0]) < MISSILE_CONE and abs(aim[1]) < MISSILE_CONE
and pn < 1.2)
if not self.dry:
self.pad.axis("LX", sx)
self.pad.axis("LY", sy)
if fire != self.firing:
(self.pad.press if fire else self.pad.release)("RB")
self.firing = fire
if (tgt and self.mode in ("ENGAGE", "DEFEND")
and abs(aim[0]) < self.SELECT_CONE
and abs(aim[1]) < self.SELECT_CONE
and tgt[4] < MISSILE_RANGE and pn < 1.2):
self.select_target(t)
if self.face_down is not None and t - self.face_down > 0.2:
self.pad.release("B")
self.pad.release("A")
self.face_down = None
elif (tgt and self.mode in ("ENGAGE", "DEFEND")
and abs(aim[0]) > self.FACE_MIN and pn < 1.2):
self.face_target(t)
if self.missile_down and t - self.missile_t > 0.15:
self.pad.release("Y")
self.missile_down = False
elif (not self.missile_down and msl
and t - self.missile_t > MISSILE_PERIOD):
self.pad.press("Y")
self.missile_down = True
self.missile_t = t
self.missiles += 1
msg = (f"{self.mode:<7} hull={hull:6.0f} shd={shield:6.0f} spd={speed:6.0f} "
f"thr={str(self.throttle):>5} yaw={math.degrees(yaw):+6.1f} "
f"pit={math.degrees(pitch):+6.1f} aim={math.degrees(aim[0]):+6.1f}"
f"/{math.degrees(aim[1]):+6.1f} fire={int(fire)} msl={self.missiles}"
f" fc={self.faces} sel={self.selects}")
if ast is not None:
msg += f" ast={a_frac*100:5.1f}%"
if a_dmg > self.ASSET_ALERT:
msg += f" ASSET-HIT -{a_dmg:.0f}"
if dmg > 0.5:
msg += f" HIT -{dmg:.0f}"
if tgt:
msg += f" tgt={tgt[1][3:24]:<21} d={tgt[4]:6.0f}"
if worst:
msg += f" | AVOID {worst[1][3:18]} miss={worst[3]:5.0f} t={worst[4]:4.1f}"
return msg
def run(self, secs):
self.W.scan()
t0 = time.time()
last, last_scan = t0, 0.0
hostiles0 = None
while time.time() - t0 < secs:
t = time.time()
if t - last_scan > 5.0:
ents = self.W.scan()
last_scan = t
n_ad = sum(1 for _, va in ents
if navigator.faction(self.W.defs[va]) == "ADAN")
if hostiles0 is None:
hostiles0 = n_ad
print(f"[{t-t0:6.1f}] scan: {len(ents)} entities, {n_ad} ADAN "
f"(start {hostiles0})", file=self.log, flush=True)
msg = self.step(t, t - last)
last = t
if msg is None:
print(f"[{t-t0:6.1f}] player object gone — stopping",
file=self.log, flush=True)
break
print(f"[{t-t0:6.1f}] {msg}", file=self.log, flush=True)
if msg == "DEAD":
break
time.sleep(max(0.0, 1.0 / self.HZ - (time.time() - t)))
if not self.dry:
self.pad.reset()
print(f"# flew {time.time()-t0:.0f}s", file=self.log, flush=True)
def main():
cfg = json.load(open(sys.argv[1]))
secs = float(sys.argv[2]) if len(sys.argv) > 2 else 180.0
W = navigator.World(cfg)
Pilot(W, Pad(), dry="--dry" in sys.argv).run(secs)
if __name__ == "__main__":
main()

View File

@@ -3,16 +3,37 @@
# - movie playing (two grabs 0.6s apart differ a lot) -> tap A to skip
# - static screen -> if the green "PRESS (A) BUTTON" glyph is there, tap A and stop
# Static logo screens are left alone, so a stray tap can never land on NEW GAME.
#
# LIVENESS IS CHECKED EVERY ITERATION, and that is not a nicety. When the
# display died 3 minutes into a run, `screenshot` started failing silently and
# left /tmp/f1.png and /tmp/f2.png at their last contents — two *stale* files,
# which compare to a constant non-zero RMSE, which reads exactly like "the
# screen is changing a lot". So the loop reported "movie -> skip A" every 5 s
# for the full 600 s timeout with no emulator and no X server running. A dead
# display and a playing movie must never be able to look the same.
set -u
export HOME=/sylph-home/re
DISP="${DISPLAY:-:98}"
alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $1}'; }
# 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
until xdotool search --name "Xenia-canary" >/dev/null 2>&1; do
xdpyinfo -display "$DISP" >/dev/null 2>&1 || { echo "DISPLAY LOST at ${SECONDS}s (before the window appeared)"; exit 3; }
[ -n "$(alive)" ] || { echo "EMULATOR GONE at ${SECONDS}s (before the window appeared)"; exit 4; }
sleep 1
done
deadline=$(( SECONDS + ${1:-900} ))
while [ $SECONDS -lt $deadline ]; do
rm -f /tmp/f1.png /tmp/f2.png
screenshot /tmp/f1.png >/dev/null 2>&1; sleep 0.6
screenshot /tmp/f2.png >/dev/null 2>&1
if [ ! -s /tmp/f1.png ] || [ ! -s /tmp/f2.png ]; then
xdpyinfo -display "$DISP" >/dev/null 2>&1 \
|| { echo "DISPLAY LOST at ${SECONDS}s"; exit 3; }
echo "SCREENSHOT FAILED at ${SECONDS}s with the display up"; exit 5
fi
[ -n "$(alive)" ] || { echo "EMULATOR GONE at ${SECONDS}s"; exit 4; }
d=$(compare -metric RMSE /tmp/f1.png /tmp/f2.png null: 2>&1 | sed 's/ .*//' | cut -d. -f1)
d=${d:-0}
read -r r g b < <(convert /tmp/f2.png -format "%[fx:int(255*p{625,618}.r)] %[fx:int(255*p{625,618}.g)] %[fx:int(255*p{625,618}.b)]" info:)

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Find the SELECTED TARGET inside the player object, and the input that changes it.
The OPTIONS key-config screen lists `Change Target` and `Padlock Mode Toggle` as
real bindable actions (docs/re/flight-controls-runtime.md), but a button sweep
that watched the *ammo counters* could not see either — neither action fires a
weapon. Screenshots of the reticle were no better: the view keeps moving, so
"did the selection change" is not legible frame to frame.
Guest memory is legible. If the craft holds a selected target, it holds a
**pointer to that target's object**, and every live entity's address is already
known from the entity scan. So:
1. enumerate live entities and their addresses;
2. read a window of the player object and keep every word that points at one
of them (allowing a small fixed delta, since a pointer to an object's base
is not a pointer to its transform);
3. tap each candidate input and see which of those words switches to a
*different* entity.
The word that follows the button is the selection, and the button that moves it
is `Change Target`. Both answers come out of the same run, and neither depends
on reading pixels.
Usage: target_probe.py <config.json> [seconds_per_button]
"""
import json
import os
import struct
import sys
import time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gmem # noqa: E402
import navigator # noqa: E402
from flight_probe import Pad # noqa: E402
BACK, FWD = 0x400, 0x1000
MAX_DELTA = 0x400 # how far below its transform an object's base may sit
BUTTONS = ["LB", "X", "B", "A", "LS", "RS", "BACK", "START", "Y", "RB"]
def main():
cfg = json.load(open(sys.argv[1]))
dwell = float(sys.argv[2]) if len(sys.argv) > 2 else 1.2
W = navigator.World(cfg)
ents = W.scan()
me = [(off, va) for off, va in ents if "Player" in W.defs[va]]
if not me:
sys.exit("player entity not found — not in flight?")
me_off = me[0][0]
print(f"# player object at {gmem.primary_va(me_off):#010x}, "
f"{len(ents)} live entities")
# every address that could be a pointer to some entity
ptr_map = {}
for off, va in ents:
pos_va = gmem.primary_va(off)
if pos_va is None:
continue
for d in range(0, MAX_DELTA, 4):
ptr_map.setdefault(pos_va - d, (W.defs[va], d, off))
def window():
b = os.pread(W.fd, BACK + FWD, me_off - BACK)
return np.frombuffer(b, dtype=">u4").copy()
# ---- global mode: the selection need not live in the player object at all.
# Scan every mapped extent for words that hold an entity pointer, then see
# which of THOSE follow a button. A word that is an entity pointer before
# AND after, pointing at a different entity, is a selection by construction.
keys = np.array(sorted(ptr_map), dtype=np.uint32)
def global_ptrs():
out = {}
for a0, b0 in gmem.extents(W.fd, W.size):
n = (b0 - a0) // 4 * 4
if n < 64:
continue
arr = np.frombuffer(os.pread(W.fd, n, a0), dtype=">u4").astype(np.uint32)
idx = np.flatnonzero(np.isin(arr, keys))
for k in idx:
out[a0 + int(k) * 4] = int(arr[k])
return out
# ---- delta mode: WHERE does an entity keep its target?
# The AI ships clearly hold pointers to other entities, so the field is a
# fixed offset from the transform — exactly the situation entities2.py
# solved for the definition pointer. Tally, over many entities, the delta at
# which a word points at *another* entity; the offset that repeats is the
# field, and reading it on the PLAYER gives our selected target.
if "--delta" in sys.argv:
from collections import Counter
votes, examples = Counter(), {}
for off, va in ents:
blob = os.pread(W.fd, 0x1000, max(0, off - 0x800))
arr = np.frombuffer(blob[:len(blob) // 4 * 4], dtype=">u4")
for i, w in enumerate(arr):
hit = ptr_map.get(int(w))
if hit and hit[2] != off:
d = i * 4 - 0x800
votes[d] += 1
examples.setdefault(d, (W.defs[va], hit[0]))
print(f"# target-pointer delta candidates over {len(ents)} entities:")
for d, n in votes.most_common(10):
src, dst = examples[d]
print(f" pos{d:+#07x} seen {n:4d} e.g. {src[:26]:<26} -> {dst}")
best = votes.most_common(1)
if best:
d = best[0][0]
pw = struct.unpack(">I", os.pread(W.fd, 4, me_off + d))[0]
hit = ptr_map.get(pw)
print(f"\n# PLAYER at that delta: {pw:#010x} -> "
f"{hit[0] if hit else 'not an entity pointer'}")
return
a = window()
cands = []
for i, w in enumerate(a):
hit = ptr_map.get(int(w))
if hit and hit[2] != me_off: # a self-pointer is not a target
cands.append((i, hit[0], hit[1]))
print(f"# {len(cands)} word(s) in the player object point at a live entity")
for i, nm, d in cands[:30]:
print(f" pos{i * 4 - BACK:+#07x} -> {nm} (entity_va - {d:#x})")
if not cands:
print("# none — widen BACK/FWD or MAX_DELTA, or nothing is selected")
# ---- which input moves the selection?
pad = Pad()
if "--global" in sys.argv:
print("\n# GLOBAL scan: every word in RAM that holds an entity pointer")
g0 = global_ptrs()
print(f"# {len(g0)} entity-pointer words in RAM")
for btn in BUTTONS:
pad.f.write(f"tap {btn} 200\n")
time.sleep(dwell)
g1 = global_ptrs()
moved = [(o, g0[o], g1[o]) for o in g0
if o in g1 and g1[o] != g0[o]]
named = []
for o, v0, v1 in moved[:4]:
n0 = ptr_map.get(v0, ("?",))[0]
n1 = ptr_map.get(v1, ("?",))[0]
named.append(f"{gmem.primary_va(o):#010x} {n0[:16]}->{n1[:16]}")
print(f" [{btn:<5}] {len(moved):4d} switched " + " ".join(named),
flush=True)
g0 = g1
pad.reset()
return
print("\n# tapping each input; a word that switches to a DIFFERENT entity is"
" the selection")
for btn in BUTTONS:
before = window()
pad.f.write(f"tap {btn} 200\n")
time.sleep(dwell)
after = window()
moved = []
for i, nm, d in cands:
if before[i] == after[i]:
continue
hit = ptr_map.get(int(after[i]))
moved.append((i, nm, hit[0] if hit else f"{int(after[i]):#010x}"))
tag = " ".join(f"pos{i * 4 - BACK:+#x} {o[:18]}->{n[:18]}"
for i, o, n in moved[:3])
print(f" [{btn:<5}] {len(moved):2d} changed {tag}", flush=True)
pad.reset()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Play one TUTORIAL and photograph what it teaches.
#
# The OPTIONS key-config screen names the in-flight actions but not the buttons
# they sit on, and probing the pad found the weapons only (an action that does
# not fire a gun is invisible in the ammo counters). The tutorials state the
# mapping outright — `ADVANCED CONTROLS` is index 5 and is where Change Target
# and Padlock Mode live — so read it from the game instead of guessing.
#
# Boot + nav is launch_mission.sh's route as far as the main menu, then TUTORIAL
# instead of LOAD GAME. Everything stays a CHILD of this script (no setsid): a
# detached process is not a survivable one here, see the session-lifetime note.
set -u
N="${1:-5}" # 0 BASIC, 1 HUD, 2 RADAR, 3 SUPPLY, 4 RADIO, 5 ADVANCED
SECS="${2:-150}"
TAG="${3:-tut}"
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}'; }
step(){ vgamepad dpad "$1"; sleep 0.25; vgamepad dpad center; sleep 0.7; }
shot(){ screenshot "$SHOTS/$TAG-$1.png" >/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
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
rm -f "/tmp/.X${DISPLAY#:}-lock" 2>/dev/null || true
nohup Xvfb "$DISPLAY" -screen 0 1280x720x24 -ac -nolisten tcp \
+extension GLX +extension RANDR </dev/null >/tmp/xvfb98.log 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 </dev/null >/tmp/openbox98.log 2>&1 &
sleep 1
fi
cd /sylph-home/re
nohup run-canary --audio --apu=sdl --log_mask=13 \
--logged_profile_slot_0_xuid=E0300000EFBEA3D4 </dev/null >/dev/null 2>&1 &
sleep 5
"$SD/skip_intro.sh" 600 || { echo "BOOT FAILED (skip_intro exit $?)"; exit 1; }
sleep 14 # main menu is not input-ready before this
step down; step down # NEW GAME -> LOAD GAME -> TUTORIAL
vgamepad tap A 250; sleep 8
shot "list"
i=0; while [ "$i" -lt "$N" ]; do step down; i=$((i+1)); done
shot "pick"
vgamepad tap A 250; sleep 6
shot "sub" # some tutorials offer Level 1 / Level 2
vgamepad tap A 250
# Then just watch. The lesson drives itself and prints its instructions; tap A
# periodically to advance any prompt, and photograph often enough to catch the
# caption before it is replaced.
end=$(( SECONDS + SECS )); n=0
while [ $SECONDS -lt $end ]; do
n=$((n+1)); shot "$(printf '%02d' $n)"
[ -n "$(alive)" ] || { echo "EMULATOR GONE at ${SECONDS}s"; exit 4; }
sleep 5
[ $((n % 3)) -eq 0 ] && vgamepad tap A 250
done
echo "TUTORIAL CAPTURE DONE ($n frames)"

45
tools/re-capture/wait_flight.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Wait until the game is actually FLYING, then return — do not guess with sleeps.
#
# `launch_mission.sh` used to allow a fixed 75 s for the launch cinematic, the
# stage load and the objective card. Under lavapipe that is not a constant: one
# run needed 75 s, the next was still in Raymond's dialogue at 140 s, so the A
# meant for the OBJECTIVE card was swallowed by the cutscene and the card sat
# there forever.
#
# The in-flight HUD is unmistakable: the SHIELD bar is a solid bright green
# block at the bottom of the screen, and no cutscene or menu has anything green
# there. So poll that pixel, and tap A every few seconds until it appears (which
# dismisses the objective card whenever it happens to be up).
#
# Same liveness rule as skip_intro.sh, for the same reason: a failed screenshot
# leaves r/g/b unset, they default to 0, the green test fails, and waiting for
# the HUD becomes indistinguishable from waiting for a dead emulator.
set -u
export HOME=/sylph-home/re
DISP="${DISPLAY:-:98}"
alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $1}'; }
DEADLINE=$(( SECONDS + ${1:-240} ))
PX=450; PY=640 # inside the SHIELD bar of the flight HUD
last_tap=0
while [ $SECONDS -lt $DEADLINE ]; do
rm -f /tmp/wf.png
if ! screenshot /tmp/wf.png >/dev/null 2>&1 || [ ! -s /tmp/wf.png ]; then
xdpyinfo -display "$DISP" >/dev/null 2>&1 \
|| { echo "DISPLAY LOST at ${SECONDS}s"; exit 3; }
sleep 2; continue
fi
[ -n "$(alive)" ] || { echo "EMULATOR GONE at ${SECONDS}s"; exit 4; }
read -r r g b < <(convert /tmp/wf.png -format \
"%[fx:int(255*p{$PX,$PY}.r)] %[fx:int(255*p{$PX,$PY}.g)] %[fx:int(255*p{$PX,$PY}.b)]" info:)
if [ "${g:-0}" -gt 140 ] && [ $(( g - r )) -gt 60 ] && [ $(( g - b )) -gt 60 ]; then
echo "IN FLIGHT at ${SECONDS}s (HUD shield bar visible)"
exit 0
fi
if [ $(( SECONDS - last_tap )) -ge 6 ]; then
vgamepad tap A 250
last_tap=$SECONDS
fi
sleep 2
done
echo "NO FLIGHT HUD within ${1:-240}s"; exit 1