Compare commits
5 Commits
auto/re-un
...
auto/re-au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ee3bfbcef | ||
|
|
91fb022caf | ||
|
|
93d6534efe | ||
| 41c34dc3ca | |||
| 2e9903d0fc |
46
docs/re/BACKLOG.md
Normal file
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,8 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|
||||
|-----------|-------|------|-------|
|
||||
| Live guest-memory read | ✅ | [`tools/re-capture/gmem.py`](../../tools/re-capture/gmem.py) | Canary backs the guest address space with `/dev/shm/xenia_memory_*`; guest VAs map in through Xenia's fixed table. Full-RAM search ~0.2 s (sparse, `SEEK_DATA`). No debugger, no emulator patch, game keeps running |
|
||||
| IDXD object layout solver | ✅ | [`tools/re-capture/weapon_runtime.py`](../../tools/re-capture/weapon_runtime.py) | Scan RAM for a class's vtable → enumerate its objects → brute-force `(field, offset, encoding)` against the disc records. Accepts a binding only on **zero** contradictions. Generalizes to any IDXD-backed definition |
|
||||
| Live entity state, anchored on the definition | ✅ | [`tools/re-capture/own_state.py`](../../tools/re-capture/own_state.py) · [autopilot](autopilot-memory-driven.md) | An undamaged craft holds its definition's own numbers, so a *solved definition field* locates the matching live field without a value scan: definition `HP` (1500) → **hull at `position+0x154`**, confirmed by a trace across a death (30/60/90 per hit, negative at 0). Reusable for any live counter whose maximum the definition carries |
|
||||
| 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
BIN
docs/re/captures/autopilot-300s-undamaged-stage02.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/re/captures/autopilot-escort-warning-stage02.png
Normal file
BIN
docs/re/captures/autopilot-escort-warning-stage02.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/re/captures/autopilot-first-kill-stage02.png
Normal file
BIN
docs/re/captures/autopilot-first-kill-stage02.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
1781
docs/re/captures/ctrl-probe-stage02.csv
Normal file
1781
docs/re/captures/ctrl-probe-stage02.csv
Normal file
File diff suppressed because it is too large
Load Diff
144
tools/re-capture/binq.py
Normal file
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
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
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
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"
|
||||
27
tools/re-capture/fly_session.sh
Executable file
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)"
|
||||
|
||||
167
tools/re-capture/mission_state.py
Executable file
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
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
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()
|
||||
319
tools/re-capture/pilot.py
Normal file
319
tools/re-capture/pilot.py
Normal file
@@ -0,0 +1,319 @@
|
||||
#!/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
|
||||
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.
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
class Pilot:
|
||||
KP, KD = 2.2, 0.45
|
||||
FIRE_CONE = math.radians(9)
|
||||
FIRE_RANGE = 5000.0
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------ 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."""
|
||||
if want == self.throttle or self.dry:
|
||||
return
|
||||
self.pad.trig("RT", 1.0 if want > 0 else 0.0)
|
||||
self.pad.trig("LT", 1.0 if want < 0 else 0.0)
|
||||
self.throttle = want
|
||||
|
||||
# -------------------------------------------------------------- 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 pick(self, me_p, me_v, fwd, hos):
|
||||
"""Nearest *fighter*, weighted by how far off the nose it is."""
|
||||
speed = max(float(np.linalg.norm(me_v)), 1.0)
|
||||
best, bestscore = None, 1e18
|
||||
for off, nm, p, v, r, hard in hos:
|
||||
if hard:
|
||||
continue # turrets and hulls are not the objective
|
||||
rel = p - me_p
|
||||
d = float(np.linalg.norm(rel))
|
||||
if d < 1e-3:
|
||||
continue
|
||||
lead = p + v * (d / max(speed, 300.0))
|
||||
theta = ang(lead - me_p, fwd)
|
||||
score = d * (1.0 + 3.0 * (theta / math.pi) ** 2)
|
||||
if score < bestscore:
|
||||
best, bestscore = (off, nm, lead, lead - me_p, d), score
|
||||
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
|
||||
|
||||
# ---- mode
|
||||
if frac <= self.RETIRE_FRAC:
|
||||
self.mode = "RETIRE"
|
||||
elif t - self.last_hit < self.EVADE_QUIET:
|
||||
self.mode = "EVADE"
|
||||
else:
|
||||
self.mode = "ENGAGE"
|
||||
|
||||
tgt = self.pick(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 == "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] > 2500.0:
|
||||
self.set_throttle(+1)
|
||||
elif tgt and tgt[4] < 500.0 and speed > 900.0:
|
||||
self.set_throttle(-1)
|
||||
else:
|
||||
self.set_throttle(0)
|
||||
fire = True
|
||||
|
||||
# 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
|
||||
|
||||
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)
|
||||
aim_ok = abs(aim[0]) < self.FIRE_CONE and abs(aim[1]) < self.FIRE_CONE
|
||||
fire = bool(fire and tgt and 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
|
||||
|
||||
msg = (f"{self.mode:<7} hull={hull:6.0f} shd={shield:6.0f} spd={speed:6.0f} "
|
||||
f"thr={self.throttle:+d} yaw={math.degrees(yaw):+6.1f} "
|
||||
f"pit={math.degrees(pitch):+6.1f} aim={math.degrees(aim[0]):+6.1f}"
|
||||
f"/{math.degrees(aim[1]):+6.1f} fire={int(fire)}")
|
||||
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:)
|
||||
|
||||
45
tools/re-capture/wait_flight.sh
Executable file
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
|
||||
Reference in New Issue
Block a user