Compare commits
15 Commits
auto/re-un
...
auto/re-mi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -1,12 +1,101 @@
|
||||
# Memory-driven autopilot — build log and current state
|
||||
|
||||
**Status: 🟢 IT FLIES AND SHOOTS — it does not yet survive.**
|
||||
Updated 2026-07-29 (second pass). The autopilot reads the live world, picks
|
||||
hostile targets, pursues them and opens fire. What it cannot do is stay alive:
|
||||
there is no evasion or shield management, so it dies before a mission ends.
|
||||
This is the honest state, not a plan.
|
||||
**Status: 🟢 IT FLIES, KILLS AND SURVIVES — but it loses the mission anyway.**
|
||||
Updated 2026-07-30. `pilot.py` flew Stage 02 for **300 s with the hull untouched
|
||||
at 1500/1500** and scored the first confirmed autopilot kill (`YOU KILLED
|
||||
WARPLANES 0001` on the HUD, screenshots `shots/pilot1-*.png`); the scene's
|
||||
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
|
||||
@@ -33,8 +122,8 @@ rescan reports the scene as e.g. `136 entities {'TCAF': 16, 'ADAN': 120}`.
|
||||
direction of travel with **cos = +1.000**.
|
||||
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
|
||||
counter in RAM fall (5958 → 5940). `RT` is *not* the throttle, and no button
|
||||
tested is.
|
||||
counter in RAM fall (5958 → 5940). (This entry also claimed `RT` is *not* the
|
||||
throttle — **wrong**, see the 2026-07-30 measurement above.)
|
||||
4. **Control.** PD on the aiming error with the derivative taken from the
|
||||
craft's own body angular velocity (from two consecutive rotation matrices),
|
||||
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
|
||||
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.**
|
||||
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
|
||||
|
||||
`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 +
|
||||
sampling, and the `Pad` FIFO client) · `flight_analyze.py` · `whatchanges.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/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.
|
||||
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))
|
||||
if len(sys.argv) > 3 and found:
|
||||
import json
|
||||
# forward axis = the row closest to our direction of travel
|
||||
vdir = None
|
||||
# Which of the orthonormal blocks is the CRAFT's attitude? The one
|
||||
# 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)
|
||||
time.sleep(0.35)
|
||||
p1 = np.array(struct.unpack(">3f", os.pread(fd, 12, off)))
|
||||
if np.linalg.norm(p1 - p0) > 1e-3:
|
||||
vdir = (p1 - p0) / np.linalg.norm(p1 - p0)
|
||||
rot_delta, M, rot_stride = found[0]
|
||||
row, sign = 2, 1
|
||||
if vdir is not None:
|
||||
step = p1 - p0
|
||||
if np.linalg.norm(step) < 1e-3:
|
||||
sys.exit("craft is not moving — cannot bind the forward axis")
|
||||
vdir = step / np.linalg.norm(step)
|
||||
best = None
|
||||
for d, M, st in found:
|
||||
cs = [float(M[r] @ vdir) for r in range(3)]
|
||||
row = int(np.argmax([abs(c) for c in cs]))
|
||||
sign = 1 if cs[row] > 0 else -1
|
||||
print(f"# forward axis = row {row} (sign {sign:+d}), "
|
||||
f"cos={cs[row]:+.3f}")
|
||||
r = int(np.argmax([abs(c) for c in cs]))
|
||||
if best is None or abs(cs[r]) > abs(best[3]):
|
||||
best = (d, M, st, cs[r], r)
|
||||
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,
|
||||
"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}
|
||||
json.dump(cfg, open(sys.argv[3], "w"), indent=1)
|
||||
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
|
||||
|
||||
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(){
|
||||
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
|
||||
setsid nohup Xvfb "$DISPLAY" -screen 0 1280x720x24 -ac -nolisten tcp \
|
||||
+extension GLX +extension RANDR </dev/null >/tmp/xvfb98.log 2>&1 &
|
||||
rm -f "/tmp/.X${DISPLAY#:}-lock" 2>/dev/null || true
|
||||
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
|
||||
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
|
||||
fi
|
||||
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
|
||||
|
||||
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 &
|
||||
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
|
||||
|
||||
step down # NEW GAME -> LOAD GAME
|
||||
@@ -55,8 +68,8 @@ fi
|
||||
|
||||
step up # BRIEFINGS -> TAKE OFF
|
||||
vgamepad tap A 250
|
||||
sleep 75 # launch cinematic + stage load + objective card
|
||||
shot "lm-objective.png"
|
||||
vgamepad tap A 250; sleep 6 # dismiss the OBJECTIVE panel
|
||||
# The launch cinematic + stage load + objective card is NOT a fixed 75 s under
|
||||
# lavapipe; wait for the flight HUD itself.
|
||||
"$SD/wait_flight.sh" 300 || { echo "NEVER REACHED FLIGHT"; exit 1; }
|
||||
shot "lm-flight.png"
|
||||
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()
|
||||
@@ -3,16 +3,37 @@
|
||||
# - 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 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
|
||||
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
|
||||
# detected as "already at the title". Blank it, and wait for the new window.
|
||||
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} ))
|
||||
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/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=${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:)
|
||||
|
||||
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
|
||||