Compare commits
10 Commits
auto/re-au
...
auto/re-mi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
695351dfc4 | ||
|
|
4821ba7fea | ||
|
|
58f421d896 | ||
|
|
9f41fe08e9 | ||
|
|
95ac545b2b | ||
|
|
ab8f5307ff | ||
|
|
76f463b611 | ||
|
|
3f6efadf9e | ||
|
|
402985adbf | ||
|
|
c277e42c92 |
@@ -32,6 +32,8 @@ 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
|
||||
|
||||
BIN
docs/re/captures/dpad-tactical-map.png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
docs/re/captures/escort-stage02-hud.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/re/captures/fire-probe-ammo-counters.png
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
BIN
docs/re/captures/first-missile-kills.png
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
docs/re/captures/kill-counters-all-runs.png
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
docs/re/captures/kills-target-commitment.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
330
docs/re/captures/mission-state-stage02-escort-weighted.jsonl
Normal file
240
docs/re/captures/mission-state-stage02.jsonl
Normal file
BIN
docs/re/captures/options-key-config-actions.png
Normal file
|
After Width: | Height: | Size: 881 KiB |
BIN
docs/re/captures/tutorial-advanced-controls-captions.png
Normal file
|
After Width: | Height: | Size: 626 KiB |
BIN
docs/re/captures/tutorial-hud-target-select.png
Normal file
|
After Width: | Height: | Size: 884 KiB |
BIN
docs/re/captures/tutorial-menu-labels.png
Normal file
|
After Width: | Height: | Size: 890 KiB |
104
docs/re/flight-controls-runtime.md
Normal 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).
|
||||
225
docs/re/mission-escort-state.md
Normal 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` 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.
|
||||
42
tools/re-capture/fire_probe.sh
Executable 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"
|
||||
34
tools/re-capture/lock_probe.sh
Executable 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"
|
||||
@@ -18,6 +18,7 @@ make a reaction possible:
|
||||
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
|
||||
@@ -27,6 +28,20 @@ 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
|
||||
@@ -40,22 +55,120 @@ 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 20–30.
|
||||
# Both numbers were previously wrong in this loop, and both mattered:
|
||||
# * flight time was computed as d / OUR speed (400–2000 u/s), so every shot
|
||||
# was led 4–16× 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)
|
||||
FIRE_RANGE = 5000.0
|
||||
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
|
||||
@@ -72,6 +185,61 @@ class Pilot:
|
||||
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):
|
||||
@@ -82,13 +250,40 @@ class Pilot:
|
||||
return hull, shield
|
||||
|
||||
def set_throttle(self, want):
|
||||
"""RT / LT are a persistent setting, so only send the change."""
|
||||
"""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
|
||||
self.pad.trig("RT", 1.0 if want > 0 else 0.0)
|
||||
self.pad.trig("LT", 1.0 if want < 0 else 0.0)
|
||||
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 = []
|
||||
@@ -98,9 +293,54 @@ class Pilot:
|
||||
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 ~10–30°, 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."""
|
||||
speed = max(float(np.linalg.norm(me_v)), 1.0)
|
||||
best, bestscore = None, 1e18
|
||||
for off, nm, p, v, r, hard in hos:
|
||||
if hard:
|
||||
@@ -109,11 +349,11 @@ class Pilot:
|
||||
d = float(np.linalg.norm(rel))
|
||||
if d < 1e-3:
|
||||
continue
|
||||
lead = p + v * (d / max(speed, 300.0))
|
||||
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), score
|
||||
best, bestscore = (off, nm, lead, lead - me_p, d, r), score
|
||||
return best
|
||||
|
||||
def threat_vector(self, me_p, hos):
|
||||
@@ -191,15 +431,32 @@ class Pilot:
|
||||
self.threat_dir = self.threat_vector(me_p, hos)
|
||||
frac = hull / self.hp0 if self.hp0 else 1.0
|
||||
|
||||
# ---- mode
|
||||
# ---- 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(me_p, me_v, fwd, hos)
|
||||
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":
|
||||
@@ -210,6 +467,33 @@ class Pilot:
|
||||
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:
|
||||
@@ -228,10 +512,10 @@ class Pilot:
|
||||
# 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] > 2500.0:
|
||||
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)
|
||||
elif tgt and tgt[4] < 500.0 and speed > 900.0:
|
||||
self.set_throttle(-1)
|
||||
else:
|
||||
self.set_throttle(0)
|
||||
fire = True
|
||||
@@ -245,6 +529,26 @@ class Pilot:
|
||||
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))
|
||||
@@ -255,20 +559,54 @@ class Pilot:
|
||||
# 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)
|
||||
aim_ok = abs(aim[0]) < self.FIRE_CONE and abs(aim[1]) < self.FIRE_CONE
|
||||
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={self.throttle:+d} yaw={math.degrees(yaw):+6.1f} "
|
||||
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)}")
|
||||
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:
|
||||
|
||||
173
tools/re-capture/target_probe.py
Normal 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()
|
||||
64
tools/re-capture/tutorial_capture.sh
Executable 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)"
|
||||