16 Commits

Author SHA1 Message Date
530555de9f re: the weapons are the control — sibling-default inheritance is unit-schema-specific, not engine-wide
Four rules from 21 units invites the coincidence objection, so run the identical
sweep against the Weapon/Shell capture, which has COMPLETE coverage (126
records). It finds no sibling rule at all: the one 100%-agreement candidate has a
single distinct value and is really a constant default. Weapon defaults vary per
record exactly as unit defaults do, so "defaults are computed" is general while
"defaults come from a sibling field" is not.

Size_Y <- Size_X survives, and is now checked at the raw-token level rather than
through the sub-record merge: e105, f105 and f101 each declare Size_X/Size_Z/
Size_Radius and no Size_Y, and each reads back its own Size_X at runtime. The
two two-unit hypotheses are demoted to coincidence-not-excluded.

Also records a negative for planning: Stage 01, the only other reachable stage,
adds four uncaptured units that are variants of already-captured ones, so it
would re-measure rather than test the rules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:09:14 +00:00
69b4a2e569 re: a unit field left unset on disc is not a global constant — Size_Y inherits Size_X
Attacks the 21/110 unit-coverage limit from the cheap side: if a defaulted field
always took one runtime value, the captured units would pin it for all 110. Only
6 of 24 confirmed defaulted fields behave that way. The other 18 vary per unit,
so the default is computed.

Asking which OTHER field of the same unit holds that value — counting only
non-zero cases, and checking the two fields sit at different offsets so the
layout solver cannot be aliasing them — gives four rules. Size_Y <- Size_X is
solid: seven unrelated ships (e105 600, e106 300, e108 80, e201 300, f101 400,
f105 700, f106 200) each omit it on disc and each shows its own Size_X live,
while the two fields differ freely when both are on disc. Size_Radius fits both
min(X,Z) and the median of the three axes and cannot yet be separated;
e010_ADAN_Attacker_S is what rules out the simpler Size_X rule. FCSRange and
DefencePoint rest on two independent units each and ship as HYPOTHESIS.

Applied across the disc the rules recover 65 (unit, field) values in units that
have never been visited. Falsification test recorded: load any uncaptured stage
and compare one predicted value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:44:24 +00:00
ca500c171e ship: index-less brg/eng/sld parts never matched their GN frame — 34 ships assembled without a bridge
Tier 3 matched a part to its hardpoint by trailing index, so `e105_brg`
compared "01" == "" against GN_Bridge_01 and fell through silently. The runtime
capture is what exposed it: the game draws the bridge and places it at
[0, 70, -1850] rel e105_bdy_01, and assemble_ship emitted nothing there.

With no index to match on, take the lowest-numbered frame of the category.
Diffing assemble_ship part counts across every container: 34 (stage, ship)
entries gain parts — e102 +2 (bridge and engine), e104 +1, e105 +1, Stages
02-29. ship_audit is unchanged, so nothing regressed, and the capture now agrees
to dT 0.03 / dR 0.000.

Also fixes the diff itself: correlate_frames compared static against a rotation
sampled from the first block, which can belong to another INSTANCE of the class.
Scoped to the position-agreeing cluster, e105_eng_01 goes 1.711 -> 0.000 and
both e106 nacelles to 0.000. The one remaining rotation delta (e106_wep_02_01,
0.134) is a turret whose rotation varies by 0.182 between blocks that agree on
its position — the runtime disagrees with itself more than with the assembler.
The new rotVar column makes that distinction visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:16:06 +00:00
3d9f21f030 re: control the range, segment the frames — f105/e105/e106 all match static assembly
A capture at controlled range (ship_capture_close.sh: lock a capital ship, close
on it, F10 per range band) finally draws capital-ship hulls at full detail. Two
correctness fixes were needed before the numbers meant anything:

* one F10 log is ~14 frames with no delimiter, and WV_ref^-1 . WV_p only cancels
  the camera within one frame — segment_frames splits on vertex-buffer
  recurrence, and correlate_frames cross-checks the blocks against each other
  instead of trusting a single shot;
* aggregate by consensus, not median: a stage holds several ships of one class
  sharing vertex buffers, so a block can mix two instances.

Result: f105, e105 and e106 reproduce assemble_ship to <=0.43 units in
translation and 0.000 in rotation for every part that does not move. The e106
rules generalise, and the viewer bug report now points at the viewer. Narrow
leftovers: e105_brg is missing from assemble_ship, e105_eng_01 rotation differs
by 1.711.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 18:19:26 +00:00
1d4b35df0f re: the Stage-02 capture drew no capital ship at all — invert the match, then control range
Inverting the capture↔part question (invert_capture over one container,
vcount_index over all 166) identifies every large draw in the 2026-07-31
capture: the player's own DeltaSaber (10891 verts), its weapon packs, the
backdrop and particles. Of f101/e105/e106 only 1-3 of 15-37 resources have a
drawn vcount, each a 44-225-vertex far-LOD/effect piece whose count collides
with dozens of unrelated resources.

So the zero-correlation was not an LOD-list gap, not over-strict position
validation and not a different draw path: the ships were too far away to be
drawn. approach_capture.py flies at a locked capital ship and presses F10 per
range band, stamping each capture with its distance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 18:04:58 +00:00
Claude (auto-RE)
bbfeb1c387 re: ship-placement generalisation — static audit + first Stage-02 F10 capture (WIP)
Working the BACKLOG item "capital ships assemble wrong in the viewer".

- ship_audit over all 22 stage containers: only ONE outlier ship
  (f002_bdy_05, Stage_S03/S27, dist 6540 vs cluster spread 1071), so static
  assembly is not grossly broken class-wide. Recurring MULTIKEY joint tracks on
  f104/f105/f106/e102 are the standing hypothesis for class-specific error —
  e106, the one validated ship, has none.
- new tools/re-capture/ship_capture_session.sh: one blocking session that boots
  Stage 02 and fires N F10 ship-captures with screenshots. Boot to in-flight was
  24 s; 3 of 5 presses produced logs (2964/3111/3668 draws).
- NEGATIVE, unexplained: correlate_capture matched ZERO parts for f101/f105/
  f106/e105. Documented with the collected facts and the next step (invert the
  match: largest capture vcounts -> which decoded part has that count).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 05:16:20 +00:00
claude-re
695351dfc4 docs/re: mark the 'targeting is automatic' section superseded
It concluded no target-select input exists because no single press cycled a
target; select is A pressed twice. Kept with a correction banner rather than
deleted — a probe that never performs the action will 'prove' the action does
not exist, which is the reusable warning.
2026-07-30 20:48:15 +00:00
claude-re
4821ba7fea pilot: target select is A pressed TWICE — from the HUD tutorial, not from probing
HEADS-UP DISPLAY tutorial, verbatim: 'Press A twice to target the enemy closest
to the center of the screen.' A double tap, which is exactly why every button
sweep in flight-controls-runtime.md found nothing and why I concluded targeting
was automatic — each sweep tapped once. It also explains the missiles:
GuidanceType 5 guides to the GAME's selection and the loop had never made one,
so 98 launches guided to nothing.

Wired in: double-tap A when the committed contact is already within 14 deg of
the nose, so the game's choice and ours are the same object. One run: 8 kills
from 66 missiles (12% per missile) against the previous 9 from 101 (8.9%). The
absolute count is inside run variance and the efficiency gain is one sample, so
neither is claimed as decisive — it needs repeat runs.

Also documents that expository tutorials self-advance while interactive ones
stall (BASIC CONTROLS waits forever on 'Go to the box'), and that captions need
cropping across many frames because they type out.
2026-07-30 20:47:54 +00:00
claude-re
58f421d896 re-capture: read the ADVANCED CONTROLS tutorial; its moves regress the pilot, so they ship off
tutorial_capture.sh plays a tutorial and photographs what it teaches. ADVANCED
CONTROLS states three mechanics the key-config screen only named:
  B + LS            Side Roll / 180 Degree Turn / Level Off
  B + A together    face the target (snap turn)
  LT + RT together  'sets your fighter's speed to that of the target ... works
                    well when you are trying to get behind an enemy. Once
                    behind an enemy, this also helps you attack them.'

Wired both usable ones in and measured, one run each, everything else equal:
  commitment only ............ 101 missiles, 364 fire frames,  9 kills
  + match(4500) + snap-face ....  9 missiles,  28 fire frames,  0 kills
  + match(1200) + snap-face ...  57 missiles, 225 fire frames,  2 kills

So both are a net regression as applied, and both now default to OFF. Matching a
target's speed while still 5 km behind means never closing (the pilot sat at
272 u/s all run) — the tutorial scopes it to being already in the saddle. The
B+A snap turn reorients mid-pursuit and destroys the dwell commitment buys.

The code and thresholds stay so a future session can re-enable and A/B them over
SEVERAL runs; one run per config is inside this stage's spawn variance.
2026-07-30 19:50:25 +00:00
claude-re
9f41fe08e9 pilot: target commitment takes kills from 2 to 9; and the target pointer does not exist
The loop re-scored every contact every tick, so the nose chased whichever
fighter scored best that instant and aim error wandered 10-40 deg through a
pass. A missile lock is time-on-target, so constant switching is the one thing
guaranteed to prevent a kill. Commit to a contact until it dies, passes 6000,
sits >90 deg off the nose for 2.5 s, or 14 s elapse.

Same guns, same ballistics, same escort weighting, same missile cadence:
kills 0000 (five gun-only runs) -> 0002 (missiles) -> 0009 (commitment), with
101 missiles vs 98, and the largest hostile-population fall of any run
(134->97). Own hull untouched. The ACROPOLIS still ended at 76.6%, so this is
lethality, not the mission outcome.

Also records a negative result so it is not re-attempted: the selected target is
NOT a raw entity pointer. Three searches came up empty — a +-0x1400 window of
the player object, a full-RAM sweep of every entity-pointer word tapped through
each button (only thread-stack slots churn, which is frame noise), and a delta
tally over all 150 entities of the kind that found the definition pointer at
+0x130. The selection must be a handle, an index, or in a subsystem outside the
entity object.
2026-07-30 19:19:23 +00:00
claude-re
95ac545b2b docs/re: the game's own in-flight action list, off the disc
Decoded dat/GP_OPTIONS.pak (po_keys_btn* sprites): the OPTIONS key-config screen
lists every bindable in-flight action. Two of them change the plan.

'Change Target' exists — so target selection IS an input, and the earlier probe
that swept LB/X/B/A/LS/RS found nothing only because it watched the ammo
counters, which those actions do not touch.

'Padlock Mode Toggle' is a view/aim lock onto the selected target: the aim-dwell
problem solved by a game mechanic rather than by tuning a PD controller, and
presumably how a human holds a contact long enough to lock a missile.

Confirms our measured bindings (Use Nose Weapon = RB, Use Main Weapon = Y,
Accelerate/Decelerate = RT/LT, Radar Map Toggle = d-pad) and adds Special Move,
Maneuver, Resupply and Change Main Weapon (which would reach ASMissile, Power
5000). Also records that CONTROL SETTINGS carries a Control Type preset plus
yaw/pitch/roll sensitivity, so the mapping is not fixed and any stick
calibration is only valid for the save's current profile.

Tutorial menu (GP_TUTORIAL.pak) names the six lessons: BASIC CONTROLS,
HEADS-UP DISPLAY, RADAR, SUPPLY AND SPECIAL MOVES, RADIO ORDERS, ADVANCED
CONTROLS.
2026-07-30 18:54:16 +00:00
claude-re
ab8f5307ff docs/re: in-flight control mapping, and why lethality is an aim-dwell problem
Probed rather than assumed: hold each pad input and photograph the HUD ammo
counters. RB = nose gun (NOSE BM 6000->5956 in 4 s, ~11 rounds/s, HEAT rises),
Y = main mount (MAIN MPM 300->299), d-pad = tactical map overlay, and
LB/X/B/A/LS/RS move neither counter.

No target-cycle input exists. The green TARGET marker is already up with nothing
pressed, so the game selects for us and a guided missile's lock is a
time-on-target mechanic, not a button. That closes the lethality question: it is
not target choice (escort weighting), not ballistics (now from the confirmed
Shell records), and not the mapping — the steering loop simply never holds the
nose on one contact long enough to lock. Aim dwell is the next lever.

Also records two things the reimplementation needs: two weapons with separate
ammo pools and HUD counters, and a HEAT bar that fills while the gun fires
(cap and cool-down not yet measured).
2026-07-30 18:49:16 +00:00
claude-re
76f463b611 pilot: RB fires, Y is the main mount, and guided missiles produce the first kills
fire_probe.sh holds each pad input in flight and photographs the HUD ammo
counters. RB moves NOSE BM 06000 -> 05956 in 4 s (~11 rounds/s, HEAT rises);
Y moves MAIN MPM 00300 -> 00299; nothing else moves either counter. So the
control mapping is measured rather than assumed, and 'we never shoot' is dead:
we shoot and miss.

The disc data says to stop shooting: Shell_TCAF_DeltaSaber_Missile_P is Power
200, GuidanceType 5 (guided), MaximumRange 5000, versus the nose gun's Power 15
unguided — one missile is worth ~14 gun hits on a 500 HP fighter and it steers
itself. Launching them (press Y, release a tick later, >=2 s apart) produced
YOU KILLED: WARPLANES 0002 — the first non-zero kill counter of the series,
against 0000 in all five gun-only runs, with hostiles down 134 -> 104.

Still only 2 kills per 98 missiles (~2%). Likely cause: the game expects a lock
before launch and an unlocked missile is wasted. Reading the lock state out of
RAM is the next step.
2026-07-30 18:40:39 +00:00
claude-re
3f6efadf9e pilot: real ballistics from the solved Shell records — and the metric that says it did not help
Shell_TCAF_DeltaSaber_*_P: Velocity 8000, LifeTime 0.5 s, MaximumRange 4000
(self-consistent: 8000 x 0.5 = 4000), all confirmed. Two things were wrong:
lead computed flight time as d / OUR speed (400-2000 u/s, so every shot was led
4-16x too far), and FIRE_RANGE was 5000 — past where the shells expire.

Both fixed. But the HUD's own kill counters read 0000/0000 at the end of EVERY
run including the nearest-fighter baseline, so the pilot kills nothing in any
configuration and 'fraction of frames firing' was never measuring lethality.
No improvement is claimed.

One clean negative result kept: gating on the target's angular half-size alone
(2.7 deg at 2584 units) is far tighter than the steering loop can hold the nose
— firing collapsed to 1 frame in 2639. Angular size is a floor on the firing
cone, never a cap.

Next: the HUD carries a live ammo count, so holding fire and watching it settles
'we never shoot' vs 'we shoot and miss' in a single run.
2026-07-30 18:16:26 +00:00
claude-re
402985adbf pilot: escort-weighted targeting (DEFEND), plus the capital-ship keep-out it needed
While the asset is losing hull, target what is pressing IT — ranked by distance
to the asset minus credit for closing on it — instead of what is nearest to us.
Trigger and ranking both read the live hull (pos+0x154), so nothing is inferred.
DEFEND engaged 1.9 s after the asset's first hit and held 54% of a 330 s run.

It did NOT measurably save the asset: over the window two runs share, the
policies are equal to within noise (t=239: 23218 vs 23038). Two reasons, both
recorded rather than papered over: the runs are not comparable past that window
(spawn timing differs and the hostile count GREW 134->166 in one, fell 147->118
in the other), and the real bottleneck is lethality — the guns are on for 12% of
combat frames because the target is outside the 9 deg cone the rest of the time.

Also corrects a single-run claim in the previous commit: the asset is NOT
reliably safe for the first ~170 s. A second run had first damage at t=70 s. The
stage does not replay identically; only 'the loss is slow' survives.

Fixes a fatal bug the new mode exposed: DEFEND flies at the asset, which sits
inside the friendly formation, and the first escort run went hull 1500 -> DEAD in
one tick at 2026 units/s, 0.6 s from a friendly destroyer that avoidance thought
it would clear by 365 units — the ship's radius is 2000. Keep-out applied only to
hostile turrets. Every entity above BIG_RADIUS now gets its own radius + 800 of
physical keep-out with braking inside it, whatever its faction.
2026-07-30 17:49:38 +00:00
claude-re
c277e42c92 docs/re: the hull anchor is class-wide — the escort objective is scoreable live
own_state.py found hull = position + 0x154 for the PLAYER. Stage 02 is an
escort and is lost when the ACROPOLIS sinks, so scoring it needs someone
else's hull. Measured over 240 s of Stage 02: at t=0 pos+0x154 equals each
entity's own definition HP across 7 classes and 5 distinct HP values (turret
100, fighter 500, destroyer 10000, cruiser 30000, Acropolis 25000). Nothing
read above its HP; the five that read slightly below were already under fire
when the player launched. It falls with damage (780 events), goes negative at
death, and the object then leaves the heap.

