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.
226 lines
12 KiB
Markdown
226 lines
12 KiB
Markdown
# 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.
|