# Memory-driven autopilot — build log and current state

**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.

## 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 125–140 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
[ 85.7] tgt=e007_ADAN_Turret  d=2797  yaw= +1.9 pit=+27.2 stick=(+0.20,-0.59) fire=0
[117.3] tgt=e007_ADAN_Turret  d=4211  yaw= +5.7 pit= +9.0 stick=(+0.25,-0.40) fire=1
```

Distance closes monotonically, yaw error is driven from −8° to ~0, and once both
errors are inside the firing cone it holds RB and the ammo counter falls. A
rescan reports the scene as e.g. `136 entities {'TCAF': 16, 'ADAN': 120}`.

**The chain that made it work** — each link checked, not assumed:

1. **Entity typing.** A live entity's definition pointer sits at
   **position + 0x130**. One heap scan then yields every craft *with its unit
   type*, which is what separates 20-odd real combatants from ~30 000 moving
   particles. Verified by the result being coherent: wingmen, enemy turrets and
   attackers, friendly capital ships, and exactly one `…_Player`.
2. **Orientation.** A 3×3 rotation at **position − 0x70**, stored with a
   **16-byte row stride** (a 4×4 transform whose translation row *is* the
   position). An earlier search for nine *contiguous* floats structurally could
   not find this, which is why the first pass concluded "no transform". The
   binding is confirmed independently: its row 2 matches the craft's measured
   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). (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
   (`score = d·(1 + 3·(θ/π)²)`) rather than pure nearest — closing on a target
   90° off the nose only raises the bearing rate, which is what held the first
   run outside its firing cone at a steady ~27° pitch error.

## Goal

Fly and fight a mission by reading the game's own world state out of guest RAM
and driving the pad from it — the RE payoff being an oracle for the
reimplementation's flight model and AI, and a way to reach missions the save
cannot otherwise reach (unit coverage for
[unit-struct-runtime](structures/unit-struct-runtime.md) is capped at 21/110
because definitions load **per stage**).

## What works (verified)

| Piece | Tool | Evidence |
|---|---|---|
| Live guest-RAM reads at loop rate | `gworld.py` | `/dev/shm` file opened once, `pread` per tick; a whole-RAM scan is ~6 s, a targeted read is microseconds |
| Whole-RAM float scanning | numpy over `SEEK_DATA` extents | 1 270 orthonormal 3×3 blocks located in 6.2 s |
| Entity enumeration by unit type | `gworld.py entities` | 116 live instances in Stage 02, typed by unit ID, incl. exactly one `…_Player` |
| Pad control at loop rate | `flight_probe.Pad` | writes command lines straight into the vgamepad FIFO; the `vgamepad` CLI spawns a process per command and its `tap`/`hold` sleep *inside* the server, so neither is usable in a control loop |
| Unattended mission entry | `launch_mission.sh` | title → LOAD GAME → slot 01 → READY ROOM → TAKE OFF → in flight, repeatable |
| Hangar loadout | `launch_mission.sh --hangar` | the "Recommended" control is **AUTO SELECT — "Mount most suitable weapons"**, already the default cursor position. At 5 % progress it is a no-op: only two weapons are developed, and they are already mounted |
| Finding moving objects | `findplayer.py` | position triples recovered from motion alone — straight-line, constant-speed filter over K whole-RAM samples |

## What is NOT solved

**Survival, and therefore mission completion.** The loop has no evasion, no
shield/armour awareness and no throttle control, so it flies a straight pursuit
into defended space and is eventually shot down — every long run so far has
ended in GAME OVER. Completing a mission needs, at least: reading own
shield/armour, breaking off when hit, and prioritising the mission's actual
objective targets over the nearest turret.

### Superseded (kept because the reasoning still matters)

The notes below were written before the chain above worked. They remain true as
statements about `0x820af030`, which is *not* the live entity —

1. **The `0x820af030` class is not the live entity.** It has one object per
   spawned thing and carries the unit-ID string, so it looked like the entity
   list — but over a 29 s in-flight capture **all 384 words of it are
   constant** (`whatchanges.py`). It is a static spawn record. The earlier
   claim in `unit-struct-runtime.md` that this class is "the spawned entity
   instance — live state" is **wrong in the second half**: it is per-spawn, but
   it is not live state. The parts of that document that depend on the
   *definition* class `0x820af844` are unaffected.
2. **No transform in or one hop from that object**: no orthonormal 3×3, and no
   unit quaternion, within `0x2000` of it or behind any of its 121 pointers.
3. **Input correlation finds *a* self-object, but not obviously the craft.**
   Holding hard-left then hard-right yaw and looking for a position whose turn
   axis reverses (`findself.py`, `selfstate.py`) gives clean hits
   (`cos ≈ −0.99`), but they cluster at `0x40009xxx` in what looks like an
   8-corner box with ±45 000 coordinates — a camera/skybox volume that follows
   the player, not the craft. Its speed (≈359/s) is suspiciously close to the
   HUD's 350, which supports "follows the player" but is not proof of identity.
4. **Entity typing is unavailable**: no definition pointer within ±0x800 of the
   self-position, so the trick of learning one object's layout and applying it
   to all the others has nothing to anchor on. Without typing, the 33 418
   moving triples in a firefight cannot be separated into enemies, friendlies
   and bullets, so there is nothing to aim at.

## Dead ends, recorded so they are not re-run

* **Speed-scan for the player object** (`findspeed.py`, the classic two-state
  value scan: coast → boost → coast). Sound method, but **`RT` is not the
  throttle** — 5 864 floats matched the cruise speed and none rose. The control
  actually bound to acceleration was never established, and the run that would
  have established it ended in GAME OVER.
* **Comparing orientation matrices 2 s apart.** At a real turn rate that is far
  outside the small-angle regime, so the skew part of `A·Bᵀ` is not the rotation
  vector and the "angular velocity" comes out as ~30 000. Sample incrementally
  (6 Hz) and re-check orthonormality on every read — blocks found by a scan get
  overwritten between the scan and the read.

## 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 2–5 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
a global almost certainly holds a pointer to the currently-targeted entity.
Reading that gives an enemy's live object address directly — which yields both
target selection *and* the entity layout (position offset within it), i.e. it
collapses problems 3 and 4 into one. It is also cheap to find: cycle the target
with the pad and watch which pointer-shaped global changes in step.

## Operational notes

* An unattended craft **dies** — the ship flies straight into a firefight, and
  two long scans were invalidated by a GAME OVER mid-run. Any scan longer than
  ~30 s needs either a survivable holding pattern or a fresh mission.
* `Xvfb` and the emulator die on their own every few minutes here, cleanly
  (exit 0), cause unidentified. Everything that must not be interrupted is run
  as **one background task** that starts the display, the emulator, the
  navigation and the measurement together, so nothing has to survive between
  tool calls.
* `pgrep` cannot be used for liveness in this container: PID 1 is
  `sleep infinity` and never reaps, so dead processes linger as `<defunct>` and
  still match by name. Use `ps -o stat=` and skip `Z`, or `xdpyinfo` for X.
* numpy is not installed system-wide; `pip install --break-system-packages
  numpy` puts it in `/sylph-home/.local`, which is only on `sys.path` when
  `HOME=/sylph-home`. Scripts run with `HOME=/sylph-home/re` need
  `PYTHONPATH=/sylph-home/.local/lib/python3.12/site-packages`.

## 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` ·
`findrot_global.py` · `findspeed.py` · `liveents.py` · `selfstate.py` (the
whole chain → JSON) · `autopilot2.py` (PD controller; **untested — it has never
had a valid config to run against**) · `launch_mission.sh`.