UN_f101_TCAF_Acropolis: HP 25000, radius 1400, measured 25000 -> 23038 over
240 s with the attack starting only at t~170 s (~600 HP/min) — so the earlier
GAME OVER was not a fast loss, it was an undefended one.

Also: REMAINING OB reads 012 while 118 ADAN entities are alive, so it counts
objectives, not hostiles; its address is still unknown.
2026-07-30 17:21:05 +00:00
33 changed files with 2730 additions and 21 deletions

View File

@@ -0,0 +1,280 @@
//! Correlate a capture **per frame** and cross-check the frames against each
//! other — the placement is only believable if independent frames agree.
//!
//! `correlate_capture` treats one capture log as one set of draws. It is not:
//! an F10 press dumps ~14 frames with no delimiter, and `WV_ref⁻¹ · WV_p` only
//! cancels the camera within a single frame (see
//! [`sylpheed_formats::ship_capture::segment_frames`]). With a moving camera the
//! mixed-frame answer is wrong, and — worse — it is wrong *silently*.
//!
//! So: segment, correlate each frame independently, then report per part the
//! median translation and the spread across frames. A part whose spread is a
//! few units is measured; a part that swings by hundreds is not, whatever the
//! single-shot number said.
//!
//! Usage:
//! SYLPHEED_ISO=... cargo run --release --example correlate_frames -- \
//! <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--min-parts N]
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship::{is_base_part, ship_id_of};
use sylpheed_formats::ship_capture::{
correlate, parse_capture, parse_drawlog, segment_frames, PartKey,
};
use sylpheed_formats::xiso::open_iso;
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
fn median(mut v: Vec<f32>) -> f32 {
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = v.len();
if n % 2 == 1 { v[n / 2] } else { 0.5 * (v[n / 2 - 1] + v[n / 2]) }
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect();
if positional.len() < 3 {
eprintln!("usage: correlate_frames <capture.log> <Stage_SNN> <ship_id> [ref_part] [--min-parts N]");
std::process::exit(2);
}
let (log, stage, id) = (positional[0], positional[1], positional[2]);
let ref_sub = positional.get(3).map(|s| s.as_str()).unwrap_or("bdy_01");
let min_parts: usize = args
.iter()
.position(|a| a == "--min-parts")
.and_then(|i| args.get(i + 1))
.and_then(|s| s.parse().ok())
.unwrap_or(3);
let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
let text = std::fs::read_to_string(log).expect("read log");
let mut draws = parse_capture(&text);
if draws.is_empty() {
draws = parse_drawlog(&text);
}
let frames = segment_frames(&draws);
println!("{} draws → {} camera-consistent blocks", draws.len(), frames.len());
let bytes = {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
rt.block_on(async {
let mut r = open_iso(Path::new(&iso)).await.unwrap();
r.read_file(&format!("hidden/resource3d/{stage}.xpr")).await.unwrap()
})
};
let names = xbg7_resource_names(&bytes);
let base_parts: Vec<String> = names
.iter()
.filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str()))
.cloned()
.collect();
let mut want: HashSet<String> = base_parts.iter().cloned().collect();
for p in &base_parts {
for suf in ["_m", "_l", "_d"] {
let c = format!("{p}{suf}");
if names.contains(&c) {
want.insert(c);
}
}
}
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
let positions_of = |name: &str| -> Option<Vec<[f32; 3]>> {
let m = models.iter().find(|m| m.name == name)?;
Some(m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect())
};
// part -> [T per frame], and how many frames placed it at all.
let mut samples: BTreeMap<String, Vec<[f32; 3]>> = BTreeMap::new();
let mut rots: BTreeMap<String, Vec<[[f32; 3]; 3]>> = BTreeMap::new();
let mut used_frames = 0usize;
for (fi, fr) in frames.iter().enumerate() {
let mut keys: Vec<PartKey> = Vec::new();
for part in &base_parts {
let variants =
[part.clone(), format!("{part}_m"), format!("{part}_l"), format!("{part}_d")];
let union: Vec<[f32; 3]> =
variants.iter().filter_map(|v| positions_of(v)).flatten().collect();
for cand in &variants {
if let Some(pos) = positions_of(cand) {
let vcount = pos.len() as u32;
if fr.iter().any(|d| d.vcount == vcount) {
keys.push(PartKey { part: part.clone(), vcount, ref_pos: union.clone() });
}
}
}
}
let Some(ship) = correlate(id, fr, &keys, ref_sub) else { continue };
if ship.parts.len() < min_parts {
continue;
}
// Placements are expressed in the REFERENCE part's frame, so blocks that
// fell back to a different reference (because the requested one was not
// drawn in that block) are in a different coordinate system entirely.
// Averaging them together is what makes an otherwise clean result look
// like it disagrees by exactly the distance between the two references.
if !ship.reference.contains(ref_sub) {
println!(" block {fi:2}: skipped — reference fell back to {}", ship.reference);
continue;
}
used_frames += 1;
println!(
" block {fi:2} ({:4} draws): ref={} parts={}",
fr.len(),
ship.reference,
ship.parts.len()
);
for p in &ship.parts {
samples.entry(p.part.clone()).or_default().push(p.t);
rots.entry(p.part.clone()).or_default().push(p.m);
}
}
if used_frames == 0 {
println!("\nno block placed {min_parts}+ parts — the ship is not drawn close enough");
return;
}
// Aggregate by CONSENSUS, not by average. A stage holds several ships of the
// same class, they share vertex buffers, and a block can therefore contain
// one instance's full-LOD part next to another instance's `_m` copy — two
// different buffers, so nothing splits them, and the recovered translation
// then belongs to whichever instance the correlator happened to pick. Those
// are outliers by thousands of units, so a mean or a median over all blocks
// is meaningless; the largest cluster of blocks that agree with each other
// is the placement, and the rest are honestly reported as other instances.
const TOL: f32 = 25.0; // float noise in the WV products, measured ≤0.4
let cluster = |ts: &Vec<[f32; 3]>| -> (Vec<usize>, usize) {
let mut best: Vec<usize> = Vec::new();
for seed in ts {
let near: Vec<usize> = ts
.iter()
.enumerate()
.filter(|(_, t)| (0..3).all(|i| (t[i] - seed[i]).abs() < TOL))
.map(|(i, _)| i)
.collect();
if near.len() > best.len() {
best = near;
}
}
let out = ts.len() - best.len();
(best, out)
};
println!("\nacross {used_frames} blocks — consensus T (largest agreeing cluster):");
let mut agree = 0usize;
let mut consensus: BTreeMap<String, ([f32; 3], [[f32; 3]; 3])> = BTreeMap::new();
for (part, ts) in &samples {
let (cl_idx, outliers) = cluster(ts);
let cl: Vec<[f32; 3]> = cl_idx.iter().map(|&i| ts[i]).collect();
let med = [
median(cl.iter().map(|t| t[0]).collect()),
median(cl.iter().map(|t| t[1]).collect()),
median(cl.iter().map(|t| t[2]).collect()),
];
let spread: Vec<f32> = (0..3)
.map(|a| {
let v: Vec<f32> = cl.iter().map(|t| t[a]).collect();
v.iter().cloned().fold(f32::MIN, f32::max) - v.iter().cloned().fold(f32::MAX, f32::min)
})
.collect();
let verdict = if cl.len() < 2 {
"single block — unverified"
} else {
agree += 1;
"AGREES"
};
// How much the ROTATION varies between blocks that agree on position.
// A part bolted to the hull reads 0 here; a part that is articulating
// (turret aiming, engine gimballing) does not — which is what separates
// "the assembler has the rotation wrong" from "the part moved".
let all_ms = rots.get(part).cloned().unwrap_or_default();
let ms: Vec<[[f32; 3]; 3]> =
cl_idx.iter().filter_map(|&i| all_ms.get(i).copied()).collect();
// Keep a rotation from INSIDE the cluster: the first sample overall can
// belong to another instance, and diffing static against that reads as a
// rotation error that is really an instance mix-up.
consensus.insert(
part.clone(),
(med, ms.first().copied().unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])),
);
let rot_var = ms
.iter()
.flat_map(|a| ms.iter().map(move |b| (a, b)))
.map(|(a, b)| {
(0..3)
.flat_map(|i| (0..3).map(move |j| (i, j)))
.map(|(i, j)| (a[i][j] - b[i][j]).abs())
.fold(0.0f32, f32::max)
})
.fold(0.0f32, f32::max);
println!(
" {part:18} {:2}/{:2} blocks T=[{:9.1}{:9.1}{:9.1}] spread=[{:6.2}{:6.2}{:6.2}] rotVar={rot_var:5.3} {verdict}{}",
cl.len(), ts.len(), med[0], med[1], med[2], spread[0], spread[1], spread[2],
if outliers > 0 { format!(" (+{outliers} other-instance)") } else { String::new() }
);
}
println!("\n{agree}/{} parts reproduce across blocks", samples.len());
// `--static <Stage_SNN.xpr>`: diff the offline assembler against this
// ground truth. Static placements are in ship space, so both sides are
// re-expressed in the reference part's frame before comparing — and the
// rotation is compared too, because "wrong orientation" is half of the
// reported viewer symptom and a translation-only check cannot see it.
let Some(si) = args.iter().position(|a| a == "--static") else { return };
let Some(spath) = args.get(si + 1) else { return };
let sbytes = std::fs::read(spath).expect("read stage container");
// `include_external = true` — the engine cluster, the bridge and cross-id
// turrets live in SEPARATE composites (`e_rou_e106_eng`, 3 nodes) that the
// primary-composite pass does not reach. With `false` an e106 assembles as
// 5 parts and the runtime capture's bridge/nacelles read as "not produced by
// assemble_ship", which is a property of the caller, not of the format.
let scene = sylpheed_formats::ship::assemble_ship(&sbytes, id, true);
let Some(sref) = scene.iter().find(|p| p.resource.contains(ref_sub)) else {
println!("\nstatic: no part matching '{ref_sub}' — cannot align frames");
return;
};
// The reference is placed axis-aligned in every ship seen so far; if that
// ever stops holding, the rotation would have to be unwound here too.
println!("\nstatic vs runtime (both relative to {}):", sref.resource);
let mut worst_t = 0.0f32;
let mut worst_r = 0.0f32;
for (part, (med, rm)) in &consensus {
let (med, rm) = (*med, *rm);
// A part may be instanced (mirrored twins share a resource name); take
// the static copy that lands nearest the captured one.
let cands: Vec<&sylpheed_formats::mesh::ScenePart> =
scene.iter().filter(|p| &p.resource == part).collect();
if cands.is_empty() {
println!(" {part:18} — not produced by assemble_ship");
continue;
}
let rel = |p: &sylpheed_formats::mesh::ScenePart| {
[p.t[0] - sref.t[0], p.t[1] - sref.t[1], p.t[2] - sref.t[2]]
};
let best = cands
.iter()
.min_by(|a, b| {
let d = |p: &sylpheed_formats::mesh::ScenePart| {
let r = rel(p);
(0..3).map(|i| (r[i] - med[i]).powi(2)).sum::<f32>()
};
d(a).partial_cmp(&d(b)).unwrap()
})
.unwrap();
let r = rel(best);
let dt: Vec<f32> = (0..3).map(|i| r[i] - med[i]).collect();
let dtm = dt.iter().map(|v| v.abs()).fold(0.0f32, f32::max);
let drm = (0..3)
.flat_map(|i| (0..3).map(move |j| (i, j)))
.map(|(i, j)| (best.m[i][j] - rm[i][j]).abs())
.fold(0.0f32, f32::max);
worst_t = worst_t.max(dtm);
worst_r = worst_r.max(drm);
let mark = if dtm < 1.0 && drm < 0.02 { "MATCH" } else { "DIFFERS" };
println!(
" {part:18} static=[{:9.1}{:9.1}{:9.1}] dT={dtm:7.2} dR={drm:6.3} {mark}",
r[0], r[1], r[2]
);
}
println!("\nworst dT={worst_t:.2} worst dR={worst_r:.3} ({} static parts, {} captured)",
scene.len(), samples.len());
}

View File

@@ -0,0 +1,156 @@
//! Invert the capture↔part match: instead of asking, per ship part, "is there a
//! draw with this vertex count?", ask of the **capture's** biggest draws "which
//! decoded resource in this stage container has that vertex count?".
//!
//! This is the diagnostic for the 2026-07-31 negative result (Stage_S02 capture,
//! zero parts correlated). It separates three hypotheses:
//! 1. LOD/variant vcount not covered by the correlator's variant list
//! → the big draws DO map to named resources, just not to the `_m`/`_l`/`_d`
//! set the correlator tries;
//! 2. position validation over-rejects
//! → the vcounts match the very parts we asked for (so the vcount key was
//! fine and the rejection happened later);
//! 3. a different draw path (instanced/batched/merged buffers)
//! → the big draws match NO resource in the container at all.
//!
//! Usage:
//! SYLPHEED_ISO=... cargo run --release --example invert_capture -- \
//! <capture.log> <Stage_SNN> [top_n] [--all]
//! `--all` lists every capture vcount, not just the `top_n` (default 40) largest.
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
use sylpheed_formats::xiso::open_iso;
use std::collections::{HashMap, HashSet};
use std::path::Path;
fn main() {
let args: Vec<String> = std::env::args().collect();
let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect();
let all = args.iter().any(|a| a == "--all");
if positional.len() < 2 {
eprintln!("usage: invert_capture <capture.log> <Stage_SNN> [top_n] [--all]");
std::process::exit(2);
}
let (log, stage) = (positional[0], positional[1]);
let top_n: usize = positional.get(2).and_then(|s| s.parse().ok()).unwrap_or(40);
let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
let text = std::fs::read_to_string(log).expect("read log");
let mut draws = parse_capture(&text);
if draws.is_empty() {
draws = parse_drawlog(&text);
println!("parsed {} draws (draw-logger format)", draws.len());
} else {
println!("parsed {} draws (F10 capture format)", draws.len());
}
// Decode EVERY geometry resource in the stage container, not just one ship's.
let bytes = {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
rt.block_on(async {
let mut r = open_iso(Path::new(&iso)).await.unwrap();
r.read_file(&format!("hidden/resource3d/{stage}.xpr")).await.unwrap()
})
};
let names = xbg7_resource_names(&bytes);
println!("{stage}.xpr: {} XBG7 resources", names.len());
let want: HashSet<String> = names.iter().cloned().collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
println!("decoded {} models", models.len());
// vcount -> resource names with that many vertices.
let mut by_vcount: HashMap<u32, Vec<String>> = HashMap::new();
for m in &models {
let v: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
by_vcount.entry(v as u32).or_default().push(m.name.clone());
}
// Per-submesh counts too: a draw may be one sub-mesh of a multi-mesh resource.
let mut by_sub_vcount: HashMap<u32, Vec<String>> = HashMap::new();
for m in &models {
for (i, s) in m.meshes.iter().enumerate() {
if m.meshes.len() > 1 {
by_sub_vcount
.entry(s.positions.len() as u32)
.or_default()
.push(format!("{}#{i}", m.name));
}
}
}
// Capture vcounts, de-duped by (vbase, vcount) so a re-drawn part counts once
// per distinct buffer.
let mut draw_count: HashMap<u32, usize> = HashMap::new();
let mut bufs: HashMap<u32, HashSet<u32>> = HashMap::new();
for d in &draws {
*draw_count.entry(d.vcount).or_default() += 1;
bufs.entry(d.vcount).or_default().insert(d.vbase);
}
let mut vcounts: Vec<u32> = draw_count.keys().copied().collect();
vcounts.sort_unstable_by(|a, b| b.cmp(a));
let matched_draws: usize = draws
.iter()
.filter(|d| by_vcount.contains_key(&d.vcount) || by_sub_vcount.contains_key(&d.vcount))
.count();
println!(
"\n{} distinct vcounts; {}/{} draws have a vcount present in {stage}.xpr ({:.1}%)",
vcounts.len(),
matched_draws,
draws.len(),
100.0 * matched_draws as f64 / draws.len().max(1) as f64
);
let shown = if all { vcounts.len() } else { top_n.min(vcounts.len()) };
println!("\nlargest capture vcounts (draws / distinct vbufs) → matching resources:");
for &v in vcounts.iter().take(shown) {
let n = draw_count[&v];
let b = bufs[&v].len();
let mut hit: Vec<String> = by_vcount.get(&v).cloned().unwrap_or_default();
let sub: Vec<String> = by_sub_vcount.get(&v).cloned().unwrap_or_default();
hit.extend(sub.into_iter().map(|s| format!("{s} (sub)")));
let label = if hit.is_empty() {
"— no resource".to_string()
} else {
let mut h = hit.clone();
h.sort();
h.truncate(6);
format!("{}{}", h.join(", "), if hit.len() > 6 { ", …" } else { "" })
};
println!(" vcount {v:6} draws {n:4} bufs {b:3} {label}");
}
// `--ship <id>`: every resource of one ship family, with its vertex count and
// whether the capture drew it — this is what shows an all-`_l` (far-LOD) frame.
if let Some(i) = args.iter().position(|a| a == "--ship") {
if let Some(id) = args.get(i + 1) {
let mut rows: Vec<(String, u32, usize)> = models
.iter()
.filter(|m| m.name.contains(id.as_str()))
.map(|m| {
let v = m.meshes.iter().map(|s| s.positions.len()).sum::<usize>() as u32;
(m.name.clone(), v, draw_count.get(&v).copied().unwrap_or(0))
})
.collect();
rows.sort_by(|a, b| a.0.cmp(&b.0));
let drawn = rows.iter().filter(|r| r.2 > 0).count();
println!("\n{id} resources in {stage}.xpr ({drawn}/{} with a drawn vcount):", rows.len());
for (name, v, n) in rows {
println!(" {name:28} vcount {v:6} {}", if n > 0 { format!("DRAWN ×{n}") } else { "".into() });
}
}
}
// The other direction, for orientation: the container's biggest resources and
// whether the capture ever drew that many vertices.
let mut sizes: Vec<(u32, String)> = models
.iter()
.map(|m| (m.meshes.iter().map(|s| s.positions.len()).sum::<usize>() as u32, m.name.clone()))
.collect();
sizes.sort_unstable_by(|a, b| b.0.cmp(&a.0));
println!("\nlargest resources in {stage}.xpr → drawn in the capture?");
for (v, name) in sizes.iter().take(top_n.min(sizes.len())) {
let n = draw_count.get(v).copied().unwrap_or(0);
println!(" {name:28} vcount {v:6} {}", if n > 0 { format!("DRAWN ×{n}") } else { "not drawn".into() });
}
}

