Compare commits
16 Commits
auto/re-un
...
auto/re-sh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbfeb1c387 | ||
|
|
695351dfc4 | ||
|
|
4821ba7fea | ||
|
|
58f421d896 | ||
|
|
9f41fe08e9 | ||
|
|
95ac545b2b | ||
|
|
ab8f5307ff | ||
|
|
76f463b611 | ||
|
|
3f6efadf9e | ||
|
|
402985adbf | ||
|
|
c277e42c92 | ||
|
|
8ee3bfbcef | ||
|
|
91fb022caf | ||
|
|
93d6534efe | ||
| 41c34dc3ca | |||
| 2e9903d0fc |
46
docs/re/BACKLOG.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# RE backlog
|
||||||
|
|
||||||
|
Open items that are *not* being worked right now. Each entry says what is wrong or
|
||||||
|
unknown, what evidence exists, and what the first step would be. Move an item into
|
||||||
|
`INDEX.md` (with a `structures/…md` or a parser + test) once it is actually settled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Capital ships assemble wrong in the viewer
|
||||||
|
|
||||||
|
**Reported:** 2026-07-30, by the user. **Status:** ❔ open, not investigated.
|
||||||
|
|
||||||
|
The reborn viewer builds capital ships from the split XBG7 parts via
|
||||||
|
`sylpheed-formats::ship::assemble_ship`, and they come out **wrong** — parts in the
|
||||||
|
wrong place / wrong orientation.
|
||||||
|
|
||||||
|
**Why this is a real finding and not a known limitation:** the RE write-up
|
||||||
|
[`ship-placement-runtime-capture.md`](ship-placement-runtime-capture.md) declares
|
||||||
|
static assembly ✅ **exact** as of 2026-07-26 — 9-channel joint tables
|
||||||
|
`[TX TY TZ RY RX RZ SX SY SZ]`, Euler `Ry·Rx·Rz`, with
|
||||||
|
`ship::tests::static_assembly_matches_runtime_capture` asserting static == runtime
|
||||||
|
capture (T < 1.0, R < 0.02). So either the viewer is not using that path, or the
|
||||||
|
claim generalises worse than the test suggests.
|
||||||
|
|
||||||
|
**The likely gap:** that test is **one ship** — the `e106` destroyer, 8 parts plus
|
||||||
|
two nacelles, two turrets and the hull mirror. Nothing pins the other classes.
|
||||||
|
Rules that were derived from `e106` and could easily be `e106`-specific:
|
||||||
|
|
||||||
|
- the engine cluster rig mounted at `GN_Engine_01` (two mirrored nacelles + centre);
|
||||||
|
- "X-reflect the shared-geometry twin whose lateral offset opposes the geometry's
|
||||||
|
dominant side" — a heuristic, not a decoded flag;
|
||||||
|
- cross-id turret instancing (×2).
|
||||||
|
|
||||||
|
**First step (the oracle already exists):** re-run the runtime capture on a *different*
|
||||||
|
capital ship and diff static vs captured, exactly as `e106` was done — F10 in the
|
||||||
|
`capture-ship-placement` build of `xenia-canary-native` dumps the ship shader's
|
||||||
|
`c0..c2` WorldViewProjection rows per part; `WV_ref⁻¹ · WV_p` is the ship-space rigid
|
||||||
|
transform, which is ground truth. Pick a class whose rig differs from `e106`
|
||||||
|
(different engine count, a ship with no `sld`, a carrier). Then extend
|
||||||
|
`static_assembly_matches_runtime_capture` into a per-ship table so a regression in one
|
||||||
|
class cannot hide behind `e106` passing.
|
||||||
|
|
||||||
|
**Also worth ruling out first, cheaply:** that the viewer's own transform stack (scale,
|
||||||
|
handedness, node-instance recursion) is not re-breaking a correct assembly — compare
|
||||||
|
the viewer's placement against `assemble_ship`'s output directly before blaming the
|
||||||
|
format layer.
|
||||||
@@ -31,6 +31,10 @@ 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 |
|
||||||
|
| 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
|
## Functions / code paths
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,101 @@
|
|||||||
# Memory-driven autopilot — build log and current state
|
# Memory-driven autopilot — build log and current state
|
||||||
|
|
||||||
**Status: 🟢 IT FLIES AND SHOOTS — it does not yet survive.**
|
**Status: 🟢 IT FLIES, KILLS AND SURVIVES — but it loses the mission anyway.**
|
||||||
Updated 2026-07-29 (second pass). The autopilot reads the live world, picks
|
Updated 2026-07-30. `pilot.py` flew Stage 02 for **300 s with the hull untouched
|
||||||
hostile targets, pursues them and opens fire. What it cannot do is stay alive:
|
at 1500/1500** and scored the first confirmed autopilot kill (`YOU KILLED
|
||||||
there is no evasion or shield management, so it dies before a mission ends.
|
WARPLANES 0001` on the HUD, screenshots `shots/pilot1-*.png`); the scene's
|
||||||
This is the honest state, not a plan.
|
hostile count fell from 134 to 111 over the run. The day before, every run was
|
||||||
|
dead inside 35 s. What is still missing is the *end* of a mission: the objective
|
||||||
|
counter (`REMAINING OB`) rises as new waves spawn, and nothing yet tracks which
|
||||||
|
targets actually close it out — and the second run proved the point the hard
|
||||||
|
way: `GAME OVER` with the hull at 1500/1500, because Stage 02 is an **escort**
|
||||||
|
and the ACROPOLIS was sunk while the pilot chased fighters two kilometres away.
|
||||||
|
|
||||||
## What the loop does now, observed
|
## 2026-07-30 — the numbers survival needs
|
||||||
|
|
||||||
|
Three things the loop was missing were measured this session, each by
|
||||||
|
consequence rather than by reading a field and hoping.
|
||||||
|
|
||||||
|
### Hull is `position + 0x154` ✅ CONFIRMED
|
||||||
|
|
||||||
|
The unit definition already had `HP` solved at `+0x054`
|
||||||
|
([unit-struct-runtime](structures/unit-struct-runtime.md)); the Delta Saber's is
|
||||||
|
**1500**. A craft that has taken no damage must therefore *contain that number*,
|
||||||
|
which turns "find the HP field" into a two-float lookup rather than a value scan
|
||||||
|
(`own_state.py`). It appears once in the entity object, at `pos+0x154`, and the
|
||||||
|
trace across a death settles it (`ctrl_probe.py` capture, `binq.py trace`):
|
||||||
|
|
||||||
|
```
|
||||||
|
t phase hull
|
||||||
|
0.00 base 1500.00 <- == definition HP
|
||||||
|
23.25 rest_A 1380.00 <- first hit, -120
|
||||||
|
26.68 … 27.18 B 1320 … 930 <- seven hits in 0.5 s
|
||||||
|
31.93 X 150.00
|
||||||
|
35.21 Y -30.00 <- goes negative
|
||||||
|
35.26 Y -180.00 -> GAME OVER on screen
|
||||||
|
```
|
||||||
|
|
||||||
|
Damage arrives in 30/60/90-point steps and the field goes *negative* at death,
|
||||||
|
so it is the raw hull counter, not a clamped display value. **1500 hull lost in
|
||||||
|
12 s** of sitting in a turret's line of fire is the whole reason every earlier
|
||||||
|
run died.
|
||||||
|
|
||||||
|
### Shield is `position + 0x430` 🟡 PROBABLE — not yet confirmed live
|
||||||
|
|
||||||
|
Same anchor trick: the definition's shield `MaxValue` is **400** and
|
||||||
|
`ChargeSpeed` **25**, and the entity object holds `400.0` at `+0x430`, `+0x434`
|
||||||
|
and `+0x438`, with `25.0` at `+0x448`. Which of the three is the *current* value
|
||||||
|
is unproven — the capture that spanned the death used a ±0x400 window and
|
||||||
|
cropped them out. `ctrl_probe.py` now samples ±0x800.
|
||||||
|
|
||||||
|
### `RT` accelerates, `LT` brakes, and the throttle is a *setting* ✅ CONFIRMED
|
||||||
|
|
||||||
|
`ctrl_probe.py` holds each input in turn and measures the craft's own speed as
|
||||||
|
displacement per second from the position triple, so no speed field is needed.
|
||||||
|
Distance flown / phase duration, one 3 s hold each, sticks neutral:
|
||||||
|
|
||||||
|
| phase | speed (units/s) | | phase | speed (units/s) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| base (no input) | 488 | | A | 287 |
|
||||||
|
| **RT** | **1510** | | B | 139 |
|
||||||
|
| rest after RT | 1056 | | X | 125 |
|
||||||
|
| **LT** | **174** | | Y | (dying) |
|
||||||
|
| rest after LT | 428 | | LB / LS / RS / RY / RX / dpad | no effect |
|
||||||
|
|
||||||
|
RT triples the speed, LT cuts it to a third, and **the braked state persists**:
|
||||||
|
after the LT phase the craft sat at 125–140 units/s with the sticks and triggers
|
||||||
|
neutral for the remaining 40 s, and nothing but RT brought it back. So these are
|
||||||
|
a throttle setting, not a momentary boost — which also means a control loop must
|
||||||
|
send only the *changes*.
|
||||||
|
|
||||||
|
This **corrects** the earlier note in this file ("`RT` is *not* the throttle,
|
||||||
|
and no button tested is"). That conclusion came from `findspeed.py`, which
|
||||||
|
assumed the control and went looking for a *field* that rose; measuring the
|
||||||
|
speed directly reverses it.
|
||||||
|
|
||||||
|
### Two method corrections
|
||||||
|
|
||||||
|
* **Pick the attitude block by the flight path, not by address order.** The
|
||||||
|
player object contains **20** orthonormal 3×3 blocks (identity frames, bone
|
||||||
|
or camera frames), and `pos-0x70` and `pos-0x30` hold the *same* matrix.
|
||||||
|
Taking `found[0]` wrote a config with `rot_delta = -0x764` and a nonsense
|
||||||
|
forward axis; `entities2.py self` now scores every block against the measured
|
||||||
|
direction of travel and picks the best (`cos = +1.000`, row 2, sign +1).
|
||||||
|
* **The entity-heap scan has to be numpy.** A per-word Python loop over the
|
||||||
|
16 MB entity region costs seconds per scan, which is the whole budget of a
|
||||||
|
10 Hz control loop; `np.isin` over a `>u4` view is milliseconds.
|
||||||
|
|
||||||
|
### Stage 02 as an autopilot testbed (from the in-flight HUD)
|
||||||
|
|
||||||
|
`OBJECTIVE: shoot down all invading enemy fighters while watching out for
|
||||||
|
attacks on the ACROPOLIS` · `DEFEAT: your fighter is shot down, or the ACROPOLIS
|
||||||
|
is sunk` · `HINT: you can resupply at the ACROPOLIS`. The HUD shows
|
||||||
|
**`REMAINING OB 004`** — only four objective targets — so this mission is
|
||||||
|
winnable by an autopilot that survives. It also shows separate **SHIELD** and
|
||||||
|
**ARMOR** bars (matching a 400-point shield over 1500 hull), `A/B 7,635`
|
||||||
|
afterburner, and `NOSE BM 06000` / `MAIN MPM 00300` ammo.
|
||||||
|
|
||||||
|
## What the loop did before that, observed
|
||||||
|
|
||||||
```
|
```
|
||||||
[ 82.5] tgt=e007_ADAN_Turret d=3384 yaw= -7.3 pit=+14.6 stick=(-0.13,-0.34) fire=0
|
[ 82.5] tgt=e007_ADAN_Turret d=3384 yaw= -7.3 pit=+14.6 stick=(-0.13,-0.34) fire=0
|
||||||
@@ -33,8 +122,8 @@ rescan reports the scene as e.g. `136 entities {'TCAF': 16, 'ADAN': 120}`.
|
|||||||
direction of travel with **cos = +1.000**.
|
direction of travel with **cos = +1.000**.
|
||||||
3. **The fire button is RB** — established by consequence, not by guessing:
|
3. **The fire button is RB** — established by consequence, not by guessing:
|
||||||
of RB/LB/A/B/X/Y/RT/LT, pressing RB is the only one that makes the nose-ammo
|
of RB/LB/A/B/X/Y/RT/LT, pressing RB is the only one that makes the nose-ammo
|
||||||
counter in RAM fall (5958 → 5940). `RT` is *not* the throttle, and no button
|
counter in RAM fall (5958 → 5940). (This entry also claimed `RT` is *not* the
|
||||||
tested is.
|
throttle — **wrong**, see the 2026-07-30 measurement above.)
|
||||||
4. **Control.** PD on the aiming error with the derivative taken from the
|
4. **Control.** PD on the aiming error with the derivative taken from the
|
||||||
craft's own body angular velocity (from two consecutive rotation matrices),
|
craft's own body angular velocity (from two consecutive rotation matrices),
|
||||||
and target selection weighted by off-boresight angle
|
and target selection weighted by off-boresight angle
|
||||||
@@ -113,7 +202,60 @@ statements about `0x820af030`, which is *not* the live entity —
|
|||||||
(6 Hz) and re-check orthonormality on every read — blocks found by a scan get
|
(6 Hz) and re-check orthonormality on every read — blocks found by a scan get
|
||||||
overwritten between the scan and the read.
|
overwritten between the scan and the read.
|
||||||
|
|
||||||
## The next step that unblocks the most
|
## The second run lost the mission **without being hit** (2026-07-30)
|
||||||
|
|
||||||
|
A second 240 s flight, with the two fixes above, ended on the `GAME OVER`
|
||||||
|
screen — while the hull read **1500/1500 on the last live tick**. Nothing shot
|
||||||
|
us down. The other defeat condition fired: *the ACROPOLIS is sunk*. The HUD had
|
||||||
|
been showing a red `WARNING` banner for a while, and the pilot spent the whole
|
||||||
|
run pursuing an `e010_ADAN_Attacker_S` two kilometres away.
|
||||||
|
|
||||||
|
So surviving is necessary and not sufficient, and "nearest hostile fighter" is
|
||||||
|
the wrong objective function for this stage. **The mission is an escort.** What
|
||||||
|
follows:
|
||||||
|
|
||||||
|
* **Prioritise hostiles by their distance to the protected asset, not to us.**
|
||||||
|
The attackers worth killing are the ones closing on the ACROPOLIS.
|
||||||
|
* **The protected asset's health is readable with the same anchor as ours** —
|
||||||
|
hull at `position + 0x154`, its maximum being its own definition's `HP`. That
|
||||||
|
gives a live "are we winning" signal for the escort, and it should drive the
|
||||||
|
target choice directly.
|
||||||
|
* A frozen tail in the log (identical position, speed and target for the last
|
||||||
|
five seconds) is what mission-end looks like from the outside, **not** an
|
||||||
|
emulator wedge. Worth knowing before diagnosing the wrong thing.
|
||||||
|
* Practical: do **not** pipe a long run's log through `tail` — that discards
|
||||||
|
everything but the end, and the interesting part of this run is gone.
|
||||||
|
|
||||||
|
## After survival, the blocker is lethality (2026-07-30)
|
||||||
|
|
||||||
|
The 300 s run took **no damage at all** and killed **one** warplane, spending
|
||||||
|
~800 rounds of nose ammo (`06000` → `05193`) to do it, while `REMAINING OB` rose
|
||||||
|
from `004` to `011` as fresh waves spawned. So attrition at this rate never
|
||||||
|
finishes the mission, and the ranking of open problems has changed:
|
||||||
|
|
||||||
|
1. **Hit rate.** It opens fire at 2–5 km with a 9° cone and a crude lead
|
||||||
|
(`p + v·d/speed`, no projectile speed). The `Shell` records in
|
||||||
|
[weapon-struct-runtime](structures/weapon-struct-runtime.md) carry the real
|
||||||
|
projectile speed and `MaximumRange` per weapon — the lead and the firing
|
||||||
|
range should come from *those*, not from constants.
|
||||||
|
2. **Which targets count.** `REMAINING OB` is the mission's own objective
|
||||||
|
counter and it is on screen, so it is in RAM; finding it turns "shoot
|
||||||
|
whatever is nearest" into "shoot what closes the mission". Objective-marked
|
||||||
|
entities also draw an `OB` badge in the HUD, so the flag is likely a word in
|
||||||
|
the entity object.
|
||||||
|
3. **Confirming the shield word** — needs a run that actually takes damage; the
|
||||||
|
pilot is now good enough at avoiding that to make it awkward, so drive
|
||||||
|
straight at a turret on purpose with `--dry` steering disabled.
|
||||||
|
4. **Does the ACROPOLIS repair?** RETIRE mode has never triggered (the hull
|
||||||
|
never fell), so the resupply hint is still untested.
|
||||||
|
|
||||||
|
## The next step that unblocks the most (superseded — kept for the reasoning)
|
||||||
|
|
||||||
|
**Update 2026-07-30: this is no longer the blocker.** Entity typing via the
|
||||||
|
definition pointer already solved target selection, so the game's own target
|
||||||
|
pointer is now a convenience rather than a prerequisite. It would still be the
|
||||||
|
cheapest route to problem 2 above (objective targets), because whatever the HUD
|
||||||
|
locks on to is what the game itself considers a target.
|
||||||
|
|
||||||
**Find the game's own target pointer instead of typing entities ourselves.**
|
**Find the game's own target pointer instead of typing entities ourselves.**
|
||||||
The HUD has a lock-on system (a `TARGET` marker and a target-cycle button), so
|
The HUD has a lock-on system (a `TARGET` marker and a target-cycle button), so
|
||||||
@@ -143,6 +285,12 @@ with the pad and watch which pointer-shaped global changes in step.
|
|||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
|
`pilot.py` (**the survival loop**) · `ctrl_probe.py` (input → speed calibration,
|
||||||
|
plus a per-tick window of the player object) · `binq.py` (query that capture) ·
|
||||||
|
`own_state.py` (definition-anchored hull/shield lookup) · `fly_session.sh`
|
||||||
|
(boot → mission → bind → fly, one task) · `wait_flight.sh` (wait for the real
|
||||||
|
HUD instead of a fixed sleep) · `navigator.py` (drift-aware steering + CPA
|
||||||
|
avoidance, reused by the pilot) ·
|
||||||
`gworld.py` (live reader + entity list) · `flight_probe.py` (scripted inputs +
|
`gworld.py` (live reader + entity list) · `flight_probe.py` (scripted inputs +
|
||||||
sampling, and the `Pad` FIFO client) · `flight_analyze.py` · `whatchanges.py`
|
sampling, and the `Pad` FIFO client) · `flight_analyze.py` · `whatchanges.py`
|
||||||
(encoding-agnostic "which words are live") · `findplayer.py` · `findself.py` ·
|
(encoding-agnostic "which words are live") · `findplayer.py` · `findself.py` ·
|
||||||
|
|||||||
BIN
docs/re/captures/autopilot-300s-undamaged-stage02.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/re/captures/autopilot-escort-warning-stage02.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/re/captures/autopilot-first-kill-stage02.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
1781
docs/re/captures/ctrl-probe-stage02.csv
Normal file
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.
|
||||||
144
tools/re-capture/binq.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Ask questions of a ctrl_probe.py capture: which words answer to which input?
|
||||||
|
|
||||||
|
The capture is a window of the player entity object sampled every tick, tagged
|
||||||
|
with the pad state that produced it. That makes the interesting question
|
||||||
|
mechanical: for each 4-byte offset, does its value during phase X differ from
|
||||||
|
its value during the rest phases either side? A word that only moves while `RT`
|
||||||
|
is held is that input's state — a throttle setting, an afterburner tank, a heat
|
||||||
|
gauge — and one that moves in *every* phase is just live physics.
|
||||||
|
|
||||||
|
Sub-commands
|
||||||
|
phases per-phase mean of every offset that moves at all
|
||||||
|
respond <phase> offsets that move during <phase> and not at rest
|
||||||
|
near <value> [tol] offsets whose first sample is ~= value (HUD anchor)
|
||||||
|
trace <off> [off...] full time series of specific offsets (pos-relative hex)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
HDR = b"SYLPHCTR"
|
||||||
|
REC = struct.Struct("<d32sff3f")
|
||||||
|
|
||||||
|
|
||||||
|
def load(path):
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
blob = f.read()
|
||||||
|
assert blob[:8] == HDR, "not a ctrl_probe capture"
|
||||||
|
n, win, back = struct.unpack_from("<III", blob, 8)
|
||||||
|
stride = REC.size + win
|
||||||
|
base = 8 + 12
|
||||||
|
ts, phase, sp, dodge, pos, wins = [], [], [], [], [], []
|
||||||
|
for i in range(n):
|
||||||
|
o = base + i * stride
|
||||||
|
t, ph, s, dg, x, y, z = REC.unpack_from(blob, o)
|
||||||
|
ts.append(t)
|
||||||
|
phase.append(ph.split(b"\0")[0].decode())
|
||||||
|
sp.append(s)
|
||||||
|
dodge.append(dg)
|
||||||
|
pos.append((x, y, z))
|
||||||
|
wins.append(blob[o + REC.size:o + REC.size + win])
|
||||||
|
# A run that ended in GAME OVER keeps sampling a dead object, and those
|
||||||
|
# frames dominate every statistic. $BINQ_TMAX truncates the capture to the
|
||||||
|
# part that was still flying.
|
||||||
|
tmax = float(os.environ.get("BINQ_TMAX", "inf"))
|
||||||
|
if tmax < float("inf"):
|
||||||
|
keep = [i for i, t in enumerate(ts) if t <= tmax]
|
||||||
|
n = len(keep)
|
||||||
|
ts = [ts[i] for i in keep]
|
||||||
|
phase = [phase[i] for i in keep]
|
||||||
|
sp = [sp[i] for i in keep]
|
||||||
|
dodge = [dodge[i] for i in keep]
|
||||||
|
pos = [pos[i] for i in keep]
|
||||||
|
wins = [wins[i] for i in keep]
|
||||||
|
A = np.frombuffer(b"".join(wins), dtype=">f4").reshape(n, win // 4).astype(np.float64)
|
||||||
|
U = np.frombuffer(b"".join(wins), dtype=">u4").reshape(n, win // 4)
|
||||||
|
return dict(n=n, win=win, back=back, t=np.array(ts), phase=phase,
|
||||||
|
speed=np.array(sp), dodge=np.array(dodge),
|
||||||
|
pos=np.array(pos), F=A, U=U)
|
||||||
|
|
||||||
|
|
||||||
|
def label(d, i):
|
||||||
|
return f"pos{i * 4 - d['back']:+#07x}"
|
||||||
|
|
||||||
|
|
||||||
|
def finite(d):
|
||||||
|
F = d["F"]
|
||||||
|
return np.all(np.isfinite(F), axis=0) & (np.max(np.abs(F), axis=0) < 1e12)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_phases(d, args):
|
||||||
|
ok = finite(d)
|
||||||
|
phases = []
|
||||||
|
for p in d["phase"]:
|
||||||
|
if p not in phases:
|
||||||
|
phases.append(p)
|
||||||
|
F = d["F"]
|
||||||
|
mv = np.zeros(F.shape[1])
|
||||||
|
means = {}
|
||||||
|
for p in phases:
|
||||||
|
m = np.array([x == p for x in d["phase"]])
|
||||||
|
means[p] = F[m].mean(axis=0)
|
||||||
|
for p in phases:
|
||||||
|
mv = np.maximum(mv, np.abs(means[p] - means[phases[0]]))
|
||||||
|
idx = np.flatnonzero(ok & (mv > 1e-3))
|
||||||
|
order = idx[np.argsort(-mv[idx])][:int(args[0]) if args else 25]
|
||||||
|
print("offset " + "".join(f"{p[:8]:>10}" for p in phases))
|
||||||
|
for i in order:
|
||||||
|
print(f"{label(d, i):<10}" + "".join(f"{means[p][i]:10.2f}" for p in phases))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_respond(d, args):
|
||||||
|
want = args[0]
|
||||||
|
F, ok = d["F"], finite(d)
|
||||||
|
inp = np.array([p == want for p in d["phase"]])
|
||||||
|
rest = np.array([p.startswith("rest") or p == "base" for p in d["phase"]])
|
||||||
|
if not inp.any():
|
||||||
|
sys.exit(f"no phase {want!r}")
|
||||||
|
# A word answering to this input must move *while it is held* and be quiet
|
||||||
|
# at rest; a word that also moves at rest is live physics, not the input.
|
||||||
|
a = F[inp]
|
||||||
|
r = F[rest]
|
||||||
|
d_in = a.max(axis=0) - a.min(axis=0)
|
||||||
|
d_rest = r.max(axis=0) - r.min(axis=0)
|
||||||
|
score = d_in - 2.0 * d_rest
|
||||||
|
idx = np.flatnonzero(ok & (d_in > 1e-3) & (score > 0))
|
||||||
|
for i in idx[np.argsort(-score[idx])][:20]:
|
||||||
|
print(f"{label(d, i):<10} in-phase {a[:, i].min():12.3f}..{a[:, i].max():12.3f}"
|
||||||
|
f" at-rest {r[:, i].min():12.3f}..{r[:, i].max():12.3f}")
|
||||||
|
if not len(idx):
|
||||||
|
print("(nothing moves under this input that is quiet at rest)")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_near(d, args):
|
||||||
|
v = float(args[0])
|
||||||
|
tol = float(args[1]) if len(args) > 1 else max(1e-3, abs(v) * 1e-3)
|
||||||
|
F, U = d["F"], d["U"]
|
||||||
|
for i in np.flatnonzero(np.abs(F[0] - v) <= tol):
|
||||||
|
print(f"{label(d, i):<10} f32 {F[0, i]:.4f} -> {F[-1, i]:.4f}")
|
||||||
|
for i in np.flatnonzero(np.abs(U[0].astype(np.float64) - v) <= tol):
|
||||||
|
print(f"{label(d, i):<10} u32 {U[0, i]} -> {U[-1, i]}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_trace(d, args):
|
||||||
|
offs = [int(a, 0) for a in args]
|
||||||
|
idx = [(o + d["back"]) // 4 for o in offs]
|
||||||
|
print("t phase speed " + " ".join(f"{o:+#07x}" for o in offs))
|
||||||
|
for k in range(d["n"]):
|
||||||
|
print(f"{d['t'][k]:6.2f} {d['phase'][k]:<12} {d['speed'][k]:6.0f} "
|
||||||
|
+ " ".join(f"{d['F'][k, i]:8.2f}" for i in idx))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
d = load(sys.argv[1])
|
||||||
|
print(f"# {d['n']} ticks, window {d['win']:#x} bytes, back {d['back']:#x}")
|
||||||
|
cmd = sys.argv[2] if len(sys.argv) > 2 else "phases"
|
||||||
|
{"phases": cmd_phases, "respond": cmd_respond, "near": cmd_near,
|
||||||
|
"trace": cmd_trace}[cmd](d, sys.argv[3:])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
272
tools/re-capture/ctrl_probe.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Which control is the throttle, and where does the craft keep its own state?
|
||||||
|
|
||||||
|
Two questions, one flight. Both are answered by *consequence* rather than by
|
||||||
|
reading a field we hope is the right one:
|
||||||
|
|
||||||
|
* **Throttle.** The craft's speed is measured from its own position — a finite
|
||||||
|
difference on the position triple in guest RAM — so no speed field has to be
|
||||||
|
found first. The probe then holds each candidate input in turn and asks which
|
||||||
|
one changes that measured speed. (`findspeed.py` failed the other way round:
|
||||||
|
it assumed `RT` was the throttle and went looking for a field that rose.)
|
||||||
|
|
||||||
|
* **Own state.** Every tick also copies a window of the player entity object.
|
||||||
|
Afterwards, offsets whose float value tracks the measured speed are candidate
|
||||||
|
speed/throttle fields, and offsets that only ever *fall* are candidate
|
||||||
|
hull/shield/ammo — the numbers survival needs.
|
||||||
|
|
||||||
|
Flying straight into a firefight for 90 s is how earlier runs died, so the loop
|
||||||
|
keeps the collision avoidance from navigator.py armed the whole time and marks
|
||||||
|
any sample where it had to intervene: a phase that had to dodge is not a clean
|
||||||
|
speed measurement, and says so rather than being quietly averaged in.
|
||||||
|
|
||||||
|
Usage: ctrl_probe.py <config.json> <out-prefix> [hold_s] [settle_s]
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
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
|
||||||
|
|
||||||
|
# 0x800 each way: the shield lives at pos+0x430 (own_state.py), which a
|
||||||
|
# 0x400 window silently cropped out of the first capture.
|
||||||
|
WIN_BACK = 0x800 # bytes of the player object kept before the position
|
||||||
|
WIN_FWD = 0x800 # ...and after
|
||||||
|
WIN = WIN_BACK + WIN_FWD
|
||||||
|
HZ = 20.0
|
||||||
|
|
||||||
|
# (label, [pad commands]) — everything the pad can do that might be a throttle.
|
||||||
|
# RB is left out: it is the fire button (autopilot-memory-driven.md) and firing
|
||||||
|
# during a speed measurement only invites return fire.
|
||||||
|
CANDIDATES = [
|
||||||
|
("RT", [("trig", "RT", 1.0)]),
|
||||||
|
("LT", [("trig", "LT", 1.0)]),
|
||||||
|
("RT+LT", [("trig", "RT", 1.0), ("trig", "LT", 1.0)]),
|
||||||
|
("A", [("press", "A")]),
|
||||||
|
("B", [("press", "B")]),
|
||||||
|
("X", [("press", "X")]),
|
||||||
|
("Y", [("press", "Y")]),
|
||||||
|
("LB", [("press", "LB")]),
|
||||||
|
("LS", [("press", "LS")]),
|
||||||
|
("RS", [("press", "RS")]),
|
||||||
|
("RY_up", [("axis", "RY", -1.0)]),
|
||||||
|
("RY_down", [("axis", "RY", 1.0)]),
|
||||||
|
("RX_right", [("axis", "RX", 1.0)]),
|
||||||
|
("dpad_up", [("dpad", "up")]),
|
||||||
|
("dpad_down", [("dpad", "down")]),
|
||||||
|
("dpad_left", [("dpad", "left")]),
|
||||||
|
("dpad_right", [("dpad", "right")]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def apply(pad, cmds):
|
||||||
|
for c in cmds:
|
||||||
|
if c[0] == "trig":
|
||||||
|
pad.trig(c[1], c[2])
|
||||||
|
elif c[0] == "axis":
|
||||||
|
pad.axis(c[1], c[2])
|
||||||
|
elif c[0] == "press":
|
||||||
|
pad.press(c[1])
|
||||||
|
elif c[0] == "dpad":
|
||||||
|
pad.f.write(f"dpad {c[1]}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def clear(pad):
|
||||||
|
pad.reset()
|
||||||
|
pad.f.write("dpad center\n")
|
||||||
|
|
||||||
|
|
||||||
|
class Run:
|
||||||
|
def __init__(self, cfg, prefix, hold, settle):
|
||||||
|
self.W = navigator.World(cfg)
|
||||||
|
self.nav = navigator.Navigator(self.W, None, dry=True)
|
||||||
|
self.prefix = prefix
|
||||||
|
self.hold = hold
|
||||||
|
self.settle = settle
|
||||||
|
self.pad = Pad()
|
||||||
|
self.rows = [] # (t, phase, pos, speed, dodged)
|
||||||
|
self.win = [] # raw window bytes per tick
|
||||||
|
ents = self.W.scan()
|
||||||
|
me = [(off, va) for off, va in ents if "Player" in self.W.defs[va]]
|
||||||
|
if not me:
|
||||||
|
sys.exit("player entity not in the scan — not in flight?")
|
||||||
|
self.me_off = me[0][0]
|
||||||
|
self.me_name = self.W.defs[me[0][1]]
|
||||||
|
print(f"# player {self.me_name} pos off {self.me_off:#x} "
|
||||||
|
f"va {gmem.primary_va(self.me_off):#x} | {len(ents)} entities",
|
||||||
|
flush=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- helpers
|
||||||
|
def pos(self):
|
||||||
|
return self.W.pos(self.me_off)
|
||||||
|
|
||||||
|
def sticks_for(self, vec):
|
||||||
|
"""Stick deflections that point the nose along `vec` (escape steering)."""
|
||||||
|
M = self.W.rot(self.me_off)
|
||||||
|
if M is None:
|
||||||
|
return 0.0, 0.0
|
||||||
|
fwd = M[self.W.fwd_row] * self.W.fwd_sign
|
||||||
|
right = M[(self.W.fwd_row + 1) % 3]
|
||||||
|
up = np.cross(fwd, right)
|
||||||
|
ez = float(np.dot(vec, fwd))
|
||||||
|
yaw = math.atan2(float(np.dot(vec, right)), ez if abs(ez) > 1e-3 else 1e-3)
|
||||||
|
pitch = math.atan2(float(np.dot(vec, up)), ez if abs(ez) > 1e-3 else 1e-3)
|
||||||
|
return (max(-1.0, min(1.0, 2.0 * yaw)), max(-1.0, min(1.0, -2.0 * pitch)))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- phase
|
||||||
|
def phase(self, label, cmds, secs):
|
||||||
|
clear(self.pad)
|
||||||
|
apply(self.pad, cmds)
|
||||||
|
t_end = time.time() + secs
|
||||||
|
dodged = False
|
||||||
|
while time.time() < t_end:
|
||||||
|
t = time.time()
|
||||||
|
p = self.pos()
|
||||||
|
if p is None:
|
||||||
|
break
|
||||||
|
self.rows.append([t, label, p, 0.0, 0])
|
||||||
|
self.win.append(os.pread(self.W.fd, WIN, self.me_off - WIN_BACK))
|
||||||
|
|
||||||
|
# avoidance runs at 5 Hz; a real threat overrides the phase and the
|
||||||
|
# samples from here on are flagged
|
||||||
|
if len(self.rows) % 4 == 0:
|
||||||
|
ents = self.W.sample(t)
|
||||||
|
me = [e for e in ents if e[0] == self.me_off]
|
||||||
|
if me:
|
||||||
|
_, _, mp, mv, mr = me[0]
|
||||||
|
push, worst = self.nav.avoidance(mp, mv, mr, ents, self.me_off)
|
||||||
|
if float(np.linalg.norm(push)) > 0.6:
|
||||||
|
sx, sy = self.sticks_for(navigator.norm(push))
|
||||||
|
self.pad.axis("LX", sx)
|
||||||
|
self.pad.axis("LY", sy)
|
||||||
|
dodged = True
|
||||||
|
self.rows[-1][4] = 1
|
||||||
|
elif dodged:
|
||||||
|
self.pad.axis("LX", 0.0)
|
||||||
|
self.pad.axis("LY", 0.0)
|
||||||
|
time.sleep(max(0.0, 1.0 / HZ - (time.time() - t)))
|
||||||
|
return dodged
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
t0 = time.time()
|
||||||
|
self.phase("base", [], self.settle * 2)
|
||||||
|
for label, cmds in CANDIDATES:
|
||||||
|
d = self.phase(label, cmds, self.hold)
|
||||||
|
self.phase(f"rest_{label}", [], self.settle)
|
||||||
|
sp = self.phase_speed(label)
|
||||||
|
print(f"[{time.time()-t0:6.1f}] {label:<10} "
|
||||||
|
f"v0={sp[0]:7.1f} v1={sp[1]:7.1f} d={sp[1]-sp[0]:+7.1f}"
|
||||||
|
f"{' (dodged)' if d else ''}", flush=True)
|
||||||
|
clear(self.pad)
|
||||||
|
self.speeds()
|
||||||
|
self.dump()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ analysis
|
||||||
|
def speeds(self):
|
||||||
|
"""Fill in per-tick speed by central difference on position."""
|
||||||
|
for i, r in enumerate(self.rows):
|
||||||
|
j, k = max(0, i - 2), min(len(self.rows) - 1, i + 2)
|
||||||
|
dt = self.rows[k][0] - self.rows[j][0]
|
||||||
|
if dt > 1e-3:
|
||||||
|
r[3] = float(np.linalg.norm(self.rows[k][2] - self.rows[j][2])) / dt
|
||||||
|
|
||||||
|
def phase_speed(self, label):
|
||||||
|
"""(speed early, speed late) within a phase — needs speeds() first."""
|
||||||
|
self.speeds()
|
||||||
|
v = [r[3] for r in self.rows if r[1] == label]
|
||||||
|
if len(v) < 6:
|
||||||
|
return (0.0, 0.0)
|
||||||
|
n = max(2, len(v) // 4)
|
||||||
|
return (float(np.mean(v[:n])), float(np.mean(v[-n:])))
|
||||||
|
|
||||||
|
def dump(self):
|
||||||
|
with open(self.prefix + ".csv", "w") as f:
|
||||||
|
f.write("t,phase,x,y,z,speed,dodged\n")
|
||||||
|
t0 = self.rows[0][0]
|
||||||
|
for t, ph, p, sp, dg in self.rows:
|
||||||
|
f.write(f"{t-t0:.3f},{ph},{p[0]:.3f},{p[1]:.3f},{p[2]:.3f},"
|
||||||
|
f"{sp:.3f},{dg}\n")
|
||||||
|
with open(self.prefix + ".bin", "wb") as f:
|
||||||
|
f.write(b"SYLPHCTR")
|
||||||
|
f.write(struct.pack("<III", len(self.rows), WIN, WIN_BACK))
|
||||||
|
t0 = self.rows[0][0]
|
||||||
|
for (t, ph, p, sp, dg), w in zip(self.rows, self.win):
|
||||||
|
f.write(struct.pack("<d32sff3f", t - t0, ph.encode()[:32], sp,
|
||||||
|
float(dg), *[float(c) for c in p]))
|
||||||
|
f.write(w.ljust(WIN, b"\0"))
|
||||||
|
print(f"# wrote {self.prefix}.csv and {self.prefix}.bin "
|
||||||
|
f"({len(self.rows)} ticks)", flush=True)
|
||||||
|
|
||||||
|
# ---- per-phase summary
|
||||||
|
print("\n# phase n v_early v_late delta dodged")
|
||||||
|
order, seen = [], set()
|
||||||
|
for r in self.rows:
|
||||||
|
if r[1] not in seen:
|
||||||
|
seen.add(r[1])
|
||||||
|
order.append(r[1])
|
||||||
|
for ph in order:
|
||||||
|
v = [r[3] for r in self.rows if r[1] == ph]
|
||||||
|
dg = sum(r[4] for r in self.rows if r[1] == ph)
|
||||||
|
if len(v) < 6:
|
||||||
|
continue
|
||||||
|
n = max(2, len(v) // 4)
|
||||||
|
a, b = float(np.mean(v[:n])), float(np.mean(v[-n:]))
|
||||||
|
print(f" {ph:<12} {len(v):4d} {a:9.1f} {b:9.1f} {b-a:+9.1f} {dg:5d}")
|
||||||
|
|
||||||
|
# ---- which words in the object track speed, and which only fall
|
||||||
|
W_ = np.frombuffer(b"".join(x.ljust(WIN, b"\0") for x in self.win),
|
||||||
|
dtype=">f4").reshape(len(self.win), WIN // 4)
|
||||||
|
sp = np.array([r[3] for r in self.rows], dtype=np.float64)
|
||||||
|
with np.errstate(invalid="ignore", over="ignore"):
|
||||||
|
A = W_.astype(np.float64)
|
||||||
|
ok = np.all(np.isfinite(A), axis=0) & (np.max(np.abs(A), axis=0) < 1e9)
|
||||||
|
var = np.std(A, axis=0)
|
||||||
|
cand = np.flatnonzero(ok & (var > 1e-6))
|
||||||
|
cor = []
|
||||||
|
for i in cand:
|
||||||
|
c = np.corrcoef(A[:, i], sp)[0, 1]
|
||||||
|
if np.isfinite(c):
|
||||||
|
cor.append((abs(c), c, i))
|
||||||
|
cor.sort(reverse=True)
|
||||||
|
print("\n# object words correlating with measured speed "
|
||||||
|
"(offset relative to the position triple)")
|
||||||
|
for ac, c, i in cor[:12]:
|
||||||
|
off = i * 4 - WIN_BACK
|
||||||
|
print(f" pos{off:+#07x} r={c:+.3f} "
|
||||||
|
f"range {A[:, i].min():.3f} .. {A[:, i].max():.3f}")
|
||||||
|
|
||||||
|
print("\n# words that never rise (candidate hull / shield / ammo)")
|
||||||
|
shown = 0
|
||||||
|
for i in cand:
|
||||||
|
col = A[:, i]
|
||||||
|
if col[-1] >= col[0] - 1e-6:
|
||||||
|
continue
|
||||||
|
if np.max(np.diff(col)) > 1e-6:
|
||||||
|
continue
|
||||||
|
off = i * 4 - WIN_BACK
|
||||||
|
print(f" pos{off:+#07x} {col[0]:.3f} -> {col[-1]:.3f}")
|
||||||
|
shown += 1
|
||||||
|
if shown >= 12:
|
||||||
|
break
|
||||||
|
if not shown:
|
||||||
|
print(" (none — nothing in the window decreased monotonically)")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = json.load(open(sys.argv[1]))
|
||||||
|
prefix = sys.argv[2]
|
||||||
|
hold = float(sys.argv[3]) if len(sys.argv) > 3 else 4.0
|
||||||
|
settle = float(sys.argv[4]) if len(sys.argv) > 4 else 2.5
|
||||||
|
Run(cfg, prefix, hold, settle).run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
31
tools/re-capture/ctrl_session.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One background task: bind the player's transform, then run the control probe.
|
||||||
|
#
|
||||||
|
# Everything that must not be interrupted lives in a single task here, because
|
||||||
|
# the display and the emulator both die on their own every few minutes in this
|
||||||
|
# container and nothing may depend on surviving between tool calls.
|
||||||
|
#
|
||||||
|
# Assumes the game is ALREADY in flight (launch_mission.sh). Pass --boot to
|
||||||
|
# have it get there itself.
|
||||||
|
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)"
|
||||||
|
|
||||||
|
CFG=/tmp/nav-live.json
|
||||||
|
PRE=/tmp/ctrl
|
||||||
|
HOLD=3.0
|
||||||
|
SETTLE=2.0
|
||||||
|
if [ "${1:-}" = "--boot" ]; then
|
||||||
|
shift
|
||||||
|
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
|
||||||
|
fi
|
||||||
|
[ $# -ge 1 ] && CFG="$1"
|
||||||
|
[ $# -ge 2 ] && PRE="$2"
|
||||||
|
[ $# -ge 3 ] && HOLD="$3"
|
||||||
|
[ $# -ge 4 ] && SETTLE="$4"
|
||||||
|
|
||||||
|
python3 "$SD/entities2.py" self 0x130 "$CFG" || { echo "TRANSFORM BIND FAILED"; exit 1; }
|
||||||
|
echo "--- config: $(cat "$CFG")"
|
||||||
|
python3 "$SD/ctrl_probe.py" "$CFG" "$PRE" "$HOLD" "$SETTLE"
|
||||||
|
echo "PROBE DONE"
|
||||||
@@ -180,24 +180,35 @@ def main():
|
|||||||
"(" + ",".join(f"{v:+.3f}" for v in row) + ")" for row in M))
|
"(" + ",".join(f"{v:+.3f}" for v in row) + ")" for row in M))
|
||||||
if len(sys.argv) > 3 and found:
|
if len(sys.argv) > 3 and found:
|
||||||
import json
|
import json
|
||||||
# forward axis = the row closest to our direction of travel
|
# Which of the orthonormal blocks is the CRAFT's attitude? The one
|
||||||
vdir = None
|
# with a row along the direction of travel. Taking found[0] is what
|
||||||
|
# produced a config with rot_delta -0x764 and a nonsense forward
|
||||||
|
# axis: several blocks inside the object are orthonormal (bone or
|
||||||
|
# camera frames), and only the craft's own has a row that tracks
|
||||||
|
# where the craft is going.
|
||||||
p0 = np.array(pos)
|
p0 = np.array(pos)
|
||||||
time.sleep(0.35)
|
time.sleep(0.35)
|
||||||
p1 = np.array(struct.unpack(">3f", os.pread(fd, 12, off)))
|
p1 = np.array(struct.unpack(">3f", os.pread(fd, 12, off)))
|
||||||
if np.linalg.norm(p1 - p0) > 1e-3:
|
step = p1 - p0
|
||||||
vdir = (p1 - p0) / np.linalg.norm(p1 - p0)
|
if np.linalg.norm(step) < 1e-3:
|
||||||
rot_delta, M, rot_stride = found[0]
|
sys.exit("craft is not moving — cannot bind the forward axis")
|
||||||
row, sign = 2, 1
|
vdir = step / np.linalg.norm(step)
|
||||||
if vdir is not None:
|
best = None
|
||||||
|
for d, M, st in found:
|
||||||
cs = [float(M[r] @ vdir) for r in range(3)]
|
cs = [float(M[r] @ vdir) for r in range(3)]
|
||||||
row = int(np.argmax([abs(c) for c in cs]))
|
r = int(np.argmax([abs(c) for c in cs]))
|
||||||
sign = 1 if cs[row] > 0 else -1
|
if best is None or abs(cs[r]) > abs(best[3]):
|
||||||
print(f"# forward axis = row {row} (sign {sign:+d}), "
|
best = (d, M, st, cs[r], r)
|
||||||
f"cos={cs[row]:+.3f}")
|
rot_delta, M, rot_stride, cos, row = best
|
||||||
|
sign = 1 if cos > 0 else -1
|
||||||
|
print(f"# attitude block pos{rot_delta:+#07x} stride {rot_stride}: "
|
||||||
|
f"forward = row {row} (sign {sign:+d}), cos={cos:+.3f}")
|
||||||
|
if abs(cos) < 0.9:
|
||||||
|
print("# WARNING: no block tracks the flight path (|cos| < 0.9)"
|
||||||
|
" — the craft may be drifting hard; re-run while flying straight")
|
||||||
cfg = {"def_delta": delta, "rot_delta": rot_delta,
|
cfg = {"def_delta": delta, "rot_delta": rot_delta,
|
||||||
"rot_stride": rot_stride,
|
"rot_stride": rot_stride,
|
||||||
"fwd_row": row, "fwd_sign": sign,
|
"fwd_row": row, "fwd_sign": sign, "fwd_cos": round(cos, 4),
|
||||||
"va_lo": ENT_VA_LO, "va_hi": ENT_VA_HI}
|
"va_lo": ENT_VA_LO, "va_hi": ENT_VA_HI}
|
||||||
json.dump(cfg, open(sys.argv[3], "w"), indent=1)
|
json.dump(cfg, open(sys.argv[3], "w"), indent=1)
|
||||||
print("# wrote " + sys.argv[3] + ": " + json.dumps(cfg))
|
print("# wrote " + sys.argv[3] + ": " + json.dumps(cfg))
|
||||||
|
|||||||
44
tools/re-capture/escort_session.sh
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One background task: boot -> Stage 02 -> fly the pilot while a second reader
|
||||||
|
# records EVERY entity's hull, so the escort question ("who is losing, and how
|
||||||
|
# fast") is answered from memory instead of from the HUD.
|
||||||
|
#
|
||||||
|
# Same one-task rule as fly_session.sh: the display and the emulator both die on
|
||||||
|
# their own in this container, so nothing may depend on surviving between tool
|
||||||
|
# calls.
|
||||||
|
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)"
|
||||||
|
SECS="${1:-300}"
|
||||||
|
TAG="${2:-escort}"
|
||||||
|
SHOTS=/sylph-home/re/shots
|
||||||
|
CFG=/tmp/nav-live.json
|
||||||
|
|
||||||
|
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
|
||||||
|
python3 "$SD/entities2.py" self 0x130 "$CFG" || { echo "BIND FAILED"; exit 1; }
|
||||||
|
echo "--- config: $(cat "$CFG")"
|
||||||
|
|
||||||
|
echo "=== initial entity table ==="
|
||||||
|
python3 "$SD/mission_state.py" scan "$CFG"
|
||||||
|
|
||||||
|
# The HUD's REMAINING OB counter has no known address yet, so the correlation
|
||||||
|
# material is a screenshot every 20 s stamped against the same clock as the
|
||||||
|
# memory samples.
|
||||||
|
( for i in $(seq 1 20); do
|
||||||
|
printf '%s SHOT %02d\n' "$(date +%s.%N)" "$i" >> "/tmp/$TAG-shots.log"
|
||||||
|
screenshot "$SHOTS/$TAG-$i.png" >/dev/null 2>&1
|
||||||
|
sleep 20
|
||||||
|
done ) &
|
||||||
|
SHOTTER=$!
|
||||||
|
|
||||||
|
date +%s.%N > "/tmp/$TAG-t0"
|
||||||
|
python3 "$SD/mission_state.py" watch "$CFG" "$SECS" 1 "/tmp/$TAG-mission.jsonl" \
|
||||||
|
> "/tmp/$TAG-mission.log" 2>&1 &
|
||||||
|
WATCHER=$!
|
||||||
|
|
||||||
|
python3 "$SD/pilot.py" "$CFG" "$SECS" > "/tmp/$TAG-pilot.log" 2>&1
|
||||||
|
wait $WATCHER 2>/dev/null
|
||||||
|
kill $SHOTTER 2>/dev/null
|
||||||
|
screenshot "$SHOTS/$TAG-end.png" >/dev/null 2>&1
|
||||||
|
echo "SESSION DONE"
|
||||||
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"
|
||||||
27
tools/re-capture/fly_session.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One background task: boot -> mission -> bind the transform -> fly the pilot,
|
||||||
|
# with periodic screenshots so the outcome has visual evidence and not just a log.
|
||||||
|
#
|
||||||
|
# It is one task on purpose: the display and the emulator both die on their own
|
||||||
|
# every few minutes in this container, so nothing may depend on surviving
|
||||||
|
# between tool calls.
|
||||||
|
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)"
|
||||||
|
SECS="${1:-240}"
|
||||||
|
TAG="${2:-pilot}"
|
||||||
|
SHOTS=/sylph-home/re/shots
|
||||||
|
CFG=/tmp/nav-live.json
|
||||||
|
|
||||||
|
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
|
||||||
|
python3 "$SD/entities2.py" self 0x130 "$CFG" || { echo "BIND FAILED"; exit 1; }
|
||||||
|
echo "--- config: $(cat "$CFG")"
|
||||||
|
python3 "$SD/own_state.py" "$CFG" 3 "/tmp/$TAG-own.json"
|
||||||
|
|
||||||
|
( for i in $(seq 1 12); do sleep 25; screenshot "$SHOTS/$TAG-$i.png" >/dev/null 2>&1; done ) &
|
||||||
|
SHOTTER=$!
|
||||||
|
python3 "$SD/pilot.py" "$CFG" "$SECS"
|
||||||
|
kill $SHOTTER 2>/dev/null
|
||||||
|
screenshot "$SHOTS/$TAG-end.png" >/dev/null 2>&1
|
||||||
|
echo "SESSION DONE"
|
||||||
@@ -13,12 +13,25 @@ SD="$(cd "$(dirname "$0")" && pwd)"
|
|||||||
SHOTS=/sylph-home/re/shots
|
SHOTS=/sylph-home/re/shots
|
||||||
|
|
||||||
alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $1}'; }
|
alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $1}'; }
|
||||||
|
|
||||||
|
# DO NOT `setsid` THE DISPLAY OR THE EMULATOR. They used to be detached into
|
||||||
|
# their own sessions so they would outlive the shell that started them. What
|
||||||
|
# that actually bought was the opposite: a process nothing owns is a process
|
||||||
|
# nothing keeps alive, and both were being reaped a couple of minutes in — the
|
||||||
|
# long-standing "Xvfb and the emulator die on their own every few minutes" note.
|
||||||
|
# Run this whole script as ONE tracked background task and leave Xvfb, openbox
|
||||||
|
# and xenia as its children: they then live exactly as long as the session does.
|
||||||
|
# `nohup` still shields them from a stray HUP; the exit-status wrapper means a
|
||||||
|
# death is reported with the server's own account instead of being inferred.
|
||||||
ensure_display(){
|
ensure_display(){
|
||||||
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
|
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
|
||||||
setsid nohup Xvfb "$DISPLAY" -screen 0 1280x720x24 -ac -nolisten tcp \
|
rm -f "/tmp/.X${DISPLAY#:}-lock" 2>/dev/null || true
|
||||||
+extension GLX +extension RANDR </dev/null >/tmp/xvfb98.log 2>&1 &
|
nohup bash -c 'Xvfb "$0" -screen 0 1280x720x24 -ac -nolisten tcp \
|
||||||
|
+extension GLX +extension RANDR >/tmp/xvfb98.log 2>&1
|
||||||
|
echo "$(date +%T) XVFB EXIT $? (128+N means signal N)" >>/tmp/xvfb-exit.log' \
|
||||||
|
"$DISPLAY" </dev/null >/dev/null 2>&1 &
|
||||||
for _ in $(seq 1 50); do xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break; sleep 0.2; done
|
for _ in $(seq 1 50); do xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break; sleep 0.2; done
|
||||||
setsid nohup env DISPLAY="$DISPLAY" HOME=/sylph-home openbox </dev/null >/tmp/openbox98.log 2>&1 &
|
nohup env DISPLAY="$DISPLAY" HOME=/sylph-home openbox </dev/null >/tmp/openbox98.log 2>&1 &
|
||||||
sleep 1
|
sleep 1
|
||||||
fi
|
fi
|
||||||
xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 || { echo "DISPLAY UNAVAILABLE"; exit 1; }
|
xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 || { echo "DISPLAY UNAVAILABLE"; exit 1; }
|
||||||
@@ -32,10 +45,10 @@ rm -f /dev/shm/xenia_memory_* /dev/shm/xenia_code_cache_* 2>/dev/null
|
|||||||
ensure_display
|
ensure_display
|
||||||
|
|
||||||
cd /sylph-home/re
|
cd /sylph-home/re
|
||||||
setsid nohup run-canary --audio --apu=sdl --log_mask=13 \
|
nohup run-canary --audio --apu=sdl --log_mask=13 \
|
||||||
--logged_profile_slot_0_xuid=E0300000EFBEA3D4 </dev/null >/dev/null 2>&1 &
|
--logged_profile_slot_0_xuid=E0300000EFBEA3D4 </dev/null >/dev/null 2>&1 &
|
||||||
sleep 5
|
sleep 5
|
||||||
"$SD/skip_intro.sh" 600 || { echo "BOOT FAILED"; exit 1; }
|
"$SD/skip_intro.sh" 600 || { echo "BOOT FAILED (skip_intro exit $?)"; exit 1; }
|
||||||
sleep 14 # main menu is not input-ready before this
|
sleep 14 # main menu is not input-ready before this
|
||||||
|
|
||||||
step down # NEW GAME -> LOAD GAME
|
step down # NEW GAME -> LOAD GAME
|
||||||
@@ -55,8 +68,8 @@ fi
|
|||||||
|
|
||||||
step up # BRIEFINGS -> TAKE OFF
|
step up # BRIEFINGS -> TAKE OFF
|
||||||
vgamepad tap A 250
|
vgamepad tap A 250
|
||||||
sleep 75 # launch cinematic + stage load + objective card
|
# The launch cinematic + stage load + objective card is NOT a fixed 75 s under
|
||||||
shot "lm-objective.png"
|
# lavapipe; wait for the flight HUD itself.
|
||||||
vgamepad tap A 250; sleep 6 # dismiss the OBJECTIVE panel
|
"$SD/wait_flight.sh" 300 || { echo "NEVER REACHED FLIGHT"; exit 1; }
|
||||||
shot "lm-flight.png"
|
shot "lm-flight.png"
|
||||||
echo "IN FLIGHT (emulator left running)"
|
echo "IN FLIGHT (emulator left running)"
|
||||||
|
|||||||
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"
|
||||||
167
tools/re-capture/mission_state.py
Executable file
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Mission state from guest RAM: every entity's hull, not just the player's.
|
||||||
|
|
||||||
|
`own_state.py` found the player's hull by anchoring on a solved definition
|
||||||
|
field: an undamaged craft carries its definition's own `HP` (+0x054), so the
|
||||||
|
live counter is the copy of that number that *falls*. The result was
|
||||||
|
`hull = position + 0x154`.
|
||||||
|
|
||||||
|
The escort question needs the same number for **someone else's** ship. Stage 02
|
||||||
|
is lost when the ACROPOLIS sinks, not when the player dies, and the 240 s run in
|
||||||
|
autopilot-memory-driven.md hit GAME OVER with our own hull untouched. So the
|
||||||
|
claim to test here is that `+0x154` is a property of the *entity class*, not of
|
||||||
|
the player object: every entity, hostile or friendly, fighter or capital ship,
|
||||||
|
should hold its own definition's `HP` there at spawn and lose it when hit.
|
||||||
|
|
||||||
|
That is falsifiable in one run: read `pos+0x154` and the definition `HP` for
|
||||||
|
every entity in the scene and compare. If the anchor is class-wide, the ratio
|
||||||
|
is 1.0 for everything undamaged and nothing else lines up by accident; if it is
|
||||||
|
player-specific, most entities hold something unrelated.
|
||||||
|
|
||||||
|
Sub-commands:
|
||||||
|
scan [cfg] one table of every entity: hull vs definition HP
|
||||||
|
watch <cfg> <secs> [hz] [out] sample over time; report what took damage
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
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
|
||||||
|
|
||||||
|
HULL_OFF = 0x154 # own_state.py, player craft
|
||||||
|
DEF_HP = 0x054 # unit-struct-runtime.md, ✅ CONFIRMED
|
||||||
|
DEF_SIZE_R = 0x50
|
||||||
|
|
||||||
|
|
||||||
|
def f32(fd, off):
|
||||||
|
b = os.pread(fd, 4, off)
|
||||||
|
if len(b) < 4:
|
||||||
|
return float("nan")
|
||||||
|
(v,) = struct.unpack(">f", b)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class Mission:
|
||||||
|
def __init__(self, cfg):
|
||||||
|
self.W = navigator.World(cfg)
|
||||||
|
self.def_hp = {}
|
||||||
|
for va in self.W.defs:
|
||||||
|
self.def_hp[va] = f32(self.W.fd, gmem.va_to_off(va) + DEF_HP)
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
"""[(off, name, faction, radius, pos, hull, hp_max)] for every entity."""
|
||||||
|
out = []
|
||||||
|
for off, va in self.W.ents:
|
||||||
|
p = self.W.pos(off)
|
||||||
|
if p is None:
|
||||||
|
continue
|
||||||
|
hull = f32(self.W.fd, off + HULL_OFF)
|
||||||
|
nm = self.W.defs[va]
|
||||||
|
out.append((off, nm, navigator.faction(nm), self.W.radius[va],
|
||||||
|
p, hull, self.def_hp.get(va, float("nan"))))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(e):
|
||||||
|
off, nm, fac, r, p, hull, hp0 = e
|
||||||
|
frac = hull / hp0 if hp0 and math.isfinite(hp0) and hp0 > 0 else float("nan")
|
||||||
|
return (f"{gmem.primary_va(off):#010x} {nm[:34]:<34} {fac:<4} r={r:6.0f} "
|
||||||
|
f"({p[0]:+8.0f},{p[1]:+8.0f},{p[2]:+8.0f}) hull={hull:9.1f} "
|
||||||
|
f"HP={hp0:9.1f} frac={frac:6.3f}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_scan(cfg):
|
||||||
|
m = Mission(cfg)
|
||||||
|
m.W.scan()
|
||||||
|
ents = m.snapshot()
|
||||||
|
print(f"# {len(ents)} entities")
|
||||||
|
ok = sum(1 for e in ents
|
||||||
|
if math.isfinite(e[6]) and e[6] > 0 and abs(e[5] / e[6] - 1.0) < 1e-3)
|
||||||
|
fin = sum(1 for e in ents if math.isfinite(e[6]) and e[6] > 0)
|
||||||
|
print(f"# hull(+{HULL_OFF:#x}) == definition HP for {ok}/{fin} entities "
|
||||||
|
f"whose definition has an HP")
|
||||||
|
for e in sorted(ents, key=lambda e: -e[3]):
|
||||||
|
print(" " + fmt(e))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_watch(cfg, secs, hz, out):
|
||||||
|
m = Mission(cfg)
|
||||||
|
m.W.scan()
|
||||||
|
base = {e[0]: e for e in m.snapshot()}
|
||||||
|
print(f"# watching {len(base)} entities for {secs:g}s at {hz:g}Hz", flush=True)
|
||||||
|
fh = open(out, "w") if out else None
|
||||||
|
t0 = time.time()
|
||||||
|
last_scan = t0
|
||||||
|
last_seen = {}
|
||||||
|
while time.time() - t0 < secs:
|
||||||
|
t = time.time()
|
||||||
|
if t - last_scan > 15.0: # new waves spawn; re-enumerate rarely
|
||||||
|
m.W.scan()
|
||||||
|
last_scan = t
|
||||||
|
for e in m.snapshot():
|
||||||
|
base.setdefault(e[0], e)
|
||||||
|
ents = m.snapshot()
|
||||||
|
rec = {"t": round(t - t0, 2),
|
||||||
|
"n": len(ents),
|
||||||
|
"adan": sum(1 for e in ents if e[2] == "ADAN"),
|
||||||
|
"tcaf": sum(1 for e in ents if e[2] == "TCAF"),
|
||||||
|
"ents": []}
|
||||||
|
for e in ents:
|
||||||
|
off, nm, fac, r, p, hull, hp0 = e
|
||||||
|
b = base.get(off)
|
||||||
|
drop = (b[5] - hull) if b and math.isfinite(b[5]) else 0.0
|
||||||
|
big = r >= navigator.Navigator.BIG_RADIUS
|
||||||
|
if big or drop > 0.5:
|
||||||
|
rec["ents"].append({"va": gmem.primary_va(off), "nm": nm,
|
||||||
|
"fac": fac, "r": round(r, 1),
|
||||||
|
"hull": round(hull, 1), "hp0": round(hp0, 1),
|
||||||
|
"drop": round(drop, 1),
|
||||||
|
"pos": [round(float(c), 1) for c in p]})
|
||||||
|
if drop > 0.5 and abs(last_seen.get(off, 0.0) - hull) > 0.5:
|
||||||
|
last_seen[off] = hull
|
||||||
|
print(f"[{t-t0:6.1f}] HIT {nm[:30]:<30} {fac} "
|
||||||
|
f"hull {hull:9.1f}/{hp0:9.1f} (-{drop:.0f})", flush=True)
|
||||||
|
if fh:
|
||||||
|
fh.write(json.dumps(rec) + "\n")
|
||||||
|
fh.flush()
|
||||||
|
time.sleep(max(0.0, 1.0 / hz - (time.time() - t)))
|
||||||
|
if fh:
|
||||||
|
fh.close()
|
||||||
|
|
||||||
|
print("\n# damage summary")
|
||||||
|
ents = {e[0]: e for e in m.snapshot()}
|
||||||
|
for off, b in sorted(base.items(), key=lambda kv: -kv[1][3]):
|
||||||
|
cur = ents.get(off)
|
||||||
|
gone = cur is None
|
||||||
|
hull = cur[5] if cur else float("nan")
|
||||||
|
if not gone and abs(hull - b[5]) < 0.5 and b[3] < navigator.Navigator.BIG_RADIUS:
|
||||||
|
continue
|
||||||
|
print(f" {b[1][:34]:<34} {b[2]:<4} r={b[3]:6.0f} "
|
||||||
|
f"{b[5]:9.1f} -> {hull:9.1f}" + (" GONE" if gone else ""))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
cfg = json.load(open(sys.argv[2]))
|
||||||
|
if cmd == "scan":
|
||||||
|
cmd_scan(cfg)
|
||||||
|
elif cmd == "watch":
|
||||||
|
cmd_watch(cfg,
|
||||||
|
float(sys.argv[3]) if len(sys.argv) > 3 else 120.0,
|
||||||
|
float(sys.argv[4]) if len(sys.argv) > 4 else 1.0,
|
||||||
|
sys.argv[5] if len(sys.argv) > 5 else None)
|
||||||
|
else:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
397
tools/re-capture/navigator.py
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Drift-aware navigation with collision avoidance, driven from guest memory.
|
||||||
|
|
||||||
|
Three things the pursuit loop in autopilot3.py did not do:
|
||||||
|
|
||||||
|
* **See everything.** It enumerated entities by looking for things that *move*,
|
||||||
|
so stations, hulls and parked structures were invisible — exactly the objects
|
||||||
|
you crash into. This scans the entity heap for words that equal a known unit
|
||||||
|
definition address and takes `position = hit - 0x130`, which finds every
|
||||||
|
entity whether it is moving or not.
|
||||||
|
|
||||||
|
* **Know how big they are.** Each entity's definition carries `Size_Radius`
|
||||||
|
(+0x50) and `Size_X/Y/Z` (+0x30/34/38) — fields already solved in
|
||||||
|
docs/re/structures/unit-struct-runtime.md — so the avoidance radius is the
|
||||||
|
game's own number, not a guess.
|
||||||
|
|
||||||
|
* **Account for drift.** The craft does not turn where it points: velocity lags
|
||||||
|
the nose like an aircraft with sideslip. Steering the *nose* at a target
|
||||||
|
therefore steers the *flight path* somewhere else, wide and late. This
|
||||||
|
measures the lag online (the angle between nose and velocity, and how fast
|
||||||
|
the velocity vector is actually swinging) and commands the nose *ahead* of
|
||||||
|
where the flight path should go, by that lag.
|
||||||
|
|
||||||
|
Avoidance is closest-point-of-approach, not distance: what matters is whether
|
||||||
|
the two paths will intersect within a horizon, which is why a fast crossing
|
||||||
|
target is dangerous at 2 km and a station drifting away is not at 300 m.
|
||||||
|
|
||||||
|
Usage: navigator.py <config.json> [seconds] [--dry]
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import gmem # noqa: E402
|
||||||
|
import gworld # noqa: E402
|
||||||
|
import entities2 # noqa: E402
|
||||||
|
from flight_probe import Pad # noqa: E402
|
||||||
|
|
||||||
|
# confirmed fields of the parsed unit definition (unit-struct-runtime.md)
|
||||||
|
DEF_SIZE_X, DEF_SIZE_Y, DEF_SIZE_Z, DEF_SIZE_R = 0x30, 0x34, 0x38, 0x50
|
||||||
|
|
||||||
|
|
||||||
|
def norm(v):
|
||||||
|
n = float(np.linalg.norm(v))
|
||||||
|
return v / n if n > 1e-9 else v * 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def ang(a, b):
|
||||||
|
return math.acos(max(-1.0, min(1.0, float(np.dot(norm(a), norm(b))))))
|
||||||
|
|
||||||
|
|
||||||
|
class World:
|
||||||
|
def __init__(self, cfg):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.w = gworld.World()
|
||||||
|
self.fd, self.size = self.w.fd, self.w.size
|
||||||
|
self.delta = cfg["def_delta"]
|
||||||
|
self.rot_delta = cfg["rot_delta"]
|
||||||
|
self.rot_stride = cfg.get("rot_stride", 12)
|
||||||
|
self.fwd_row = cfg["fwd_row"]
|
||||||
|
self.fwd_sign = cfg["fwd_sign"]
|
||||||
|
self.defs = {} # def_va -> name
|
||||||
|
self.def_word = {} # 4-byte BE -> def_va
|
||||||
|
self.radius = {} # def_va -> collision radius
|
||||||
|
self.prev = {} # pos_off -> (t, pos) for velocity
|
||||||
|
self.vel = {} # pos_off -> smoothed velocity
|
||||||
|
self.ents = []
|
||||||
|
self._load_defs()
|
||||||
|
|
||||||
|
def _load_defs(self):
|
||||||
|
for off in self.w.scan_vtable(gworld.DEF_VTABLE):
|
||||||
|
nm = self.w.name_of(off)
|
||||||
|
if not (nm and nm.startswith("UN_")):
|
||||||
|
continue
|
||||||
|
va = gmem.primary_va(off)
|
||||||
|
if va is None:
|
||||||
|
continue
|
||||||
|
self.defs[va] = nm
|
||||||
|
self.def_word[struct.pack(">I", va)] = va
|
||||||
|
b = os.pread(self.fd, 0x60, off)
|
||||||
|
try:
|
||||||
|
sx, sy, sz = (struct.unpack_from(">f", b, o)[0]
|
||||||
|
for o in (DEF_SIZE_X, DEF_SIZE_Y, DEF_SIZE_Z))
|
||||||
|
sr = struct.unpack_from(">f", b, DEF_SIZE_R)[0]
|
||||||
|
except struct.error:
|
||||||
|
sx = sy = sz = sr = 0.0
|
||||||
|
vals = [v for v in (sr, sx, sy, sz) if math.isfinite(v) and 0 < v < 1e5]
|
||||||
|
self.radius[va] = max(vals) if vals else 50.0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- entities
|
||||||
|
def scan(self):
|
||||||
|
"""Every entity in the heap — moving or not — by its definition pointer."""
|
||||||
|
lo = gmem.va_to_off(self.cfg["va_lo"])
|
||||||
|
hi = gmem.va_to_off(self.cfg["va_hi"])
|
||||||
|
found = []
|
||||||
|
# numpy, not a per-word python loop: the entity heap is 16 MB, so
|
||||||
|
# stepping it 4 bytes at a time costs seconds per scan and the control
|
||||||
|
# loop spends its whole budget scanning instead of flying.
|
||||||
|
want = np.array(sorted(self.defs.keys()), dtype=np.uint32)
|
||||||
|
for a, b in gmem.extents(self.fd, self.size):
|
||||||
|
a, b = max(a, lo), min(b, hi)
|
||||||
|
n = (b - a) // 4 * 4
|
||||||
|
if n < 64:
|
||||||
|
continue
|
||||||
|
arr = np.frombuffer(os.pread(self.fd, n, a), dtype=">u4").astype(np.uint32)
|
||||||
|
for k4 in np.flatnonzero(np.isin(arr, want)):
|
||||||
|
k = int(k4) * 4
|
||||||
|
va = int(arr[k4])
|
||||||
|
poff = a + k - self.delta
|
||||||
|
if poff < 0:
|
||||||
|
continue
|
||||||
|
pb = os.pread(self.fd, 12, poff)
|
||||||
|
if len(pb) < 12:
|
||||||
|
continue
|
||||||
|
p = np.array(struct.unpack(">3f", pb))
|
||||||
|
if not np.all(np.isfinite(p)) or np.max(np.abs(p)) > 1e7:
|
||||||
|
continue
|
||||||
|
found.append((poff, va))
|
||||||
|
# De-duplicate by POSITION: one entity is mirrored at several
|
||||||
|
# addresses, so keying on the address keeps every copy and the scene
|
||||||
|
# looks several times more crowded than it is.
|
||||||
|
seen, uniq = {}, {}
|
||||||
|
for poff, va in found:
|
||||||
|
p = self.pos(poff)
|
||||||
|
if p is None:
|
||||||
|
continue
|
||||||
|
key = (va, tuple(np.round(p, 0)))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen[key] = poff
|
||||||
|
uniq[poff] = va
|
||||||
|
self.ents = list(uniq.items())
|
||||||
|
return self.ents
|
||||||
|
|
||||||
|
def pos(self, off):
|
||||||
|
b = os.pread(self.fd, 12, off)
|
||||||
|
if len(b) < 12:
|
||||||
|
return None
|
||||||
|
p = np.array(struct.unpack(">3f", b))
|
||||||
|
return p if np.all(np.isfinite(p)) else None
|
||||||
|
|
||||||
|
def rot(self, off):
|
||||||
|
n = self.rot_stride * 2 + 12
|
||||||
|
b = os.pread(self.fd, n, off + self.rot_delta)
|
||||||
|
if len(b) < n:
|
||||||
|
return None
|
||||||
|
M = np.array([struct.unpack_from(">3f", b, self.rot_stride * r) for r in range(3)])
|
||||||
|
if not np.all(np.isfinite(M)) or np.max(np.abs(M @ M.T - np.eye(3))) > 5e-3:
|
||||||
|
return None
|
||||||
|
return M
|
||||||
|
|
||||||
|
def sample(self, t):
|
||||||
|
"""[(off, name, pos, vel, radius)] with velocity by finite difference."""
|
||||||
|
out = []
|
||||||
|
for off, va in self.ents:
|
||||||
|
p = self.pos(off)
|
||||||
|
if p is None:
|
||||||
|
continue
|
||||||
|
# A frame the emulator did not advance gives an identical position
|
||||||
|
# and a bogus zero velocity, which then reads as "stopped" and
|
||||||
|
# wrecks both the drift estimate and every closing-rate. Keep the
|
||||||
|
# last good velocity instead, and smooth it.
|
||||||
|
prev = self.prev.get(off)
|
||||||
|
v = self.vel.get(off, np.zeros(3))
|
||||||
|
if prev is not None:
|
||||||
|
dt = t - prev[0]
|
||||||
|
moved = float(np.linalg.norm(p - prev[1]))
|
||||||
|
if dt > 0.02 and moved > 1e-4:
|
||||||
|
inst = (p - prev[1]) / dt
|
||||||
|
if float(np.linalg.norm(inst)) < 5000.0:
|
||||||
|
v = 0.5 * v + 0.5 * inst if np.any(v) else inst
|
||||||
|
self.prev[off] = (t, p)
|
||||||
|
else:
|
||||||
|
self.prev[off] = (t, p)
|
||||||
|
self.vel[off] = v
|
||||||
|
out.append((off, self.defs[va], p, v, self.radius[va]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def faction(nm):
|
||||||
|
b = nm[3:]
|
||||||
|
return "ADAN" if b.startswith("be") or b.startswith("e") else "TCAF"
|
||||||
|
|
||||||
|
|
||||||
|
class Navigator:
|
||||||
|
KP, KD = 2.2, 0.45
|
||||||
|
FIRE_CONE = math.radians(9)
|
||||||
|
FIRE_RANGE = 6000.0
|
||||||
|
HORIZON = 6.0 # s of look-ahead for collision checks
|
||||||
|
MARGIN_BIG = 220.0 # clearance around hulls and structures
|
||||||
|
MARGIN_SMALL = 45.0 # ...around fighters, which manoeuvre themselves
|
||||||
|
BIG_RADIUS = 120.0 # above this an entity counts as a structure
|
||||||
|
SELF_MIRROR = 25.0 # the same object is mirrored at several addresses
|
||||||
|
|
||||||
|
def __init__(self, W, pad, dry=False, log=sys.stdout):
|
||||||
|
self.W = W
|
||||||
|
self.pad = pad
|
||||||
|
self.dry = dry
|
||||||
|
self.log = log
|
||||||
|
self.prevM = None
|
||||||
|
self.firing = False
|
||||||
|
self.tau = 0.8 # velocity-lag time constant, refined online
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ drift
|
||||||
|
def update_tau(self, fwd, vel, prev_vhat, dt):
|
||||||
|
"""How long the flight path takes to catch the nose.
|
||||||
|
|
||||||
|
The velocity vector swings toward the nose; the angle between them
|
||||||
|
divided by the rate the velocity is actually swinging is that lag, and
|
||||||
|
it is what the nose has to be commanded ahead by.
|
||||||
|
"""
|
||||||
|
if prev_vhat is None or dt <= 1e-3:
|
||||||
|
return
|
||||||
|
vh = norm(vel)
|
||||||
|
if np.linalg.norm(vh) < 1e-6:
|
||||||
|
return
|
||||||
|
swing = ang(prev_vhat, vh) / dt # rad/s the path is turning
|
||||||
|
lag = ang(fwd, vh) # rad the path is behind
|
||||||
|
if swing > 0.02 and lag > 0.02:
|
||||||
|
tau = lag / swing
|
||||||
|
if 0.05 < tau < 5.0:
|
||||||
|
self.tau = 0.9 * self.tau + 0.1 * tau
|
||||||
|
|
||||||
|
# ------------------------------------------------- collision avoidance
|
||||||
|
def avoidance(self, me_p, me_v, me_r, ents, me_off):
|
||||||
|
"""Sum of escape directions, weighted by how soon and how close."""
|
||||||
|
push = np.zeros(3)
|
||||||
|
worst = None
|
||||||
|
for off, nm, p, v, r in ents:
|
||||||
|
if off == me_off:
|
||||||
|
continue
|
||||||
|
rel_p = p - me_p
|
||||||
|
rel_v = v - me_v
|
||||||
|
d = float(np.linalg.norm(rel_p))
|
||||||
|
# The same entity exists at several mirrored addresses, so our own
|
||||||
|
# copy shows up as an obstacle at zero distance and pins the sticks
|
||||||
|
# at full deflection forever. Anything this close is us.
|
||||||
|
if d < self.SELF_MIRROR:
|
||||||
|
continue
|
||||||
|
# A wingman flying formation is metres away by design and steers
|
||||||
|
# itself; giving it a hull-sized margin makes the loop thrash.
|
||||||
|
big = r >= self.BIG_RADIUS
|
||||||
|
margin = self.MARGIN_BIG if big else self.MARGIN_SMALL
|
||||||
|
if not big and faction(nm) == "TCAF":
|
||||||
|
margin *= 0.5
|
||||||
|
safe = me_r + r + margin
|
||||||
|
if d > 1e4:
|
||||||
|
continue
|
||||||
|
vv = float(np.dot(rel_v, rel_v))
|
||||||
|
t_cpa = 0.0 if vv < 1e-6 else -float(np.dot(rel_p, rel_v)) / vv
|
||||||
|
t_cpa = max(0.0, min(self.HORIZON, t_cpa))
|
||||||
|
cpa = rel_p + rel_v * t_cpa
|
||||||
|
miss = float(np.linalg.norm(cpa))
|
||||||
|
if miss >= safe:
|
||||||
|
continue
|
||||||
|
urgency = (1.0 - miss / safe) * (1.0 - t_cpa / self.HORIZON)
|
||||||
|
if urgency <= 0:
|
||||||
|
continue
|
||||||
|
esc = -norm(cpa) if miss > 1e-3 else norm(np.cross(rel_p, me_v))
|
||||||
|
if np.linalg.norm(esc) < 1e-6:
|
||||||
|
esc = norm(np.cross(rel_p, np.array([0.0, 1.0, 0.0])))
|
||||||
|
push += esc * urgency
|
||||||
|
if worst is None or urgency > worst[0]:
|
||||||
|
worst = (urgency, nm, d, miss, t_cpa)
|
||||||
|
return push, worst
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- target
|
||||||
|
def pick(self, me_p, me_v, fwd, ents, me_off):
|
||||||
|
speed = max(float(np.linalg.norm(me_v)), 1.0)
|
||||||
|
best, bestscore = None, 1e18
|
||||||
|
for off, nm, p, v, r in ents:
|
||||||
|
if off == me_off or faction(nm) != "ADAN":
|
||||||
|
continue
|
||||||
|
rel = p - me_p
|
||||||
|
d = float(np.linalg.norm(rel))
|
||||||
|
if d < 1e-3:
|
||||||
|
continue
|
||||||
|
# lead: where it will be when a shot gets there
|
||||||
|
lead = p + v * (d / max(speed, 200.0))
|
||||||
|
rel_l = lead - me_p
|
||||||
|
theta = ang(rel_l, fwd)
|
||||||
|
score = d * (1.0 + 3.0 * (theta / math.pi) ** 2)
|
||||||
|
if score < bestscore:
|
||||||
|
best, bestscore = (off, nm, lead, rel_l, d), score
|
||||||
|
return best
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- step
|
||||||
|
def step(self, t, dt, prev_vhat):
|
||||||
|
ents = self.W.sample(t)
|
||||||
|
me = None
|
||||||
|
for e in ents:
|
||||||
|
if "Player" in e[1]:
|
||||||
|
me = e
|
||||||
|
break
|
||||||
|
if me is None:
|
||||||
|
return "no-player", prev_vhat
|
||||||
|
me_off, me_nm, me_p, me_v, me_r = me
|
||||||
|
M = self.W.rot(me_off)
|
||||||
|
if M is None:
|
||||||
|
return "no-orientation", prev_vhat
|
||||||
|
fwd = M[self.W.fwd_row] * self.W.fwd_sign
|
||||||
|
right = M[(self.W.fwd_row + 1) % 3]
|
||||||
|
up = np.cross(fwd, right)
|
||||||
|
|
||||||
|
speed = float(np.linalg.norm(me_v))
|
||||||
|
vhat = norm(me_v) if speed > 1.0 else fwd
|
||||||
|
self.update_tau(fwd, me_v, prev_vhat, dt)
|
||||||
|
|
||||||
|
# body angular velocity for the damping term
|
||||||
|
w = np.zeros(3)
|
||||||
|
if self.prevM is not None and dt > 1e-3:
|
||||||
|
D = self.prevM @ M.T
|
||||||
|
w = np.array([D[2, 1] - D[1, 2], D[0, 2] - D[2, 0], D[1, 0] - D[0, 1]]) / (2 * dt)
|
||||||
|
self.prevM = M
|
||||||
|
|
||||||
|
tgt = self.pick(me_p, me_v, fwd, ents, me_off)
|
||||||
|
goal = norm(tgt[3]) if tgt else fwd
|
||||||
|
|
||||||
|
push, worst = self.avoidance(me_p, me_v, me_r, ents, me_off)
|
||||||
|
pn = float(np.linalg.norm(push))
|
||||||
|
# avoidance outranks the target when it is urgent
|
||||||
|
want = norm(goal + push * (3.0 if pn > 0.6 else 1.5)) if pn > 1e-6 else goal
|
||||||
|
|
||||||
|
# Command the NOSE ahead of where the flight path must go, by the
|
||||||
|
# measured lag -- steering the nose straight at the target makes the
|
||||||
|
# path arrive wide and late.
|
||||||
|
nose_cmd = norm(want + (want - vhat) * min(self.tau * 1.6, 2.5))
|
||||||
|
|
||||||
|
ex = float(np.dot(nose_cmd, right))
|
||||||
|
ey = float(np.dot(nose_cmd, up))
|
||||||
|
ez = float(np.dot(nose_cmd, fwd))
|
||||||
|
yaw = math.atan2(ex, ez if abs(ez) > 1e-3 else 1e-3)
|
||||||
|
pitch = math.atan2(ey, ez if abs(ez) > 1e-3 else 1e-3)
|
||||||
|
if ez < 0:
|
||||||
|
yaw = math.copysign(math.pi / 2, ex if ex else 1.0)
|
||||||
|
|
||||||
|
sx = max(-1.0, min(1.0, self.KP * yaw - self.KD * float(np.dot(w, up))))
|
||||||
|
sy = max(-1.0, min(1.0, -(self.KP * pitch - self.KD * float(np.dot(w, right)))))
|
||||||
|
|
||||||
|
# fire only when the *flight path* is clear and the nose is on target
|
||||||
|
aim_ok = tgt and abs(yaw) < self.FIRE_CONE and abs(pitch) < self.FIRE_CONE
|
||||||
|
fire = bool(aim_ok and tgt[4] < self.FIRE_RANGE 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
|
||||||
|
|
||||||
|
drift = math.degrees(ang(fwd, vhat))
|
||||||
|
msg = (f"spd={speed:6.0f} drift={drift:5.1f}d tau={self.tau:4.2f} "
|
||||||
|
f"yaw={math.degrees(yaw):+6.1f} pit={math.degrees(pitch):+6.1f} "
|
||||||
|
f"stick=({sx:+.2f},{sy:+.2f}) fire={int(fire)}")
|
||||||
|
if tgt:
|
||||||
|
msg += f" tgt={tgt[1][3:22]:<20} d={tgt[4]:7.0f}"
|
||||||
|
if worst:
|
||||||
|
msg += (f" | AVOID {worst[1][3:20]} miss={worst[3]:6.0f} "
|
||||||
|
f"t={worst[4]:4.1f}s u={worst[0]:.2f}")
|
||||||
|
return msg, vhat
|
||||||
|
|
||||||
|
def run(self, secs, hz=10.0):
|
||||||
|
self.W.scan()
|
||||||
|
t0 = time.time()
|
||||||
|
last, last_scan, prev_vhat = t0, 0.0, None
|
||||||
|
while time.time() - t0 < secs:
|
||||||
|
t = time.time()
|
||||||
|
if t - last_scan > 4.0:
|
||||||
|
ents = self.W.scan()
|
||||||
|
last_scan = t
|
||||||
|
c = Counter(faction(self.W.defs[va]) for _, va in ents)
|
||||||
|
print(f"[{t-t0:6.1f}] scan: {len(ents)} entities {dict(c)}",
|
||||||
|
file=self.log, flush=True)
|
||||||
|
msg, prev_vhat = self.step(t, t - last, prev_vhat)
|
||||||
|
last = t
|
||||||
|
print(f"[{t-t0:6.1f}] {msg}", file=self.log, flush=True)
|
||||||
|
time.sleep(max(0, 1.0 / hz - (time.time() - t)))
|
||||||
|
if not self.dry:
|
||||||
|
self.pad.reset()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = json.load(open(sys.argv[1]))
|
||||||
|
secs = float(sys.argv[2]) if len(sys.argv) > 2 else 90.0
|
||||||
|
W = World(cfg)
|
||||||
|
Navigator(W, Pad(), dry="--dry" in sys.argv).run(secs)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
117
tools/re-capture/own_state.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Where does the craft keep its hull and shield? Anchor on the definition.
|
||||||
|
|
||||||
|
The parsed unit definition already has solved fields (unit-struct-runtime.md):
|
||||||
|
`HP` at `+0x054`, shield `MaxValue` at `+0x238`, shield `ChargeSpeed` at
|
||||||
|
`+0x244`. A craft that has taken no damage is at full hull and full shield, so
|
||||||
|
its live entity object must *contain those very numbers*. That turns "find the
|
||||||
|
HP field" from a value-scan over 4 GB into: read two floats from the definition,
|
||||||
|
then look for them inside the entity object.
|
||||||
|
|
||||||
|
Then watch the candidates while the craft is under fire. The live field is the
|
||||||
|
one that falls; a copy of the definition value that never moves is not it.
|
||||||
|
|
||||||
|
Usage: own_state.py <config.json> [watch_seconds] [out.json]
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
DEF_HP, DEF_SHIELD_MAX, DEF_SHIELD_CHG = 0x054, 0x238, 0x244
|
||||||
|
BACK, FWD = 0x800, 0x800
|
||||||
|
|
||||||
|
|
||||||
|
def near(a, v):
|
||||||
|
return np.abs(a - v) <= max(1e-3, abs(v) * 1e-4)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = json.load(open(sys.argv[1]))
|
||||||
|
secs = float(sys.argv[2]) if len(sys.argv) > 2 else 40.0
|
||||||
|
out = sys.argv[3] if len(sys.argv) > 3 else None
|
||||||
|
|
||||||
|
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, def_va = me[0]
|
||||||
|
print(f"# player {W.defs[def_va]} pos off {me_off:#x} def {def_va:#010x}")
|
||||||
|
|
||||||
|
d = os.pread(W.fd, 0x300, gmem.va_to_off(def_va))
|
||||||
|
want = {}
|
||||||
|
for nm, o in (("HP", DEF_HP), ("Shield_MaxValue", DEF_SHIELD_MAX),
|
||||||
|
("Shield_ChargeSpeed", DEF_SHIELD_CHG)):
|
||||||
|
(v,) = struct.unpack_from(">f", d, o)
|
||||||
|
want[nm] = v
|
||||||
|
print(f"# definition {nm:<18} = {v:g}")
|
||||||
|
|
||||||
|
blob = os.pread(W.fd, BACK + FWD, me_off - BACK)
|
||||||
|
arr = np.frombuffer(blob, dtype=">f4").astype(np.float64)
|
||||||
|
cands = [] # (label, delta_from_position)
|
||||||
|
for nm, v in want.items():
|
||||||
|
if not np.isfinite(v) or v == 0.0:
|
||||||
|
continue
|
||||||
|
for i in np.flatnonzero(near(arr, v)):
|
||||||
|
cands.append((nm, int(i) * 4 - BACK))
|
||||||
|
print(f"# {len(cands)} candidate offsets inside the entity object:")
|
||||||
|
for nm, dlt in cands:
|
||||||
|
print(f" pos{dlt:+#07x} == definition {nm}")
|
||||||
|
if not cands:
|
||||||
|
print("# none — the object does not carry the definition's own numbers"
|
||||||
|
" at this offset window; widen BACK/FWD or the craft is damaged")
|
||||||
|
|
||||||
|
# ---- watch them; the live field is the one that moves
|
||||||
|
hist = {c: [] for c in cands}
|
||||||
|
t0 = time.time()
|
||||||
|
last_print = 0.0
|
||||||
|
while time.time() - t0 < secs:
|
||||||
|
p = W.pos(me_off)
|
||||||
|
if p is None:
|
||||||
|
print("# player object gone (death / stage change?)")
|
||||||
|
break
|
||||||
|
w = os.pread(W.fd, BACK + FWD, me_off - BACK)
|
||||||
|
a = np.frombuffer(w, dtype=">f4").astype(np.float64)
|
||||||
|
for c in cands:
|
||||||
|
i = (c[1] + BACK) // 4
|
||||||
|
hist[c].append(float(a[i]))
|
||||||
|
t = time.time() - t0
|
||||||
|
if t - last_print > 4.0:
|
||||||
|
last_print = t
|
||||||
|
cur = " ".join(f"{c[0][:4]}{c[1]:+#x}={hist[c][-1]:.1f}" for c in cands[:6])
|
||||||
|
print(f"[{t:6.1f}] {cur}", flush=True)
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
print("\n# offset anchor first last min moved")
|
||||||
|
moving = []
|
||||||
|
for c in cands:
|
||||||
|
h = np.array(hist[c])
|
||||||
|
if not len(h):
|
||||||
|
continue
|
||||||
|
mv = float(h.max() - h.min())
|
||||||
|
print(f" pos{c[1]:+#07x} {c[0]:<18} {h[0]:8.1f} {h[-1]:8.1f} "
|
||||||
|
f"{h.min():8.1f} {mv:7.3f}")
|
||||||
|
if mv > 1e-3:
|
||||||
|
moving.append({"anchor": c[0], "delta": c[1],
|
||||||
|
"first": h[0], "last": float(h[-1]),
|
||||||
|
"min": float(h.min())})
|
||||||
|
if not moving:
|
||||||
|
print("# nothing moved — the craft took no damage during the window")
|
||||||
|
if out:
|
||||||
|
json.dump({"player": W.defs[def_va], "def_va": def_va,
|
||||||
|
"definition": want,
|
||||||
|
"candidates": [{"anchor": a, "delta": b} for a, b in cands],
|
||||||
|
"moved": moving}, open(out, "w"), indent=1)
|
||||||
|
print(f"# wrote {out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
657
tools/re-capture/pilot.py
Normal file
@@ -0,0 +1,657 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""A pilot that tries to stay alive, not just to shoot.
|
||||||
|
|
||||||
|
Every earlier loop flew a straight pursuit and was shot down; the trace from
|
||||||
|
ctrl_probe.py shows why — 1500 hull points gone in twelve seconds while sitting
|
||||||
|
in a turret's line of fire, with no reaction of any kind. Three measured facts
|
||||||
|
make a reaction possible:
|
||||||
|
|
||||||
|
* **Hull is `position + 0x154`** — at spawn it equals the unit definition's own
|
||||||
|
`HP` (1500 for the Delta Saber), it steps down 30/60/90 per hit, and it goes
|
||||||
|
negative at death. So damage is observable *as it happens*, not inferred.
|
||||||
|
* **`RT` accelerates and `LT` brakes**, and the setting persists: measured
|
||||||
|
ground speed went 488 → 1510 under RT, 488 → 174 under LT and then *stayed*
|
||||||
|
near 130 with the sticks neutral. (The older note "RT is not the throttle" was
|
||||||
|
drawn from a value-scan for a speed field, not from measuring the speed.)
|
||||||
|
* **`RB` fires** (autopilot-memory-driven.md).
|
||||||
|
|
||||||
|
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
|
||||||
|
mission's own hint says is where you resupply
|
||||||
|
|
||||||
|
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
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
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) # 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
|
||||||
|
self.pad = pad
|
||||||
|
self.dry = dry
|
||||||
|
self.log = log
|
||||||
|
# closest-point-of-approach avoidance is navigator.py's, reused as-is
|
||||||
|
self.av = navigator.Navigator(W, pad, dry=True, log=log)
|
||||||
|
self.prevM = None
|
||||||
|
self.firing = False
|
||||||
|
self.throttle = 0 # -1 brake, 0 coast, +1 accelerate
|
||||||
|
self.mode = "ENGAGE"
|
||||||
|
self.hp_hist = deque(maxlen=64)
|
||||||
|
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):
|
||||||
|
b = os.pread(self.W.fd, 8, off + HULL_OFF)
|
||||||
|
hull = struct.unpack_from(">f", b, 0)[0] if len(b) >= 4 else float("nan")
|
||||||
|
b2 = os.pread(self.W.fd, 4, off + SHIELD_OFF)
|
||||||
|
shield = struct.unpack(">f", b2)[0] if len(b2) == 4 else float("nan")
|
||||||
|
return hull, shield
|
||||||
|
|
||||||
|
def set_throttle(self, want):
|
||||||
|
"""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
|
||||||
|
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 = []
|
||||||
|
for off, nm, p, v, r in ents:
|
||||||
|
if off == me_off or navigator.faction(nm) != "ADAN":
|
||||||
|
continue
|
||||||
|
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."""
|
||||||
|
best, bestscore = None, 1e18
|
||||||
|
for off, nm, p, v, r, hard in hos:
|
||||||
|
if hard:
|
||||||
|
continue # turrets and hulls are not the objective
|
||||||
|
rel = p - me_p
|
||||||
|
d = float(np.linalg.norm(rel))
|
||||||
|
if d < 1e-3:
|
||||||
|
continue
|
||||||
|
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, r), score
|
||||||
|
return best
|
||||||
|
|
||||||
|
def threat_vector(self, me_p, hos):
|
||||||
|
"""Where the danger is: inverse-square weighted direction to shooters."""
|
||||||
|
acc = np.zeros(3)
|
||||||
|
for off, nm, p, v, r, hard in hos:
|
||||||
|
rel = p - me_p
|
||||||
|
d = float(np.linalg.norm(rel))
|
||||||
|
if d < 1.0 or d > 8000.0:
|
||||||
|
continue
|
||||||
|
w = (1500.0 / d) ** 2 * (3.0 if hard else 1.0)
|
||||||
|
acc += norm(rel) * w
|
||||||
|
return norm(acc) if np.linalg.norm(acc) > 1e-6 else None
|
||||||
|
|
||||||
|
def friendly_base(self, ents, me_off):
|
||||||
|
"""The biggest friendly — the carrier the briefing says to resupply at."""
|
||||||
|
best = None
|
||||||
|
for off, nm, p, v, r in ents:
|
||||||
|
if off == me_off or navigator.faction(nm) != "TCAF":
|
||||||
|
continue
|
||||||
|
if best is None or r > best[4]:
|
||||||
|
best = (off, nm, p, v, r)
|
||||||
|
return best
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ steering
|
||||||
|
def sticks(self, want, M, w):
|
||||||
|
fwd = M[self.W.fwd_row] * self.W.fwd_sign
|
||||||
|
right = M[(self.W.fwd_row + 1) % 3]
|
||||||
|
up = np.cross(fwd, right)
|
||||||
|
ex, ey, ez = (float(np.dot(want, right)), float(np.dot(want, up)),
|
||||||
|
float(np.dot(want, fwd)))
|
||||||
|
yaw = math.atan2(ex, ez if abs(ez) > 1e-3 else 1e-3)
|
||||||
|
pitch = math.atan2(ey, ez if abs(ez) > 1e-3 else 1e-3)
|
||||||
|
if ez < 0: # target behind: commit to a full turn
|
||||||
|
yaw = math.copysign(math.pi / 2, ex if ex else 1.0)
|
||||||
|
sx = max(-1.0, min(1.0, self.KP * yaw - self.KD * float(np.dot(w, up))))
|
||||||
|
sy = max(-1.0, min(1.0, -(self.KP * pitch - self.KD * float(np.dot(w, right)))))
|
||||||
|
return sx, sy, yaw, pitch
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- step
|
||||||
|
def step(self, t, dt):
|
||||||
|
ents = self.W.sample(t)
|
||||||
|
me = next((e for e in ents if "Player" in e[1]), None)
|
||||||
|
if me is None:
|
||||||
|
return None
|
||||||
|
me_off, me_nm, me_p, me_v, me_r = me
|
||||||
|
M = self.W.rot(me_off)
|
||||||
|
if M is None:
|
||||||
|
return "no-orientation"
|
||||||
|
fwd = M[self.W.fwd_row] * self.W.fwd_sign
|
||||||
|
right = M[(self.W.fwd_row + 1) % 3]
|
||||||
|
up = np.cross(fwd, right)
|
||||||
|
speed = float(np.linalg.norm(me_v))
|
||||||
|
|
||||||
|
hull, shield = self.own(me_off)
|
||||||
|
if self.hp0 is None and math.isfinite(hull) and hull > 0:
|
||||||
|
self.hp0 = hull
|
||||||
|
self.hp_hist.append((t, hull))
|
||||||
|
# damage over the last ~2 s; the hull only ever falls, so any drop is a hit
|
||||||
|
recent = [h for (ts, h) in self.hp_hist if t - ts <= 2.0]
|
||||||
|
dmg = (max(recent) - hull) if recent else 0.0
|
||||||
|
if dmg > 0.5:
|
||||||
|
self.last_hit = t
|
||||||
|
if hull <= 0:
|
||||||
|
return "DEAD"
|
||||||
|
|
||||||
|
# body angular velocity, for the damping term
|
||||||
|
w = np.zeros(3)
|
||||||
|
if self.prevM is not None and dt > 1e-3:
|
||||||
|
D = self.prevM @ M.T
|
||||||
|
w = np.array([D[2, 1] - D[1, 2], D[0, 2] - D[2, 0], D[1, 0] - D[0, 1]]) / (2 * dt)
|
||||||
|
self.prevM = M
|
||||||
|
|
||||||
|
hos = self.hostiles(ents, me_off)
|
||||||
|
self.threat_dir = self.threat_vector(me_p, hos)
|
||||||
|
frac = hull / self.hp0 if self.hp0 else 1.0
|
||||||
|
|
||||||
|
# ---- 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_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":
|
||||||
|
# Away from the guns, plus a jink so a straight escape line is not
|
||||||
|
# itself an easy solution for whatever is shooting.
|
||||||
|
away = -self.threat_dir if self.threat_dir is not None else fwd
|
||||||
|
jink = right * math.sin(t * 1.7) * 0.5 + up * math.cos(t * 2.3) * 0.35
|
||||||
|
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:
|
||||||
|
rel = base[2] - me_p
|
||||||
|
d = float(np.linalg.norm(rel))
|
||||||
|
want = norm(rel) if d > base[4] + 400.0 else norm(np.cross(rel, up))
|
||||||
|
else:
|
||||||
|
want = -self.threat_dir if self.threat_dir is not None else fwd
|
||||||
|
self.set_throttle(+1)
|
||||||
|
fire = False
|
||||||
|
else:
|
||||||
|
# Straight lead pursuit and fly *through*. An earlier version orbited
|
||||||
|
# once inside a standoff radius and braked while doing it: it then
|
||||||
|
# circled one attacker for 40 s at ~700 m, at 60-100 units/s, never
|
||||||
|
# inside the firing cone. Overshooting and re-acquiring is better
|
||||||
|
# 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] < 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)
|
||||||
|
else:
|
||||||
|
self.set_throttle(0)
|
||||||
|
fire = True
|
||||||
|
|
||||||
|
# a turret inside its keep-out radius outranks the target
|
||||||
|
for off, nm, p, v, r, hard in hos:
|
||||||
|
if not hard:
|
||||||
|
continue
|
||||||
|
d = float(np.linalg.norm(p - me_p))
|
||||||
|
if d < self.TURRET_KEEPOUT:
|
||||||
|
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))
|
||||||
|
|
||||||
|
sx, sy, yaw, pitch = self.sticks(want, M, w)
|
||||||
|
# The firing gate has to be measured against the TARGET, not against the
|
||||||
|
# commanded direction: `want` carries the avoidance and keep-out terms,
|
||||||
|
# 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)
|
||||||
|
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={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)} 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:
|
||||||
|
msg += f" tgt={tgt[1][3:24]:<21} d={tgt[4]:6.0f}"
|
||||||
|
if worst:
|
||||||
|
msg += f" | AVOID {worst[1][3:18]} miss={worst[3]:5.0f} t={worst[4]:4.1f}"
|
||||||
|
return msg
|
||||||
|
|
||||||
|
def run(self, secs):
|
||||||
|
self.W.scan()
|
||||||
|
t0 = time.time()
|
||||||
|
last, last_scan = t0, 0.0
|
||||||
|
hostiles0 = None
|
||||||
|
while time.time() - t0 < secs:
|
||||||
|
t = time.time()
|
||||||
|
if t - last_scan > 5.0:
|
||||||
|
ents = self.W.scan()
|
||||||
|
last_scan = t
|
||||||
|
n_ad = sum(1 for _, va in ents
|
||||||
|
if navigator.faction(self.W.defs[va]) == "ADAN")
|
||||||
|
if hostiles0 is None:
|
||||||
|
hostiles0 = n_ad
|
||||||
|
print(f"[{t-t0:6.1f}] scan: {len(ents)} entities, {n_ad} ADAN "
|
||||||
|
f"(start {hostiles0})", file=self.log, flush=True)
|
||||||
|
msg = self.step(t, t - last)
|
||||||
|
last = t
|
||||||
|
if msg is None:
|
||||||
|
print(f"[{t-t0:6.1f}] player object gone — stopping",
|
||||||
|
file=self.log, flush=True)
|
||||||
|
break
|
||||||
|
print(f"[{t-t0:6.1f}] {msg}", file=self.log, flush=True)
|
||||||
|
if msg == "DEAD":
|
||||||
|
break
|
||||||
|
time.sleep(max(0.0, 1.0 / self.HZ - (time.time() - t)))
|
||||||
|
if not self.dry:
|
||||||
|
self.pad.reset()
|
||||||
|
print(f"# flew {time.time()-t0:.0f}s", file=self.log, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = json.load(open(sys.argv[1]))
|
||||||
|
secs = float(sys.argv[2]) if len(sys.argv) > 2 else 180.0
|
||||||
|
W = navigator.World(cfg)
|
||||||
|
Pilot(W, Pad(), dry="--dry" in sys.argv).run(secs)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
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"
|
||||||
@@ -3,16 +3,37 @@
|
|||||||
# - movie playing (two grabs 0.6s apart differ a lot) -> tap A to skip
|
# - movie playing (two grabs 0.6s apart differ a lot) -> tap A to skip
|
||||||
# - static screen -> if the green "PRESS (A) BUTTON" glyph is there, tap A and stop
|
# - static screen -> if the green "PRESS (A) BUTTON" glyph is there, tap A and stop
|
||||||
# Static logo screens are left alone, so a stray tap can never land on NEW GAME.
|
# Static logo screens are left alone, so a stray tap can never land on NEW GAME.
|
||||||
|
#
|
||||||
|
# LIVENESS IS CHECKED EVERY ITERATION, and that is not a nicety. When the
|
||||||
|
# display died 3 minutes into a run, `screenshot` started failing silently and
|
||||||
|
# left /tmp/f1.png and /tmp/f2.png at their last contents — two *stale* files,
|
||||||
|
# which compare to a constant non-zero RMSE, which reads exactly like "the
|
||||||
|
# screen is changing a lot". So the loop reported "movie -> skip A" every 5 s
|
||||||
|
# for the full 600 s timeout with no emulator and no X server running. A dead
|
||||||
|
# display and a playing movie must never be able to look the same.
|
||||||
set -u
|
set -u
|
||||||
export HOME=/sylph-home/re
|
export HOME=/sylph-home/re
|
||||||
|
DISP="${DISPLAY:-:98}"
|
||||||
|
alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $1}'; }
|
||||||
# The X root keeps the DEAD session's last frame, so a fresh launch would be
|
# The X root keeps the DEAD session's last frame, so a fresh launch would be
|
||||||
# detected as "already at the title". Blank it, and wait for the new window.
|
# detected as "already at the title". Blank it, and wait for the new window.
|
||||||
xsetroot -solid black 2>/dev/null || true
|
xsetroot -solid black 2>/dev/null || true
|
||||||
until xdotool search --name "Xenia-canary" >/dev/null 2>&1; do sleep 1; done
|
until xdotool search --name "Xenia-canary" >/dev/null 2>&1; do
|
||||||
|
xdpyinfo -display "$DISP" >/dev/null 2>&1 || { echo "DISPLAY LOST at ${SECONDS}s (before the window appeared)"; exit 3; }
|
||||||
|
[ -n "$(alive)" ] || { echo "EMULATOR GONE at ${SECONDS}s (before the window appeared)"; exit 4; }
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
deadline=$(( SECONDS + ${1:-900} ))
|
deadline=$(( SECONDS + ${1:-900} ))
|
||||||
while [ $SECONDS -lt $deadline ]; do
|
while [ $SECONDS -lt $deadline ]; do
|
||||||
|
rm -f /tmp/f1.png /tmp/f2.png
|
||||||
screenshot /tmp/f1.png >/dev/null 2>&1; sleep 0.6
|
screenshot /tmp/f1.png >/dev/null 2>&1; sleep 0.6
|
||||||
screenshot /tmp/f2.png >/dev/null 2>&1
|
screenshot /tmp/f2.png >/dev/null 2>&1
|
||||||
|
if [ ! -s /tmp/f1.png ] || [ ! -s /tmp/f2.png ]; then
|
||||||
|
xdpyinfo -display "$DISP" >/dev/null 2>&1 \
|
||||||
|
|| { echo "DISPLAY LOST at ${SECONDS}s"; exit 3; }
|
||||||
|
echo "SCREENSHOT FAILED at ${SECONDS}s with the display up"; exit 5
|
||||||
|
fi
|
||||||
|
[ -n "$(alive)" ] || { echo "EMULATOR GONE at ${SECONDS}s"; exit 4; }
|
||||||
d=$(compare -metric RMSE /tmp/f1.png /tmp/f2.png null: 2>&1 | sed 's/ .*//' | cut -d. -f1)
|
d=$(compare -metric RMSE /tmp/f1.png /tmp/f2.png null: 2>&1 | sed 's/ .*//' | cut -d. -f1)
|
||||||
d=${d:-0}
|
d=${d:-0}
|
||||||
read -r r g b < <(convert /tmp/f2.png -format "%[fx:int(255*p{625,618}.r)] %[fx:int(255*p{625,618}.g)] %[fx:int(255*p{625,618}.b)]" info:)
|
read -r r g b < <(convert /tmp/f2.png -format "%[fx:int(255*p{625,618}.r)] %[fx:int(255*p{625,618}.g)] %[fx:int(255*p{625,618}.b)]" info:)
|
||||||
|
|||||||
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)"
|
||||||
45
tools/re-capture/wait_flight.sh
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Wait until the game is actually FLYING, then return — do not guess with sleeps.
|
||||||
|
#
|
||||||
|
# `launch_mission.sh` used to allow a fixed 75 s for the launch cinematic, the
|
||||||
|
# stage load and the objective card. Under lavapipe that is not a constant: one
|
||||||
|
# run needed 75 s, the next was still in Raymond's dialogue at 140 s, so the A
|
||||||
|
# meant for the OBJECTIVE card was swallowed by the cutscene and the card sat
|
||||||
|
# there forever.
|
||||||
|
#
|
||||||
|
# The in-flight HUD is unmistakable: the SHIELD bar is a solid bright green
|
||||||
|
# block at the bottom of the screen, and no cutscene or menu has anything green
|
||||||
|
# there. So poll that pixel, and tap A every few seconds until it appears (which
|
||||||
|
# dismisses the objective card whenever it happens to be up).
|
||||||
|
#
|
||||||
|
# Same liveness rule as skip_intro.sh, for the same reason: a failed screenshot
|
||||||
|
# leaves r/g/b unset, they default to 0, the green test fails, and waiting for
|
||||||
|
# the HUD becomes indistinguishable from waiting for a dead emulator.
|
||||||
|
set -u
|
||||||
|
export HOME=/sylph-home/re
|
||||||
|
DISP="${DISPLAY:-:98}"
|
||||||
|
alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $1}'; }
|
||||||
|
DEADLINE=$(( SECONDS + ${1:-240} ))
|
||||||
|
PX=450; PY=640 # inside the SHIELD bar of the flight HUD
|
||||||
|
last_tap=0
|
||||||
|
while [ $SECONDS -lt $DEADLINE ]; do
|
||||||
|
rm -f /tmp/wf.png
|
||||||
|
if ! screenshot /tmp/wf.png >/dev/null 2>&1 || [ ! -s /tmp/wf.png ]; then
|
||||||
|
xdpyinfo -display "$DISP" >/dev/null 2>&1 \
|
||||||
|
|| { echo "DISPLAY LOST at ${SECONDS}s"; exit 3; }
|
||||||
|
sleep 2; continue
|
||||||
|
fi
|
||||||
|
[ -n "$(alive)" ] || { echo "EMULATOR GONE at ${SECONDS}s"; exit 4; }
|
||||||
|
read -r r g b < <(convert /tmp/wf.png -format \
|
||||||
|
"%[fx:int(255*p{$PX,$PY}.r)] %[fx:int(255*p{$PX,$PY}.g)] %[fx:int(255*p{$PX,$PY}.b)]" info:)
|
||||||
|
if [ "${g:-0}" -gt 140 ] && [ $(( g - r )) -gt 60 ] && [ $(( g - b )) -gt 60 ]; then
|
||||||
|
echo "IN FLIGHT at ${SECONDS}s (HUD shield bar visible)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ $(( SECONDS - last_tap )) -ge 6 ]; then
|
||||||
|
vgamepad tap A 250
|
||||||
|
last_tap=$SECONDS
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "NO FLIGHT HUD within ${1:-240}s"; exit 1
|
||||||