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