Compare commits
17 Commits
auto/re-au
...
auto/re-mi
| Author | SHA1 | Date | |
|---|---|---|---|
| f4d59c5783 | |||
| 530555de9f | |||
| 69b4a2e569 | |||
| ca500c171e | |||
| 3d9f21f030 | |||
| 1d4b35df0f | |||
|
|
bbfeb1c387 | ||
|
|
695351dfc4 | ||
|
|
4821ba7fea | ||
|
|
58f421d896 | ||
|
|
9f41fe08e9 | ||
|
|
95ac545b2b | ||
|
|
ab8f5307ff | ||
|
|
76f463b611 | ||
|
|
3f6efadf9e | ||
|
|
402985adbf | ||
|
|
c277e42c92 |
280
crates/sylpheed-formats/examples/correlate_frames.rs
Normal 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());
|
||||
}
|
||||
156
crates/sylpheed-formats/examples/invert_capture.rs
Normal 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() });
|
||||
}
|
||||
}
|
||||
98
crates/sylpheed-formats/examples/vcount_index.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 02–29 — 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
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
docs/re/captures/dpad-tactical-map.png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
docs/re/captures/escort-stage02-hud.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/re/captures/fire-probe-ammo-counters.png
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
BIN
docs/re/captures/first-missile-kills.png
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
docs/re/captures/kill-counters-all-runs.png
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
docs/re/captures/kills-target-commitment.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
330
docs/re/captures/mission-state-stage02-escort-weighted.jsonl
Normal file
240
docs/re/captures/mission-state-stage02.jsonl
Normal file
BIN
docs/re/captures/mission01-t485.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/re/captures/options-key-config-actions.png
Normal file
|
After Width: | Height: | Size: 881 KiB |
BIN
docs/re/captures/shipcap-close-f105-2994.png
Normal file
|
After Width: | Height: | Size: 562 KiB |
BIN
docs/re/captures/shipcap-stage02-launch.png
Normal file
|
After Width: | Height: | Size: 543 KiB |
BIN
docs/re/captures/tutorial-advanced-controls-captions.png
Normal file
|
After Width: | Height: | Size: 626 KiB |
BIN
docs/re/captures/tutorial-hud-target-select.png
Normal file
|
After Width: | Height: | Size: 884 KiB |
BIN
docs/re/captures/tutorial-menu-labels.png
Normal file
|
After Width: | Height: | Size: 890 KiB |
104
docs/re/flight-controls-runtime.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# In-flight control mapping — measured, not assumed
|
||||
|
||||
**Status:** ✅ for the weapon bindings (ammo counters move), 🟡 for the rest (HUD
|
||||
observation only). Probes: `tools/re-capture/fire_probe.sh` (hold each input, photograph
|
||||
the ammo counters) and `lock_probe.sh` (tap each, watch the reticle). Stage 02, in flight.
|
||||
Evidence: [`captures/fire-probe-ammo-counters.png`](captures/fire-probe-ammo-counters.png).
|
||||
|
||||
| input | effect | confidence |
|
||||
|---|---|---|
|
||||
| **`RB`** | **Nose gun.** `NOSE BM` 06000 → 05956 in a 4 s hold ≈ **11 rounds/s**; `HEAT` bar rises | ✅ |
|
||||
| **`Y`** | **Main mount** (missiles). `MAIN MPM` 00300 → 00299 per tap | ✅ |
|
||||
| **`RT` / `LT`** | Throttle up / brake, a *persistent* setting (488 → 1510 → 174 u/s) | ✅ (earlier session) |
|
||||
| **d-pad** | **Tactical map** overlay (grid with contact blips) — not target cycling | 🟡 |
|
||||
| `LB`, `X`, `B`, `A`, `LS`, `RS` | No change to either ammo counter | ✅ (as "not a weapon") |
|
||||
|
||||
## ~~Targeting appears to be automatic~~ — WRONG, corrected below
|
||||
|
||||
> **Superseded.** This section concluded targeting was automatic because no input
|
||||
> cycled a target. It is wrong: the HUD tutorial states target select is **Ⓐ pressed
|
||||
> twice**, and every sweep here tapped once. Kept because the reasoning is a useful
|
||||
> warning — a probe that never performs the action will "prove" the action does not
|
||||
> exist. The rest of the section's measurements stand.
|
||||
|
||||
No *single* press cycled a target. The green `TARGET` marker is already present in
|
||||
idle frames with nothing pressed, which I read as the game selecting for us.
|
||||
|
||||
That fits the measurements end to end:
|
||||
|
||||
- the guns fire fine (11 rounds/s) but the kill counters read `0000` after five gun-only
|
||||
runs → **we shoot and miss**;
|
||||
- guided missiles (`Missile_P`, Power 200, `GuidanceType` 5) got the first kills,
|
||||
`WARPLANES 0002`, but only **2 per 98 launches**;
|
||||
- the pilot's own log shows aim error wandering between ~10° and ~40° for most of a
|
||||
pass.
|
||||
|
||||
At the time I concluded the bottleneck was aim dwell. Partly right — target
|
||||
**commitment** did take kills 2 → 9 — but the larger cause was simply that no target was
|
||||
ever selected, so the guided missiles had nothing to guide to.
|
||||
|
||||
## The game's own action list (from the OPTIONS key-config screen)
|
||||
|
||||
Decoded from `dat/GP_OPTIONS.pak` (`po_keys_btn*` sprites) — this is the authoritative
|
||||
set of bindable in-flight actions, straight off the disc, no probing required:
|
||||
|
||||
| # | Action | Our mapping |
|
||||
|---|---|---|
|
||||
| 1 | Aircraft Control | LX/LY ✅ |
|
||||
| 2 | View Point Control | RX/RY (unused by the pilot) |
|
||||
| 3 / 4 | Left / Right Yaw Control | — (separate from pitch/roll!) |
|
||||
| 5 / 6 | Accelerate / Decelerate | `RT` / `LT` ✅ |
|
||||
| 7 | **Use Main Weapon** | `Y` ✅ |
|
||||
| 8 | **Use Nose Weapon** | `RB` ✅ |
|
||||
| 9 | Special Move | ❔ |
|
||||
| 10 | Maneuver | ❔ |
|
||||
| 11 | Resupply | ❔ |
|
||||
| 12 | **Change Target** | ❔ — **this is the target-select the loop needs** |
|
||||
| 13 | Change Main Weapon | ❔ (would reach `ASMissile`, Power 5000) |
|
||||
| 14 | **Padlock Mode Toggle** | ❔ — **the aim-dwell mechanism** |
|
||||
| 15 | Radar Map Toggle | d-pad 🟡 (matches the observed map overlay) |
|
||||
|
||||
Two entries change the plan outright:
|
||||
|
||||
- **`Change Target` exists**, so target selection *is* an input after all. The earlier
|
||||
probe swept `LB/X/B/A/LS/RS` and found no ammo change — consistent with those being
|
||||
exactly these non-weapon actions. The probe simply watched the wrong indicator.
|
||||
- **`Padlock Mode Toggle`** is a view/aim lock onto the selected target. That is the
|
||||
aim-dwell problem solved *by a game mechanic* rather than by tuning a PD controller —
|
||||
and it is why a human player can hold a contact long enough to lock a missile.
|
||||
|
||||
Also note `CONTROL SETTINGS` carries a **`Control Type`** preset plus **Yaw / Pitch /
|
||||
Roll Sensitivity** and a separate **`Throttle`** option: the mapping is not fixed, and
|
||||
the craft's response to a given stick deflection is configurable. Any calibration done
|
||||
against one profile (e.g. the `ctrl_probe.py` throttle numbers) is only valid for the
|
||||
save's current settings.
|
||||
|
||||
## What the tutorials state outright
|
||||
|
||||
`tutorial_capture.sh <index> <secs> <tag>` plays one lesson and photographs it. Captions
|
||||
use a typewriter effect, so crop `900x125+160+40` from many frames to read a full
|
||||
sentence. Lessons that require the player to *do* something stall (BASIC CONTROLS sits
|
||||
on "Go to the box on your screen" forever with nobody flying); the expository ones run
|
||||
on their own.
|
||||
|
||||
- **HEADS-UP DISPLAY (index 1):** *"Enemies are displayed with **red markers** and allies
|
||||
with **blue markers**." · "Targeting an enemy displays an Armor Gauge…" ·* **"Press Ⓐ
|
||||
twice to target the enemy closest to the center of the screen."**
|
||||
- **ADVANCED CONTROLS (index 5):** `B`+`LS` = Side Roll / 180 Degree Turn / Level Off ·
|
||||
`B`+`A` together = face the target · `LT`+`RT` together = *"sets your fighter's speed
|
||||
to that of the target… works well when you are trying to get behind an enemy. Once
|
||||
behind an enemy, this also helps you attack them."*
|
||||
|
||||
**`Change Target` is Ⓐ pressed TWICE** — a double tap. That is why every button sweep in
|
||||
this document found nothing and why I wrongly concluded targeting was automatic: each
|
||||
sweep tapped once. It also explains the missiles — `GuidanceType 5` needs the *game's*
|
||||
selection, and the loop had never made one, so 98 launches guided to nothing.
|
||||
|
||||
## Notes for the reimplementation
|
||||
|
||||
- Two independent weapons with separate ammo pools and separate HUD counters:
|
||||
`NOSE BM` (gun, 6000) and `MAIN MPM` (missiles, 300).
|
||||
- The gun has a **HEAT** bar that fills while firing — a sustained-fire limit the
|
||||
reimplementation needs; its cap and cool-down rate are not measured yet.
|
||||
- The tactical map is a full-screen overlay bound to the d-pad and does not pause flight
|
||||
(the craft kept taking fire with it open).
|
||||
225
docs/re/mission-escort-state.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Escort / mission state from guest RAM — every entity's hull
|
||||
|
||||
**Status:** ✅ CONFIRMED (2026-07-30). Capture: `tools/re-capture/mission_state.py`,
|
||||
session `tools/re-capture/escort_session.sh`, Stage 02 from save slot 01, 240 s of
|
||||
flight, 240 samples at 1 Hz → [`captures/mission-state-stage02.jsonl`](captures/mission-state-stage02.jsonl).
|
||||
Screenshot evidence: [`captures/escort-stage02-hud.png`](captures/escort-stage02-hud.png).
|
||||
|
||||
## The question
|
||||
|
||||
`own_state.py` found the **player's** hull by anchoring on a solved definition
|
||||
field — an undamaged craft carries its definition's `HP` (+0x054), so the live
|
||||
counter is the copy of that number that falls. Result: `hull = position + 0x154`
|
||||
([autopilot](autopilot-memory-driven.md)).
|
||||
|
||||
Stage 02 is an **escort**, and it is lost when the ACROPOLIS sinks, not when the
|
||||
player dies: a 240 s run hit `GAME OVER` with our own hull at 1500/1500. Scoring
|
||||
that objective needs *someone else's* hull. So: is `+0x154` a property of the
|
||||
**entity class**, or of the player object?
|
||||
|
||||
## Finding — it is class-wide
|
||||
|
||||
At the first sample of the run, before this session's fighting had touched them,
|
||||
`pos+0x154` equals the entity's own definition `HP` across **seven classes and
|
||||
five distinct HP values**:
|
||||
|
||||
| Class | radius | definition `HP` | `pos+0x154` at t=0 |
|
||||
|---|---|---|---|
|
||||
| `UN_e007_ADAN_Turret` | 22 | 100 | 100.0 (all 60 instances) |
|
||||
| `UN_e010_ADAN_Attacker_S` | 100 | 500 | 500.0 (all 19) |
|
||||
| `UN_f106_TCAF_Destroyer` | 2000 | 10000 | 10000.0 |
|
||||
| `UN_e106_ADAN_Destroyer` | 2100 | 10000 | 10000.0 |
|
||||
| `UN_f105_TCAF_Cruiser` | 3800 | 30000 | 30000.0 |
|
||||
| `UN_e105_ADAN_Cruiser` | 3800 | 30000 | 30000.0 |
|
||||
| **`UN_f101_TCAF_Acropolis`** | 1400 | **25000** | **25000.0** |
|
||||
|
||||
Measured directly by `mission_state.py scan` at the start of three separate runs:
|
||||
**146/150, 147/150 and 147/150 entities** hold exactly their definition's `HP` at
|
||||
`pos+0x154`. The handful that do not sit *slightly below* it (9800/10000,
|
||||
29933.3/30000, 9725/10000, …) — the battle is already in progress when the player
|
||||
launches, so those ships have already been shot at. **Nothing read above its `HP`,
|
||||
and nothing read an unrelated number**, which is what a coincidental offset would
|
||||
produce.
|
||||
|
||||
The value behaves like a live counter, not a copy of the definition:
|
||||
|
||||
- it **falls under fire** — 780 distinct damage events were logged across the run;
|
||||
- it **goes negative at death** and the entity then disappears from the heap
|
||||
(`UN_f106_TCAF_Destroyer` → `-30.0` of 10000, another → `-0.0`, a third GONE);
|
||||
- the drops match what the HUD draws — the screenshot shows the ACROPOLIS and the
|
||||
destroyer *CHARON* each with their own health bar, CHARON's already red.
|
||||
|
||||
So **`hull = position + 0x154` for every entity**, and the escort objective is
|
||||
directly scoreable: read the protected ship's hull, normalise by its definition's
|
||||
`HP`, done. No new anchor, no value scan.
|
||||
|
||||
## The escort asset, measured
|
||||
|
||||
`UN_f101_TCAF_Acropolis`, one instance, `HP` 25000, collision radius 1400.
|
||||
|
||||
Its hull over the 240 s run (pilot chasing the nearest hostile fighter, the
|
||||
current `pilot.py` behaviour):
|
||||
|
||||
```
|
||||
t= 0..150s 25000.0 untouched
|
||||
t= 180.1s 24779.5
|
||||
t= 210.1s 24149.5
|
||||
t= 239.1s 23038.2 -1961.8 total, ≈ -600 HP/min once it starts
|
||||
```
|
||||
|
||||
**⚠️ Onset is NOT a fixed schedule — corrected by a later run.** From this run alone
|
||||
it looked like the asset is safe for the first ~170 s. A second run put the first
|
||||
damage at **t = 70 s**, and its hostile population *grew* (134 → 166 ADAN) where this
|
||||
one's shrank (147 → 118). So the stage is not replaying identically, and "the asset
|
||||
is untouched early" is a property of one run, not of Stage 02. What survives the
|
||||
second run is the weaker, still useful claim: **the loss is slow** — a few hundred to
|
||||
~1400 HP/min against 25000, so tens of minutes to sink. The earlier `GAME OVER`
|
||||
therefore was not a fast loss; it was an undefended one.
|
||||
|
||||
## Also captured
|
||||
|
||||
- Hostile population fell 147 → 118 over the run (the pilot fired on 435 of 1913
|
||||
engage frames; most of the remainder it was manoeuvring with the target outside
|
||||
the 9° firing cone).
|
||||
- Two friendly destroyers were lost while the pilot was elsewhere.
|
||||
- The HUD's `REMAINING OB` read **012** at t≈240 s while 118 ADAN entities were
|
||||
alive, so that counter is **objectives, not hostiles** — its RAM address is still
|
||||
unknown (❔ open).
|
||||
|
||||
## Escort-weighted targeting — implemented, and what it did NOT fix
|
||||
|
||||
`pilot.py` gained a **DEFEND** mode (2026-07-30): while the asset is losing hull,
|
||||
target the hostiles pressing *it* — ranked by distance to the asset minus credit for
|
||||
closing on it — instead of the ones nearest to us. Trigger and ranking both read the
|
||||
live hull, so nothing is inferred.
|
||||
|
||||
It works mechanically: DEFEND engaged **1.9 s after the asset's first hit** in one run
|
||||
(t=167.0), and held for 54 % of a 330 s run. **But it did not measurably save the
|
||||
asset.** Over the window the two policies share, they are the same to within noise:
|
||||
|
||||
| t (s) | nearest-fighter | escort-weighted |
|
||||
|---|---|---|
|
||||
| 120 | 25000.0 | 24910.0 |
|
||||
| 180 | 24779.5 | 24460.0 |
|
||||
| 239 | 23038.2 | 23218.0 |
|
||||
|
||||
Two honest reasons it cannot yet be scored better than "no worse":
|
||||
|
||||
1. **The runs are not comparable past that window** — different spawn timing and, in
|
||||
the escort-weighted run, a hostile population that *grew* 134 → 166 while the
|
||||
baseline's fell 147 → 118.
|
||||
2. **Lethality is the real bottleneck, not target choice.** The guns are on for only
|
||||
**12 % of combat frames** (320 of 2630); the rest of the time the target is outside
|
||||
the 9° firing cone while the loop manoeuvres. Choosing a better target does little
|
||||
when most passes do not shoot.
|
||||
|
||||
**One bug found and fixed by the first escort run** (worth keeping as a pattern): the
|
||||
new mode flies *at* the asset, which sits inside the friendly formation, and the run
|
||||
ended `hull 1500 -> DEAD` in a single tick at 2026 units/s, 0.6 s from a friendly
|
||||
destroyer the avoidance expected to clear by 365 units — against a hull of radius
|
||||
2000. Keep-out had been applied only to hostile turrets. Every entity above
|
||||
`BIG_RADIUS` now gets a physical keep-out of **its own radius + 800**, with braking
|
||||
inside it, whatever its faction; the next run survived its full 330 s untouched.
|
||||
|
||||
## Ballistics from the disc data — and the measurement that invalidates the metric
|
||||
|
||||
The solved `Shell` records give the player's guns exactly
|
||||
(`Shell_TCAF_DeltaSaber_{NoseGun,Gun,Beam}_P`, all ✅ CONFIRMED):
|
||||
**`Velocity` 8000**, **`LifeTime` 0.5 s**, **`MaximumRange` 4000** — self-consistent,
|
||||
since 8000 × 0.5 = 4000 — plus shell `Radius` 20–30 and `Power` 15/30/40.
|
||||
|
||||
Two things in `pilot.py` were plainly wrong against those numbers, and both are fixed:
|
||||
|
||||
- **Lead used our own speed as the shell speed.** Flight time was `d / max(our_speed,
|
||||
300)`, i.e. 400–2000 u/s instead of 8000 — every shot led **4–16× too far ahead**.
|
||||
- **`FIRE_RANGE` was 5000**, past the range at which the shells expire.
|
||||
|
||||
**But the outcome metric says none of this has been shown to help.** The HUD's own
|
||||
counters — `YOU KILLED: WARSHIPS` / `WARPLANES` — read **0000 / 0000 at the end of
|
||||
every run**, including the nearest-fighter baseline. The pilot is not killing
|
||||
anything in any configuration, so "fraction of frames with the guns on" (12 % → 5 % →
|
||||
1 frame in 2639 as the firing gate was varied) was never measuring lethality. The
|
||||
corrections above are right on the physics and fix demonstrably wrong code; **they are
|
||||
not evidence of improvement**, and none is claimed.
|
||||
|
||||
The firing gate itself produced one clean result worth keeping: gating on the target's
|
||||
angular half-size **alone** (2.7° at 2584 units for a fighter) is far tighter than the
|
||||
steering loop can hold the nose, and firing collapsed to 1 frame in 2639. Angular size
|
||||
belongs in the gate as a **floor** that opens it up close, never as a cap.
|
||||
|
||||
### Why nothing died — settled by probe, then fixed
|
||||
|
||||
`fire_probe.sh` holds each pad input in turn in flight and photographs the HUD ammo
|
||||
counters. Result:
|
||||
|
||||
| input | `NOSE BM` | `MAIN MPM` |
|
||||
|---|---|---|
|
||||
| idle | 06000 | 00300 |
|
||||
| **RB** | **05956** (−44 in 4 s, HEAT rises) | 00300 |
|
||||
| **Y** | 05951 | **00299** (−1) |
|
||||
| LB / X / B / A / RT / LT | no change | no change |
|
||||
|
||||
So **`RB` is the nose gun (~11 rounds/s) and `Y` is the main mount** — measured, not
|
||||
assumed — and the "we never shoot" hypothesis is dead: **we shoot and miss.**
|
||||
|
||||
Which is what the disc data says to stop doing. `Shell_TCAF_DeltaSaber_Missile_P` is
|
||||
**Power 200, `GuidanceType` 5 (guided), `MaximumRange` 5000**, against the nose gun's
|
||||
**Power 15, unguided**. One missile is worth ~14 gun hits on a 500 HP fighter *and it
|
||||
steers itself* — the accuracy problem solved rather than tuned. (`ASMissile_P` is
|
||||
Power **5000**, the anti-ship option.)
|
||||
|
||||
Adding missile launches to the pilot (press `Y`, release a tick later, ≥2 s apart)
|
||||
produced **the first kills of the whole series: `YOU KILLED: WARPLANES 0002`**, versus
|
||||
`0000` in all five gun-only runs, with hostiles down 134 → 104 (the largest fall yet).
|
||||
|
||||
**Still poor, and stated as such: 98 missiles for 2 kills (~2 %).** The likely cause is
|
||||
that the game expects a *lock* — holding the target in the reticle before launch — and
|
||||
an unlocked launch is wasted. Reading the lock state (or the lock timer) out of RAM is
|
||||
the next step, and it is the same anchoring trick as everything else here.
|
||||
|
||||
## Target commitment — the change that actually moved kills
|
||||
|
||||
The pilot re-scored every contact every tick, so the nose chased whichever fighter was
|
||||
momentarily best-scoring and the aim error wandered 10–40° through a pass. Since a
|
||||
missile lock is time-on-target, constant switching is the one thing guaranteed to
|
||||
prevent a kill. **Commitment**: stay on the chosen contact until it dies, gets beyond
|
||||
6000, sits >90° off the nose for 2.5 s, or 14 s elapse.
|
||||
|
||||
Nothing else changed — same guns, same ballistics, same escort weighting, same missile
|
||||
cadence:
|
||||
|
||||
| run | kills (`WARPLANES`) | missiles | hostiles |
|
||||
|---|---|---|---|
|
||||
| gun-only × 5 | **0000** | 0 | 147→118 … 134→166 |
|
||||
| + guided missiles | **0002** | 98 | 134→104 |
|
||||
| + **target commitment** | **0009** | 101 | **134→97** |
|
||||
|
||||
4.5× the kills for the same ammunition, and the largest fall in hostile population of
|
||||
any run. Our own hull finished untouched at 1500/1500.
|
||||
|
||||
**The escort is still not saved** — the ACROPOLIS finished at 76.6 % — so this improves
|
||||
lethality, not the mission outcome, and the two should not be conflated.
|
||||
|
||||
### Negative result: the selected target is not a raw entity pointer
|
||||
|
||||
Worth recording so it is not re-attempted. `target_probe.py` looked for the selection
|
||||
three ways: (1) every word in a ±0x1400 window of the player object that points at a
|
||||
live entity — **none**; (2) every word in *all* of RAM holding an entity pointer, tapped
|
||||
through each button — only thread-stack slots (`0x70xx_xxxx`) churned, which is frame
|
||||
noise, not selection; (3) a delta tally over all 150 entities looking for a repeated
|
||||
offset holding a pointer to *another* entity, the same trick that found the definition
|
||||
pointer at `+0x130` — **zero candidates**.
|
||||
|
||||
So neither the player nor the AI ships keep a raw pointer to their target near their
|
||||
transform. The selection is a handle, an index, or lives in a targeting subsystem
|
||||
outside the entity object.
|
||||
|
||||
## Reimplementation notes
|
||||
|
||||
- Defeat conditions for an escort stage are readable as: protected-asset
|
||||
`hull ≤ 0`, or player `hull ≤ 0`.
|
||||
- Every unit's effective HP is the definition's `HP`, confirmed live for 7 classes —
|
||||
the same field the [unit struct](structures/unit-struct-runtime.md) already solves
|
||||
statically, so disc data and runtime agree.
|
||||
- Entity removal on death is observable (the object leaves the heap), which gives a
|
||||
clean lifetime signal for anything modelling spawn/despawn.
|
||||
89
docs/re/mission-outcome-stage02.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Why Stage 02 is never won — the escort sinks at ~11 minutes (2026-08-10)
|
||||
|
||||
**Status: ✅ measured, one 500 s run.** The standing open item since 2026-07-29 was
|
||||
"no mission completed". This is the first session whose deliverable was the
|
||||
*ending* rather than a measurement, and it settles why: **the mission is lost
|
||||
before it can be won, and the pilot's survival policy is what guarantees it.**
|
||||
|
||||
Run: `tools/re-capture/mission_run.sh 500 mission01` — boot → Stage 02 in flight →
|
||||
`pilot.py` (escort-weighted targeting, target commitment, guided missiles) for
|
||||
500 s, with every entity's hull sampled at 2 Hz and a screenshot every 30 s.
|
||||
Artifacts at `/sylph-home/re/mission01/` (9 MB `mission.jsonl`, not committed).
|
||||
|
||||
## The escort's decay is linear, and it ends the mission
|
||||
|
||||
| t (s) | ACROPOLIS hull | % |
|
||||
|---|---|---|
|
||||
| 0–160 | 25000 | 100 % |
|
||||
| 180 | 24510 | 98.0 |
|
||||
| 280 | 20279 | 81.1 |
|
||||
| 380 | 13799 | 55.2 |
|
||||
| 480 | 8541 | 34.2 |
|
||||
| 485 (end) | 8182 | 32.7 |
|
||||
|
||||
Untouched until **t ≈ 170 s**, then **≈53 HP/s** with no let-up — so the asset
|
||||
reaches zero at **t ≈ 640 s**, and the whole-run average rate puts it at 722 s.
|
||||
Either way the escort is dead at **10–12 minutes**, and "the ACROPOLIS is sunk"
|
||||
is a defeat condition ([mission-escort-state](mission-escort-state.md)).
|
||||
|
||||
This also retires a suspicion: the 240 s time-box of earlier runs was *not*
|
||||
hiding a win, and the ~500 s ceiling of a single blocking tool call is **not**
|
||||
the binding constraint. A longer session would simply watch the loss arrive.
|
||||
|
||||
## What is actually killing it — and the conflict that follows
|
||||
|
||||
Attributing damage by co-presence (which hostiles are within 3000 units of the
|
||||
asset in the sample where its hull drops, damage split evenly among the classes
|
||||
present — suggestive, not per-shot proof), only **two** classes are ever near it:
|
||||
|
||||
| class | samples present | attributed damage |
|
||||
|---|---|---|
|
||||
| `UN_e007_ADAN_Turret` | 200 | 8483 |
|
||||
| `UN_e010_ADAN_Attacker_S` | 194 | 8334 |
|
||||
|
||||
Roughly half the damage comes from **turrets** — and `pilot.py` treats turrets as
|
||||
**keep-out zones at 2500 units, never as targets**. That rule is not arbitrary: a
|
||||
turret is what shot down every pilot before 2026-07-30, and it is why the craft
|
||||
now survives. But it means **the policy that keeps the pilot alive also
|
||||
guarantees the escort dies.** Survival and the objective are in direct conflict,
|
||||
and the pilot currently resolves it entirely in favour of survival.
|
||||
|
||||
The HUD at t≈485 s says the same thing from the game's side:
|
||||
|
||||
- `YOU KILLED WARSHIPS` **0000** — not one warship in 500 s, across every run ever;
|
||||
- `YOU KILLED WARPLANES` **0009** — fighters only;
|
||||
- `REMAINING OB` **004 → 008** — objectives are being *added* by waves faster than
|
||||
any are cleared, so the pilot is not touching the objective set at all;
|
||||
- SHIELD and ARMOR bars full, hull **1500/1500**, 120 missiles spent.
|
||||
|
||||

