# 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` 20–30 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. 400–2000 u/s instead of 8000 — every shot led **4–16× 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 10–40° 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.
