Compare commits
11 Commits
auto/re-au
...
auto/re-sh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbfeb1c387 | ||
|
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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/shipcap-stage02-launch.png
Normal file
|
After Width: | Height: | Size: 543 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.
|
||||||
100
docs/re/ship-placement-capture-generalisation.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Capital-ship placement — does the `e106` result generalise? (WIP, 2026-07-31)
|
||||||
|
|
||||||
|
**Status:** 🚧 **WIP, time-boxed session.** Two results so far: a static audit across all
|
||||||
|
22 stage containers (done, below) and a first in-mission F10 capture run in Stage 02
|
||||||
|
(done — three capture logs, but **no capital-ship part correlated**; see "Open").
|
||||||
|
|
||||||
|
Context: [`BACKLOG.md`](BACKLOG.md) — "Capital ships assemble wrong in the viewer",
|
||||||
|
reported 2026-07-30. The oracle and the correlator already exist
|
||||||
|
([`ship-placement-runtime-capture.md`](ship-placement-runtime-capture.md)); the open
|
||||||
|
question is whether the rules derived from the one validated ship (`e106`, Stage_S01)
|
||||||
|
hold for other classes.
|
||||||
|
|
||||||
|
## 1. Static audit across all stages (offline, reproducible)
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo run --release --example ship_audit -- ../sylph_extract/hidden/resource3d
|
||||||
|
```
|
||||||
|
|
||||||
|
87 lines of output, of which:
|
||||||
|
|
||||||
|
- **Only two OUTLIER lines, and they are the same ship twice**:
|
||||||
|
`Stage_S03`/`Stage_S27`, `f002_bdy_05` centroid `[-1398 6251 918]`, `dist=6540`
|
||||||
|
vs a cluster spread of `1071`. Every other assembled ship in every other stage
|
||||||
|
has all parts inside its own cluster.
|
||||||
|
→ **The user-visible breakage is NOT a gross static-placement outlier for most
|
||||||
|
classes.** Whatever is wrong in the viewer is either subtler than "part flung far
|
||||||
|
away" (wrong rotation, wrong mirror, missing part) or lives in the viewer, not in
|
||||||
|
`assemble_ship`. `f002_bdy_05` is a genuine, separate, reproducible static bug.
|
||||||
|
- **MULTIKEY**: joint tracks with more than one keyframe, which `read_trs9`'s
|
||||||
|
single-key read does not model. Recurring rigs: `e_rou_f104` (3), `e_rou_f105` (2),
|
||||||
|
`e_rou_f106` (2), `e_rou_e102` (6), `e_rou_e108_Missile_open` (2), `e_rou_e501` (1),
|
||||||
|
and the `e901` boss with 2–15 tracks per pose. Several of these (`Missile_open`, the
|
||||||
|
`e901_attack*` poses) are obviously *animation* and harmless for a static pose; the
|
||||||
|
plain hull rigs `f104`/`f105`/`f106`/`e102` are **not** obviously animation and are
|
||||||
|
the best hypothesis for a class-specific assembly error. ❔ **HYPOTHESIS — not
|
||||||
|
verified.** `e106`, the one validated ship, has **no** multikey tracks, which is
|
||||||
|
exactly how a rule that only works for single-key rigs could have passed unnoticed.
|
||||||
|
|
||||||
|
Raw audit output is reproducible with the command above (not checked in; it is
|
||||||
|
deterministic from the disc).
|
||||||
|
|
||||||
|
## 2. First in-mission capture run (Stage 02)
|
||||||
|
|
||||||
|
New tool: [`tools/re-capture/ship_capture_session.sh`](../../tools/re-capture/ship_capture_session.sh)
|
||||||
|
— one blocking session (per the session-lifetime rule): boot → Stage 02 in flight →
|
||||||
|
N× {screenshot, F10, small yaw}. Each F10 writes its own
|
||||||
|
`xenia_ship_capture_NN.log` next to the binary.
|
||||||
|
|
||||||
|
Run 2026-07-31, 5 presses requested:
|
||||||
|
|
||||||
|
- **Boot to in-flight took 24 s** (`skip_intro.sh` skipped the movie at 1 s and 6 s,
|
||||||
|
title at 11 s, HUD shield bar at 24 s) — much faster than the ~100 s in the notes.
|
||||||
|
- **3 of 5 F10 presses produced a log** (`_01`…`_03`, 2964 / 3111 / 3668 draws).
|
||||||
|
Logs (8–10 MB each) and the screenshots are at `/sylph-home/re/shipcap/`; not
|
||||||
|
committed for size. One screenshot is checked in as
|
||||||
|
[`captures/shipcap-stage02-launch.png`](captures/shipcap-stage02-launch.png).
|
||||||
|
- The screenshot confirms the capture frames are real in-mission combat frames
|
||||||
|
(HUD live, `REMAINING OB 004`, ACROPOLIS + a Destroyer labelled on screen, a
|
||||||
|
capital-ship hull filling the bottom of the frame).
|
||||||
|
|
||||||
|
### Result: no correlation yet ❌
|
||||||
|
|
||||||
|
```
|
||||||
|
correlate_capture xenia_ship_capture_03.log Stage_S02 <id> bdy_01
|
||||||
|
```
|
||||||
|
for `f101` (ACROPOLIS), `f105`, `f106`, `e105` reports *"no draw matches any LOD
|
||||||
|
(culled/off-screen?)"* for essentially every part — only two speculative LOD tries
|
||||||
|
(`f101_bdy_03` vcount 90 `[l]`, `e105_wep_01` vcount 60 `[l]`) and **zero accepted
|
||||||
|
matches**.
|
||||||
|
|
||||||
|
That is a **negative result, and it is not yet explained**. Facts collected:
|
||||||
|
|
||||||
|
- The capture is not empty or degenerate: 3668 draws in `_03`, top shaders
|
||||||
|
`0xDA51B0745ABF85D2` (1258), `0xE0BAFB4F520FE441` (1091), `0xEEA84C59D7F95371` (770).
|
||||||
|
None is the `e106` ship-shader hash from the 2026-07-26 capture; the F10 path does
|
||||||
|
not filter by hash, so this alone is not the cause.
|
||||||
|
- Large vertex counts *are* present (3024, 2772, 1736, 1612, 1240 …), so capital-ship-
|
||||||
|
sized geometry is being drawn.
|
||||||
|
|
||||||
|
Candidate explanations, **untested**:
|
||||||
|
1. the Stage-02 capital ships on screen are drawn from LOD/damage variants
|
||||||
|
(`_d00`, `_m`, `_l`) whose vcounts the correlator's variant list does not cover;
|
||||||
|
2. the position-validation step rejects otherwise-correct vcount hits (the capture
|
||||||
|
dumps ≤64 positions — a set-membership test against the wrong variant fails);
|
||||||
|
3. the ships in view at launch are drawn by a *different* draw path than `e106` in
|
||||||
|
Stage_S01 (e.g. instanced/batched), so no single draw equals one part.
|
||||||
|
|
||||||
|
**First step next session:** take the largest few vcounts in the capture and ask which
|
||||||
|
decoded part in `Stage_S02.xpr` has that count (invert the match), instead of asking
|
||||||
|
per-part whether a draw exists. That distinguishes (1)/(2) from (3) immediately.
|
||||||
|
|
||||||
|
## Honest summary
|
||||||
|
|
||||||
|
- ✅ Static assembly is **not** grossly broken across stages — 1 outlier ship
|
||||||
|
(`f002_bdy_05`), reproducible.
|
||||||
|
- 🟡 A concrete, testable hypothesis for class-specific breakage exists (multikey joint
|
||||||
|
tracks on `f104`/`f105`/`f106`/`e102`; `e106` has none).
|
||||||
|
- ❌ The runtime oracle did **not** reproduce on a second ship class yet. The capture
|
||||||
|
pipeline works end-to-end (boot → F10 → logs); the correlation step is where it
|
||||||
|
stops. **NEEDS-HUMAN / next session**, do not assume the `e106` rules generalise.
|
||||||
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:
|
So the loop is a state machine on damage rather than a pure pursuit:
|
||||||
|
|
||||||
ENGAGE chase and shoot the nearest hostile fighter
|
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,
|
EVADE entered the moment the hull drops — turn away from the threats,
|
||||||
full throttle, jink; leave only after several quiet seconds
|
full throttle, jink; leave only after several quiet seconds
|
||||||
RETIRE hull below a floor: break for the friendly capital ship, which the
|
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
|
objective is the invading fighters, and the turret is what killed every previous
|
||||||
run.
|
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]
|
Usage: pilot.py <config.json> [seconds] [--dry]
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
@@ -40,22 +55,120 @@ from collections import deque
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import gmem # noqa: E402
|
||||||
import navigator # noqa: E402
|
import navigator # noqa: E402
|
||||||
from navigator import ang, norm # noqa: E402
|
from navigator import ang, norm # noqa: E402
|
||||||
from flight_probe import Pad # noqa: E402
|
from flight_probe import Pad # noqa: E402
|
||||||
|
|
||||||
HULL_OFF = 0x154 # confirmed: == definition HP at spawn, falls when hit
|
HULL_OFF = 0x154 # confirmed: == definition HP at spawn, falls when hit
|
||||||
SHIELD_OFF = 0x430 # candidate: == definition Shield MaxValue at spawn
|
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:
|
class Pilot:
|
||||||
KP, KD = 2.2, 0.45
|
KP, KD = 2.2, 0.45
|
||||||
FIRE_CONE = math.radians(9)
|
FIRE_CONE = math.radians(9) # fallback only; the real gate is angular size
|
||||||
FIRE_RANGE = 5000.0
|
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
|
TURRET_KEEPOUT = 2500.0 # ...and stay this far from things that shoot back
|
||||||
EVADE_QUIET = 5.0 # seconds without damage before re-engaging
|
EVADE_QUIET = 5.0 # seconds without damage before re-engaging
|
||||||
RETIRE_FRAC = 0.30 # hull fraction that sends us home
|
RETIRE_FRAC = 0.30 # hull fraction that sends us home
|
||||||
HZ = 8.0
|
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):
|
def __init__(self, W, pad, dry=False, log=sys.stdout):
|
||||||
self.W = W
|
self.W = W
|
||||||
@@ -72,6 +185,61 @@ class Pilot:
|
|||||||
self.hp0 = None
|
self.hp0 = None
|
||||||
self.last_hit = -1e9
|
self.last_hit = -1e9
|
||||||
self.threat_dir = None
|
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
|
# ------------------------------------------------------------ own state
|
||||||
def own(self, off):
|
def own(self, off):
|
||||||
@@ -82,13 +250,40 @@ class Pilot:
|
|||||||
return hull, shield
|
return hull, shield
|
||||||
|
|
||||||
def set_throttle(self, want):
|
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:
|
if want == self.throttle or self.dry:
|
||||||
return
|
return
|
||||||
self.pad.trig("RT", 1.0 if want > 0 else 0.0)
|
if want == "match":
|
||||||
self.pad.trig("LT", 1.0 if want < 0 else 0.0)
|
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
|
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
|
# -------------------------------------------------------------- targets
|
||||||
def hostiles(self, ents, me_off):
|
def hostiles(self, ents, me_off):
|
||||||
out = []
|
out = []
|
||||||
@@ -98,9 +293,54 @@ class Pilot:
|
|||||||
out.append((off, nm, p, v, r, "Turret" in nm or r >= navigator.Navigator.BIG_RADIUS))
|
out.append((off, nm, p, v, r, "Turret" in nm or r >= navigator.Navigator.BIG_RADIUS))
|
||||||
return out
|
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):
|
def pick(self, me_p, me_v, fwd, hos):
|
||||||
"""Nearest *fighter*, weighted by how far off the nose it is."""
|
"""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
|
best, bestscore = None, 1e18
|
||||||
for off, nm, p, v, r, hard in hos:
|
for off, nm, p, v, r, hard in hos:
|
||||||
if hard:
|
if hard:
|
||||||
@@ -109,11 +349,11 @@ class Pilot:
|
|||||||
d = float(np.linalg.norm(rel))
|
d = float(np.linalg.norm(rel))
|
||||||
if d < 1e-3:
|
if d < 1e-3:
|
||||||
continue
|
continue
|
||||||
lead = p + v * (d / max(speed, 300.0))
|
lead = self.lead_point(p, v, d)
|
||||||
theta = ang(lead - me_p, fwd)
|
theta = ang(lead - me_p, fwd)
|
||||||
score = d * (1.0 + 3.0 * (theta / math.pi) ** 2)
|
score = d * (1.0 + 3.0 * (theta / math.pi) ** 2)
|
||||||
if score < bestscore:
|
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
|
return best
|
||||||
|
|
||||||
def threat_vector(self, me_p, hos):
|
def threat_vector(self, me_p, hos):
|
||||||
@@ -191,15 +431,32 @@ class Pilot:
|
|||||||
self.threat_dir = self.threat_vector(me_p, hos)
|
self.threat_dir = self.threat_vector(me_p, hos)
|
||||||
frac = hull / self.hp0 if self.hp0 else 1.0
|
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:
|
if frac <= self.RETIRE_FRAC:
|
||||||
self.mode = "RETIRE"
|
self.mode = "RETIRE"
|
||||||
elif t - self.last_hit < self.EVADE_QUIET:
|
elif t - self.last_hit < self.EVADE_QUIET:
|
||||||
self.mode = "EVADE"
|
self.mode = "EVADE"
|
||||||
|
elif ast is not None and t - self.asset_last_hit < self.ASSET_QUIET:
|
||||||
|
self.mode = "DEFEND"
|
||||||
else:
|
else:
|
||||||
self.mode = "ENGAGE"
|
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)
|
push, worst = self.av.avoidance(me_p, me_v, me_r, ents, me_off)
|
||||||
|
|
||||||
if self.mode == "EVADE":
|
if self.mode == "EVADE":
|
||||||
@@ -210,6 +467,33 @@ class Pilot:
|
|||||||
want = norm(away + jink)
|
want = norm(away + jink)
|
||||||
self.set_throttle(+1)
|
self.set_throttle(+1)
|
||||||
fire = False
|
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":
|
elif self.mode == "RETIRE":
|
||||||
base = self.friendly_base(ents, me_off)
|
base = self.friendly_base(ents, me_off)
|
||||||
if base is not None:
|
if base is not None:
|
||||||
@@ -228,10 +512,10 @@ class Pilot:
|
|||||||
# than a stall in the middle of a battle; collision avoidance already
|
# than a stall in the middle of a battle; collision avoidance already
|
||||||
# keeps a fighter-sized margin.
|
# keeps a fighter-sized margin.
|
||||||
want = norm(tgt[3]) if tgt else fwd
|
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)
|
self.set_throttle(+1)
|
||||||
elif tgt and tgt[4] < 500.0 and speed > 900.0:
|
|
||||||
self.set_throttle(-1)
|
|
||||||
else:
|
else:
|
||||||
self.set_throttle(0)
|
self.set_throttle(0)
|
||||||
fire = True
|
fire = True
|
||||||
@@ -245,6 +529,26 @@ class Pilot:
|
|||||||
want = norm(want + norm(me_p - p) * (2.0 * (1.0 - d / self.TURRET_KEEPOUT)))
|
want = norm(want + norm(me_p - p) * (2.0 * (1.0 - d / self.TURRET_KEEPOUT)))
|
||||||
break
|
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))
|
pn = float(np.linalg.norm(push))
|
||||||
if pn > 1e-6:
|
if pn > 1e-6:
|
||||||
want = norm(want + push * (3.0 if pn > 0.6 else 1.5))
|
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
|
# so gating on it means the guns stay cold exactly when the loop is
|
||||||
# manoeuvring — which is most of a dogfight.
|
# manoeuvring — which is most of a dogfight.
|
||||||
aim = self.sticks(norm(tgt[3]), M, w)[2:] if tgt else (math.pi, math.pi)
|
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)
|
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:
|
if not self.dry:
|
||||||
self.pad.axis("LX", sx)
|
self.pad.axis("LX", sx)
|
||||||
self.pad.axis("LY", sy)
|
self.pad.axis("LY", sy)
|
||||||
if fire != self.firing:
|
if fire != self.firing:
|
||||||
(self.pad.press if fire else self.pad.release)("RB")
|
(self.pad.press if fire else self.pad.release)("RB")
|
||||||
self.firing = fire
|
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} "
|
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"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:
|
if dmg > 0.5:
|
||||||
msg += f" HIT -{dmg:.0f}"
|
msg += f" HIT -{dmg:.0f}"
|
||||||
if tgt:
|
if tgt:
|
||||||
|
|||||||
45
tools/re-capture/ship_capture_session.sh
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ONE blocking session: boot -> Stage 02 in flight -> sweep the view and press
|
||||||
|
# F10 several times, so each press dumps xenia_ship_capture_NN.log with whatever
|
||||||
|
# capital ships are on screen at that moment.
|
||||||
|
#
|
||||||
|
# Why a sweep and not a single press: the 2026-07-26 e106 capture missed the
|
||||||
|
# bridge because it was culled at that camera angle. Several presses at
|
||||||
|
# different headings cost nothing (the capture is a one-frame draw dump) and
|
||||||
|
# each one is independently correlatable.
|
||||||
|
#
|
||||||
|
# Usage: ship_capture_session.sh [presses] [out_dir]
|
||||||
|
set -u
|
||||||
|
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
|
||||||
|
SD="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PRESSES="${1:-6}"
|
||||||
|
OUT="${2:-/sylph-home/re/shipcap}"
|
||||||
|
BINDIR="/home/fabi/RE - Project Sylpheed/xenia-canary-native/build/bin/Linux/Release"
|
||||||
|
SHOTS=/sylph-home/re/shots
|
||||||
|
mkdir -p "$OUT" "$SHOTS"
|
||||||
|
|
||||||
|
rm -f "$BINDIR"/xenia_ship_capture_*.log
|
||||||
|
|
||||||
|
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
|
||||||
|
|
||||||
|
# F10 goes to the emulator window through XTEST; the window must be focused.
|
||||||
|
win="$(xdotool search --class -- xenia | tail -1)"
|
||||||
|
[ -z "$win" ] && win="$(xdotool search --name -- Xenia | tail -1)"
|
||||||
|
echo "WINDOW=$win"
|
||||||
|
[ -n "$win" ] && { xdotool windowactivate "$win" 2>/dev/null; xdotool windowfocus "$win" 2>/dev/null; }
|
||||||
|
|
||||||
|
for i in $(seq 1 "$PRESSES"); do
|
||||||
|
screenshot "$SHOTS/shipcap-$i.png" >/dev/null 2>&1
|
||||||
|
if [ -n "$win" ]; then xdotool key --window "$win" F10; else xdotool key F10; fi
|
||||||
|
sleep 3
|
||||||
|
# Yaw a little between presses so a culled part gets another chance, and the
|
||||||
|
# craft keeps closing on the friendly formation (the capital ships).
|
||||||
|
vgamepad axis LX 0.45; sleep 1.2; vgamepad axis LX 0.0
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
cp -v "$BINDIR"/xenia_ship_capture_*.log "$OUT"/ 2>/dev/null
|
||||||
|
cp -v "$SHOTS"/shipcap-*.png "$OUT"/ 2>/dev/null
|
||||||
|
grep -c '^DRAW' "$OUT"/xenia_ship_capture_*.log 2>/dev/null
|
||||||
|
echo "CAPTURE SESSION DONE"
|
||||||
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)"
|
||||||