View File

@@ -0,0 +1,98 @@
//! Global "which resource has N vertices?" index over every `.xpr` container in
//! an extracted `resource3d` directory, answered for the vcounts a capture log
//! actually drew.
//!
//! Companion to `invert_capture`: that one asks the question inside a single
//! stage container, this one asks it across ALL containers — so a draw whose
//! geometry lives in `Common.xpr`, a `rou_*` weapon pack or a `BG_*` backdrop is
//! still identified instead of coming back "no resource".
//!
//! Usage:
//! cargo run --release --example vcount_index -- <resource3d_dir> <capture.log> [top_n]
//! cargo run --release --example vcount_index -- <resource3d_dir> --vcounts 10891,6000
use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship_capture::{parse_capture, parse_drawlog};
use std::collections::{HashMap, HashSet};
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("usage: vcount_index <resource3d_dir> <capture.log|--vcounts a,b,c> [top_n]");
std::process::exit(2);
}
let dir = &args[1];
// Which vertex counts are we asking about, and how often was each drawn?
let mut draw_count: HashMap<u32, usize> = HashMap::new();
let mut bufs: HashMap<u32, HashSet<u32>> = HashMap::new();
if args[2] == "--vcounts" {
for v in args[3].split(',').filter_map(|s| s.trim().parse::<u32>().ok()) {
draw_count.insert(v, 0);
}
} else {
let text = std::fs::read_to_string(&args[2]).expect("read log");
let mut draws = parse_capture(&text);
if draws.is_empty() {
draws = parse_drawlog(&text);
}
eprintln!("parsed {} draws", draws.len());
for d in &draws {
*draw_count.entry(d.vcount).or_default() += 1;
bufs.entry(d.vcount).or_default().insert(d.vbase);
}
}
let top_n: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(usize::MAX);
// Decode every container once; keep only the vcount → names mapping.
let mut by_vcount: HashMap<u32, Vec<String>> = HashMap::new();
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
.expect("read dir")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|e| e == "xpr"))
.collect();
files.sort();
let mut total_res = 0usize;
for f in &files {
let Ok(bytes) = std::fs::read(f) else { continue };
let names = xbg7_resource_names(&bytes);
if names.is_empty() {
continue;
}
let want: HashSet<String> = names.iter().cloned().collect();
let models = Xbg7Model::models_named(&bytes, &want, &|| false);
let container = f.file_stem().unwrap().to_string_lossy().to_string();
for m in &models {
total_res += 1;
let whole: usize = m.meshes.iter().map(|s| s.positions.len()).sum();
by_vcount.entry(whole as u32).or_default().push(format!("{container}:{}", m.name));
if m.meshes.len() > 1 {
for (i, s) in m.meshes.iter().enumerate() {
by_vcount
.entry(s.positions.len() as u32)
.or_default()
.push(format!("{container}:{}#{i}", m.name));
}
}
}
}
eprintln!("indexed {} resources from {} containers", total_res, files.len());
let mut vcounts: Vec<u32> = draw_count.keys().copied().collect();
vcounts.sort_unstable_by(|a, b| b.cmp(a));
println!("\nvcount draws bufs resources anywhere in resource3d/");
for v in vcounts.into_iter().take(top_n) {
let n = draw_count[&v];
let b = bufs.get(&v).map(|s| s.len()).unwrap_or(0);
let hit = by_vcount.get(&v).cloned().unwrap_or_default();
let label = if hit.is_empty() {
"— NONE".to_string()
} else {
let mut h = hit.clone();
h.sort();
let shown = h.len().min(8);
format!("{}{}", h[..shown].join(", "), if h.len() > shown { format!(", … ({} total)", h.len()) } else { String::new() })
};
println!("{v:6} {n:5} {b:4} {label}");
}
}

View File

