From 813ae8c681694f1689ef239e18ada81b52953ffa Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Sun, 23 Aug 2026 21:27:27 +0000 Subject: [PATCH] docs+tools: why the pilot never fires - the target is behind it, 100% of the time fire=1 appears in 0 of 13521 samples. The gate that closes is measured rather than guessed: over the 3004 samples that had a target, |aim yaw| is EXACTLY 90.0 degrees every time, which is the `if ez < 0` branch in sticks() - the target is astern - with pitch near 180 and a range that grows 22km -> 49km and plateaus. The craft flies away from what it is chasing for fifteen minutes and the turn never completes. What is NOT established is why, and the attempt is withdrawn rather than kept: aim_probe.py reported the forward vector pinned at [-1,0,0] with 0.00 deg/s under neutral, full-left and full-right stick, which looks like a stale attitude matrix - but the guest had FROZEN partway through the probe, confirmed after the fact by frozen.py and by the player position being identical across 3 s. A dead world holds every matrix still. The probe is committed because it is the right experiment; its numbers are not evidence. One confusion resolved: today's entities2.py "0 moving triples" bind failures are the freeze, not a tool defect - moving() types entities by position CHANGING, so a frozen world yields nothing by construction. live_delta.py gains a per-1MB-region summary; a flat list is useless at 236000 hits. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE --- docs/re/pilot-never-fires.md | 77 +++++++++++++++++++++++++++++++ tools/re-capture/aim_probe.py | 82 ++++++++++++++++++++++++++++++++++ tools/re-capture/live_delta.py | 10 +++++ 3 files changed, 169 insertions(+) create mode 100644 docs/re/pilot-never-fires.md create mode 100755 tools/re-capture/aim_probe.py diff --git a/docs/re/pilot-never-fires.md b/docs/re/pilot-never-fires.md new file mode 100644 index 0000000..6a9773f --- /dev/null +++ b/docs/re/pilot-never-fires.md @@ -0,0 +1,77 @@ +# `pilot.py` never pulls the trigger — the proximal cause, measured + +**Status: ✅ the gate that blocks it is identified and measured; 🔴 the root cause +is not, and one attempt to find it was invalidated by a freeze.** + +## The observation + +Over a 900 s Stage 02 run, `fire=1` appears in **0 of 13 521** logged samples. +The loop flies, chases, evades and retires; the guns are never fired. Every +"kill something and watch X" experiment in this corpus has therefore been leaning +entirely on the wingmen. + +## Which gate closes + +`pilot.py`'s trigger needs all of: mode `ENGAGE`/`DEFEND`, a target, the aim error +inside the firing cone, range < `FIRE_RANGE` (`SHELL_MAX_RANGE` = 4 000), and a +small avoidance push. Over the 3 004 samples that had a target: + +| quantity | median | min | inside its gate | +|---|---|---|---| +| `\|aim yaw\|` | **90.0°** | **90.0°** | 0 of 3 004 below 25° | +| `\|aim pitch\|` | 179.2° | 166.4° | 0 of 3 004 below 25° | +| range | 49 634 | 45 078 | 0 below 4 000, 0 below 10 000 | + +`|aim yaw|` being **exactly** 90.0 in every sample is not a coincidence, it is a +branch: `sticks()` contains + +```python +if ez < 0: # target behind: commit to a full turn + yaw = math.copysign(math.pi / 2, ex if ex else 1.0) +``` + +So the committed target was **behind the ship in 100 % of samples**, with a pitch +error near 180° — directly astern — and the range to it grew monotonically: + +``` +t= 0 s 22 265 t= 231 s 33 632 +t=510 s 49 475 t= 884 s 48 200 (plateau) +``` + +The craft flies *away* from the thing it is chasing for fifteen minutes and the +turn never completes. Nothing else needs to be wrong for the trigger to stay +cold. + +## What is NOT established + +Three explanations fit that shape and this pass did not separate them: + +1. the attitude matrix the pilot reads is **stale**, so the ship turns and the + controller cannot see it; +2. the matrix is live but `fwd_row`/`fwd_sign` name the wrong axis; +3. both are fine and the **yaw stick sign** is inverted, so the loop turns away + from the error it is nulling. + +🔴 **An attempt to separate them was invalidated and is withdrawn.** `aim_probe.py` +watched the forward vector under neutral, full-left and full-right stick and +reported it pinned at `[-1, 0, 0]` with `|turn| = 0.00 °/s` in every phase — which +looks like explanation (1). It is not evidence: **the guest had frozen** partway +through, confirmed immediately afterwards by `frozen.py` (max pixel delta 0) and +by the player's position being byte-identical across 3 s. A dead world holds every +matrix still. The probe is committed because it is the right experiment; its +result is not. + +## One confusion resolved on the way + +Today's repeated `entities2.py self` failures — `# 0 moving triples`, "player +entity not found" — are **the freeze**, not a tool defect. `moving()` types +entities by their position *changing* between two samples, so a frozen world +yields nothing by construction. Runs where the bind failed were runs that had +already stopped. + +## Next step + +Re-run `aim_probe.py` on a run confirmed to be animating **at the end of the +probe as well as the start** — the tool should check `frozen.py` itself and +discard the phase otherwise. If the matrix does move under stick, the question +becomes the sign; if it does not, the binding is what to chase. diff --git a/tools/re-capture/aim_probe.py b/tools/re-capture/aim_probe.py new file mode 100755 index 0000000..e260d53 --- /dev/null +++ b/tools/re-capture/aim_probe.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Is the pilot's attitude binding live, and is its yaw stick the right way round? + +`pilot.py` never fires: `fire=1` in 0 of 13 521 samples over a 900 s run. The log +says why the gate never opens — `|aim yaw|` is **exactly 90.0°** in all 3 004 +samples that had a target, which is the `ez < 0` branch of `sticks()`, i.e. *the +target is behind us*, always — and the range to the committed target grows +22 km → 33 km → 49 km and stays there. The craft flies away from what it is +chasing and the turn never completes. + +Three things could do that and they need separating by measurement, not argument: + +1. the attitude matrix the pilot reads is **stale**, so the ship turns and the + pilot cannot see it; +2. the matrix is live but `fwd_sign` / `fwd_row` name the wrong axis, so + "forward" points backwards; +3. both are fine and the **yaw stick sign** is inverted, so the controller turns + away from the error it is trying to null. + +This probe separates them: watch the forward vector with no input at all, then +under a hard left stick, then a hard right one. + +Usage: aim_probe.py [seconds_per_phase] +""" +import json +import math +import os +import sys +import time + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import navigator # noqa: E402 +from flight_probe import Pad # noqa: E402 + + +def main(): + cfg = json.load(open(sys.argv[1])) + secs = float(sys.argv[2]) if len(sys.argv) > 2 else 4.0 + W = navigator.World(cfg) + W.scan() + pad = Pad() + + def fwd_now(): + ents = W.sample(time.time()) + me = next((e for e in ents if "Player" in e[1]), None) + if me is None: + return None, None + M = W.rot(me[0]) + if M is None: + return None, None + return M[W.fwd_row] * W.fwd_sign, me[2] + + def phase(name, lx): + pad.axis("LX", lx) + f0, p0 = fwd_now() + t0 = time.time() + time.sleep(secs) + f1, p1 = fwd_now() + pad.axis("LX", 0.0) + if f0 is None or f1 is None: + print(f"{name:>12}: NO PLAYER/ORIENTATION") + return + dot = float(np.clip(np.dot(f0, f1), -1, 1)) + turn = math.degrees(math.acos(dot)) / max(time.time() - t0, 1e-3) + cross = np.cross(f0, f1) + moved = float(np.linalg.norm(p1 - p0)) if p0 is not None else float("nan") + print(f"{name:>12}: |turn| {turn:6.2f} deg/s fwd {f0.round(3)} -> " + f"{f1.round(3)} cross {cross.round(3)} moved {moved:8.1f}") + + print("# phases: neutral, then full LEFT stick, then full RIGHT stick") + phase("neutral", 0.0) + phase("LX=-1", -1.0) + phase("neutral", 0.0) + phase("LX=+1", +1.0) + pad.reset() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/re-capture/live_delta.py b/tools/re-capture/live_delta.py index 4550767..d43db23 100755 --- a/tools/re-capture/live_delta.py +++ b/tools/re-capture/live_delta.py @@ -64,6 +64,16 @@ def main(): changed.append((off + int(i) * 4, int(arr0[i]), int(arr1[i]))) print(f"# {len(changed)} words changed in {gap}s " f"({len(changed)*4/max(total,1)*100:.4f} % of the data)", flush=True) + # A flat list is useless at 235 973 hits. Summarise by 1 MB region first: + # where the activity is says far more than which word moved. + from collections import Counter + reg = Counter() + for o, _, _ in changed: + va = gmem.primary_va(o) + reg[(va >> 20) << 20 if va is not None else 0] += 1 + print("# by 1 MB region (top 12):") + for base, n in reg.most_common(12): + print(f" {base:#010x} {n:8d} words") for o, v0, v1 in changed[:top]: va = gmem.primary_va(o) d = v1 - v0