|
||||
|
||||
## The conclusion that matters
|
||||
|
||||
The pilot optimises the wrong thing. It maximises survival and fighter kills;
|
||||
the mission scores **objectives** and **the escort**, and the fighter population
|
||||
(134 → 92) is close to irrelevant to both. An untouched 1500/1500 hull at the
|
||||
moment the escort passes 33 % is not a good run — it is **unspent risk budget**.
|
||||
|
||||
Concretely, for the next attempt, in priority order:
|
||||
|
||||
1. **Turrets near the asset must become targets**, not keep-out zones — accepting
|
||||
hull damage is the only way to cut ~50 % of the incoming escort damage. The
|
||||
keep-out rule should be scoped to turrets that are *not* threatening the
|
||||
asset, rather than applied globally.
|
||||
2. **Engage warships.** `WARSHIPS 0000` forever means the objective class has
|
||||
never been attacked; `REMAINING OB` rising is the scoreboard saying so.
|
||||
3. Re-check whether the escort damage rate actually falls once turrets die —
|
||||
that is the experiment that tells us whether (1) is sufficient or whether the
|
||||
bombers need dedicated intercept too.
|
||||
|
||||
## Method note, learned the hard way
|
||||
|
||||
A harness-tracked **background** task does *not* protect the display: the same
|
||||
run launched in the background lost Xvfb 11 s in, at the turn boundary
|
||||
(`skip_intro` exit 3, "DISPLAY LOST"). The comment in `launch_mission.sh` saying
|
||||
the script may be run as a tracked background task is **wrong**; the
|
||||
one-blocking-foreground-call rule still stands, which caps a single attempt at
|
||||
the tool's 600 s timeout. And do not pipe a long run through `tail` — the first
|
||||
attempt printed nothing because `timeout` killed the pipeline before it flushed;
|
||||
the on-disk artifacts are what survived.
|
||||
247
docs/re/ship-placement-capture-generalisation.md
Normal 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 2–15 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 (8–10 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 | 14–112 | `rou_f001_wep_*` — the player's weapons |
|
||||
| 417 / 279 / 201 / 167 | 104–448 | `Base:j00*`, `ptc_pack:*` — HUD/particles |
|
||||
| 8 / 4 / 3 / 1 | 317–590 | 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 44–225-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.03–0.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 02–29. 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.
|
||||
@@ -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.
|
||||
|
||||
144
tools/re-capture/approach_capture.py
Executable 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
@@ -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"
|
||||
@@ -19,7 +19,9 @@ alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $
|
||||
# that actually bought was the opposite: a process nothing owns is a process
|
||||
# nothing keeps alive, and both were being reaped a couple of minutes in — the
|
||||
# long-standing "Xvfb and the emulator die on their own every few minutes" note.
|
||||
# Run this whole script as ONE tracked background task and leave Xvfb, openbox
|
||||
# MEASURED WRONG 2026-08-10: a harness-tracked BACKGROUND task does not protect
|
||||
# them either — the display was lost 11 s in, at the turn boundary. Run this
|
||||
# whole script as ONE BLOCKING FOREGROUND call and leave Xvfb, openbox
|
||||
# and xenia as its children: they then live exactly as long as the session does.
|
||||
# `nohup` still shields them from a stray HUP; the exit-status wrapper means a
|
||||
# death is reported with the server's own account instead of being inferred.
|
||||
|
||||
34
tools/re-capture/lock_probe.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Which input SELECTS a target, and does a lock then build?
|
||||
#
|
||||
# 98 guided missiles produced 2 kills. A guided missile with nothing to guide to
|
||||
# flies straight, so the suspicion is that the loop has never selected a target
|
||||
# at all: the HUD carries a `TARGET` marker and a lock reticle, and no button in
|
||||
# pilot.py has ever touched them. fire_probe.sh already showed LB/X/B/A/LS/RS do
|
||||
# not discharge a weapon — but "does not fire" says nothing about "does not
|
||||
# select", so sweep them again watching the RETICLE instead of the ammo.
|
||||
#
|
||||
# Captures the centre of the screen (reticle + lock brackets) and the right-hand
|
||||
# target panel, so a selection or a building lock is visible either way.
|
||||
set -u
|
||||
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
|
||||
SD="$(cd "$(dirname "$0")" && pwd)"
|
||||
SHOTS=/sylph-home/re/shots
|
||||
HOLD="${1:-3}"
|
||||
|
||||
shot(){ screenshot "$SHOTS/lock-$1.png" >/dev/null 2>&1; echo " shot $1"; }
|
||||
|
||||
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
|
||||
sleep 3
|
||||
shot "00-idle"
|
||||
|
||||
# tap, not hold: a select is an edge, and holding a cycle button would just spin
|
||||
# through every contact. Two taps each, so a cycle that lands on nothing the
|
||||
# first time still shows on the second.
|
||||
for b in RS LS LB B X A Y; do
|
||||
vgamepad tap "$b" 200; sleep 0.4; vgamepad tap "$b" 200; sleep "$HOLD"
|
||||
shot "tap-$b"
|
||||
done
|
||||
vgamepad reset
|
||||
shot "99-final"
|
||||
echo "PROBE DONE"
|
||||
60
tools/re-capture/mission_run.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Attempt to COMPLETE a mission and record how it ends.
|
||||
#
|
||||
# Every previous flight session was time-boxed to 240 s to measure something
|
||||
# (escort hull, lethality, ship placement) and none ever reached a mission
|
||||
# outcome — "no mission completed" has been the standing open item. Unit
|
||||
# definitions are instantiated per stage, so story progress is the only thing
|
||||
# that grows unit coverage past 21/110, and that needs a WIN, not a survival.
|
||||
#
|
||||
# So this run is deliberately long and its only deliverable is the ENDING:
|
||||
# screenshots throughout, every entity's hull sampled, and the pilot log kept
|
||||
# whole (never tail-piped — a frozen log tail is what mission-end looks like
|
||||
# from outside, and tailing throws away the transition).
|
||||
#
|
||||
# Runs as ONE tracked background task with Xvfb/openbox/xenia as plain nohup
|
||||
# children — see docs/re/session-lifetime notes; do NOT setsid anything.
|
||||
#
|
||||
# Usage: mission_run.sh [flight_seconds] [tag]
|
||||
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:-900}"
|
||||
TAG="${2:-mission}"
|
||||
SHOTS=/sylph-home/re/shots
|
||||
OUT="/sylph-home/re/$TAG"
|
||||
mkdir -p "$SHOTS" "$OUT"
|
||||
|
||||
"$SD/launch_mission.sh" fly || { echo "BOOT FAILED"; exit 1; }
|
||||
python3 "$SD/entities2.py" self 0x130 "$OUT/cfg.json" || { echo "BIND FAILED"; exit 1; }
|
||||
|
||||
echo "=== initial entity table ==="
|
||||
python3 "$SD/mission_state.py" scan "$OUT/cfg.json"
|
||||
|
||||
# A screenshot every 30 s for the WHOLE run: the outcome card (MISSION COMPLETE
|
||||
# / GAME OVER) is on screen only briefly, so sampling must not stop early.
|
||||
( n=$(( SECS / 30 + 4 ))
|
||||
for i in $(seq 1 "$n"); do
|
||||
printf '%s SHOT %03d\n' "$(date +%s)" "$i" >> "$OUT/shots.log"
|
||||
screenshot "$SHOTS/$TAG-$(printf %03d "$i").png" >/dev/null 2>&1
|
||||
sleep 30
|
||||
done ) &
|
||||
SHOTTER=$!
|
||||
|
||||
date +%s > "$OUT/t0"
|
||||
python3 "$SD/mission_state.py" watch "$OUT/cfg.json" "$SECS" 2 "$OUT/mission.jsonl" \
|
||||
> "$OUT/mission.log" 2>&1 &
|
||||
WATCHER=$!
|
||||
|
||||
python3 "$SD/pilot.py" "$OUT/cfg.json" "$SECS" > "$OUT/pilot.log" 2>&1
|
||||
PILOT_RC=$?
|
||||
wait $WATCHER 2>/dev/null
|
||||
kill $SHOTTER 2>/dev/null
|
||||
screenshot "$SHOTS/$TAG-end.png" >/dev/null 2>&1
|
||||
cp -f "$SHOTS/$TAG-end.png" "$OUT/end.png" 2>/dev/null
|
||||
|
||||
echo "PILOT_RC=$PILOT_RC"
|
||||
echo "--- last 5 pilot lines ---"; tail -5 "$OUT/pilot.log"
|
||||
echo "--- shots: $(ls "$SHOTS/$TAG-"*.png 2>/dev/null | wc -l) ---"
|
||||
echo "MISSION RUN DONE ($TAG, ${SECS}s)"
|
||||
@@ -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 20–30.
|
||||
# Both numbers were previously wrong in this loop, and both mattered:
|
||||
# * flight time was computed as d / OUR speed (400–2000 u/s), so every shot
|
||||
# was led 4–16× too far ahead of the target;
|
||||
# * FIRE_RANGE was 5000, i.e. a quarter of the shots were fired at targets
|
||||
# the shells expire before reaching.
|
||||
SHELL_VELOCITY = 8000.0
|
||||
SHELL_MAX_RANGE = 4000.0
|
||||
SHELL_RADIUS = 20.0
|
||||
|
||||
# The MAIN weapon, fired with Y — measured, not assumed: a hold-each-input probe
|
||||
# (fire_probe.sh) moved NOSE BM 06000 -> 05956 under RB and MAIN MPM 00300 ->
|
||||
# 00299 under Y, so RB is the nose gun (~11 rounds/s) and Y is the main mount.
|
||||
# That probe also settled the lethality question the other way round: we DO
|
||||
# shoot, so the kill counters reading 0000 mean we shoot and MISS.
|
||||
#
|
||||
# Which is exactly what the disc data says to stop doing. Shell_TCAF_DeltaSaber_
|
||||
# Missile_P is Power 200 with GuidanceType 5 (guided) and MaximumRange 5000,
|
||||
# against the nose gun's Power 15 unguided — one missile is worth ~14 gun hits
|
||||
# on a 500 HP fighter, and it steers itself, which is the accuracy problem
|
||||
# solved rather than tuned. Range is held under the confirmed 5000 because which
|
||||
# main weapon is actually loaded is not read from RAM yet.
|
||||
MISSILE_RANGE = 4000.0
|
||||
MISSILE_CONE = math.radians(20.0)
|
||||
MISSILE_PERIOD = 2.0 # s between launches; 300 rounds is not unlimited
|
||||
|
||||
# Stage 02's protected asset. Named rather than derived: "the biggest friendly"
|
||||
# picks the f105 cruiser (30000 HP > the Acropolis's 25000), and "the friendly
|
||||
# with the most HP" picks it too, so neither rule finds the right ship. The
|
||||
# per-stage asset is mission script, not a property of the entity, so it is
|
||||
# configuration here — override with $SYLPH_ASSET for another stage.
|
||||
ASSET_NAME = os.environ.get("SYLPH_ASSET", "Acropolis")
|
||||
|
||||
|
||||
class Pilot:
|
||||
KP, KD = 2.2, 0.45
|
||||
FIRE_CONE = math.radians(9)
|
||||
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
|
||||
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 ~10–30°, and firing collapsed
|
||||
to 1 frame in 2639. Ammunition is free and the guns are continuous, so
|
||||
the angular size belongs here as a **floor** that opens the gate wider
|
||||
up close, never as a cap that closes it far out.
|
||||
"""
|
||||
if d < 1.0:
|
||||
return self.CONE_MAX
|
||||
return min(self.CONE_MAX, max(self.FIRE_CONE, math.atan2(r + SHELL_RADIUS, d)))
|
||||
|
||||
def pick_committed(self, t, me_p, me_v, fwd, hos):
|
||||
"""pick(), but stay on the same contact long enough to actually kill it."""
|
||||
cur = None
|
||||
for off, nm, p, v, r, hard in hos:
|
||||
if off == self.commit_off and not hard:
|
||||
cur = (off, nm, p, v, r)
|
||||
break
|
||||
if cur is not None:
|
||||
off, nm, p, v, r = cur
|
||||
rel = p - me_p
|
||||
d = float(np.linalg.norm(rel))
|
||||
behind = ang(rel, fwd) > math.pi / 2
|
||||
self.behind_since = (self.behind_since if behind else None) or (t if behind else None)
|
||||
stale = (t - self.commit_t > self.COMMIT_MAX
|
||||
or d > self.COMMIT_DROP
|
||||
or (self.behind_since is not None
|
||||
and t - self.behind_since > self.COMMIT_BEHIND))
|
||||
if not stale:
|
||||
lead = self.lead_point(p, v, d)
|
||||
return (off, nm, lead, lead - me_p, d, r)
|
||||
# commit to a fresh one
|
||||
tgt = self.pick(me_p, me_v, fwd, hos)
|
||||
self.commit_off = tgt[0] if tgt else None
|
||||
self.commit_t = t
|
||||
self.behind_since = None
|
||||
return tgt
|
||||
|
||||
def pick(self, me_p, me_v, fwd, hos):
|
||||
"""Nearest *fighter*, weighted by how far off the nose it is."""
|
||||
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:
|
||||
|
||||
45
tools/re-capture/ship_capture_close.sh
Executable 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"
|
||||
45
tools/re-capture/ship_capture_session.sh
Executable 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"
|
||||
173
tools/re-capture/target_probe.py
Normal file
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Find the SELECTED TARGET inside the player object, and the input that changes it.
|
||||
|
||||
The OPTIONS key-config screen lists `Change Target` and `Padlock Mode Toggle` as
|
||||
real bindable actions (docs/re/flight-controls-runtime.md), but a button sweep
|
||||
that watched the *ammo counters* could not see either — neither action fires a
|
||||
weapon. Screenshots of the reticle were no better: the view keeps moving, so
|
||||
"did the selection change" is not legible frame to frame.
|
||||
|
||||
Guest memory is legible. If the craft holds a selected target, it holds a
|
||||
**pointer to that target's object**, and every live entity's address is already
|
||||
known from the entity scan. So:
|
||||
|
||||
1. enumerate live entities and their addresses;
|
||||
2. read a window of the player object and keep every word that points at one
|
||||
of them (allowing a small fixed delta, since a pointer to an object's base
|
||||
is not a pointer to its transform);
|
||||
3. tap each candidate input and see which of those words switches to a
|
||||
*different* entity.
|
||||
|
||||
The word that follows the button is the selection, and the button that moves it
|
||||
is `Change Target`. Both answers come out of the same run, and neither depends
|
||||
on reading pixels.
|
||||
|
||||
Usage: target_probe.py <config.json> [seconds_per_button]
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import gmem # noqa: E402
|
||||
import navigator # noqa: E402
|
||||
from flight_probe import Pad # noqa: E402
|
||||
|
||||
BACK, FWD = 0x400, 0x1000
|
||||
MAX_DELTA = 0x400 # how far below its transform an object's base may sit
|
||||
BUTTONS = ["LB", "X", "B", "A", "LS", "RS", "BACK", "START", "Y", "RB"]
|
||||
|
||||
|
||||
def main():
|
||||
cfg = json.load(open(sys.argv[1]))
|
||||
dwell = float(sys.argv[2]) if len(sys.argv) > 2 else 1.2
|
||||
W = navigator.World(cfg)
|
||||
ents = W.scan()
|
||||
me = [(off, va) for off, va in ents if "Player" in W.defs[va]]
|
||||
if not me:
|
||||
sys.exit("player entity not found — not in flight?")
|
||||
me_off = me[0][0]
|
||||
print(f"# player object at {gmem.primary_va(me_off):#010x}, "
|
||||
f"{len(ents)} live entities")
|
||||
|
||||
# every address that could be a pointer to some entity
|
||||
ptr_map = {}
|
||||
for off, va in ents:
|
||||
pos_va = gmem.primary_va(off)
|
||||
if pos_va is None:
|
||||
continue
|
||||
for d in range(0, MAX_DELTA, 4):
|
||||
ptr_map.setdefault(pos_va - d, (W.defs[va], d, off))
|
||||
|
||||
def window():
|
||||
b = os.pread(W.fd, BACK + FWD, me_off - BACK)
|
||||
return np.frombuffer(b, dtype=">u4").copy()
|
||||
|
||||
# ---- global mode: the selection need not live in the player object at all.
|
||||
# Scan every mapped extent for words that hold an entity pointer, then see
|
||||
# which of THOSE follow a button. A word that is an entity pointer before
|
||||
# AND after, pointing at a different entity, is a selection by construction.
|
||||
keys = np.array(sorted(ptr_map), dtype=np.uint32)
|
||||
|
||||
def global_ptrs():
|
||||
out = {}
|
||||
for a0, b0 in gmem.extents(W.fd, W.size):
|
||||
n = (b0 - a0) // 4 * 4
|
||||
if n < 64:
|
||||
continue
|
||||
arr = np.frombuffer(os.pread(W.fd, n, a0), dtype=">u4").astype(np.uint32)
|
||||
idx = np.flatnonzero(np.isin(arr, keys))
|
||||
for k in idx:
|
||||
out[a0 + int(k) * 4] = int(arr[k])
|
||||
return out
|
||||
|
||||
# ---- delta mode: WHERE does an entity keep its target?
|
||||
# The AI ships clearly hold pointers to other entities, so the field is a
|
||||
# fixed offset from the transform — exactly the situation entities2.py
|
||||
# solved for the definition pointer. Tally, over many entities, the delta at
|
||||
# which a word points at *another* entity; the offset that repeats is the
|
||||
# field, and reading it on the PLAYER gives our selected target.
|
||||
if "--delta" in sys.argv:
|
||||
from collections import Counter
|
||||
votes, examples = Counter(), {}
|
||||
for off, va in ents:
|
||||
blob = os.pread(W.fd, 0x1000, max(0, off - 0x800))
|
||||
arr = np.frombuffer(blob[:len(blob) // 4 * 4], dtype=">u4")
|
||||
for i, w in enumerate(arr):
|
||||
hit = ptr_map.get(int(w))
|
||||
if hit and hit[2] != off:
|
||||
d = i * 4 - 0x800
|
||||
votes[d] += 1
|
||||
examples.setdefault(d, (W.defs[va], hit[0]))
|
||||
print(f"# target-pointer delta candidates over {len(ents)} entities:")
|
||||
for d, n in votes.most_common(10):
|
||||
src, dst = examples[d]
|
||||
print(f" pos{d:+#07x} seen {n:4d} e.g. {src[:26]:<26} -> {dst}")
|
||||
best = votes.most_common(1)
|
||||
if best:
|
||||
d = best[0][0]
|
||||
pw = struct.unpack(">I", os.pread(W.fd, 4, me_off + d))[0]
|
||||
hit = ptr_map.get(pw)
|
||||
print(f"\n# PLAYER at that delta: {pw:#010x} -> "
|
||||
f"{hit[0] if hit else 'not an entity pointer'}")
|
||||
return
|
||||
|
||||
a = window()
|
||||
cands = []
|
||||
for i, w in enumerate(a):
|
||||
hit = ptr_map.get(int(w))
|
||||
if hit and hit[2] != me_off: # a self-pointer is not a target
|
||||
cands.append((i, hit[0], hit[1]))
|
||||
print(f"# {len(cands)} word(s) in the player object point at a live entity")
|
||||
for i, nm, d in cands[:30]:
|
||||
print(f" pos{i * 4 - BACK:+#07x} -> {nm} (entity_va - {d:#x})")
|
||||
if not cands:
|
||||
print("# none — widen BACK/FWD or MAX_DELTA, or nothing is selected")
|
||||
|
||||
# ---- which input moves the selection?
|
||||
pad = Pad()
|
||||
if "--global" in sys.argv:
|
||||
print("\n# GLOBAL scan: every word in RAM that holds an entity pointer")
|
||||
g0 = global_ptrs()
|
||||
print(f"# {len(g0)} entity-pointer words in RAM")
|
||||
for btn in BUTTONS:
|
||||
pad.f.write(f"tap {btn} 200\n")
|
||||
time.sleep(dwell)
|
||||
g1 = global_ptrs()
|
||||
moved = [(o, g0[o], g1[o]) for o in g0
|
||||
if o in g1 and g1[o] != g0[o]]
|
||||
named = []
|
||||
for o, v0, v1 in moved[:4]:
|
||||
n0 = ptr_map.get(v0, ("?",))[0]
|
||||
n1 = ptr_map.get(v1, ("?",))[0]
|
||||
named.append(f"{gmem.primary_va(o):#010x} {n0[:16]}->{n1[:16]}")
|
||||
print(f" [{btn:<5}] {len(moved):4d} switched " + " ".join(named),
|
||||
flush=True)
|
||||
g0 = g1
|
||||
pad.reset()
|
||||
return
|
||||
print("\n# tapping each input; a word that switches to a DIFFERENT entity is"
|
||||
" the selection")
|
||||
for btn in BUTTONS:
|
||||
before = window()
|
||||
pad.f.write(f"tap {btn} 200\n")
|
||||
time.sleep(dwell)
|
||||
after = window()
|
||||
moved = []
|
||||
for i, nm, d in cands:
|
||||
if before[i] == after[i]:
|
||||
continue
|
||||
hit = ptr_map.get(int(after[i]))
|
||||
moved.append((i, nm, hit[0] if hit else f"{int(after[i]):#010x}"))
|
||||
tag = " ".join(f"pos{i * 4 - BACK:+#x} {o[:18]}->{n[:18]}"
|
||||
for i, o, n in moved[:3])
|
||||
print(f" [{btn:<5}] {len(moved):2d} changed {tag}", flush=True)
|
||||
pad.reset()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
64
tools/re-capture/tutorial_capture.sh
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Play one TUTORIAL and photograph what it teaches.
|
||||
#
|
||||
# The OPTIONS key-config screen names the in-flight actions but not the buttons
|
||||
# they sit on, and probing the pad found the weapons only (an action that does
|
||||
# not fire a gun is invisible in the ammo counters). The tutorials state the
|
||||
# mapping outright — `ADVANCED CONTROLS` is index 5 and is where Change Target
|
||||
# and Padlock Mode live — so read it from the game instead of guessing.
|
||||
#
|
||||
# Boot + nav is launch_mission.sh's route as far as the main menu, then TUTORIAL
|
||||
# instead of LOAD GAME. Everything stays a CHILD of this script (no setsid): a
|
||||
# detached process is not a survivable one here, see the session-lifetime note.
|
||||
set -u
|
||||
N="${1:-5}" # 0 BASIC, 1 HUD, 2 RADAR, 3 SUPPLY, 4 RADIO, 5 ADVANCED
|
||||
SECS="${2:-150}"
|
||||
TAG="${3:-tut}"
|
||||
export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98
|
||||
SD="$(cd "$(dirname "$0")" && pwd)"
|
||||
SHOTS=/sylph-home/re/shots
|
||||
|
||||
alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $1}'; }
|
||||
step(){ vgamepad dpad "$1"; sleep 0.25; vgamepad dpad center; sleep 0.7; }
|
||||
shot(){ screenshot "$SHOTS/$TAG-$1.png" >/dev/null 2>&1; }
|
||||
|
||||
pkill -x xenia_canary 2>/dev/null; sleep 2
|
||||
[ -n "$(alive)" ] && { kill -9 $(alive) 2>/dev/null; sleep 2; }
|
||||
rm -f /dev/shm/xenia_memory_* /dev/shm/xenia_code_cache_* 2>/dev/null
|
||||
|
||||
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
|
||||
rm -f "/tmp/.X${DISPLAY#:}-lock" 2>/dev/null || true
|
||||
nohup Xvfb "$DISPLAY" -screen 0 1280x720x24 -ac -nolisten tcp \
|
||||
+extension GLX +extension RANDR </dev/null >/tmp/xvfb98.log 2>&1 &
|
||||
for _ in $(seq 1 50); do xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break; sleep 0.2; done
|
||||
nohup env DISPLAY="$DISPLAY" HOME=/sylph-home openbox </dev/null >/tmp/openbox98.log 2>&1 &
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
cd /sylph-home/re
|
||||
nohup run-canary --audio --apu=sdl --log_mask=13 \
|
||||
--logged_profile_slot_0_xuid=E0300000EFBEA3D4 </dev/null >/dev/null 2>&1 &
|
||||
sleep 5
|
||||
"$SD/skip_intro.sh" 600 || { echo "BOOT FAILED (skip_intro exit $?)"; exit 1; }
|
||||
sleep 14 # main menu is not input-ready before this
|
||||
|
||||
step down; step down # NEW GAME -> LOAD GAME -> TUTORIAL
|
||||
vgamepad tap A 250; sleep 8
|
||||
shot "list"
|
||||
i=0; while [ "$i" -lt "$N" ]; do step down; i=$((i+1)); done
|
||||
shot "pick"
|
||||
vgamepad tap A 250; sleep 6
|
||||
shot "sub" # some tutorials offer Level 1 / Level 2
|
||||
vgamepad tap A 250
|
||||
|
||||
# Then just watch. The lesson drives itself and prints its instructions; tap A
|
||||
# periodically to advance any prompt, and photograph often enough to catch the
|
||||
# caption before it is replaced.
|
||||
end=$(( SECONDS + SECS )); n=0
|
||||
while [ $SECONDS -lt $end ]; do
|
||||
n=$((n+1)); shot "$(printf '%02d' $n)"
|
||||
[ -n "$(alive)" ] || { echo "EMULATOR GONE at ${SECONDS}s"; exit 4; }
|
||||
sleep 5
|
||||
[ $((n % 3)) -eq 0 ] && vgamepad tap A 250
|
||||
done
|
||||
echo "TUTORIAL CAPTURE DONE ($n frames)"
|
||||