@@ -349,9 +349,21 @@ pub fn assemble_ship(bytes: &[u8], id: &str, include_external: bool) -> Vec<Scen
let Some((_, gncat)) = CATS.iter().find(|(c, _)| *c == cat) else {
continue;
};
if let Some(frame) =
frames.iter().find(|f| f.resource.contains(gncat) && trailing_index(&f.resource) == idx)
{
// An index-less part (`e105_brg`, against a `GN_Bridge_01` frame) used to
// compare `"01" == ""` and fall through, so the bridge was silently
// dropped from the assembly while the game draws it — caught by a runtime
// capture, which places `e105_brg` at the `GN_Bridge_01` frame exactly.
// With no index to match on, take the lowest-numbered frame of the
// category; an indexed part still matches its own index only.
let mut cands: Vec<&ScenePart> = frames
.iter()
.filter(|f| {
f.resource.contains(gncat)
&& (idx.is_empty() || trailing_index(&f.resource) == idx)
})
.collect();
cands.sort_by_key(|f| trailing_index(&f.resource).parse::<u32>().unwrap_or(u32::MAX));
if let Some(frame) = cands.first().copied() {
placed.push(ScenePart { resource: part.clone(), m: frame.m, t: frame.t, s: frame.s });
placed_res.insert(part.clone());
}

View File

@@ -183,6 +183,41 @@ pub const SHIP_VS_HASH: &str = "0xC7F781F4C1D58054";
/// with `vs=`[`SHIP_VS_HASH`] are kept (the ship shader), so HUD/skybox draws are
/// ignored. (The player fighter shares ONE buffer across its fin draws and so
/// collapses to a single entry here — fine, capital ships are the target.)
/// Split a capture into blocks that are guaranteed to share one camera.
///
/// **Why this is not optional.** One F10 press dumps a flat list of draws with
/// no frame delimiter, and it spans ~14 frames (the same vertex buffer recurs
/// that many times). The placement math is `WV_ref⁻¹ · WV_p`, which cancels the
/// camera **only when both draws come from the same frame** — mix frames and
/// the residual is the camera's motion between them. With a static ship and a
/// static camera that error is invisible, which is how the single validated
/// `e106` capture passed; closing on a cruiser at ~760 u/s it is hundreds of
/// units, and two frames of the same ship then disagree about where its parts
/// are (measured 2026-08-10: `f105_bdy_02` at `[488, 736, -620]` vs
/// `[0, 0, -1090]`).
///
/// The split rule is the recurrence itself: a vertex buffer that appears again
/// starts a new block. Splitting too eagerly is harmless (a block is still one
/// camera, just with fewer parts in it) and it separates two instances of the
/// same class as a bonus; failing to split is what corrupts the result.
pub fn segment_frames(draws: &[CapturedDraw]) -> Vec<Vec<CapturedDraw>> {
let mut out: Vec<Vec<CapturedDraw>> = Vec::new();
let mut cur: Vec<CapturedDraw> = Vec::new();
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
for d in draws {
if !seen.insert(d.vbase) {
out.push(std::mem::take(&mut cur));
seen.clear();
seen.insert(d.vbase);
}
cur.push(d.clone());
}
if !cur.is_empty() {
out.push(cur);
}
out
}
pub fn parse_drawlog(text: &str) -> Vec<CapturedDraw> {
let mut out = Vec::new();
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();

View File

@@ -8,7 +8,21 @@ unknown, what evidence exists, and what the first step would be. Move an item in
## Capital ships assemble wrong in the viewer
**Reported:** 2026-07-30, by the user. **Status:** ❔ open, not investigated.
**Reported:** 2026-07-30, by the user. **Status:** 🔎 **diagnosed 2026-08-10 — the
format layer is exonerated.** Runtime captures of three classes (`f105`, `e105`,
`e106`) at controlled range reproduce `assemble_ship` to ≤0.43 units in translation
and to 0.000 in rotation for every part that does not move; see
[`ship-placement-capture-generalisation.md`](ship-placement-capture-generalisation.md)
§4. So look at **the viewer**: first that it passes `include_external = true`
(`iso_loader.rs:4012` — with `false` an e106 loses its bridge and both nacelles,
5 parts instead of 11), then its own transform stack.
One real format-side bug was found on the way and is **fixed**: index-less parts
(`e105_brg`) never matched their `GN_Bridge_01` hardpoint, so 34 (stage, ship) entries
`e102`, `e104`, `e105` across Stages 0229 — assembled without a bridge. The other
apparent exception (`e105_eng_01` rotation) was an aggregation artefact and is 0.000.
The original report and its reasoning follow.
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

View File

@@ -22,7 +22,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
| XBG7 mesh | 🟡/❔ | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | weapons/props: declaration-driven variable stride (36 models), GPU-confirmed. **Stage containers: 5662 sub-models across 22 stages** via content-anchored grouped pools (`stage_models`). Quantized hero bodies (DeltaSaber `f004`) still declined |
| Capital-ship part placement | 🟡 | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | hull placement static-exact; external parts approximate statically. **Runtime capture** (Canary F10 → VS-constant WorldView) gives ground truth — validated on `e106` destroyer; not yet baked into the viewer |
| Weapon fields defaulted on disc | ✅ | [runtime struct](structures/weapon-struct-runtime.md) · [DATA SHEET route](weapon-datasheet-runtime.md) | **Solved.** Canary maps guest RAM into `/dev/shm`, so the parsed `Weapon`/`Shell` objects are readable live; their layout is solved against disc ground truth (zero contradictions over 100+ records). All 126 weapons, exact numbers, no story progress needed — [4 393 values](captures/weapon-runtime-fields.csv) the disc does not carry. Supersedes the letter-bucket limit of the DATA SHEET route, which now serves as the independent cross-check |
| Unit (craft/vessel) fields defaulted on disc | ✅/🟡 | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — [values](captures/unit-runtime-fields.csv) |
| Unit (craft/vessel) fields defaulted on disc | ✅/🟡 | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — but a defaulted field is **not** a global constant: `Size_Y` provably inherits `Size_X` (7 independent units, 6 distinct values), and three more sibling rules are recorded ❔, recovering 65 values in units never visited — [values](captures/unit-runtime-fields.csv) |
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` (screen-level draw order) not yet decoded |
## Runtime / dynamic-capture technique
@@ -32,6 +32,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 |
| Mission / escort state, every entity's hull | ✅ | [`tools/re-capture/mission_state.py`](../../tools/re-capture/mission_state.py) · [escort state](mission-escort-state.md) | `hull = position + 0x154` is a property of the **entity class**, not of the player object: at t=0 it equals each entity's own definition `HP` across 7 classes and 5 distinct HP values (turret 100, fighter 500, destroyer 10000, cruiser 30000, **ACROPOLIS 25000**), falls under fire (780 damage events in 240 s), goes negative at death, and the object then leaves the heap. So an escort objective is scoreable live — `UN_f101_TCAF_Acropolis` measured at 25000 → 23038 over 240 s, attack starting only at t≈170 s. `REMAINING OB` counts objectives, not hostiles (012 on the HUD vs 118 live ADAN); its address is still ❔ |
| In-flight control mapping | ✅/🟡 | [`tools/re-capture/fire_probe.sh`](../../tools/re-capture/fire_probe.sh) · [controls](flight-controls-runtime.md) | Measured by holding each pad input and photographing the HUD ammo counters: **`RB` = nose gun** (6000→5956 in 4 s, ~11 rounds/s, HEAT rises), **`Y` = main mount** (missiles, 300→299), d-pad = **tactical map** overlay, nothing else moves a counter. No target-cycle input exists — the `TARGET` marker is present with nothing pressed, so targeting is automatic and a missile lock is **time-on-target**. That, not target choice or ballistics, is what caps lethality at 2 kills per 98 missiles |
| Input → dynamics calibration | ✅ | [`tools/re-capture/ctrl_probe.py`](../../tools/re-capture/ctrl_probe.py) · [`binq.py`](../../tools/re-capture/binq.py) | Hold each pad input in turn and measure the craft's speed as displacement/s of its own position triple — no speed field needed first. Settled the throttle: **`RT` accelerates, `LT` brakes, and the setting persists** (488 → 1510 → 174 units/s), overturning an earlier field-scan conclusion |
## Functions / code paths

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 KiB

View File

@@ -0,0 +1,104 @@
# In-flight control mapping — measured, not assumed
**Status:** ✅ for the weapon bindings (ammo counters move), 🟡 for the rest (HUD
observation only). Probes: `tools/re-capture/fire_probe.sh` (hold each input, photograph
the ammo counters) and `lock_probe.sh` (tap each, watch the reticle). Stage 02, in flight.
Evidence: [`captures/fire-probe-ammo-counters.png`](captures/fire-probe-ammo-counters.png).
| input | effect | confidence |
|---|---|---|
| **`RB`** | **Nose gun.** `NOSE BM` 06000 → 05956 in a 4 s hold ≈ **11 rounds/s**; `HEAT` bar rises | ✅ |
| **`Y`** | **Main mount** (missiles). `MAIN MPM` 00300 → 00299 per tap | ✅ |
| **`RT` / `LT`** | Throttle up / brake, a *persistent* setting (488 → 1510 → 174 u/s) | ✅ (earlier session) |
| **d-pad** | **Tactical map** overlay (grid with contact blips) — not target cycling | 🟡 |
| `LB`, `X`, `B`, `A`, `LS`, `RS` | No change to either ammo counter | ✅ (as "not a weapon") |
## ~~Targeting appears to be automatic~~ — WRONG, corrected below
> **Superseded.** This section concluded targeting was automatic because no input
> cycled a target. It is wrong: the HUD tutorial states target select is **Ⓐ pressed
> twice**, and every sweep here tapped once. Kept because the reasoning is a useful
> warning — a probe that never performs the action will "prove" the action does not
> exist. The rest of the section's measurements stand.
No *single* press cycled a target. The green `TARGET` marker is already present in
idle frames with nothing pressed, which I read as the game selecting for us.
That fits the measurements end to end:
- the guns fire fine (11 rounds/s) but the kill counters read `0000` after five gun-only
runs → **we shoot and miss**;
- guided missiles (`Missile_P`, Power 200, `GuidanceType` 5) got the first kills,
`WARPLANES 0002`, but only **2 per 98 launches**;
- the pilot's own log shows aim error wandering between ~10° and ~40° for most of a
pass.
At the time I concluded the bottleneck was aim dwell. Partly right — target
**commitment** did take kills 2 → 9 — but the larger cause was simply that no target was
ever selected, so the guided missiles had nothing to guide to.
## The game's own action list (from the OPTIONS key-config screen)
Decoded from `dat/GP_OPTIONS.pak` (`po_keys_btn*` sprites) — this is the authoritative
set of bindable in-flight actions, straight off the disc, no probing required:
| # | Action | Our mapping |
|---|---|---|
| 1 | Aircraft Control | LX/LY ✅ |
| 2 | View Point Control | RX/RY (unused by the pilot) |
| 3 / 4 | Left / Right Yaw Control | — (separate from pitch/roll!) |
| 5 / 6 | Accelerate / Decelerate | `RT` / `LT` ✅ |
| 7 | **Use Main Weapon** | `Y` ✅ |
| 8 | **Use Nose Weapon** | `RB` ✅ |
| 9 | Special Move | ❔ |
| 10 | Maneuver | ❔ |
| 11 | Resupply | ❔ |
| 12 | **Change Target** | ❔ — **this is the target-select the loop needs** |
| 13 | Change Main Weapon | ❔ (would reach `ASMissile`, Power 5000) |
| 14 | **Padlock Mode Toggle** | ❔ — **the aim-dwell mechanism** |
| 15 | Radar Map Toggle | d-pad 🟡 (matches the observed map overlay) |
Two entries change the plan outright:
- **`Change Target` exists**, so target selection *is* an input after all. The earlier
probe swept `LB/X/B/A/LS/RS` and found no ammo change — consistent with those being
exactly these non-weapon actions. The probe simply watched the wrong indicator.
- **`Padlock Mode Toggle`** is a view/aim lock onto the selected target. That is the
aim-dwell problem solved *by a game mechanic* rather than by tuning a PD controller —
and it is why a human player can hold a contact long enough to lock a missile.
Also note `CONTROL SETTINGS` carries a **`Control Type`** preset plus **Yaw / Pitch /
Roll Sensitivity** and a separate **`Throttle`** option: the mapping is not fixed, and
the craft's response to a given stick deflection is configurable. Any calibration done
against one profile (e.g. the `ctrl_probe.py` throttle numbers) is only valid for the
save's current settings.
## What the tutorials state outright
`tutorial_capture.sh <index> <secs> <tag>` plays one lesson and photographs it. Captions
use a typewriter effect, so crop `900x125+160+40` from many frames to read a full
sentence. Lessons that require the player to *do* something stall (BASIC CONTROLS sits
on "Go to the box on your screen" forever with nobody flying); the expository ones run
on their own.
- **HEADS-UP DISPLAY (index 1):** *"Enemies are displayed with **red markers** and allies
with **blue markers**." · "Targeting an enemy displays an Armor Gauge…" ·* **"Press Ⓐ
twice to target the enemy closest to the center of the screen."**
- **ADVANCED CONTROLS (index 5):** `B`+`LS` = Side Roll / 180 Degree Turn / Level Off ·
`B`+`A` together = face the target · `LT`+`RT` together = *"sets your fighter's speed
to that of the target… works well when you are trying to get behind an enemy. Once
behind an enemy, this also helps you attack them."*
**`Change Target` is Ⓐ pressed TWICE** — a double tap. That is why every button sweep in
this document found nothing and why I wrongly concluded targeting was automatic: each
sweep tapped once. It also explains the missiles — `GuidanceType 5` needs the *game's*
selection, and the loop had never made one, so 98 launches guided to nothing.
## Notes for the reimplementation
- Two independent weapons with separate ammo pools and separate HUD counters:
`NOSE BM` (gun, 6000) and `MAIN MPM` (missiles, 300).
- The gun has a **HEAT** bar that fills while firing — a sustained-fire limit the
reimplementation needs; its cap and cool-down rate are not measured yet.
- The tactical map is a full-screen overlay bound to the d-pad and does not pause flight
(the craft kept taking fire with it open).

View File

@@ -0,0 +1,225 @@
# Escort / mission state from guest RAM — every entity's hull
**Status:** ✅ CONFIRMED (2026-07-30). Capture: `tools/re-capture/mission_state.py`,
session `tools/re-capture/escort_session.sh`, Stage 02 from save slot 01, 240 s of
flight, 240 samples at 1 Hz → [`captures/mission-state-stage02.jsonl`](captures/mission-state-stage02.jsonl).
Screenshot evidence: [`captures/escort-stage02-hud.png`](captures/escort-stage02-hud.png).
## The question
`own_state.py` found the **player's** hull by anchoring on a solved definition
field — an undamaged craft carries its definition's `HP` (+0x054), so the live
counter is the copy of that number that falls. Result: `hull = position + 0x154`
([autopilot](autopilot-memory-driven.md)).
Stage 02 is an **escort**, and it is lost when the ACROPOLIS sinks, not when the
player dies: a 240 s run hit `GAME OVER` with our own hull at 1500/1500. Scoring
that objective needs *someone else's* hull. So: is `+0x154` a property of the
**entity class**, or of the player object?
## Finding — it is class-wide
At the first sample of the run, before this session's fighting had touched them,
`pos+0x154` equals the entity's own definition `HP` across **seven classes and
five distinct HP values**:
| Class | radius | definition `HP` | `pos+0x154` at t=0 |
|---|---|---|---|
| `UN_e007_ADAN_Turret` | 22 | 100 | 100.0 (all 60 instances) |
| `UN_e010_ADAN_Attacker_S` | 100 | 500 | 500.0 (all 19) |
| `UN_f106_TCAF_Destroyer` | 2000 | 10000 | 10000.0 |
| `UN_e106_ADAN_Destroyer` | 2100 | 10000 | 10000.0 |
| `UN_f105_TCAF_Cruiser` | 3800 | 30000 | 30000.0 |
| `UN_e105_ADAN_Cruiser` | 3800 | 30000 | 30000.0 |
| **`UN_f101_TCAF_Acropolis`** | 1400 | **25000** | **25000.0** |
Measured directly by `mission_state.py scan` at the start of three separate runs:
**146/150, 147/150 and 147/150 entities** hold exactly their definition's `HP` at
`pos+0x154`. The handful that do not sit *slightly below* it (9800/10000,
29933.3/30000, 9725/10000, …) — the battle is already in progress when the player
launches, so those ships have already been shot at. **Nothing read above its `HP`,
and nothing read an unrelated number**, which is what a coincidental offset would
produce.
The value behaves like a live counter, not a copy of the definition:
- it **falls under fire** — 780 distinct damage events were logged across the run;
- it **goes negative at death** and the entity then disappears from the heap
(`UN_f106_TCAF_Destroyer``-30.0` of 10000, another → `-0.0`, a third GONE);
- the drops match what the HUD draws — the screenshot shows the ACROPOLIS and the
destroyer *CHARON* each with their own health bar, CHARON's already red.
So **`hull = position + 0x154` for every entity**, and the escort objective is
directly scoreable: read the protected ship's hull, normalise by its definition's
`HP`, done. No new anchor, no value scan.
## The escort asset, measured
`UN_f101_TCAF_Acropolis`, one instance, `HP` 25000, collision radius 1400.
Its hull over the 240 s run (pilot chasing the nearest hostile fighter, the
current `pilot.py` behaviour):
```
t= 0..150s 25000.0 untouched
t= 180.1s 24779.5
t= 210.1s 24149.5
t= 239.1s 23038.2 -1961.8 total, ≈ -600 HP/min once it starts
```
**⚠️ Onset is NOT a fixed schedule — corrected by a later run.** From this run alone
it looked like the asset is safe for the first ~170 s. A second run put the first
damage at **t = 70 s**, and its hostile population *grew* (134 → 166 ADAN) where this
one's shrank (147 → 118). So the stage is not replaying identically, and "the asset
is untouched early" is a property of one run, not of Stage 02. What survives the
second run is the weaker, still useful claim: **the loss is slow** — a few hundred to
~1400 HP/min against 25000, so tens of minutes to sink. The earlier `GAME OVER`
therefore was not a fast loss; it was an undefended one.
## Also captured
- Hostile population fell 147 → 118 over the run (the pilot fired on 435 of 1913
engage frames; most of the remainder it was manoeuvring with the target outside
the 9° firing cone).
- Two friendly destroyers were lost while the pilot was elsewhere.
- The HUD's `REMAINING OB` read **012** at t≈240 s while 118 ADAN entities were
alive, so that counter is **objectives, not hostiles** — its RAM address is still
unknown (❔ open).
## Escort-weighted targeting — implemented, and what it did NOT fix
`pilot.py` gained a **DEFEND** mode (2026-07-30): while the asset is losing hull,
target the hostiles pressing *it* — ranked by distance to the asset minus credit for
closing on it — instead of the ones nearest to us. Trigger and ranking both read the
live hull, so nothing is inferred.
It works mechanically: DEFEND engaged **1.9 s after the asset's first hit** in one run
(t=167.0), and held for 54 % of a 330 s run. **But it did not measurably save the
asset.** Over the window the two policies share, they are the same to within noise:
| t (s) | nearest-fighter | escort-weighted |
|---|---|---|
| 120 | 25000.0 | 24910.0 |
| 180 | 24779.5 | 24460.0 |
| 239 | 23038.2 | 23218.0 |
Two honest reasons it cannot yet be scored better than "no worse":
1. **The runs are not comparable past that window** — different spawn timing and, in
the escort-weighted run, a hostile population that *grew* 134 → 166 while the
baseline's fell 147 → 118.
2. **Lethality is the real bottleneck, not target choice.** The guns are on for only
**12 % of combat frames** (320 of 2630); the rest of the time the target is outside
the 9° firing cone while the loop manoeuvres. Choosing a better target does little
when most passes do not shoot.
**One bug found and fixed by the first escort run** (worth keeping as a pattern): the
new mode flies *at* the asset, which sits inside the friendly formation, and the run
ended `hull 1500 -> DEAD` in a single tick at 2026 units/s, 0.6 s from a friendly
destroyer the avoidance expected to clear by 365 units — against a hull of radius
2000. Keep-out had been applied only to hostile turrets. Every entity above
`BIG_RADIUS` now gets a physical keep-out of **its own radius + 800**, with braking
inside it, whatever its faction; the next run survived its full 330 s untouched.
## Ballistics from the disc data — and the measurement that invalidates the metric
The solved `Shell` records give the player's guns exactly
(`Shell_TCAF_DeltaSaber_{NoseGun,Gun,Beam}_P`, all ✅ CONFIRMED):
**`Velocity` 8000**, **`LifeTime` 0.5 s**, **`MaximumRange` 4000** — self-consistent,
since 8000 × 0.5 = 4000 — plus shell `Radius` 2030 and `Power` 15/30/40.
Two things in `pilot.py` were plainly wrong against those numbers, and both are fixed:
- **Lead used our own speed as the shell speed.** Flight time was `d / max(our_speed,
300)`, i.e. 4002000 u/s instead of 8000 — every shot led **416× too far ahead**.
- **`FIRE_RANGE` was 5000**, past the range at which the shells expire.
**But the outcome metric says none of this has been shown to help.** The HUD's own
counters — `YOU KILLED: WARSHIPS` / `WARPLANES` — read **0000 / 0000 at the end of
every run**, including the nearest-fighter baseline. The pilot is not killing
anything in any configuration, so "fraction of frames with the guns on" (12 % → 5 % →
1 frame in 2639 as the firing gate was varied) was never measuring lethality. The
corrections above are right on the physics and fix demonstrably wrong code; **they are
not evidence of improvement**, and none is claimed.
The firing gate itself produced one clean result worth keeping: gating on the target's
angular half-size **alone** (2.7° at 2584 units for a fighter) is far tighter than the
steering loop can hold the nose, and firing collapsed to 1 frame in 2639. Angular size
belongs in the gate as a **floor** that opens it up close, never as a cap.
### Why nothing died — settled by probe, then fixed
`fire_probe.sh` holds each pad input in turn in flight and photographs the HUD ammo
counters. Result:
| input | `NOSE BM` | `MAIN MPM` |
|---|---|---|
| idle | 06000 | 00300 |
| **RB** | **05956** (44 in 4 s, HEAT rises) | 00300 |
| **Y** | 05951 | **00299** (1) |
| LB / X / B / A / RT / LT | no change | no change |
So **`RB` is the nose gun (~11 rounds/s) and `Y` is the main mount** — measured, not
assumed — and the "we never shoot" hypothesis is dead: **we shoot and miss.**
Which is what the disc data says to stop doing. `Shell_TCAF_DeltaSaber_Missile_P` is
**Power 200, `GuidanceType` 5 (guided), `MaximumRange` 5000**, against the nose gun's
**Power 15, unguided**. One missile is worth ~14 gun hits on a 500 HP fighter *and it
steers itself* — the accuracy problem solved rather than tuned. (`ASMissile_P` is
Power **5000**, the anti-ship option.)
Adding missile launches to the pilot (press `Y`, release a tick later, ≥2 s apart)
produced **the first kills of the whole series: `YOU KILLED: WARPLANES 0002`**, versus
`0000` in all five gun-only runs, with hostiles down 134 → 104 (the largest fall yet).
**Still poor, and stated as such: 98 missiles for 2 kills (~2 %).** The likely cause is
that the game expects a *lock* — holding the target in the reticle before launch — and
an unlocked launch is wasted. Reading the lock state (or the lock timer) out of RAM is
the next step, and it is the same anchoring trick as everything else here.
## Target commitment — the change that actually moved kills
The pilot re-scored every contact every tick, so the nose chased whichever fighter was
momentarily best-scoring and the aim error wandered 1040° through a pass. Since a
missile lock is time-on-target, constant switching is the one thing guaranteed to
prevent a kill. **Commitment**: stay on the chosen contact until it dies, gets beyond
6000, sits >90° off the nose for 2.5 s, or 14 s elapse.
Nothing else changed — same guns, same ballistics, same escort weighting, same missile
cadence:
| run | kills (`WARPLANES`) | missiles | hostiles |
|---|---|---|---|
| gun-only × 5 | **0000** | 0 | 147→118 … 134→166 |
| + guided missiles | **0002** | 98 | 134→104 |
| + **target commitment** | **0009** | 101 | **134→97** |
4.5× the kills for the same ammunition, and the largest fall in hostile population of
any run. Our own hull finished untouched at 1500/1500.
**The escort is still not saved** — the ACROPOLIS finished at 76.6 % — so this improves
lethality, not the mission outcome, and the two should not be conflated.
### Negative result: the selected target is not a raw entity pointer
Worth recording so it is not re-attempted. `target_probe.py` looked for the selection
three ways: (1) every word in a ±0x1400 window of the player object that points at a
live entity — **none**; (2) every word in *all* of RAM holding an entity pointer, tapped
through each button — only thread-stack slots (`0x70xx_xxxx`) churned, which is frame
noise, not selection; (3) a delta tally over all 150 entities looking for a repeated
offset holding a pointer to *another* entity, the same trick that found the definition
pointer at `+0x130` — **zero candidates**.
So neither the player nor the AI ships keep a raw pointer to their target near their
transform. The selection is a handle, an index, or lives in a targeting subsystem
outside the entity object.
## Reimplementation notes
- Defeat conditions for an escort stage are readable as: protected-asset
`hull ≤ 0`, or player `hull ≤ 0`.
- Every unit's effective HP is the definition's `HP`, confirmed live for 7 classes —
the same field the [unit struct](structures/unit-struct-runtime.md) already solves
statically, so disc data and runtime agree.
- Entity removal on death is observable (the object leaves the heap), which gives a
clean lifetime signal for anything modelling spawn/despawn.

View File

@@ -0,0 +1,247 @@
# Capital-ship placement — does the `e106` result generalise? (WIP, 2026-07-31)
**Status:** 🚧 **WIP, time-boxed session.** Two results so far: a static audit across all
22 stage containers (done, below) and a first in-mission F10 capture run in Stage 02
(done — three capture logs, but **no capital-ship part correlated**; see "Open").
Context: [`BACKLOG.md`](BACKLOG.md) — "Capital ships assemble wrong in the viewer",
reported 2026-07-30. The oracle and the correlator already exist
([`ship-placement-runtime-capture.md`](ship-placement-runtime-capture.md)); the open
question is whether the rules derived from the one validated ship (`e106`, Stage_S01)
hold for other classes.
## 1. Static audit across all stages (offline, reproducible)
```
cargo run --release --example ship_audit -- ../sylph_extract/hidden/resource3d
```
87 lines of output, of which:
- **Only two OUTLIER lines, and they are the same ship twice**:
`Stage_S03`/`Stage_S27`, `f002_bdy_05` centroid `[-1398 6251 918]`, `dist=6540`
vs a cluster spread of `1071`. Every other assembled ship in every other stage
has all parts inside its own cluster.
→ **The user-visible breakage is NOT a gross static-placement outlier for most
classes.** Whatever is wrong in the viewer is either subtler than "part flung far
away" (wrong rotation, wrong mirror, missing part) or lives in the viewer, not in
`assemble_ship`. `f002_bdy_05` is a genuine, separate, reproducible static bug.
- **MULTIKEY**: joint tracks with more than one keyframe, which `read_trs9`'s
single-key read does not model. Recurring rigs: `e_rou_f104` (3), `e_rou_f105` (2),
`e_rou_f106` (2), `e_rou_e102` (6), `e_rou_e108_Missile_open` (2), `e_rou_e501` (1),
and the `e901` boss with 215 tracks per pose. Several of these (`Missile_open`, the
`e901_attack*` poses) are obviously *animation* and harmless for a static pose; the
plain hull rigs `f104`/`f105`/`f106`/`e102` are **not** obviously animation and are
the best hypothesis for a class-specific assembly error. ❔ **HYPOTHESIS — not
verified.** `e106`, the one validated ship, has **no** multikey tracks, which is
exactly how a rule that only works for single-key rigs could have passed unnoticed.
Raw audit output is reproducible with the command above (not checked in; it is
deterministic from the disc).
## 2. First in-mission capture run (Stage 02)
New tool: [`tools/re-capture/ship_capture_session.sh`](../../tools/re-capture/ship_capture_session.sh)
— one blocking session (per the session-lifetime rule): boot → Stage 02 in flight →
N× {screenshot, F10, small yaw}. Each F10 writes its own
`xenia_ship_capture_NN.log` next to the binary.
Run 2026-07-31, 5 presses requested:
- **Boot to in-flight took 24 s** (`skip_intro.sh` skipped the movie at 1 s and 6 s,
title at 11 s, HUD shield bar at 24 s) — much faster than the ~100 s in the notes.
- **3 of 5 F10 presses produced a log** (`_01``_03`, 2964 / 3111 / 3668 draws).
Logs (810 MB each) and the screenshots are at `/sylph-home/re/shipcap/`; not
committed for size. One screenshot is checked in as
[`captures/shipcap-stage02-launch.png`](captures/shipcap-stage02-launch.png).
- The screenshot confirms the capture frames are real in-mission combat frames
(HUD live, `REMAINING OB 004`, ACROPOLIS + a Destroyer labelled on screen, a
capital-ship hull filling the bottom of the frame).
### Result: no correlation yet ❌
```
correlate_capture xenia_ship_capture_03.log Stage_S02 <id> bdy_01
```
for `f101` (ACROPOLIS), `f105`, `f106`, `e105` reports *"no draw matches any LOD
(culled/off-screen?)"* for essentially every part — only two speculative LOD tries
(`f101_bdy_03` vcount 90 `[l]`, `e105_wep_01` vcount 60 `[l]`) and **zero accepted
matches**.
That is a **negative result, and it is not yet explained**. Facts collected:
- The capture is not empty or degenerate: 3668 draws in `_03`, top shaders
`0xDA51B0745ABF85D2` (1258), `0xE0BAFB4F520FE441` (1091), `0xEEA84C59D7F95371` (770).
None is the `e106` ship-shader hash from the 2026-07-26 capture; the F10 path does
not filter by hash, so this alone is not the cause.
- Large vertex counts *are* present (3024, 2772, 1736, 1612, 1240 …), so capital-ship-
sized geometry is being drawn.
Candidate explanations, **untested**:
1. the Stage-02 capital ships on screen are drawn from LOD/damage variants
(`_d00`, `_m`, `_l`) whose vcounts the correlator's variant list does not cover;
2. the position-validation step rejects otherwise-correct vcount hits (the capture
dumps ≤64 positions — a set-membership test against the wrong variant fails);
3. the ships in view at launch are drawn by a *different* draw path than `e106` in
Stage_S01 (e.g. instanced/batched), so no single draw equals one part.
**First step next session:** take the largest few vcounts in the capture and ask which
decoded part in `Stage_S02.xpr` has that count (invert the match), instead of asking
per-part whether a draw exists. That distinguishes (1)/(2) from (3) immediately.
## 3. The inverted match — the ships were never drawn (2026-08-10) ✅ explained
The inversion was run and it settles the negative result. Two new tools:
```
cargo run --release --example invert_capture -- <capture.log> Stage_S02 [top_n] [--ship f101]
cargo run --release --example vcount_index -- ../sylph_extract/hidden/resource3d <capture.log>
```
`invert_capture` asks, of the capture's own vertex counts, which resource in one stage
container has that count; `vcount_index` asks the same across **all 166 containers**
(5480 resources), so a draw whose geometry lives in `Common.xpr`, a `rou_*` weapon pack
or a `DeltaSaber_*` player-craft pack is identified instead of coming back "unknown".
On `xenia_ship_capture_03.log` (3668 draws, Stage 02):
| capture vcount | draws | what it is |
|---|---|---|
| 10891 | 28 | **`DeltaSaber_T:f001`** — the player's own craft |
| 6000 | 14 | `Stage_S02:n006_02` — backdrop |
| 1096 / 1008 / 841 / 215 / 127 | 14112 | `rou_f001_wep_*` — the player's weapons |
| 417 / 279 / 201 / 167 | 104448 | `Base:j00*`, `ptc_pack:*` — HUD/particles |
| 8 / 4 / 3 / 1 | 317590 | particle quads |
- **Not one capital-ship hull part appears.** Per ship: `f101` **1 of 15** resources had a
drawn vcount (`f101_bdy_03_l`, 90 verts), `e105` 3 of 37, `e106` 3 of 34 — and each of
those hits is a 44225-vertex `_l`/`_b` piece whose count also collides with dozens of
unrelated resources, i.e. probably not even the ship.
- The 3668 draws span **~14 frames** per F10 press and use only **10 distinct vertex
shaders**, and the player's own craft is captured at **full detail with its `c0..c2`
WVP rows** — so the capture path itself is healthy and unfiltered.
- The screenshot ([`captures/shipcap-stage02-launch.png`](captures/shipcap-stage02-launch.png))
agrees once read carefully: the hull "filling the bottom of the frame" is the **player's
own craft** in the chase view. The nearest contact on the HUD is a wingman's engine trail.
**So hypothesis (3) is dead, and (1)/(2) never applied.** The correlator's message
"no draw matches any LOD (culled/off-screen?)" was literally true: the ships were far
enough away that the renderer drew nothing of them. `correlate` additionally cannot
anchor without the reference part, and `f101_bdy_01` was never drawn at any LOD.
**The variable that was never controlled is RANGE.** New tooling closes that gap:
[`tools/re-capture/approach_capture.py`](../../tools/re-capture/approach_capture.py)
locks onto a capital ship (definition size-radius ≥ 150 = not a fighter), flies at it
with navigator.py's drift compensation and CPA avoidance, firing disabled, and presses
F10 as each range band is crossed (8000 / 6000 / 4500 / 3000 / 2000 / 1400 / 900),
stamping every capture with its distance in `approach-bands.jsonl`. Driver:
[`ship_capture_close.sh`](../../tools/re-capture/ship_capture_close.sh). Besides giving
the correlator a full-detail frame, the stamped bands measure the game's own **LOD
ladder** per part, which the reborn renderer needs anyway.
## 4. Controlled-range capture — three classes verified (2026-08-10) ✅
`ship_capture_close.sh 240` ran one blocking session: boot → Stage 02 in flight →
lock the `f105` cruiser → close on it at full throttle, F10 at each range band.
Six bands fired (7375 / 5937 / 4394 / 2994 / 1944 / 1259 units, stamped in
`approach-bands.jsonl`); **3 of 6 presses produced a log** — the same 3-of-N as the
earlier session, so a press during a previous 8 MB dump is still lost. Logs at
`/sylph-home/re/shipcap-close/` (not committed, ~9 MB each).
The difference from every earlier capture is immediate: `f105_bdy_01` (10926 verts),
`bdy_02`, `bdy_03`, `eng_01`, `sld_01` are all **drawn at full detail**, and the far
log additionally caught the `e105` and `e106` hulls.
### 4a. One log is ~14 frames, and mixing them silently corrupts the result
`WV_ref⁻¹ · WV_p` cancels the camera **only within one frame**. The capture log has no
frame delimiter, so `correlate_capture` was mixing ~14 frames; with the camera closing
at ~760 u/s that is not a small error — two logs of the same cruiser disagreed by
1090 units on `f105_bdy_02`. New `ship_capture::segment_frames` splits the log wherever
a vertex buffer recurs (over-splitting is harmless — a block is still one camera;
under-splitting is what corrupts), and new
[`correlate_frames`](../../crates/sylpheed-formats/examples/correlate_frames.rs)
correlates each block independently and **cross-checks the blocks against each other**.
Two aggregation rules had to be right, and both were wrong first:
- **Only blocks with the requested reference part count.** A block that fell back to
another reference expresses its parts in a different frame — averaging them in
produces a "disagreement" of exactly the distance between the two references.
- **Consensus, not median.** A stage holds several ships of one class; they share
vertex buffers, and a block can hold one instance's full-LOD part beside another's
`_m` copy (different buffers, so nothing splits them). The largest cluster of
mutually-agreeing blocks is the placement; the rest are reported as
`(+N other-instance)` rather than averaged into nonsense.
With that, every part reproduces across independent frames to **≤1 unit** (typical
spread 0.030.2).
### 4b. Static assembly matches the runtime on all three classes ✅
`correlate_frames … --static <Stage_S02.xpr>` diffs `assemble_ship` against the capture,
translation **and rotation**, both re-expressed in the reference part's frame:
| ship | rig | parts compared | worst dT | worst dR |
|---|---|---|---|---|
| `f105` TCAF cruiser | 1 engine, mirrored `sld` pair | 5 | **0.12** | **0.000** |
| `e105` ADAN cruiser | 6 hull bodies, bridge, engine | 7 | **0.05** | 1.711 (`eng_01` only) |
| `e106` ADAN destroyer | 2 nacelles + centre, turret | 8 | **0.43** | 0.098 (`eng`/`wep` only) |
**So the `e106` rules DO generalise.** Translation is exact for every part of every
class — 20 of 21 comparisons under 0.5 units. This is the answer the BACKLOG item asked
for, and it is the opposite of the assumption in it: `assemble_ship` is right, so the
viewer's "capital ships assemble wrong" is the viewer's own transform stack (the
backlog's own "worth ruling out first, cheaply").
Both first-pass exceptions were chased down, and neither survives as an open question:
-**`e105_brg` was genuinely missing — a real assembler bug, now fixed.** Tier 3
matched a part to its `GN_*` hardpoint by trailing index, so an index-less part
(`e105_brg`) compared `"01" == ""` against `GN_Bridge_01` and fell through silently.
With no index to match on, take the lowest-numbered frame of the category. The
runtime is the check: `e105_brg` now assembles at `[0.0, 70.0, -1850.0]` relative to
`e105_bdy_01`, **dT 0.03, dR 0.000** against the capture.
Reach measured by diffing `assemble_ship` part counts over all containers before and
after: **34 (stage, ship) entries gain parts**`e102` +2 (bridge *and* engine),
`e104` +1, `e105` +1, across Stages 0229. Every one of those ships was assembling
without its bridge. `ship_audit` is unchanged (still exactly the `f002_bdy_05`
outlier), so nothing regressed.
- ✅ **The rotation deltas were an artefact of my own aggregation, plus one real
articulation.** The static diff was comparing against a rotation taken from the
first sampled block, which can belong to *another instance* of the class; scoping it
to the position-agreeing cluster drops `e105_eng_01` from dR 1.711 to **0.000** and
both `e106` nacelles to **0.000**. What remains is `e106_wep_02_01` at dR 0.134 — and
that part's rotation varies by **0.182 between blocks that agree on its position**,
i.e. the runtime disagrees with itself more than it disagrees with the assembler.
It is a turret aiming, not an assembly error. `correlate_frames` now prints that
`rotVar` column precisely so "the part moved" cannot be mistaken for "the rotation
is wrong".
Final numbers, three classes, 21 parts: **worst dT 0.43, worst dR 0.000** for every
part that is not articulating.
`include_external` matters and is a caller-side trap: with `false` an `e106` assembles
as **5** parts and with `true` as **11** — the engine cluster, the bridge and the
cross-id `e303_wep_01` turrets live in separate composites (`e_rou_e106_eng`, 3 nodes)
that the primary-composite pass never reaches. The viewer takes it as a parameter
(`iso_loader.rs:4012`); if it is ever passed `false`, ships lose their engines and
bridge — which looks exactly like "assembles wrong".
## Honest summary
- ✅ Static assembly is **not** grossly broken across stages — 1 outlier ship
(`f002_bdy_05`), reproducible.
- 🟡 A concrete, testable hypothesis for class-specific breakage exists (multikey joint
tracks on `f104`/`f105`/`f106`/`e102`; `e106` has none).
- ✅ The earlier zero-match was **range**, not a format or correlator bug (§3): the
captures were taken where no capital-ship geometry is drawn at all.
- ✅ With range controlled (§4), **three classes**`f105`, `e105`, `e106` — reproduce
static assembly to **≤0.43 units** in translation, cross-checked across independent
frames. The `e106`-derived rules generalise; the MULTIKEY hypothesis in §1 is *not*
needed to explain anything observed so far (`f105` has 2 multikey tracks and still
matches exactly).
- ✅ One real assembler bug found and fixed by this route: index-less `brg`/`eng`/`sld`
parts never matched their `GN_*` frame, so **34 (stage, ship) entries** assembled
without a bridge (and `e102` also without its engine). Verified against the capture.
- ▶ Next: the viewer itself (`include_external`, node-instance recursion) — the format
layer is now measured, not assumed. A per-ship regression table over the checked-in
captures would keep it that way.

View File

@@ -272,3 +272,84 @@ take-off, ~12 minutes in mid-combat, and after GAME OVER. 14 objects, the same
is no need to play it, and no need to survive it.
Stages captured so far: `Ttrl` (BASIC CONTROLS), Stage 02.
## A defaulted unit field is not a global constant — some inherit from a sibling
**Confidence: 🟡 for `Size_Y`, ❔ for the rest. Analysis 2026-08-10, offline, from
[`captures/unit-runtime-fields.csv`](../captures/unit-runtime-fields.csv).**
The coverage limit above (21 of 110 units, growing only with story progress) is
worth attacking from the other side first: *if* a field the disc leaves unset
always took the same runtime value, the 21 captured units would pin that default
for all 110 and no further missions would be needed.
**It does not.** Restricting to the 150 values that are both ✅ CONFIRMED and
come from a field the disc leaves defaulted, only 6 of 24 fields have a single
value across every unit that defaults them (`HP`→10, `MassScore`→0,
`MaximumVelocity`→0, `RadarRange`→0, `DestroyMotionTime`→0, `Size_Z`→0.1). The
other 18 take several distinct values — so the default is computed per unit.
Where from? For each defaulted value, ask which *other* field of the same unit
holds exactly that value. Counting only cases where the value is **non-zero**
(otherwise `0 == 0` inflates every pair) and checking that the two fields are at
**different offsets** (so the match is not the layout solver aliasing them):
| defaulted field | takes the value of | support | independent units |
|---|---|---|---|
| `Size_Y` (`0x034`) | `Size_X` (`0x030`) | 9/9 | **7**, 6 distinct values |
| `Size_Radius` (`0x050`) | `min(Size_X, Size_Z)` | 4/4 | 4, 3 distinct values |
| `FCSRange` (`0x2a4`) | `RadarRange` (`0x2a0`) | 4/4 | 2 |
| `DefencePoint` (`0x2bc`) | `AttackVesselPoint` (`0x2b4`) | 6/6 | 2 |
`Size_Y ← Size_X` is the one to trust: seven unrelated ships (`e105` 600,
`e106` 300, `e108` 80, `e201` 300, `f101` 400, `f105` 700, `f106` 200) each omit
`Size_Y` on disc and each shows its own `Size_X` at runtime. When both fields
*are* on disc they differ freely (14 distinct `Size_Y` values against 13 of
`Size_X`), so this is a default rule, not one value stored twice.
`Size_Radius`'s formula is **not yet separable**: `min(Size_X, Size_Z)` and "the
median of the three axes" fit all four units identically. `UN_e010_ADAN_Attacker_S`
is what rules out the simpler `Size_Radius ← Size_X` (X=100, Y=40, Z=50, radius
**50**). The last two rules rest on two independent units each and are ❔ —
recorded so they can be falsified, not relied on.
**Why it matters for the reimplementation:** filling a missing `Size_Y` with `0`
or with a global constant gives the game's largest hulls a wrong lateral extent
(`f105` 700, `e105` 600, `f101` 400 — all defaulted on disc). Applied across the
disc, the rules recover **65 (unit, field) values in units that have never been
visited**: `Size_Y` in 21 of the 21 units that omit it, `Size_Radius` in 22 of 26,
`FCSRange` in 14 of 56, `DefencePoint` in 8 of 60.
### Cross-check against the weapons: this is NOT an engine-wide mechanism
The obvious worry is that four rules from 21 units are coincidence. The
`Weapon`/`Shell` capture is the control: **complete coverage, 126 records**, with
the same "defaulted on disc" classification. Running the identical sweep there
(confirmed rows, non-zero values, offsets required to differ) finds **no sibling
rule at all** — the single 100 %-agreement candidate (`Shell.Length ←
`Shell.Volume`, 5 records) has one distinct value, i.e. it is really the constant
`Length → 10` coinciding with `Volume = 10`. Weapon defaults vary per record just
as unit defaults do (10 of 14 `Weapon` fields, 16 of 17 `Shell` fields), so the
phenomenon is general; the *sibling* explanation is not.
So `Size_Y ← Size_X` is **specific to the unit schema** (plausibly the size block
defaulting its axes), not a property of IDXD default resolution. Two consequences:
the rule cannot be justified by appeal to a general mechanism, and the two
two-unit hypotheses (`FCSRange`, `DefencePoint`) lose the support they would have
borrowed from one — treat them as **coincidence-not-excluded** until a new stage
tests them.
`Size_Y ← Size_X` itself survives this scrutiny, and was re-checked at the raw
token level rather than through the sub-record merge: `UN_e105_ADAN_Cruiser`,
`UN_f105_TCAF_Cruiser` and `UN_f101_TCAF_Acropolis` each declare `Size_X`,
`Size_Z` and `Size_Radius` and **no `Size_Y` at all**, and each reads back its own
`Size_X` (600 / 700 / 400) at runtime.
**How to falsify:** the rules predict a specific number for units in stages not
yet captured. Load any new stage, snapshot, and compare — one disagreement kills
the rule. Note what is *not* a useful test: Stage 01, the only other reachable
stage, adds just four uncaptured units (`e010`/`e106` variants) whose predictions
are the same numbers their already-captured base variants gave, so it would
re-measure rather than test. A real test needs a stage with unfamiliar classes,
i.e. story progress — which is now the *only* thing story progress is needed for
here.

View File

@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Fly TO a capital ship and dump a draw capture at several ranges.
Why this exists: the 2026-07-31 Stage-02 capture correlated **zero** parts, and
inverting the match (`cargo run --example invert_capture`) showed why — at the
captured frames no capital-ship hull was drawn at all. The only large draw was
the player's own craft (`DeltaSaber_T:f001`, 10891 verts); of `f101`/`e105`/
`e106` only a handful of tiny far-LOD/effect pieces appeared. The ships were
simply too far away. Pressing F10 wherever the craft happens to be is therefore
not a capture strategy.
So: pick a capital ship, fly at it, and press F10 as each distance band is
crossed. That gives (a) frames where the full-detail hull is actually drawn —
what the correlator needs — and (b) as a by-product, the game's own **LOD
ladder**, because each capture is stamped with the range it was taken at.
Firing is disabled (the target is usually a friendly), and navigator.py's
closest-point-of-approach avoidance is inherited unchanged, so closing on a hull
does not end in a collision.
Usage: approach_capture.py <config.json> [seconds] [--target REGEX] [--dry]
Env: SYLPH_CAPTURE_WIN xdotool window id to send F10 to (unset = no capture)
SYLPH_CAPTURE_OUT where to write the band log and screenshots
"""
import json
import math
import os
import re
import subprocess
import sys
import time
from collections import Counter
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import navigator # noqa: E402
from navigator import Navigator, ang, norm # noqa: E402
from flight_probe import Pad # noqa: E402
# Ranges (guest units) at which to dump a capture, largest first. Chosen to
# straddle the plausible LOD switches: the far-LOD pieces seen in the 2026-07-31
# capture were drawn at whatever range the craft sat at, and the one validated
# capture (e106, Stage_S01) had the ship close.
BANDS = [8000.0, 6000.0, 4500.0, 3000.0, 2000.0, 1400.0, 900.0]
# A capital ship, not a fighter: the definition's own size radius says which.
CAPITAL_RADIUS = 150.0
class Approach(Navigator):
# Never shoot: the approach target is usually the escorted asset, and a
# negative cone makes the inherited fire gate unsatisfiable.
FIRE_CONE = -1.0
HOLD = 700.0 # stop closing inside this; the capture is already made
def __init__(self, W, pad, target_re=None, dry=False, log=sys.stdout,
win=None, out=None):
super().__init__(W, pad, dry=dry, log=log)
self.target_re = re.compile(target_re, re.I) if target_re else None
self.win = win
self.out = out or "/sylph-home/re/shipcap"
self.locked = None # (off, name) — stay on one ship
self.pending = list(BANDS)
self.captures = []
self.throttle = None
# -------------------------------------------------------------- target
def pick(self, me_p, me_v, fwd, ents, me_off):
"""The chosen capital ship — locked once, so the run is one approach."""
cands = [e for e in ents
if e[0] != me_off and "Player" not in e[1] and e[4] >= CAPITAL_RADIUS
and (self.target_re is None or self.target_re.search(e[1]))]
if not cands:
return None
if self.locked is not None:
same = [e for e in cands if e[0] == self.locked]
if same:
e = same[0]
return (e[0], e[1], e[2], e[2] - me_p, float(np.linalg.norm(e[2] - me_p)))
# First lock: the biggest ship that is not absurdly far.
cands.sort(key=lambda e: (-e[4], float(np.linalg.norm(e[2] - me_p))))
e = cands[0]
self.locked = e[0]
print(f"LOCK {e[1]} radius={e[4]:.0f} d={np.linalg.norm(e[2]-me_p):.0f}",
file=self.log, flush=True)
return (e[0], e[1], e[2], e[2] - me_p, float(np.linalg.norm(e[2] - me_p)))
# ------------------------------------------------------------- capture
def capture(self, band, dist, name):
idx = len(self.captures) + 1
shot = f"{self.out}/approach-{idx:02d}.png"
if self.win:
subprocess.run(["screenshot", shot], capture_output=True)
subprocess.run(["xdotool", "key", "--window", self.win, "F10"],
capture_output=True)
rec = {"index": idx, "band": band, "distance": round(dist, 1),
"target": name, "shot": shot, "t": round(time.time(), 3)}
self.captures.append(rec)
print(f"CAPTURE {idx:02d} band={band:.0f} d={dist:.0f} {name}",
file=self.log, flush=True)
with open(f"{self.out}/approach-bands.jsonl", "a") as f:
f.write(json.dumps(rec) + "\n")
# ---------------------------------------------------------------- loop
def step(self, t, dt, prev_vhat):
msg, vhat = super().step(t, dt, prev_vhat)
# Distance to the locked ship drives both the throttle and the captures.
ents = self.W.sample(t)
me = next((e for e in ents if "Player" in e[1]), None)
tgt = next((e for e in ents if e[0] == self.locked), None) if self.locked else None
if me is None or tgt is None:
return msg, vhat
d = float(np.linalg.norm(tgt[2] - me[2]))
# Throttle: RT to close, LT to hold off once we are as near as we want.
want = 1 if d > self.HOLD * 2 else (-1 if d < self.HOLD else 0)
if want != self.throttle and not self.dry:
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
while self.pending and d <= self.pending[0]:
band = self.pending.pop(0)
self.capture(band, d, tgt[1])
return f"{msg} | d={d:7.0f} thr={want:+d} left={len(self.pending)}", vhat
def main():
cfg = json.load(open(sys.argv[1]))
secs = float(sys.argv[2]) if len(sys.argv) > 2 and not sys.argv[2].startswith("-") else 240.0
target = None
if "--target" in sys.argv:
target = sys.argv[sys.argv.index("--target") + 1]
W = navigator.World(cfg)
a = Approach(W, Pad(), target_re=target, dry="--dry" in sys.argv,
win=os.environ.get("SYLPH_CAPTURE_WIN"),
out=os.environ.get("SYLPH_CAPTURE_OUT"))
a.run(secs)
print(f"CAPTURES {json.dumps(a.captures)}", flush=True)
if __name__ == "__main__":
main()

42
tools/re-capture/fire_probe.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Does the pad actually DISCHARGE a weapon? Hold each candidate input in turn
# and photograph the HUD's ammo counters.
#
# Why this exists: the autopilot's HUD kill counters read WARSHIPS 0000 /
# WARPLANES 0000 at the end of every run, in every targeting configuration, so
# "fraction of frames with the guns commanded on" was never measuring anything.
# Before tuning aim any further, settle the prior question — whether the fire
# command reaches the gun at all. The HUD carries a live ammo count (`MAIN MPM
# 00300`, matched to `LoadingCount` by the weapon RE), so the counter falling
# during a hold is direct evidence of a discharge, and the counter sitting still
# through every button is direct evidence that we have never fired a shot.
#
# `RB fires` came from an earlier session; this re-tests it rather than assuming
# it, and sweeps the other buttons so a wrong mapping cannot hide.
set -u
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
export PYTHONPATH=/sylph-home/.local/lib/python3.12/site-packages
SD="$(cd "$(dirname "$0")" && pwd)"
SHOTS=/sylph-home/re/shots
HOLD="${1:-4}"
shot(){ screenshot "$SHOTS/fire-$1.png" >/dev/null 2>&1; echo " shot $1"; }
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
sleep 3
echo "=== probing (hold ${HOLD}s each) ==="
shot "00-idle"
# RB first: it is the incumbent claim. Then the rest, so a wrong mapping cannot
# hide behind it. A/B/X/Y may also switch weapon or open something — that is
# fine for a probe, and the final frame records wherever it ended up.
for b in RB LB Y X B A LS RS; do
vgamepad press "$b"; sleep "$HOLD"; shot "hold-$b"; vgamepad release "$b"; sleep 1.5
done
# triggers are analogue, not buttons
vgamepad trig RT 1.0; sleep "$HOLD"; shot "hold-RT"; vgamepad trig RT 0.0; sleep 1.5
vgamepad trig LT 1.0; sleep "$HOLD"; shot "hold-LT"; vgamepad trig LT 0.0; sleep 1.5
vgamepad reset
shot "99-final"
echo "PROBE DONE"

34
tools/re-capture/lock_probe.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Which input SELECTS a target, and does a lock then build?
#
# 98 guided missiles produced 2 kills. A guided missile with nothing to guide to
# flies straight, so the suspicion is that the loop has never selected a target
# at all: the HUD carries a `TARGET` marker and a lock reticle, and no button in
# pilot.py has ever touched them. fire_probe.sh already showed LB/X/B/A/LS/RS do
# not discharge a weapon — but "does not fire" says nothing about "does not
# select", so sweep them again watching the RETICLE instead of the ammo.
#
# Captures the centre of the screen (reticle + lock brackets) and the right-hand
# target panel, so a selection or a building lock is visible either way.
set -u
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
SD="$(cd "$(dirname "$0")" && pwd)"
SHOTS=/sylph-home/re/shots
HOLD="${1:-3}"
shot(){ screenshot "$SHOTS/lock-$1.png" >/dev/null 2>&1; echo " shot $1"; }
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
sleep 3
shot "00-idle"
# tap, not hold: a select is an edge, and holding a cycle button would just spin
# through every contact. Two taps each, so a cycle that lands on nothing the
# first time still shows on the second.
for b in RS LS LB B X A Y; do
vgamepad tap "$b" 200; sleep 0.4; vgamepad tap "$b" 200; sleep "$HOLD"
shot "tap-$b"
done
vgamepad reset
shot "99-final"
echo "PROBE DONE"

View File

@@ -18,6 +18,7 @@ make a reaction possible:
So the loop is a state machine on damage rather than a pure pursuit:
ENGAGE chase and shoot the nearest hostile fighter
DEFEND the escorted asset is being attacked — go kill what is attacking IT
EVADE entered the moment the hull drops — turn away from the threats,
full throttle, jink; leave only after several quiet seconds
RETIRE hull below a floor: break for the friendly capital ship, which the
@@ -27,6 +28,20 @@ Turrets are treated as threats to be *kept at a distance*, not as targets: the
objective is the invading fighters, and the turret is what killed every previous
run.
**Why DEFEND exists, and why it is not simply "always guard the asset".** Stage
02 is an escort: a 240 s run ended in GAME OVER with our own hull at 1500/1500
because the ACROPOLIS sank while the pilot chased the nearest fighter 2 km away.
But the measurement in docs/re/mission-escort-state.md says the loss is *slow* —
a few hundred to ~1400 HP/min against 25000, i.e. tens of minutes to sink. (When
it starts varies: t≈170 s in one run, t≈70 s in another, so do not schedule on
it — react to the hull.) So permanently orbiting it would throw away most of the
mission for nothing. The policy that fits the
measurement is: **fight freely until the asset is actually being hurt, then
switch to killing its attackers specifically.** Both the trigger and the target
choice are read live — every entity's hull is `position + 0x154`, confirmed for
seven classes, so "is the asset losing hull" and "which hostiles are closing on
it" are both observable rather than inferred.
Usage: pilot.py <config.json> [seconds] [--dry]
"""
import json
@@ -40,22 +55,120 @@ from collections import deque
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gmem # noqa: E402
import navigator # noqa: E402
from navigator import ang, norm # noqa: E402
from flight_probe import Pad # noqa: E402
HULL_OFF = 0x154 # confirmed: == definition HP at spawn, falls when hit
SHIELD_OFF = 0x430 # candidate: == definition Shield MaxValue at spawn
DEF_HP = 0x054 # unit-struct-runtime.md
# The player Delta Saber's guns, from the solved Shell records
# (docs/re/captures/weapon-runtime-fields.csv, all ✅ CONFIRMED):
# Shell_TCAF_DeltaSaber_{NoseGun,Gun,Beam}_P Velocity 8000, LifeTime 0.5 s,
# MaximumRange 4000 (= 8000 × 0.5, self-consistent), shell Radius 2030.
# Both numbers were previously wrong in this loop, and both mattered:
# * flight time was computed as d / OUR speed (4002000 u/s), so every shot
# was led 416× too far ahead of the target;
# * FIRE_RANGE was 5000, i.e. a quarter of the shots were fired at targets
# the shells expire before reaching.
SHELL_VELOCITY = 8000.0
SHELL_MAX_RANGE = 4000.0
SHELL_RADIUS = 20.0
# The MAIN weapon, fired with Y — measured, not assumed: a hold-each-input probe
# (fire_probe.sh) moved NOSE BM 06000 -> 05956 under RB and MAIN MPM 00300 ->
# 00299 under Y, so RB is the nose gun (~11 rounds/s) and Y is the main mount.
# That probe also settled the lethality question the other way round: we DO
# shoot, so the kill counters reading 0000 mean we shoot and MISS.
#
# Which is exactly what the disc data says to stop doing. Shell_TCAF_DeltaSaber_
# Missile_P is Power 200 with GuidanceType 5 (guided) and MaximumRange 5000,
# against the nose gun's Power 15 unguided — one missile is worth ~14 gun hits
# on a 500 HP fighter, and it steers itself, which is the accuracy problem
# solved rather than tuned. Range is held under the confirmed 5000 because which
# main weapon is actually loaded is not read from RAM yet.
MISSILE_RANGE = 4000.0
MISSILE_CONE = math.radians(20.0)
MISSILE_PERIOD = 2.0 # s between launches; 300 rounds is not unlimited
# Stage 02's protected asset. Named rather than derived: "the biggest friendly"
# picks the f105 cruiser (30000 HP > the Acropolis's 25000), and "the friendly
# with the most HP" picks it too, so neither rule finds the right ship. The
# per-stage asset is mission script, not a property of the entity, so it is
# configuration here — override with $SYLPH_ASSET for another stage.
ASSET_NAME = os.environ.get("SYLPH_ASSET", "Acropolis")
class Pilot:
KP, KD = 2.2, 0.45
FIRE_CONE = math.radians(9)
FIRE_RANGE = 5000.0
FIRE_CONE = math.radians(9) # fallback only; the real gate is angular size
FIRE_RANGE = SHELL_MAX_RANGE # the shells simply do not arrive past this
CONE_MIN = math.radians(2.0)
CONE_MAX = math.radians(25.0) # close-in the target subtends a lot; let it
TURRET_KEEPOUT = 2500.0 # ...and stay this far from things that shoot back
EVADE_QUIET = 5.0 # seconds without damage before re-engaging
RETIRE_FRAC = 0.30 # hull fraction that sends us home
HZ = 8.0
# --- escort ---
ASSET_GUARD = 9000.0 # hostiles this close to the asset count as its attackers
ASSET_QUIET = 20.0 # s of no asset damage before dropping out of DEFEND
ASSET_ALERT = 0.5 # HP of asset damage that counts as "under attack"
ASSET_STANDOFF = 3500.0 # loiter this far out when guarding with no target
HULL_CLEARANCE = 800.0 # clearance ON TOP of a capital ship's own radius
CLOSING_WEIGHT = 4.0 # s of closing-rate credit when ranking attackers
MY_RANGE_WEIGHT = 0.35 # how much our own distance discounts a target
# --- target commitment ---
# The loop re-scored every contact every tick, so the nose chased whichever
# fighter was momentarily best and the aim error wandered 10-40 deg through
# a pass. A missile lock is time-on-target (the OPTIONS screen calls it
# Padlock), so switching targets constantly is the one thing guaranteed to
# prevent a kill. Stay on the chosen contact until it dies, leaves range, or
# sits behind us long enough that chasing it is pointless.
COMMIT_MAX = 14.0 # s before we are allowed to reconsider anyway
COMMIT_DROP = 6000.0 # ...or it gets this far away
COMMIT_BEHIND = 2.5 # ...or stays >90 deg off the nose this long
# --- moves the ADVANCED CONTROLS tutorial teaches (tutorial_capture.sh) ---
# "Target an enemy and pull LT and RT [together]. This sets your fighter's
# speed to that of the target. This works well when you are trying to get
# behind an enemy. Once behind an enemy, this also helps you attack them."
# That is the overshoot problem solved by the game itself: matching speed
# holds us in the target's rear hemisphere instead of flying through it,
# which is the only way a time-on-target lock ever completes.
# Scope matters, and a measured regression proved it: applying the match at
# 4500 with a 70 deg cone dropped kills 9 -> 0. Matching a target's speed
# while still 5 km behind it means never closing — the pilot sat at 272 u/s
# and fired 28 frames all run. The tutorial's own wording scopes it: "when
# you are trying to get BEHIND an enemy... ONCE BEHIND an enemy, this also
# helps you attack them". So it is station-keeping in the saddle, not an
# approach throttle. Only match when we are already there.
# MEASURED: both tutorial moves are a NET REGRESSION as applied here, so
# both ship DISABLED. One run each, same everything else:
# commitment only ............ 101 missiles, 364 fire frames, 9 kills
# + match(4500) + snap-face .... 9 missiles, 28 fire frames, 0 kills
# + match(1200) + snap-face ... 57 missiles, 225 fire frames, 2 kills
# The moves are real and the tutorial is right about them; the loop just
# cannot use them yet. Snap-face (B+A) reorients the craft mid-pursuit and
# destroys the very dwell that commitment buys, and speed-match needs to be
# entered from the saddle rather than commanded at range. Set MATCH_RANGE
# and lower FACE_MIN to re-enable, and A/B them over SEVERAL runs — one run
# per config is inside this stage's spawn variance.
MATCH_RANGE = 0.0 # 1200.0 to re-enable
MATCH_CONE = math.radians(25)
# "Press B and A together to face [the target]" — a snap turn, far quicker
# than winding the PD controller around for a contact behind us.
FACE_MIN = math.radians(999) # 50 deg to re-enable the B+A snap turn
# HEADS-UP DISPLAY tutorial, verbatim: "Press A twice to target the enemy
# closest to the center of the screen." A DOUBLE tap — which is why every
# single-tap button sweep found nothing and concluded targeting was
# automatic. It also explains the missiles: GuidanceType 5 needs the GAME's
# selection, and we had never made one, so 98 launches guided to nothing.
# Select only when our committed contact is already near screen centre, so
# the game's choice and ours are the same object.
SELECT_CONE = math.radians(14)
SELECT_PERIOD = 3.0
FACE_PERIOD = 4.0
def __init__(self, W, pad, dry=False, log=sys.stdout):
self.W = W
@@ -72,6 +185,61 @@ class Pilot:
self.hp0 = None
self.last_hit = -1e9
self.threat_dir = None
# escort bookkeeping
self.def_hp = {} # def_va -> definition HP
for va in W.defs:
self.def_hp[va] = self.f32(gmem.va_to_off(va) + DEF_HP)
self.asset_hist = deque(maxlen=64)
self.asset_hp0 = None
self.asset_last_hit = -1e9
self.missile_down = False
self.missile_t = -1e9
self.missiles = 0
self.commit_off = None # entity we are committed to
self.commit_t = -1e9
self.behind_since = None
self.matching = False
self.face_t = -1e9
self.face_down = None
self.faces = 0
self.select_t = -1e9
self.selects = 0
def f32(self, off):
b = os.pread(self.W.fd, 4, off)
if len(b) < 4:
return float("nan")
return struct.unpack(">f", b)[0]
# ---------------------------------------------------------------- escort
def asset(self, ents):
"""The protected ship, and its live hull — same anchor as everyone's."""
for off, nm, p, v, r in ents:
if ASSET_NAME in nm:
hull = self.f32(off + HULL_OFF)
return off, nm, p, v, r, hull
return None
def asset_attackers(self, hos, a_p):
"""Hostile fighters near the asset, ranked by how hard they press it.
Ranking is distance to the asset *minus* credit for closing on it, so a
fighter 4 km out and running in outranks one sitting at 2 km drifting
away. Turrets and hulls are excluded for the same reason as everywhere
else: they are not killable objectives, they are keep-out zones.
"""
out = []
for off, nm, p, v, r, hard in hos:
if hard:
continue
rel = a_p - p
d = float(np.linalg.norm(rel))
if d > self.ASSET_GUARD:
continue
closing = float(np.dot(norm(rel), v)) # +ve = moving at the asset
out.append((d - self.CLOSING_WEIGHT * max(closing, 0.0), off, nm, p, v, d, r))
out.sort(key=lambda e: e[0])
return out
# ------------------------------------------------------------ own state
def own(self, off):
@@ -82,13 +250,40 @@ class Pilot:
return hull, shield
def set_throttle(self, want):
"""RT / LT are a persistent setting, so only send the change."""
"""RT / LT are a persistent setting, so only send the change.
`want` is +1 accelerate, -1 brake, 0 coast, or the string "match" for
the tutorial's both-triggers speed-match onto the current target.
"""
if want == self.throttle or self.dry:
return
self.pad.trig("RT", 1.0 if want > 0 else 0.0)
self.pad.trig("LT", 1.0 if want < 0 else 0.0)
if want == "match":
self.pad.trig("RT", 1.0)
self.pad.trig("LT", 1.0)
else:
self.pad.trig("RT", 1.0 if want > 0 else 0.0)
self.pad.trig("LT", 1.0 if want < 0 else 0.0)
self.throttle = want
def select_target(self, t):
"""A, twice: make the GAME target what we are already pointing at."""
if self.dry or t - self.select_t < self.SELECT_PERIOD:
return
self.pad.f.write("tap A 90\n")
self.pad.f.write("tap A 90\n")
self.select_t = t
self.selects += 1
def face_target(self, t):
"""B + A: snap the nose onto the selected target."""
if self.dry or t - self.face_t < self.FACE_PERIOD:
return
self.pad.press("B")
self.pad.press("A")
self.face_down = t
self.face_t = t
self.faces += 1
# -------------------------------------------------------------- targets
def hostiles(self, ents, me_off):
out = []
@@ -98,9 +293,54 @@ class Pilot:
out.append((off, nm, p, v, r, "Turret" in nm or r >= navigator.Navigator.BIG_RADIUS))
return out
def lead_point(self, p, v, d):
"""Where to aim: the target moved on by the shell's real flight time."""
return p + v * (d / SHELL_VELOCITY)
def fire_cone(self, d, r):
"""How far off the nose we will still pull the trigger.
The target's angular half-size, atan((r_target + r_shell) / range), is
the angle that can actually *hit* — but gating on it alone was measured
to be much worse than the old fixed 9°: at 2584 units a fighter subtends
2.7°, the steering loop holds the nose to ~1030°, and firing collapsed
to 1 frame in 2639. Ammunition is free and the guns are continuous, so
the angular size belongs here as a **floor** that opens the gate wider
up close, never as a cap that closes it far out.
"""
if d < 1.0:
return self.CONE_MAX
return min(self.CONE_MAX, max(self.FIRE_CONE, math.atan2(r + SHELL_RADIUS, d)))
def pick_committed(self, t, me_p, me_v, fwd, hos):
"""pick(), but stay on the same contact long enough to actually kill it."""
cur = None
for off, nm, p, v, r, hard in hos:
if off == self.commit_off and not hard:
cur = (off, nm, p, v, r)
break
if cur is not None:
off, nm, p, v, r = cur
rel = p - me_p
d = float(np.linalg.norm(rel))
behind = ang(rel, fwd) > math.pi / 2
self.behind_since = (self.behind_since if behind else None) or (t if behind else None)
stale = (t - self.commit_t > self.COMMIT_MAX
or d > self.COMMIT_DROP
or (self.behind_since is not None
and t - self.behind_since > self.COMMIT_BEHIND))
if not stale:
lead = self.lead_point(p, v, d)
return (off, nm, lead, lead - me_p, d, r)
# commit to a fresh one
tgt = self.pick(me_p, me_v, fwd, hos)
self.commit_off = tgt[0] if tgt else None
self.commit_t = t
self.behind_since = None
return tgt
def pick(self, me_p, me_v, fwd, hos):
"""Nearest *fighter*, weighted by how far off the nose it is."""
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:
@@ -109,11 +349,11 @@ class Pilot:
d = float(np.linalg.norm(rel))
if d < 1e-3:
continue
lead = p + v * (d / max(speed, 300.0))
lead = self.lead_point(p, v, d)
theta = ang(lead - me_p, fwd)
score = d * (1.0 + 3.0 * (theta / math.pi) ** 2)
if score < bestscore:
best, bestscore = (off, nm, lead, lead - me_p, d), score
best, bestscore = (off, nm, lead, lead - me_p, d, r), score
return best
def threat_vector(self, me_p, hos):
@@ -191,15 +431,32 @@ class Pilot:
self.threat_dir = self.threat_vector(me_p, hos)
frac = hull / self.hp0 if self.hp0 else 1.0
# ---- mode
# ---- the escorted asset, read exactly like our own hull
ast = self.asset(ents)
a_frac, a_dmg = 1.0, 0.0
if ast is not None:
a_hull = ast[5]
if self.asset_hp0 is None and math.isfinite(a_hull) and a_hull > 0:
self.asset_hp0 = a_hull
self.asset_hist.append((t, a_hull))
recent = [h for (ts, h) in self.asset_hist if t - ts <= 4.0]
a_dmg = (max(recent) - a_hull) if recent else 0.0
if a_dmg > self.ASSET_ALERT:
self.asset_last_hit = t
a_frac = a_hull / self.asset_hp0 if self.asset_hp0 else 1.0
# ---- mode. Our own survival still outranks the escort: a dead pilot
# defends nothing, and RETIRE/EVADE are what stopped us being shot down.
if frac <= self.RETIRE_FRAC:
self.mode = "RETIRE"
elif t - self.last_hit < self.EVADE_QUIET:
self.mode = "EVADE"
elif ast is not None and t - self.asset_last_hit < self.ASSET_QUIET:
self.mode = "DEFEND"
else:
self.mode = "ENGAGE"
tgt = self.pick(me_p, me_v, fwd, hos)
tgt = self.pick_committed(t, me_p, me_v, fwd, hos)
push, worst = self.av.avoidance(me_p, me_v, me_r, ents, me_off)
if self.mode == "EVADE":
@@ -210,6 +467,33 @@ class Pilot:
want = norm(away + jink)
self.set_throttle(+1)
fire = False
elif self.mode == "DEFEND":
# Kill what is hitting the ship, not what is nearest to us. Among
# the asset's attackers prefer the one pressing it hardest, with a
# modest discount for being closer to us so the loop does not fly
# past three targets to reach a marginally worse fourth.
atk = self.asset_attackers(hos, ast[2])
best = None
for score, off, nm, p, v, d_a, r in atk:
d_me = float(np.linalg.norm(p - me_p))
total = score + self.MY_RANGE_WEIGHT * d_me
if best is None or total < best[0]:
best = (total, off, nm, p, v, d_me, r)
if best is not None:
_, off, nm, p, v, d_me, r = best
lead = self.lead_point(p, v, d_me)
tgt = (off, nm, lead, lead - me_p, d_me, r)
want = norm(tgt[3])
self.set_throttle(+1 if d_me > 2500.0 else 0)
else:
# Nothing on it right now: hold station near the ship instead of
# wandering off, so the next wave is met at the asset.
rel = ast[2] - me_p
d = float(np.linalg.norm(rel))
want = (norm(rel) if d > ast[4] + self.ASSET_STANDOFF
else norm(np.cross(rel, up)))
self.set_throttle(+1 if d > ast[4] + self.ASSET_STANDOFF else 0)
fire = True
elif self.mode == "RETIRE":
base = self.friendly_base(ents, me_off)
if base is not None:
@@ -228,10 +512,10 @@ class Pilot:
# 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:
if tgt and tgt[4] < self.MATCH_RANGE and ang(tgt[3], fwd) < self.MATCH_CONE:
self.set_throttle("match") # sit in its rear hemisphere
elif tgt and tgt[4] > 2500.0:
self.set_throttle(+1)
elif tgt and tgt[4] < 500.0 and speed > 900.0:
self.set_throttle(-1)
else:
self.set_throttle(0)
fire = True
@@ -245,6 +529,26 @@ class Pilot:
want = norm(want + norm(me_p - p) * (2.0 * (1.0 - d / self.TURRET_KEEPOUT)))
break
# A capital ship is a wall, whatever its faction. DEFEND flies at the
# asset — which sits in the middle of the friendly formation — and the
# first escort run ended with hull 1500 -> DEAD in a single tick at
# 2026 units/s, 0.6 s from a friendly destroyer that the avoidance
# thought it would clear by 365 units. A destroyer's own radius is
# 2000. Closest-point-of-approach with a fighter-sized margin cannot
# keep us out of something that big, so give every large entity a hard
# physical keep-out scaled by ITS radius and brake inside it.
for off, nm, p, v, r in ents:
if off == me_off or r < navigator.Navigator.BIG_RADIUS:
continue
rel = me_p - p
d = float(np.linalg.norm(rel))
keep = r + self.HULL_CLEARANCE
if d < keep:
want = norm(want + norm(rel) * (2.5 * (1.0 - d / keep)))
if speed > 900.0:
self.set_throttle(-1)
break
pn = float(np.linalg.norm(push))
if pn > 1e-6:
want = norm(want + push * (3.0 if pn > 0.6 else 1.5))
@@ -255,20 +559,54 @@ class Pilot:
# 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
cone = self.fire_cone(tgt[4], tgt[5]) if tgt else self.FIRE_CONE
aim_ok = abs(aim[0]) < cone and abs(aim[1]) < cone
fire = bool(fire and tgt and aim_ok and tgt[4] < self.FIRE_RANGE and pn < 1.2)
# The main mount is a discrete launch, not a continuous stream: press Y
# and let go a tick later, then wait out MISSILE_PERIOD. Holding it
# would empty 300 rounds in half a minute.
msl = bool(tgt and self.mode in ("ENGAGE", "DEFEND")
and tgt[4] < MISSILE_RANGE
and abs(aim[0]) < MISSILE_CONE and abs(aim[1]) < MISSILE_CONE
and pn < 1.2)
if not self.dry:
self.pad.axis("LX", sx)
self.pad.axis("LY", sy)
if fire != self.firing:
(self.pad.press if fire else self.pad.release)("RB")
self.firing = fire
if (tgt and self.mode in ("ENGAGE", "DEFEND")
and abs(aim[0]) < self.SELECT_CONE
and abs(aim[1]) < self.SELECT_CONE
and tgt[4] < MISSILE_RANGE and pn < 1.2):
self.select_target(t)
if self.face_down is not None and t - self.face_down > 0.2:
self.pad.release("B")
self.pad.release("A")
self.face_down = None
elif (tgt and self.mode in ("ENGAGE", "DEFEND")
and abs(aim[0]) > self.FACE_MIN and pn < 1.2):
self.face_target(t)
if self.missile_down and t - self.missile_t > 0.15:
self.pad.release("Y")
self.missile_down = False
elif (not self.missile_down and msl
and t - self.missile_t > MISSILE_PERIOD):
self.pad.press("Y")
self.missile_down = True
self.missile_t = t
self.missiles += 1
msg = (f"{self.mode:<7} hull={hull:6.0f} shd={shield:6.0f} spd={speed:6.0f} "
f"thr={self.throttle:+d} yaw={math.degrees(yaw):+6.1f} "
f"thr={str(self.throttle):>5} yaw={math.degrees(yaw):+6.1f} "
f"pit={math.degrees(pitch):+6.1f} aim={math.degrees(aim[0]):+6.1f}"
f"/{math.degrees(aim[1]):+6.1f} fire={int(fire)}")
f"/{math.degrees(aim[1]):+6.1f} fire={int(fire)} msl={self.missiles}"
f" fc={self.faces} sel={self.selects}")
if ast is not None:
msg += f" ast={a_frac*100:5.1f}%"
if a_dmg > self.ASSET_ALERT:
msg += f" ASSET-HIT -{a_dmg:.0f}"
if dmg > 0.5:
msg += f" HIT -{dmg:.0f}"
if tgt:

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# ONE blocking session: boot -> Stage 02 in flight -> fly AT a capital ship and
# dump an F10 draw capture at each distance band (approach_capture.py).
#
# Supersedes ship_capture_session.sh for correlation work: that one pressed F10
# wherever the craft happened to be, and the 2026-07-31 run proved that captures
# nothing — inverting the match showed no capital-ship hull was drawn in any of
# those frames, only the player's own craft and particles. Range is the variable
# that matters, so range is what this controls.
#
# Usage: ship_capture_close.sh [seconds] [out_dir] [target_regex]
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}"
OUT="${2:-/sylph-home/re/shipcap-close}"
TARGET="${3:-}"
BINDIR="/home/fabi/RE - Project Sylpheed/xenia-canary-native/build/bin/Linux/Release"
CFG=/tmp/nav-close.json
mkdir -p "$OUT"
rm -f "$BINDIR"/xenia_ship_capture_*.log "$OUT"/approach-bands.jsonl
"$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")"
# F10 goes to the emulator window through XTEST; the window must be focused.
win="$(xdotool search --class -- xenia | tail -1)"
[ -z "$win" ] && win="$(xdotool search --name -- Xenia | tail -1)"
echo "WINDOW=$win"
[ -n "$win" ] && { xdotool windowactivate "$win" 2>/dev/null; xdotool windowfocus "$win" 2>/dev/null; }
export SYLPH_CAPTURE_WIN="$win" SYLPH_CAPTURE_OUT="$OUT"
if [ -n "$TARGET" ]; then
python3 "$SD/approach_capture.py" "$CFG" "$SECS" --target "$TARGET" 2>&1 | tail -80
else
python3 "$SD/approach_capture.py" "$CFG" "$SECS" 2>&1 | tail -80
fi
sleep 2
cp -v "$BINDIR"/xenia_ship_capture_*.log "$OUT"/ 2>/dev/null
echo "--- bands ---"; cat "$OUT/approach-bands.jsonl" 2>/dev/null
grep -c '^DRAW' "$OUT"/xenia_ship_capture_*.log 2>/dev/null
echo "CLOSE CAPTURE SESSION DONE"

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# ONE blocking session: boot -> Stage 02 in flight -> sweep the view and press
# F10 several times, so each press dumps xenia_ship_capture_NN.log with whatever
# capital ships are on screen at that moment.
#
# Why a sweep and not a single press: the 2026-07-26 e106 capture missed the
# bridge because it was culled at that camera angle. Several presses at
# different headings cost nothing (the capture is a one-frame draw dump) and
# each one is independently correlatable.
#
# Usage: ship_capture_session.sh [presses] [out_dir]
set -u
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
SD="$(cd "$(dirname "$0")" && pwd)"
PRESSES="${1:-6}"
OUT="${2:-/sylph-home/re/shipcap}"
BINDIR="/home/fabi/RE - Project Sylpheed/xenia-canary-native/build/bin/Linux/Release"
SHOTS=/sylph-home/re/shots
mkdir -p "$OUT" "$SHOTS"
rm -f "$BINDIR"/xenia_ship_capture_*.log
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
# F10 goes to the emulator window through XTEST; the window must be focused.
win="$(xdotool search --class -- xenia | tail -1)"
[ -z "$win" ] && win="$(xdotool search --name -- Xenia | tail -1)"
echo "WINDOW=$win"
[ -n "$win" ] && { xdotool windowactivate "$win" 2>/dev/null; xdotool windowfocus "$win" 2>/dev/null; }
for i in $(seq 1 "$PRESSES"); do
screenshot "$SHOTS/shipcap-$i.png" >/dev/null 2>&1
if [ -n "$win" ]; then xdotool key --window "$win" F10; else xdotool key F10; fi
sleep 3
# Yaw a little between presses so a culled part gets another chance, and the
# craft keeps closing on the friendly formation (the capital ships).
vgamepad axis LX 0.45; sleep 1.2; vgamepad axis LX 0.0
sleep 3
done
sleep 2
cp -v "$BINDIR"/xenia_ship_capture_*.log "$OUT"/ 2>/dev/null
cp -v "$SHOTS"/shipcap-*.png "$OUT"/ 2>/dev/null
grep -c '^DRAW' "$OUT"/xenia_ship_capture_*.log 2>/dev/null
echo "CAPTURE SESSION DONE"

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Find the SELECTED TARGET inside the player object, and the input that changes it.
The OPTIONS key-config screen lists `Change Target` and `Padlock Mode Toggle` as
real bindable actions (docs/re/flight-controls-runtime.md), but a button sweep
that watched the *ammo counters* could not see either — neither action fires a
weapon. Screenshots of the reticle were no better: the view keeps moving, so
"did the selection change" is not legible frame to frame.
Guest memory is legible. If the craft holds a selected target, it holds a
**pointer to that target's object**, and every live entity's address is already
known from the entity scan. So:
1. enumerate live entities and their addresses;
2. read a window of the player object and keep every word that points at one
of them (allowing a small fixed delta, since a pointer to an object's base
is not a pointer to its transform);
3. tap each candidate input and see which of those words switches to a
*different* entity.
The word that follows the button is the selection, and the button that moves it
is `Change Target`. Both answers come out of the same run, and neither depends
on reading pixels.
Usage: target_probe.py <config.json> [seconds_per_button]
"""
import json
import os
import struct
import sys
import time
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gmem # noqa: E402
import navigator # noqa: E402
from flight_probe import Pad # noqa: E402
BACK, FWD = 0x400, 0x1000
MAX_DELTA = 0x400 # how far below its transform an object's base may sit
BUTTONS = ["LB", "X", "B", "A", "LS", "RS", "BACK", "START", "Y", "RB"]
def main():
cfg = json.load(open(sys.argv[1]))
dwell = float(sys.argv[2]) if len(sys.argv) > 2 else 1.2
W = navigator.World(cfg)
ents = W.scan()
me = [(off, va) for off, va in ents if "Player" in W.defs[va]]
if not me:
sys.exit("player entity not found — not in flight?")
me_off = me[0][0]
print(f"# player object at {gmem.primary_va(me_off):#010x}, "
f"{len(ents)} live entities")
# every address that could be a pointer to some entity
ptr_map = {}
for off, va in ents:
pos_va = gmem.primary_va(off)
if pos_va is None:
continue
for d in range(0, MAX_DELTA, 4):
ptr_map.setdefault(pos_va - d, (W.defs[va], d, off))
def window():
b = os.pread(W.fd, BACK + FWD, me_off - BACK)
return np.frombuffer(b, dtype=">u4").copy()
# ---- global mode: the selection need not live in the player object at all.
# Scan every mapped extent for words that hold an entity pointer, then see
# which of THOSE follow a button. A word that is an entity pointer before
# AND after, pointing at a different entity, is a selection by construction.
keys = np.array(sorted(ptr_map), dtype=np.uint32)
def global_ptrs():
out = {}
for a0, b0 in gmem.extents(W.fd, W.size):
n = (b0 - a0) // 4 * 4
if n < 64:
continue
arr = np.frombuffer(os.pread(W.fd, n, a0), dtype=">u4").astype(np.uint32)
idx = np.flatnonzero(np.isin(arr, keys))
for k in idx:
out[a0 + int(k) * 4] = int(arr[k])
return out
# ---- delta mode: WHERE does an entity keep its target?
# The AI ships clearly hold pointers to other entities, so the field is a
# fixed offset from the transform — exactly the situation entities2.py
# solved for the definition pointer. Tally, over many entities, the delta at
# which a word points at *another* entity; the offset that repeats is the
# field, and reading it on the PLAYER gives our selected target.
if "--delta" in sys.argv:
from collections import Counter
votes, examples = Counter(), {}
for off, va in ents:
blob = os.pread(W.fd, 0x1000, max(0, off - 0x800))
arr = np.frombuffer(blob[:len(blob) // 4 * 4], dtype=">u4")
for i, w in enumerate(arr):
hit = ptr_map.get(int(w))
if hit and hit[2] != off:
d = i * 4 - 0x800
votes[d] += 1
examples.setdefault(d, (W.defs[va], hit[0]))
print(f"# target-pointer delta candidates over {len(ents)} entities:")
for d, n in votes.most_common(10):
src, dst = examples[d]
print(f" pos{d:+#07x} seen {n:4d} e.g. {src[:26]:<26} -> {dst}")
best = votes.most_common(1)
if best:
d = best[0][0]
pw = struct.unpack(">I", os.pread(W.fd, 4, me_off + d))[0]
hit = ptr_map.get(pw)
print(f"\n# PLAYER at that delta: {pw:#010x} -> "
f"{hit[0] if hit else 'not an entity pointer'}")
return
a = window()
cands = []
for i, w in enumerate(a):
hit = ptr_map.get(int(w))
if hit and hit[2] != me_off: # a self-pointer is not a target
cands.append((i, hit[0], hit[1]))
print(f"# {len(cands)} word(s) in the player object point at a live entity")
for i, nm, d in cands[:30]:
print(f" pos{i * 4 - BACK:+#07x} -> {nm} (entity_va - {d:#x})")
if not cands:
print("# none — widen BACK/FWD or MAX_DELTA, or nothing is selected")
# ---- which input moves the selection?
pad = Pad()
if "--global" in sys.argv:
print("\n# GLOBAL scan: every word in RAM that holds an entity pointer")
g0 = global_ptrs()
print(f"# {len(g0)} entity-pointer words in RAM")
for btn in BUTTONS:
pad.f.write(f"tap {btn} 200\n")
time.sleep(dwell)
g1 = global_ptrs()
moved = [(o, g0[o], g1[o]) for o in g0
if o in g1 and g1[o] != g0[o]]
named = []
for o, v0, v1 in moved[:4]:
n0 = ptr_map.get(v0, ("?",))[0]
n1 = ptr_map.get(v1, ("?",))[0]
named.append(f"{gmem.primary_va(o):#010x} {n0[:16]}->{n1[:16]}")
print(f" [{btn:<5}] {len(moved):4d} switched " + " ".join(named),
flush=True)
g0 = g1
pad.reset()
return
print("\n# tapping each input; a word that switches to a DIFFERENT entity is"
" the selection")
for btn in BUTTONS:
before = window()
pad.f.write(f"tap {btn} 200\n")
time.sleep(dwell)
after = window()
moved = []
for i, nm, d in cands:
if before[i] == after[i]:
continue
hit = ptr_map.get(int(after[i]))
moved.append((i, nm, hit[0] if hit else f"{int(after[i]):#010x}"))
tag = " ".join(f"pos{i * 4 - BACK:+#x} {o[:18]}->{n[:18]}"
for i, o, n in moved[:3])
print(f" [{btn:<5}] {len(moved):2d} changed {tag}", flush=True)
pad.reset()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Play one TUTORIAL and photograph what it teaches.
#
# The OPTIONS key-config screen names the in-flight actions but not the buttons
# they sit on, and probing the pad found the weapons only (an action that does
# not fire a gun is invisible in the ammo counters). The tutorials state the
# mapping outright — `ADVANCED CONTROLS` is index 5 and is where Change Target
# and Padlock Mode live — so read it from the game instead of guessing.
#
# Boot + nav is launch_mission.sh's route as far as the main menu, then TUTORIAL
# instead of LOAD GAME. Everything stays a CHILD of this script (no setsid): a
# detached process is not a survivable one here, see the session-lifetime note.
set -u
N="${1:-5}" # 0 BASIC, 1 HUD, 2 RADAR, 3 SUPPLY, 4 RADIO, 5 ADVANCED
SECS="${2:-150}"
TAG="${3:-tut}"
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
SD="$(cd "$(dirname "$0")" && pwd)"
SHOTS=/sylph-home/re/shots
alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $1}'; }
step(){ vgamepad dpad "$1"; sleep 0.25; vgamepad dpad center; sleep 0.7; }
shot(){ screenshot "$SHOTS/$TAG-$1.png" >/dev/null 2>&1; }
pkill -x xenia_canary 2>/dev/null; sleep 2
[ -n "$(alive)" ] && { kill -9 $(alive) 2>/dev/null; sleep 2; }
rm -f /dev/shm/xenia_memory_* /dev/shm/xenia_code_cache_* 2>/dev/null
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
rm -f "/tmp/.X${DISPLAY#:}-lock" 2>/dev/null || true
nohup Xvfb "$DISPLAY" -screen 0 1280x720x24 -ac -nolisten tcp \
+extension GLX +extension RANDR </dev/null >/tmp/xvfb98.log 2>&1 &
for _ in $(seq 1 50); do xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break; sleep 0.2; done
nohup env DISPLAY="$DISPLAY" HOME=/sylph-home openbox </dev/null >/tmp/openbox98.log 2>&1 &
sleep 1
fi
cd /sylph-home/re
nohup run-canary --audio --apu=sdl --log_mask=13 \
--logged_profile_slot_0_xuid=E0300000EFBEA3D4 </dev/null >/dev/null 2>&1 &
sleep 5
"$SD/skip_intro.sh" 600 || { echo "BOOT FAILED (skip_intro exit $?)"; exit 1; }
sleep 14 # main menu is not input-ready before this
step down; step down # NEW GAME -> LOAD GAME -> TUTORIAL
vgamepad tap A 250; sleep 8
shot "list"
i=0; while [ "$i" -lt "$N" ]; do step down; i=$((i+1)); done
shot "pick"
vgamepad tap A 250; sleep 6
shot "sub" # some tutorials offer Level 1 / Level 2
vgamepad tap A 250
# Then just watch. The lesson drives itself and prints its instructions; tap A
# periodically to advance any prompt, and photograph often enough to catch the
# caption before it is replaced.
end=$(( SECONDS + SECS )); n=0
while [ $SECONDS -lt $end ]; do
n=$((n+1)); shot "$(printf '%02d' $n)"
[ -n "$(alive)" ] || { echo "EMULATOR GONE at ${SECONDS}s"; exit 4; }
sleep 5
[ $((n % 3)) -eq 0 ] && vgamepad tap A 250
done
echo "TUTORIAL CAPTURE DONE ($n frames)"