Compare commits
7 Commits
feature/ip
...
auto/re-we
| Author | SHA1 | Date | |
|---|---|---|---|
| e3f3d55d29 | |||
| 9a86a09f8d | |||
| aaa4ea60b3 | |||
| 621b9dd6e9 | |||
| 07eff85819 | |||
| c3b5f3f401 | |||
| ba33c533da |
50
crates/sylpheed-formats/examples/default_owners.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
//! Scratch analysis: for one IDXD key, list every object that DECLARES it and
|
||||||
|
//! whether it carries a value on disc or is left at the title-code default.
|
||||||
|
//!
|
||||||
|
//! Run: cargo run -p sylpheed-formats --example default_owners -- <KEY> [KEY...]
|
||||||
|
|
||||||
|
use sylpheed_formats::idxd::IdxdObject;
|
||||||
|
use sylpheed_formats::pak::PakArchive;
|
||||||
|
|
||||||
|
fn is_number(s: &str) -> bool {
|
||||||
|
let s = s.strip_suffix(['f', 'F']).unwrap_or(s);
|
||||||
|
let t = s.strip_prefix(['-', '+']).unwrap_or(s);
|
||||||
|
!t.is_empty() && t.chars().all(|c| c.is_ascii_digit() || c == '.')
|
||||||
|
}
|
||||||
|
fn is_key(s: &str) -> bool {
|
||||||
|
!s.is_empty()
|
||||||
|
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||||
|
&& !is_number(s)
|
||||||
|
}
|
||||||
|
fn is_value(s: &str) -> bool {
|
||||||
|
is_number(s) || !is_key(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let root = std::env::var("SYLPHEED_DISC")
|
||||||
|
.unwrap_or_else(|_| "/home/fabi/RE - Project Sylpheed/sylph_extract".into());
|
||||||
|
let wanted: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let arc = PakArchive::open(std::path::Path::new(&root).join("dat/GP_MAIN_GAME_E.pak")).unwrap();
|
||||||
|
|
||||||
|
for e in arc.entries() {
|
||||||
|
let Ok(bytes) = arc.read(e) else { continue };
|
||||||
|
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
|
||||||
|
let toks = obj.tokens().to_vec();
|
||||||
|
let mut hits: Vec<String> = vec![];
|
||||||
|
for (i, t) in toks.iter().enumerate() {
|
||||||
|
if !wanted.iter().any(|w| w == t) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let valued = i > 0 && is_value(&toks[i - 1]);
|
||||||
|
hits.push(if valued {
|
||||||
|
format!("{t}={}", toks[i - 1])
|
||||||
|
} else {
|
||||||
|
format!("{t}=<DEFAULT>")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !hits.is_empty() {
|
||||||
|
println!("0x{:08x} {:<44} {}", obj.schema_hash, obj.identity(), hits.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
crates/sylpheed-formats/examples/defaulted_fields.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
//! Scratch analysis: which IDXD fields are DECLARED but left at their default on
|
||||||
|
//! disc, per schema. Those defaults live in title code, so they can only be read
|
||||||
|
//! from the running game — this prints the shopping list for that dynamic capture.
|
||||||
|
//!
|
||||||
|
//! Run: cargo run -p sylpheed-formats --example defaulted_fields -- <disc-root>
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use sylpheed_formats::idxd::IdxdObject;
|
||||||
|
use sylpheed_formats::pak::PakArchive;
|
||||||
|
|
||||||
|
fn is_number(s: &str) -> bool {
|
||||||
|
let s = s.strip_suffix(['f', 'F']).unwrap_or(s);
|
||||||
|
let t = s.strip_prefix(['-', '+']).unwrap_or(s);
|
||||||
|
!t.is_empty() && t.chars().all(|c| c.is_ascii_digit() || c == '.')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same shape-test the parser uses: an identifier-looking token that is not a
|
||||||
|
/// value literal is a field-name key.
|
||||||
|
fn is_key(s: &str) -> bool {
|
||||||
|
!s.is_empty()
|
||||||
|
&& s.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||||
|
&& !is_number(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_value(s: &str) -> bool {
|
||||||
|
is_number(s) || !is_key(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let root = std::env::args()
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or_else(|| "/home/fabi/RE - Project Sylpheed/sylph_extract".into());
|
||||||
|
let paks: Vec<String> = std::env::args().skip(2).collect();
|
||||||
|
let paks = if paks.is_empty() {
|
||||||
|
vec![
|
||||||
|
"dat/GP_MAIN_GAME_E.pak".to_string(),
|
||||||
|
"dat/GP_HANGAR_ARSENAL.pak".to_string(),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
paks
|
||||||
|
};
|
||||||
|
|
||||||
|
for pak_rel in &paks {
|
||||||
|
let path = std::path::Path::new(&root).join(pak_rel);
|
||||||
|
let Ok(arc) = PakArchive::open(&path) else {
|
||||||
|
eprintln!("-- skip {pak_rel} (open failed)");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
println!("\n================ {pak_rel} ================");
|
||||||
|
|
||||||
|
// schema -> key -> (n_set, n_defaulted, distinct values, example owners)
|
||||||
|
type Stat = (usize, usize, BTreeSet<String>, Vec<String>);
|
||||||
|
let mut per_schema: BTreeMap<u32, (usize, BTreeMap<String, Stat>)> = BTreeMap::new();
|
||||||
|
|
||||||
|
for e in arc.entries() {
|
||||||
|
let Ok(bytes) = arc.read(e) else { continue };
|
||||||
|
let Ok(obj) = IdxdObject::parse(&bytes) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let ident = obj.identity();
|
||||||
|
let toks = obj.tokens().to_vec();
|
||||||
|
let entry = per_schema.entry(obj.schema_hash).or_default();
|
||||||
|
entry.0 += 1;
|
||||||
|
// Track which keys this object declares, and whether each is valued.
|
||||||
|
let mut seen_here: BTreeMap<String, Option<String>> = BTreeMap::new();
|
||||||
|
for (i, t) in toks.iter().enumerate() {
|
||||||
|
if !is_key(t) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let prev = if i == 0 { None } else { Some(&toks[i - 1]) };
|
||||||
|
let valued = prev.map(|p| is_value(p)).unwrap_or(false);
|
||||||
|
let v = if valued { Some(toks[i - 1].clone()) } else { None };
|
||||||
|
seen_here.entry(t.clone()).or_insert(v);
|
||||||
|
}
|
||||||
|
for (k, v) in seen_here {
|
||||||
|
let s = entry.1.entry(k).or_default();
|
||||||
|
match v {
|
||||||
|
Some(val) => {
|
||||||
|
s.0 += 1;
|
||||||
|
if s.2.len() < 12 {
|
||||||
|
s.2.insert(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
s.1 += 1;
|
||||||
|
if s.3.len() < 6 {
|
||||||
|
s.3.push(ident.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (schema, (n_obj, keys)) in per_schema {
|
||||||
|
if n_obj < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = match schema {
|
||||||
|
0x0426_e81d => "PLAYER",
|
||||||
|
0x6ab4_825a => "WEAPON",
|
||||||
|
0x43fa_a517 => "UNIT",
|
||||||
|
0x3c5b_0549 => "VESSEL",
|
||||||
|
0xbd86_d41c => "CHARACTER",
|
||||||
|
0x3c9a_e32e => "STAGE",
|
||||||
|
0xb412_e6d8 => "MESSAGE",
|
||||||
|
_ => "?",
|
||||||
|
};
|
||||||
|
let defaulted: Vec<_> = keys
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, s)| s.1 > 0)
|
||||||
|
.collect();
|
||||||
|
if defaulted.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
println!("\n--- schema 0x{schema:08x} {name} ({n_obj} objects) ---");
|
||||||
|
println!(
|
||||||
|
"{:<30} {:>5} {:>5} {}",
|
||||||
|
"KEY", "set", "dflt", "values seen (≤12) | owners defaulting"
|
||||||
|
);
|
||||||
|
for (k, (n_set, n_def, vals, owners)) in defaulted {
|
||||||
|
let vv: Vec<&str> = vals.iter().map(|s| s.as_str()).collect();
|
||||||
|
println!(
|
||||||
|
"{:<30} {:>5} {:>5} {} | {}",
|
||||||
|
k,
|
||||||
|
n_set,
|
||||||
|
n_def,
|
||||||
|
vv.join(","),
|
||||||
|
owners.join(",")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
crates/sylpheed-formats/examples/ui_screen.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
//! Scratch analysis: inventory one UI screen's pak — every entry, and for RATC
|
||||||
|
//! entries the children they bundle. Optionally write an entry's decompressed
|
||||||
|
//! bytes out for hex inspection.
|
||||||
|
//!
|
||||||
|
//! Run: cargo run -p sylpheed-formats --example ui_screen -- <PAK> [--dump 0xHASH out.bin]
|
||||||
|
|
||||||
|
use sylpheed_formats::pak::{self, PakArchive};
|
||||||
|
use sylpheed_formats::ratc;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
let pak = args.next().expect("usage: ui_screen <pak> [--dump 0xHASH out.bin]");
|
||||||
|
let arc = PakArchive::open(&pak).expect("open pak");
|
||||||
|
|
||||||
|
let rest: Vec<String> = args.collect();
|
||||||
|
if rest.first().map(|s| s == "--dump").unwrap_or(false) {
|
||||||
|
let h = u32::from_str_radix(rest[1].trim_start_matches("0x"), 16).unwrap();
|
||||||
|
let bytes = arc.read_by_hash(h).expect("entry present").expect("decompress");
|
||||||
|
std::fs::write(&rest[2], &bytes).unwrap();
|
||||||
|
println!("wrote {} bytes to {}", bytes.len(), rest[2]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if rest.first().map(|s| s == "--child").unwrap_or(false) {
|
||||||
|
// --child 0xHASH <child-name> <out>: carve one RATC child out by its
|
||||||
|
// listed offset/size, so a 165-byte layout record can be hexdumped alone.
|
||||||
|
let h = u32::from_str_radix(rest[1].trim_start_matches("0x"), 16).unwrap();
|
||||||
|
let bytes = arc.read_by_hash(h).expect("entry present").expect("decompress");
|
||||||
|
let kids = ratc::parse(&bytes).expect("ratc");
|
||||||
|
let k = kids.iter().find(|k| k.name == rest[2]).expect("child not found");
|
||||||
|
std::fs::write(&rest[3], &bytes[k.offset..k.offset + k.size]).unwrap();
|
||||||
|
println!("wrote {} B ({} @ {:#x}) to {}", k.size, k.name, k.offset, rest[3]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{pak}: {} entries", arc.len());
|
||||||
|
for e in arc.entries() {
|
||||||
|
let Ok(bytes) = arc.read(e) else {
|
||||||
|
println!(" {:08x} <decompress failed>", e.name_hash);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let label = pak::inner_format_label(&bytes);
|
||||||
|
println!(" {:08x} {:>9} B {}", e.name_hash, bytes.len(), label);
|
||||||
|
if ratc::is_ratc(&bytes) {
|
||||||
|
if let Some(kids) = ratc::parse(&bytes) {
|
||||||
|
for k in &kids {
|
||||||
|
println!(" - {:<40} {:>9} B {}", k.name, k.size, k.kind);
|
||||||
|
}
|
||||||
|
if kids.is_empty() {
|
||||||
|
println!(" (no children found)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|
|||||||
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
|
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
|
||||||
| 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 |
|
| 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 |
|
| 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 DATA SHEET](weapon-datasheet-runtime.md) | The Arsenal Gallery-Mode panel reads the live record: `Ammo Capacity` = `LoadingCount` ✅, `Max. Lock Ons` = `TriggerShotCount` ✅. Recovers `TriggerShotCount` for `wep_05`/`wep_60` (both **4**); brackets defaulted `Power`/`MaximumRange` via the letter classes. 9 of ~61 weapons reachable at 5 % save progress |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
## Functions / code paths
|
## Functions / code paths
|
||||||
|
|
||||||
|
|||||||
BIN
docs/re/captures/hud-runtime/hud-afterburner-1193.png
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
docs/re/captures/hud-runtime/hud-cruise-loadout.png
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
docs/re/captures/hud-runtime/hud-target-armor-gauge.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
docs/re/captures/ui-layout/pause-mission-rebuilt.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
docs/re/captures/ui-layout/pause-tutorial-real-vs-rebuilt.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
docs/re/captures/ui-layout/title-mainmenu-real-vs-rebuilt.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
docs/re/captures/weapon-datasheet/asm-hound-smh.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
docs/re/captures/weapon-datasheet/asm-terrier-smh.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
docs/re/captures/weapon-datasheet/beam-dagger.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
docs/re/captures/weapon-datasheet/beam-pilum-bp.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
docs/re/captures/weapon-datasheet/beam-stiletto.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
docs/re/captures/weapon-datasheet/br-dart-23-rocket.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/re/captures/weapon-datasheet/cn-tomahawk-alpha-rail-gun.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/re/captures/weapon-datasheet/gun-broad-sword-sg1.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/re/captures/weapon-datasheet/gun-light-machine-gun-mg1.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
docs/re/captures/weapon-datasheet/gun-mg1-full.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/re/captures/weapon-datasheet/hangar-mainweapon1-falcon.png
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
docs/re/captures/weapon-datasheet/mpm-buzzard-10am.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/re/captures/weapon-datasheet/mpm-falcon-9am.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
182
docs/re/structures/ui-rat-layout.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# `.rat` — the UI element layout / animation record
|
||||||
|
|
||||||
|
**Status:** ✅ `CONFIRMED` for placement (2026-07-28). The retail UI can be
|
||||||
|
**reassembled from the disc**: the tutorial PAUSE menu rebuilds pixel-accurately from its
|
||||||
|
sprites placed at the coordinates in their `.rat` records — no fitting, no manual nudging.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Left: the running game (Canary screenshot). Right: rebuilt from `GP_PAUSE_MENU.pak` alone.
|
||||||
|
The remaining differences are the animated frame/glow sprites (`*eff*`) that were not
|
||||||
|
placed, and the live 3D background.*
|
||||||
|
|
||||||
|
## Screen composition
|
||||||
|
|
||||||
|
The UI is **one pak per screen** — `GP_TITLE`, `GP_PAUSE_MENU`, `GP_READY_ROOM`,
|
||||||
|
`GP_SAVE_LOAD`, `GP_MISSION_SELECT`, `GP_OPTIONS`, `GP_SYSTEM`, `GP_TUTORIAL`,
|
||||||
|
`GP_STAGE_CLEAR`, `GP_MOVIE_THEATER`, `GP_DEBRIEFING_PILOTLOG`, `GP_LEADERBOARD`,
|
||||||
|
`GP_MISSION_LOG`, `GP_BUNK`, `GP_DIALOG`, `GP_GAMEOVER`, `GP_CHALLENGE`,
|
||||||
|
`GP_HANGAR_ARSENAL`.
|
||||||
|
|
||||||
|
Inside a screen pak, each top-level [RATC](../INDEX.md) bundle is **one (context × language)
|
||||||
|
build of that screen**. Its own header is the screen's **element declaration table**, and
|
||||||
|
the elements themselves follow as children:
|
||||||
|
|
||||||
|
| child | what it is |
|
||||||
|
|---|---|
|
||||||
|
| `<name>.t32` | the sprite ([T8aD](texture-color-k8888.md)) |
|
||||||
|
| `<name>.rat` | that sprite's **layout record** (this document) |
|
||||||
|
| `<screen>loop1.rat` | a looping sprite animation (see below) |
|
||||||
|
|
||||||
|
### The bundle header — element declaration table
|
||||||
|
|
||||||
|
```
|
||||||
|
0x14 u32 entry count
|
||||||
|
0x20 entry[count], 60 bytes each:
|
||||||
|
+0 char[28] element name, NUL-padded ("pgp_ttrl_eff10.t32", "pgp_ttrl_btn10.rat")
|
||||||
|
+28 u32 ×4 flags (0xffffffff / 0xffffffff / 0 / 0xffffffff on every entry seen)
|
||||||
|
+48 u32 pivot X
|
||||||
|
+52 u32 pivot Y
|
||||||
|
+56 u32 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The table lists **both** sprites and `.rat` records — it is the screen's element list.
|
||||||
|
`pgp_ttrl` declares 11: six `eff*`, `msg`, and four `.rat`s (`title`, `btn10..12`).
|
||||||
|
|
||||||
|
✅ **Verified:** for all 7 `.t32` entries the declared pivot is *exactly* half the decoded
|
||||||
|
texture's dimensions — `eff10` 408×120 → 204,60; `eff21` 428×360 → 214,180; `msg` 381×38 →
|
||||||
|
190,19; and so on, 7/7 with no mismatch (`tools/re-capture/ratc_decls.py`).
|
||||||
|
|
||||||
|
`GP_PAUSE_MENU.pak`'s six bundles are `{in-mission, tutorial} × {English, Japanese}`, with
|
||||||
|
the two in-mission builds each present twice at identical size. The purpose of that
|
||||||
|
duplicate is **NEEDS-HUMAN** (resolution or aspect variant?), and where the other four
|
||||||
|
shipped languages live is likewise unresolved — this pak holds only EN and JP.
|
||||||
|
|
||||||
|
Naming is transparent: `pgp` = pause screen, `pgp_ttrl_` = its tutorial context, `btnNN` =
|
||||||
|
menu item, `btnNNf` = that item's **focused** sprite, `eff*` = frame/glow decoration,
|
||||||
|
`deli*` = the item divider, `title`, `msg`.
|
||||||
|
|
||||||
|
## Record layout
|
||||||
|
|
||||||
|
Big-endian u32 throughout (Xbox 360), and **tag-driven**: 4-char ASCII tags (`opt `,
|
||||||
|
`PRMD`, `end `) mark sections, so a record is a stream of blocks rather than a fixed struct.
|
||||||
|
A minimal record (a static button) is 165 bytes:
|
||||||
|
|
||||||
|
```
|
||||||
|
0x00 "RATC" magic — a RATC bundle reused as a data record
|
||||||
|
0x08 u32 payload size
|
||||||
|
0x14 u32 entry count (loop1.rat: 3, matching its 3 sprite names)
|
||||||
|
0x18 u32 design width = 1280
|
||||||
|
0x1c u32 design height = 720
|
||||||
|
0x20 char[16] the sprite this record places, e.g. "pgpbtn00.t32"
|
||||||
|
0x50 u32 pivot X = texture width / 2
|
||||||
|
0x54 u32 pivot Y = texture height / 2
|
||||||
|
...
|
||||||
|
── placement block ──
|
||||||
|
u32 scale X = 100 (percent)
|
||||||
|
u32 scale Y = 100
|
||||||
|
u32 tint = 0xffffffff (RGBA, white = untinted)
|
||||||
|
u32 X ← top-left position
|
||||||
|
u32 Y ←
|
||||||
|
u32 time (keyframe records only)
|
||||||
|
...
|
||||||
|
"opt " u32 len char[len] link to another record, e.g. "pgpbtn00f.rat"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **X/Y is the sprite's top-left**, not its centre: compositing at these coordinates
|
||||||
|
reproduces the screenshot, which drawing centred on them would not.
|
||||||
|
- **The pivot at 0x50/0x54 is half the texture size** — 4 of the 5 plain sprites match
|
||||||
|
exactly (`pgpbtn00` 86×42 → 43,21; `pgpbtn05` 221×42 → 110,21; `pgptitle` 202×73 →
|
||||||
|
101,36; `pgpbtn04` 172×43 → 86,22). It is a rotation/scale centre, not a draw offset.
|
||||||
|
- **Animated elements are a keyframe list.** `pgptitle.rat` (752 B) is the same placement
|
||||||
|
block repeated with a varying trailing `time` field — the PAUSE title's fly-in.
|
||||||
|
- **`opt `** carries a length-prefixed record name. On `pgpbtn00.rat` it points at
|
||||||
|
`pgpbtn00f.rat`, i.e. *normal state → focused state*. Focused records place their sprite
|
||||||
|
42 px left and 8 px up of the base, because the focused art includes the selection ring
|
||||||
|
that hangs off the left edge; their pivot is a constant (21,25) rather than half-size.
|
||||||
|
- `<screen>loop1.rat` is **not** a screen composition — it is a looping sprite animation:
|
||||||
|
a 3-name table (`pgpeff34/35/36.t32`) plus ~30 keyframes all at one position.
|
||||||
|
- The small (380 B) top-level entries are **`PRMD` primitives**, not sprites: a colour and
|
||||||
|
four explicit corner coordinates `(0,0) (1280,0) (0,720) (1280,720)` — the full-screen
|
||||||
|
quad that dims the scene behind the pause menu — terminated by `end `.
|
||||||
|
|
||||||
|
### The records are language-independent
|
||||||
|
|
||||||
|
`pgpbtn00.rat` is **byte-identical** in the English and Japanese bundles. The layout is
|
||||||
|
authored once and only the `.t32` sprites are swapped, which has two consequences:
|
||||||
|
|
||||||
|
- The baked pivot belongs to *whichever build the record was authored from*, not to the
|
||||||
|
sprite actually shipped beside it. That is why `pgpbtn01`'s pivot (113 → a 226 px wide
|
||||||
|
texture) matches neither the English sprite (207) nor the Japanese one (148).
|
||||||
|
- The split is visible inside a single bundle: in the **English** tutorial build, every
|
||||||
|
`.t32` declaration carries the correct English pivot (7/7), while the `.rat` declarations
|
||||||
|
carry Japanese-derived ones (`btn10` → 43,21 = 86/2, the *Japanese* sprite). So the
|
||||||
|
`.t32` table is regenerated per language and the `.rat` layer is inherited from the
|
||||||
|
Japanese master.
|
||||||
|
- **Do not infer anything about a language from a texture size** — see the traps below.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Positions were read out of the records and checked against a screenshot of the running
|
||||||
|
game, twice, in that order — the records were never fitted to the picture.
|
||||||
|
|
||||||
|
1. **Differential.** Across `pgpbtn00/01/04/05.rat`, exactly one field varies and it steps
|
||||||
|
`268 → 338 → 408 → 478` — a constant 70 px pitch — while the field before it is 226 in
|
||||||
|
all four. A vertical menu: constant X, evenly spaced Y.
|
||||||
|
2. **Absolute placement — the decisive test.** The *tutorial* build's records give
|
||||||
|
546/288, 546/358, 546/428 and 540/119. Compositing its sprites at exactly those numbers,
|
||||||
|
with no offset and no fitting, reproduces the screenshot (image above).
|
||||||
|
3. **Pivot.** 4 of 5 plain sprites carry exactly half their texture's dimensions at
|
||||||
|
0x50/0x54 (above).
|
||||||
|
|
||||||
|
> A caution on step 2, because the first pass here got it subtly wrong: the screenshot is
|
||||||
|
> of the **tutorial** pause menu, so only the `pgp_ttrl_*` records can be checked against
|
||||||
|
> it. The in-mission records (X = 226) also map onto the same screenshot under a single
|
||||||
|
> constant offset — but that only works because both builds share the 70 px pitch, and it
|
||||||
|
> proves nothing. The in-mission coordinates remain **unverified**: confirming them needs a
|
||||||
|
> screenshot of a pause during an actual mission.
|
||||||
|
|
||||||
|
## It generalizes — the title screen
|
||||||
|
|
||||||
|
The same method run against `GP_TITLE.pak` reproduces the **main menu**, which is a
|
||||||
|
different screen with a different item count and a different pitch:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
`ptbtn01..05.rat` give X = 542 for all five and Y = 162 / 242 / 322 / 402 / 482 — an
|
||||||
|
**80 px** pitch, where the pause menu used 70. Measured against the screenshot, the sprite
|
||||||
|
tops land at a constant **+46 px** for all five (one reads 45, a 1-px edge-detection
|
||||||
|
wobble), and 46 is exactly the 45 px of Xenia window chrome plus one. So the record's Y is
|
||||||
|
the sprite's top edge in the guest framebuffer, to the pixel, on a second screen.
|
||||||
|
|
||||||
|
`GP_TITLE.pak` also splits by sub-screen the way the pause pak splits by context:
|
||||||
|
`ptbtn00` alone (the `PRESS Ⓐ BUTTON` prompt), `ptbtn01..05` (main menu), `ptbtn11..13`
|
||||||
|
(the EXTRAS submenu), plus `pgloading_*` for the loading screen.
|
||||||
|
|
||||||
|
## Two traps this caught
|
||||||
|
|
||||||
|
Both were mistakes made during this analysis, caught by comparing against the real game —
|
||||||
|
recording them because a static-only reading would have shipped them:
|
||||||
|
|
||||||
|
- **Texture width does not identify a language.** English `RESUME` (166 px) and Japanese
|
||||||
|
`再開` (86 px) differ hugely, but Japanese `通信ログ` (148 px) is within a few px of an
|
||||||
|
English label. The first language assignment made here was wrong; rendering the sprites
|
||||||
|
is the only reliable check. (The records being language-independent makes this worse:
|
||||||
|
a record's baked pivot implies a texture width that matches *no* shipped sprite.)
|
||||||
|
- **The in-mission and tutorial pause menus are different sprite sets, not one set
|
||||||
|
re-packed.** In-mission is `RESUME / RADIO LOG / OPTIONS / BACK TO TITLE` (4 items,
|
||||||
|
`pgpbtnNN`); the tutorial is `RESUME / OPTIONS / BACK TO MENU` (3 items,
|
||||||
|
`pgp_ttrl_btn1N`). Matching the 3-item screenshot against the 4-item set suggests a
|
||||||
|
runtime slot-packing rule that does not exist.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- **The `eff*` / `deli*` / `msg` placements are still missing** — those sprites have no
|
||||||
|
`.rat` of their own, and `loop1.rat` turned out to be an animation, not a composition.
|
||||||
|
So the screen's draw list lives somewhere not yet found (the parent RATC's own header
|
||||||
|
region, or title code). That is the gap between the rebuild above and a complete screen.
|
||||||
|
- The same method should now unroll the other screens directly; `GP_HANGAR_ARSENAL.pak`
|
||||||
|
(789 T8aD + 510 RATC) is the big one, and the ARSENAL `DATA SHEET` panel documented in
|
||||||
|
[weapon-datasheet-runtime.md](../weapon-datasheet-runtime.md) is a ready-made oracle for it.
|
||||||
|
- Tooling: `crates/sylpheed-formats/examples/ui_screen.rs` (inventory a screen pak, carve a
|
||||||
|
named RATC child), `sylpheed-cli pak textures` (decode every sprite).
|
||||||
232
docs/re/weapon-datasheet-runtime.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# Weapon DATA SHEET — runtime capture (Route B)
|
||||||
|
|
||||||
|
**Status:** 🟡 first dynamic capture, 2026-07-28. The Arsenal's *Gallery Mode* panel is a
|
||||||
|
direct runtime readout of the IDXD weapon record, which makes it an oracle for the fields
|
||||||
|
the disc leaves **defaulted**. Two field mappings are ✅ `CONFIRMED`; two defaulted values
|
||||||
|
are recovered at 🟡 `PROBABLE`. Captured from the retail game under Xenia Canary
|
||||||
|
(software Vulkan, headless) — see [the container recipe](#how-this-was-captured).
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`sylpheed-formats::game_data` reads the combat tables out of `dat/GP_MAIN_GAME_E.pak`, but
|
||||||
|
**a field left at its default value carries no value on disc** — the key is present in the
|
||||||
|
IDXD string pool with no value token in front of it. Those defaults live in title code, so
|
||||||
|
a static read can only ever say "not set", never *what* the game uses. That is a real hole
|
||||||
|
for the reimplementation: e.g. `Weapon_DSaber_P_wep_01_Beam` — the Delta Saber's starting
|
||||||
|
beam gun — has no `TriggerShotCount` and no `Power` on disc.
|
||||||
|
|
||||||
|
Ruled out first: the defaults are **not** hiding in another pak. `hidden/DefTables.pak`
|
||||||
|
contains no `WEAPON`-schema (`0x6ab4825a`) objects at all, and the other `GP_MAIN_GAME_*`
|
||||||
|
paks are localized duplicates of the English one.
|
||||||
|
|
||||||
|
The two scratch analyses that produced the shopping list live next to the crate:
|
||||||
|
`crates/sylpheed-formats/examples/defaulted_fields.rs` (per-schema: which keys are declared
|
||||||
|
but defaulted, and by whom) and `examples/default_owners.rs` (per-key: every owner, valued
|
||||||
|
or `<DEFAULT>`).
|
||||||
|
|
||||||
|
> Caveat on those tools: they classify a token as a *value* only if it is numeric or
|
||||||
|
> non-identifier-shaped. **Boolean/enum-valued fields therefore read as `<DEFAULT>`
|
||||||
|
> spuriously** (`Yes`, `Homing`, `Single`, `Burst` are identifier-shaped). Every finding
|
||||||
|
> below concerns numeric fields, where the classification is sound.
|
||||||
|
|
||||||
|
## Finding — the DATA SHEET reads the record
|
||||||
|
|
||||||
|
In **ARSENAL → (weapon type) → Y (Gallery Mode)** each entry shows a `DATA SHEET`, and it
|
||||||
|
is shown for weapons that have **not** been developed yet — only fully hidden (dashed)
|
||||||
|
entries are withheld. The same panel appears in **HANGAR → (hard point)**, with an extra
|
||||||
|
`Weapon Type` row.
|
||||||
|
|
||||||
|
| DATA SHEET row | IDXD field | Confidence | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Ammo Capacity` | `LoadingCount` | ✅ CONFIRMED | every one of the 10 identified entries lands on a `LoadingCount` that exists in its own weapon-type tab — and see the circularity note below |
|
||||||
|
| `Max. Lock Ons` | `TriggerShotCount` | ✅ CONFIRMED | FALCON 9AM = 12 vs `wep_02`'s 12; BUZZARD 10AM = 22 vs `wep_04`'s 22 (two distinctive values, two independent records) |
|
||||||
|
| `Hard Point` | mount slot | ✅ CONFIRMED | matches the HANGAR slot the weapon is mountable/equipped on (`STILETTO BG I` → NOSE, `FALCON 9AM` → MAIN WEAPON1) |
|
||||||
|
| `Range Class` | bucket of `MaximumRange` | 🟡 PROBABLE | monotone in the on-disc metres, see the bracket table below |
|
||||||
|
| `Damage Class` | bucket of `Power` | 🟡 PROBABLE | monotone in the on-disc power, see below |
|
||||||
|
| `Weight Class` | bucket of the hangar-table `Weight` | ❔ HYPOTHESIS | only one clean pair so far (`wep_33`, Weight 0.3 → "Light") |
|
||||||
|
| `Speed Class` | ❔ | ❔ | present only on missiles (MPM = A, ASM = B); no on-disc pairing established |
|
||||||
|
| `Sight Homing`, `Lock on Overlap` | ❔ (`Available` / `–`) | ❔ | the plausible on-disc partners (`Homing`, `OverlapLockon`) are identifier-valued and not yet decoded; **NEEDS-HUMAN** |
|
||||||
|
|
||||||
|
### Why the `Ammo Capacity` evidence is not circular
|
||||||
|
|
||||||
|
Each UI entry was **identified** by matching its `Ammo Capacity` against the records in
|
||||||
|
that weapon-type tab, so "the ammo matches" cannot on its own prove the mapping. What does:
|
||||||
|
|
||||||
|
1. **The match is forced and unique.** For 10 of the 11 entries, exactly one record in that
|
||||||
|
tab carries that number (`600` occurs three times overall — `wep_04`, `wep_24`, `wep_55`
|
||||||
|
— but in three different tabs: MPM, BEAM, B/R). An unrelated quantity would not land on
|
||||||
|
a valid, tab-unique `LoadingCount` eleven times running.
|
||||||
|
2. **A second, independent field then agrees.** FALCON 9AM and BUZZARD 10AM were pinned by
|
||||||
|
ammo alone, and their `Max. Lock Ons` (12, 22) then matched those same records'
|
||||||
|
`TriggerShotCount` (12, 22) — values that play no part in the identification. A wrong
|
||||||
|
identification would have to be wrong twice, consistently.
|
||||||
|
3. **One entry is identified without ammo at all.** STILETTO BG I is described in-game as
|
||||||
|
"the initially mounted Delta Saber beam gun" and is the weapon the HANGAR shows equipped
|
||||||
|
on the nose; `wep_01_Beam` is the corresponding record, and its `LoadingCount` 6000 is
|
||||||
|
what the panel shows.
|
||||||
|
|
||||||
|
The weakest identification is LIGHT MACHINE GUN MG I: three guns share `LoadingCount = 3000`
|
||||||
|
(`wep_09`, `wep_33`, `wep_83`). `wep_33` is picked on `Weight Class = Light` (it has by far
|
||||||
|
the smallest `Mass`, 1.5 vs 3.6 / 33.0) — 🟡 PROBABLE, not certain.
|
||||||
|
|
||||||
|
## Finding — recovered defaults
|
||||||
|
|
||||||
|
| Weapon | UI name | Field | On disc | **Runtime** | Conf. |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `Weapon_DSaber_P_wep_05_ASMissile` | TERRIER SMH | `TriggerShotCount` | *(defaulted)* | **4** | 🟡 |
|
||||||
|
| `Weapon_DSaber_P_wep_60_ASMissile` | HOUND SMH | `TriggerShotCount` | *(defaulted)* | **4** | 🟡 |
|
||||||
|
|
||||||
|
Both defaulted weapons read **4**, which is consistent with a single title-code default of
|
||||||
|
`TriggerShotCount = 4` rather than two per-weapon constants — but two samples cannot tell
|
||||||
|
those apart. ❔ HYPOTHESIS: *the title-code default for `TriggerShotCount` is 4.* It would
|
||||||
|
be confirmed by a third weapon that defaults the field and also reads 4 (candidates that
|
||||||
|
were still locked in this save: `wep_27`, `wep_30`, and the seven Beams), or refuted by one
|
||||||
|
that reads anything else.
|
||||||
|
|
||||||
|
`Power` and `MaximumRange` defaults are **not** exactly recoverable from this panel — it
|
||||||
|
shows only the letter bucket. They are bracketed instead (below).
|
||||||
|
|
||||||
|
## Captured rows
|
||||||
|
|
||||||
|
All values are from one session on save slot 01 (Stage 02, "At Standby", 5 % clear, 4101 P);
|
||||||
|
`✓` marks a value that matches the on-disc record exactly.
|
||||||
|
|
||||||
|
| UI name | Record | Rng | Dmg | Spd | Weight | Ammo | Lock | Homing | Overlap | Hard point |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| LIGHT MACHINE GUN MG I | `wep_33_Gun` 🟡 | D | E | – | Light | 3000 ✓ | – | – | – | NOSE WEAPON (Nose) |
|
||||||
|
| BROAD SWORD SG I | *see below* | E | D | – | Light | 200 | – | – | – | NOSE WEAPON (Nose) |
|
||||||
|
| STILETTO BG I | `wep_01_Beam` | E | E | – | Light | 6000 ✓ | – | – | – | NOSE WEAPON (Nose) |
|
||||||
|
| DAGGER BG2 | `wep_37_Beam` | D | E | – | Light | 4000 ✓ | – | – | – | NOSE WEAPON (Nose) |
|
||||||
|
| PILUM BP | `wep_24_Beam` | B | D | – | Light | 600 ✓ | – | – | – | MAIN WEAPON1 (Fore) |
|
||||||
|
| FALCON 9AM | `wep_02_Missile` | D | D | A | Heavy | 300 ✓ | 12 ✓ | Available | – | MAIN WEAPON1 (Fore) |
|
||||||
|
| BUZZARD 10AM | `wep_04_Missile` | C | D | A | Heavy | 600 ✓ | 22 ✓ | – | Available | MAIN WEAPON1 (Fore) |
|
||||||
|
| DART 23 ROCKET | `wep_55_Rocket` | C | D | – | Heavy | 600 ✓ | – | – | – | MAIN WEAPON1 (Fore) |
|
||||||
|
| TERRIER SMH | `wep_05_ASMissile` | D | C | B | Heavy | 45 ✓ | **4** | Available | Available | MAIN WEAPON2 (Rear) |
|
||||||
|
| HOUND SMH | `wep_60_ASMissile` | B | C | B | Medium | 18 ✓ | **4** | Available | Available | MAIN WEAPON2 (Rear) |
|
||||||
|
| TOMAHAWK ALPHA RAIL GUN | `wep_03_Cannon` | C | C | – | Medium | 75 ✓ | – | – | – | MAIN WEAPON3 (Lower) |
|
||||||
|
|
||||||
|
**BROAD SWORD SG I is unidentified — NEEDS-HUMAN.** `Ammo Capacity 200` narrows it to
|
||||||
|
`wep_38` / `wep_41` / `wep_42_Shotgun` (all `LoadingCount = 200`); "SG" and the GUN tab fit
|
||||||
|
a shotgun. `wep_42` is excluded by range (2500 m would not share class E with `wep_38`/
|
||||||
|
`wep_41`'s 3000 m *if* the class is a pure range bucket), leaving `wep_38` vs `wep_41`,
|
||||||
|
which the panel cannot separate. Its `Damage Class D` also does not fit the `Power` bracket
|
||||||
|
below (all three shotguns are `Power ≤ 16`, i.e. bucket E), so either the identification or
|
||||||
|
the "Damage Class = bucket of `Power`" model is wrong for shotguns.
|
||||||
|
|
||||||
|
### Letter-class brackets
|
||||||
|
|
||||||
|
Sorting the identified rows by their on-disc numbers gives monotone, non-overlapping bands:
|
||||||
|
|
||||||
|
```
|
||||||
|
Range Class E: 3000 (wep_01)
|
||||||
|
D: 3500 · 4000 · 4000 · 4000 (wep_33, wep_37, wep_02, wep_05)
|
||||||
|
C: 4500 · 5000 · 5000 (wep_55, wep_04, wep_03)
|
||||||
|
B: 6500 · 6500 (wep_24, wep_60)
|
||||||
|
|
||||||
|
Damage Class E: 10 · 14 · 16 (wep_33, wep_01, wep_37)
|
||||||
|
D: 70 · 75 · 100 (wep_24, wep_04, wep_55)
|
||||||
|
C: 200 · 400 (wep_03, wep_05)
|
||||||
|
```
|
||||||
|
|
||||||
|
Two consequences for the reimplementation:
|
||||||
|
|
||||||
|
- The **thresholds are not pinned** — only bracketed (e.g. the D/C range boundary lies in
|
||||||
|
(4000, 4500]). More weapons, or a static read of the title-code table, would pin them.
|
||||||
|
- They **bracket the defaulted numbers**: `wep_02_Missile`'s defaulted `Power` sits in the
|
||||||
|
D band (≈ 17…150 by the observed edges) and `wep_60_ASMissile`'s in the C band
|
||||||
|
(≈ 150…500). 🟡 PROBABLE, and only as good as the bucket model.
|
||||||
|
|
||||||
|
## How this was captured
|
||||||
|
|
||||||
|
Container recipe (`sylph-container/mission.md`), with two corrections worth keeping:
|
||||||
|
|
||||||
|
- **Skip the intro movie with A.** The brief warns it crashes; it does not. Skipping cuts
|
||||||
|
boot-to-main-menu from ~13 min to **~1 min** under lavapipe. (Thanks: user tip.)
|
||||||
|
- **The title screen falls back to the attract loop within a few seconds**, so a
|
||||||
|
screenshot→look→tap cycle always misses it. Poll the framebuffer and tap in the same
|
||||||
|
process. Both are automated in `scratchpad/skip_intro.sh` (movie detected by frame-to-
|
||||||
|
frame RMSE; title by the green Ⓐ glyph at pixel 625,618).
|
||||||
|
- Under lavapipe the game polls input at its own low frame rate: **a 60 ms d-pad tap is
|
||||||
|
dropped roughly half the time**; 200 ms is reliable and 300 ms starts to auto-repeat.
|
||||||
|
- The Hangar hard-point weapon carousel is cycled with d-pad **down**, not left/right.
|
||||||
|
|
||||||
|
Path: title → A → LOAD GAME → slot 01 → *Load game?* **YES** → READY ROOM → ARSENAL →
|
||||||
|
type tab (LB/RB) → **Y** for the DATA SHEET → d-pad down through the list.
|
||||||
|
|
||||||
|
## Open / next
|
||||||
|
|
||||||
|
- Only **9 weapons of ~61** are revealed at 5 % completion, and none of the four whose
|
||||||
|
`LoadingCount` is defaulted (`wep_11`, `wep_28`, `wep_36`, `wep_70`) is among them.
|
||||||
|
Progressing the save (or a later save) is what unlocks the rest — the panel itself
|
||||||
|
already shows undeveloped weapons, so no points need to be spent.
|
||||||
|
- **Craft (`UNIT`-schema) defaults are not reachable this way.** The Hangar exposes exactly
|
||||||
|
one craft-level runtime number, `Gross Weight` (a class, "Light"). The defaulted craft
|
||||||
|
fields (`ShieldRatio`, `BarrelRoll_Count*`, `HoldPosition_*Ratio`, `Slalom_TurnCount_Max`,
|
||||||
|
`UsingChaffRatio`, …) are AI/flight-model constants with no UI surface; they would need
|
||||||
|
in-flight behavioural measurement or a guest-memory read, not a menu screenshot.
|
||||||
|
- The `Sight Homing` / `Lock on Overlap` on-disc partners are still unidentified.
|
||||||
|
|
||||||
|
Screenshots for every row above: `/sylph-home/re/caps/` in the container.
|
||||||
|
|
||||||
|
Evidence PNGs are committed under [`captures/weapon-datasheet/`](captures/weapon-datasheet/)
|
||||||
|
(64-colour quantized for size; the numbers stay legible).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Addendum — the in-flight HUD (2026-07-28)
|
||||||
|
|
||||||
|
Reached via **TUTORIAL → BASIC CONTROLS / HEADS-UP DISPLAY** from the title menu (no
|
||||||
|
save needed). Evidence: [`captures/hud-runtime/`](captures/hud-runtime/).
|
||||||
|
|
||||||
|
## The HUD corroborates the ammo mapping, independently
|
||||||
|
|
||||||
|
The Delta Saber's HUD prints its two equipped weapons as `NOSE <TYPE> <AMMO>` and
|
||||||
|
`MAIN <TYPE> <AMMO>`. With the loadout known from the HANGAR (nose = STILETTO BG I, main =
|
||||||
|
FALCON 9AM) the HUD reads **`NOSE BM 06000`** and **`MAIN MPM 00300`** — exactly
|
||||||
|
`wep_01_Beam`'s `LoadingCount = 6000` and `wep_02_Missile`'s `300`.
|
||||||
|
|
||||||
|
This matters because it is **not** the Arsenal panel: the weapons were identified from the
|
||||||
|
HANGAR loadout, and the numbers come from a different renderer in a different game mode. It
|
||||||
|
is a genuinely independent confirmation of `Ammo Capacity == LoadingCount`.
|
||||||
|
|
||||||
|
The type tags (`BM`, `MPM`) are the same short codes as the Arsenal type tabs.
|
||||||
|
|
||||||
|
## HUD element inventory
|
||||||
|
|
||||||
|
| HUD element | Backing data | Conf. |
|
||||||
|
|---|---|---|
|
||||||
|
| `NOSE` / `MAIN` + type tag + 5-digit ammo | equipped weapon, `LoadingCount` | ✅ |
|
||||||
|
| `HEAT` / `L.HEAT` bar under each weapon | the `Heating` / `Cooling` pair | 🟡 |
|
||||||
|
| Speed readout + throttle scale (`0`, `100`, `A/B`) | craft velocity | ✅ |
|
||||||
|
| `SHIELD` and `ARMOR` bars (two separate pools) | craft `HP` + a shield pool | 🟡 |
|
||||||
|
| Target reticle: name / type / distance + ring **Armor Gauge** | target unit record | 🟡 |
|
||||||
|
| `RANGE` gauge with `MAIN` and `NOSE` tick markers | each equipped weapon's `MaximumRange`, plotted against target distance | 🟡 |
|
||||||
|
| `YOU KILLED: WARSHIPS nnnn` + a second counter | score / `ScorePoint` | ❔ |
|
||||||
|
|
||||||
|
The `RANGE` gauge is the interesting one for future work: it draws a **per-weapon marker on
|
||||||
|
a distance scale**, so a weapon whose `MaximumRange` is defaulted on disc (`wep_25`,
|
||||||
|
`wep_58`, `wep_82`) would have its value *drawn* rather than bucketed into a letter — if the
|
||||||
|
scale can be calibrated against two weapons with known ranges, that recovers a real number
|
||||||
|
where the Arsenal panel only gives a class.
|
||||||
|
|
||||||
|
## Craft velocity
|
||||||
|
|
||||||
|
Full afterburner (RT) peaked at **1193** against `UN_f001_TCAF_DeltaSaber_T`'s on-disc
|
||||||
|
`MaximumVelocity = 1200`. 🟡 PROBABLE — one observation, and the readout may have been
|
||||||
|
still climbing. Cruise sat at 350, which is *not* the record's `CruisingVelocity` (700), so
|
||||||
|
the number is the current throttle setting, not a named constant; don't read more into it.
|
||||||
|
|
||||||
|
## Notes for the next session
|
||||||
|
|
||||||
|
- The tutorials need **no save game** and are reachable in ~1 min from a cold boot, which
|
||||||
|
makes them the cheapest way back into a live flight scene.
|
||||||
|
- `tools/re-capture/autopilot.py` chases the yellow off-screen waypoint arrow (colour +
|
||||||
|
shape, `-sample` not `-resize`, ~0.65 s per detection). It **finds the arrow reliably but
|
||||||
|
oscillates** — the proportional gain is too high for the craft's turn rate. It needs a
|
||||||
|
damping/derivative term before it can actually fly a waypoint.
|
||||||
|
- The HEADS-UP DISPLAY tutorial reaches a scripted targeting segment where the ship stops
|
||||||
|
moving (target distance pinned) and the on-screen controller highlights **A**; tapping and
|
||||||
|
holding A did not advance it. **NEEDS-HUMAN**: what input that segment wants.
|
||||||
|
- Canary is unstable here: it died twice mid-session (once loading BEAM `Resource3D`, once
|
||||||
|
hanging on tutorial teardown) with no crash dump. Re-launch is cheap; just don't assume a
|
||||||
|
long session survives.
|
||||||
20
tools/re-capture/README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Runtime-capture harness (sylph-re container)
|
||||||
|
|
||||||
|
Screenshot-driven scripts for reading the running retail game's menus under Xenia
|
||||||
|
Canary + lavapipe, headless. They assume the container helpers `screenshot`,
|
||||||
|
`vgamepad`, `pad` are on `$PATH` and `HOME=/sylph-home/re`.
|
||||||
|
|
||||||
|
| Script | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `skip_intro.sh` | Boot → main menu, unattended. Taps A only while the intro movie is actually playing (frame-to-frame RMSE), then once at the `PRESS Ⓐ BUTTON` title. Static logo screens are left alone, so a stray tap can never land on NEW GAME. |
|
||||||
|
| `wait_title.sh` | Older variant: wait for the title (green Ⓐ glyph at px 625,618) and tap A. Superseded by `skip_intro.sh`. |
|
||||||
|
| `step.sh` | One Arsenal navigation step (`down`/`up`/`next`/`prev`/`none`) + a compact capture: weapon list stacked over the `DATA SHEET`. |
|
||||||
|
| `sweep.sh` | Walk a whole weapon-type list, capturing **only** rows that show a `DATA SHEET` — locked rows (a "Conditions to Develop" panel) are detected by the brightness of the `Range Class` label box and skipped. |
|
||||||
|
| `type.sh` | Change weapon-type tab N times (RB) and report the header strip. |
|
||||||
|
| `hp.sh` / `cyc.sh` | Hangar hard-point carousel: `cyc.sh` steps it (d-pad **down**, not left/right) and captures the Name + `DATA SHEET`. |
|
||||||
|
|
||||||
|
**Input timing under lavapipe:** the game polls input at its own low frame rate, so a
|
||||||
|
60 ms d-pad tap is dropped roughly half the time. 200 ms is reliable; 300 ms starts to
|
||||||
|
auto-repeat (two rows per press).
|
||||||
|
|
||||||
|
Findings produced with these: [`docs/re/weapon-datasheet-runtime.md`](../../docs/re/weapon-datasheet-runtime.md).
|
||||||
95
tools/re-capture/autopilot.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fly the Delta Saber toward the tutorial waypoint by chasing the yellow
|
||||||
|
off-screen direction arrow.
|
||||||
|
|
||||||
|
Detection: no PIL/numpy in the box, so the frame comes from `convert ... txt:-`.
|
||||||
|
Two things make it fast AND correct:
|
||||||
|
* `-sample` (point sampling, not `-resize`/`-scale` averaging) — a 1-px-thin
|
||||||
|
glyph keeps its true colour, so the exact colour predicate still fires while
|
||||||
|
the dump shrinks ~9x (3.5s -> 0.65s).
|
||||||
|
* shape, not just colour — the instruction-frame bars and the throttle marker
|
||||||
|
are the same dim yellow, so the arrow is the largest blob that is roughly as
|
||||||
|
tall as it is wide, with the fixed throttle marker blacklisted by position.
|
||||||
|
A ~1.1 s control loop is fast enough to converge; the earlier ~6 s loop was not.
|
||||||
|
"""
|
||||||
|
import re, subprocess, sys, time
|
||||||
|
|
||||||
|
X0, Y0, W, H = 80, 60, 820, 640 # flight view (the arrow also rides the bottom edge)
|
||||||
|
K = 3.03 # 1/0.33 sample factor
|
||||||
|
CX, CY = 640, 405 # crosshair
|
||||||
|
THROTTLE = (382, 456) # fixed yellow decoy on the speed gauge
|
||||||
|
PX = re.compile(r"^(\d+),(\d+):.*?srgb\((\d+),(\d+),(\d+)\)")
|
||||||
|
|
||||||
|
|
||||||
|
def pad(*a):
|
||||||
|
subprocess.run(["/opt/sylph/vgamepad.py", *a], check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def arrow(png="/tmp/ap.png"):
|
||||||
|
subprocess.run(["/opt/sylph/screenshot.sh", png], check=False,
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
out = subprocess.run(["convert", png, "-crop", f"{W}x{H}+{X0}+{Y0}", "+repage",
|
||||||
|
"-sample", "33%", "txt:-"], capture_output=True, text=True).stdout
|
||||||
|
pts = set()
|
||||||
|
for l in out.splitlines()[1:]:
|
||||||
|
m = PX.match(l)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
x, y, r, g, b = (int(v) for v in m.groups())
|
||||||
|
if r >= 60 and g >= 48 and b <= 0.35 * g and (r - g) <= 0.45 * r:
|
||||||
|
pts.add((x, y))
|
||||||
|
seen, best = set(), None
|
||||||
|
for p in pts:
|
||||||
|
if p in seen:
|
||||||
|
continue
|
||||||
|
st, c = [p], []
|
||||||
|
seen.add(p)
|
||||||
|
while st:
|
||||||
|
x, y = st.pop(); c.append((x, y))
|
||||||
|
for dx in (-1, 0, 1):
|
||||||
|
for dy in (-1, 0, 1):
|
||||||
|
q = (x + dx, y + dy)
|
||||||
|
if q in pts and q not in seen:
|
||||||
|
seen.add(q); st.append(q)
|
||||||
|
xs = [q[0] for q in c]; ys = [q[1] for q in c]
|
||||||
|
cx, cy = X0 + sum(xs) / len(c) * K, Y0 + sum(ys) / len(c) * K
|
||||||
|
if abs(cx - THROTTLE[0]) < 30 and abs(cy - THROTTLE[1]) < 30:
|
||||||
|
continue
|
||||||
|
if len(c) < 8:
|
||||||
|
continue
|
||||||
|
if best is None or len(c) > best[2]:
|
||||||
|
best = (cx, cy, len(c))
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
steps = int(sys.argv[1]) if len(sys.argv) > 1 else 25
|
||||||
|
centred = 0
|
||||||
|
for i in range(steps):
|
||||||
|
a = arrow()
|
||||||
|
if a is None:
|
||||||
|
print(f"{i:2d}: no arrow -> boost (target ahead)")
|
||||||
|
pad("trig", "RT", "0.55"); time.sleep(1.2); pad("trig", "RT", "0")
|
||||||
|
centred += 1
|
||||||
|
if centred >= 6:
|
||||||
|
print(" arrival likely — stopping"); return 0
|
||||||
|
continue
|
||||||
|
x, y, n = a
|
||||||
|
dx, dy = x - CX, y - CY
|
||||||
|
if abs(dx) < 70 and abs(dy) < 70:
|
||||||
|
centred += 1
|
||||||
|
print(f"{i:2d}: arrow ({x:.0f},{y:.0f}) centred -> boost")
|
||||||
|
pad("trig", "RT", "0.55"); time.sleep(1.2); pad("trig", "RT", "0")
|
||||||
|
continue
|
||||||
|
centred = 0
|
||||||
|
lx = max(-1.0, min(1.0, dx / 200))
|
||||||
|
ly = max(-1.0, min(1.0, dy / 160))
|
||||||
|
print(f"{i:2d}: arrow ({x:.0f},{y:.0f}) n={n} -> LX {lx:+.2f} LY {ly:+.2f}")
|
||||||
|
pad("axis", "LX", f"{lx:.2f}"); pad("axis", "LY", f"{ly:.2f}")
|
||||||
|
time.sleep(0.45)
|
||||||
|
pad("axis", "LX", "0"); pad("axis", "LY", "0")
|
||||||
|
print("steps exhausted")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
sys.exit(main())
|
||||||
9
tools/re-capture/cyc.sh
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Step the Hangar weapons-container carousel right and capture the Name+DATA SHEET.
|
||||||
|
set -u
|
||||||
|
export HOME=/sylph-home/re
|
||||||
|
OUT=/sylph-home/re/caps; mkdir -p "$OUT"
|
||||||
|
vgamepad dpad right; sleep 0.30; vgamepad dpad center; sleep 3.0
|
||||||
|
screenshot /tmp/h.png >/dev/null 2>&1
|
||||||
|
convert /tmp/h.png -crop 530x480+700+95 +repage "$OUT/$1.png"
|
||||||
|
echo "$OUT/$1.png"
|
||||||
11
tools/re-capture/hp.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Cycle the Hangar hard-point weapon carousel one step right and capture the
|
||||||
|
# Name + DATA SHEET panel (the mountable-weapon list for that hard point).
|
||||||
|
set -u
|
||||||
|
export HOME=/sylph-home/re
|
||||||
|
OUT=/sylph-home/re/caps; mkdir -p "$OUT"
|
||||||
|
[ "${1:-}" = "none" ] || { vgamepad dpad right; sleep 0.20; vgamepad dpad center; }
|
||||||
|
sleep 2.5
|
||||||
|
screenshot /tmp/h.png >/dev/null 2>&1
|
||||||
|
convert /tmp/h.png -crop 495x460+705+95 +repage "$OUT/$2.png"
|
||||||
|
echo "$OUT/$2.png"
|
||||||
41
tools/re-capture/ratc_decls.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Parse a RATC bundle's header SPRITE DECLARATION TABLE: u32 count at 0x14, then
|
||||||
|
fixed 60-byte entries of [name, NUL-padded | 4 u32 flags | pivotX | pivotY | 0].
|
||||||
|
Check each pivot against half the decoded texture's real dimensions."""
|
||||||
|
import struct, sys, glob, re, os
|
||||||
|
|
||||||
|
def decls(d):
|
||||||
|
n = struct.unpack_from(">I", d, 0x14)[0]
|
||||||
|
out = []
|
||||||
|
for i in range(n):
|
||||||
|
o = 0x20 + i * 60
|
||||||
|
if o + 60 > len(d): break
|
||||||
|
name = d[o:o+28].split(b"\0")[0].decode("ascii", "replace")
|
||||||
|
px, py = struct.unpack_from(">II", d, o + 48)
|
||||||
|
out.append((name, px, py))
|
||||||
|
return n, out
|
||||||
|
|
||||||
|
def texmap(prefix):
|
||||||
|
t = {}
|
||||||
|
for f in glob.glob(f"pause-tex/{prefix}_*.png"):
|
||||||
|
m = re.match(rf".*/{prefix}_(.+)\.t32_(\d+)x(\d+)\.png", f)
|
||||||
|
if m: t[m.group(1) + ".t32"] = (int(m.group(2)), int(m.group(3)))
|
||||||
|
return t
|
||||||
|
|
||||||
|
for path, prefix in [(sys.argv[1], sys.argv[2])]:
|
||||||
|
d = open(path, "rb").read()
|
||||||
|
n, ds = decls(d)
|
||||||
|
tm = texmap(prefix)
|
||||||
|
print(f"{os.path.basename(path)}: count={n}, parsed={len(ds)}")
|
||||||
|
ok = bad = miss = 0
|
||||||
|
for name, px, py in ds:
|
||||||
|
if name in tm:
|
||||||
|
w, h = tm[name]
|
||||||
|
hit = (px == w // 2 and py == h // 2)
|
||||||
|
ok, bad = ok + hit, bad + (not hit)
|
||||||
|
flag = "OK " if hit else "MISMATCH"
|
||||||
|
print(f" {flag} {name:28s} tex {w:4d}x{h:<4d} half {w//2:4d},{h//2:<4d} decl {px:4d},{py:<4d}")
|
||||||
|
else:
|
||||||
|
miss += 1
|
||||||
|
print(f" ? {name:28s} (no decoded texture) decl {px:4d},{py:<4d}")
|
||||||
|
print(f" => pivot == half(texture): {ok} ok, {bad} mismatch, {miss} unchecked")
|
||||||
27
tools/re-capture/skip_intro.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Drive the boot sequence to the main menu without a human in the loop.
|
||||||
|
# - movie playing (two grabs 0.6s apart differ a lot) -> tap A to skip
|
||||||
|
# - static screen -> if the green "PRESS (A) BUTTON" glyph is there, tap A and stop
|
||||||
|
# Static logo screens are left alone, so a stray tap can never land on NEW GAME.
|
||||||
|
set -u
|
||||||
|
export HOME=/sylph-home/re
|
||||||
|
# The X root keeps the DEAD session's last frame, so a fresh launch would be
|
||||||
|
# detected as "already at the title". Blank it, and wait for the new window.
|
||||||
|
xsetroot -solid black 2>/dev/null || true
|
||||||
|
until xdotool search --name "Xenia-canary" >/dev/null 2>&1; do sleep 1; done
|
||||||
|
deadline=$(( SECONDS + ${1:-900} ))
|
||||||
|
while [ $SECONDS -lt $deadline ]; do
|
||||||
|
screenshot /tmp/f1.png >/dev/null 2>&1; sleep 0.6
|
||||||
|
screenshot /tmp/f2.png >/dev/null 2>&1
|
||||||
|
d=$(compare -metric RMSE /tmp/f1.png /tmp/f2.png null: 2>&1 | sed 's/ .*//' | cut -d. -f1)
|
||||||
|
d=${d:-0}
|
||||||
|
read -r r g b < <(convert /tmp/f2.png -format "%[fx:int(255*p{625,618}.r)] %[fx:int(255*p{625,618}.g)] %[fx:int(255*p{625,618}.b)]" info:)
|
||||||
|
if [ "$g" -gt 130 ] && [ $((g - r)) -gt 45 ] && [ $((g - b)) -gt 45 ]; then
|
||||||
|
echo "TITLE at ${SECONDS}s -> A"; vgamepad tap A 250; exit 0
|
||||||
|
fi
|
||||||
|
if [ "$d" -gt 1500 ]; then
|
||||||
|
echo "movie (rmse $d) at ${SECONDS}s -> skip A"; vgamepad tap A 250; sleep 3
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "TIMEOUT"; exit 1
|
||||||
22
tools/re-capture/step.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One Arsenal navigation step + a compact capture: the weapon list (which row is
|
||||||
|
# selected) stacked over the DATA SHEET numeric rows. Everything else is dropped.
|
||||||
|
# Usage: step.sh <down|up|next|prev|none> <tag> [settle_s]
|
||||||
|
set -u
|
||||||
|
export HOME=/sylph-home/re
|
||||||
|
OUT=/sylph-home/re/caps; mkdir -p "$OUT"
|
||||||
|
case "${1}" in
|
||||||
|
down) vgamepad dpad down; sleep 0.20; vgamepad dpad center ;;
|
||||||
|
up) vgamepad dpad up; sleep 0.20; vgamepad dpad center ;;
|
||||||
|
next) vgamepad hold RB 0.35 ;;
|
||||||
|
prev) vgamepad hold LB 0.35 ;;
|
||||||
|
none) : ;;
|
||||||
|
esac
|
||||||
|
sleep "${3:-2.5}"
|
||||||
|
raw="$OUT/$2.raw.png"
|
||||||
|
screenshot "$raw" >/dev/null 2>&1
|
||||||
|
convert "$raw" -crop 500x420+150+180 +repage /tmp/_list.png
|
||||||
|
convert "$raw" -crop 500x200+700+100 +repage /tmp/_sheet.png
|
||||||
|
convert /tmp/_list.png /tmp/_sheet.png -background black -append "$OUT/$2.png"
|
||||||
|
rm -f "$raw"
|
||||||
|
echo "$OUT/$2.png"
|
||||||
21
tools/re-capture/sweep.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Walk down a weapon-type list N rows; capture ONLY rows that show a DATA SHEET
|
||||||
|
# (locked rows show a "Conditions to Develop" panel instead — detected by the
|
||||||
|
# brightness of the "Range Class" label box, ~5 when absent, >>20 when present).
|
||||||
|
set -u
|
||||||
|
export HOME=/sylph-home/re
|
||||||
|
OUT=/sylph-home/re/caps; mkdir -p "$OUT"
|
||||||
|
pfx=$1; n=${2:-8}
|
||||||
|
for ((i=1;i<=n;i++)); do
|
||||||
|
vgamepad dpad down; sleep 0.20; vgamepad dpad center; sleep 3.0
|
||||||
|
screenshot /tmp/s.png >/dev/null 2>&1
|
||||||
|
m=$(convert /tmp/s.png -crop 150x24+730+116 +repage -colorspace Gray -format "%[fx:int(255*mean)]" info:)
|
||||||
|
if [ "$m" -gt 45 ]; then
|
||||||
|
convert /tmp/s.png -crop 500x420+150+180 +repage /tmp/_l.png
|
||||||
|
convert /tmp/s.png -crop 500x200+700+100 +repage /tmp/_s.png
|
||||||
|
convert /tmp/_l.png /tmp/_s.png -background black -append "$OUT/$pfx-r$i.png"
|
||||||
|
echo "row $i: DATA SHEET -> $OUT/$pfx-r$i.png"
|
||||||
|
else
|
||||||
|
echo "row $i: locked (mean $m)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
11
tools/re-capture/type.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Change Arsenal weapon type N times (RB) and report the header strip only.
|
||||||
|
set -u
|
||||||
|
export HOME=/sylph-home/re
|
||||||
|
OUT=/sylph-home/re/caps; mkdir -p "$OUT"
|
||||||
|
n=${1:-1}
|
||||||
|
for ((i=0;i<n;i++)); do vgamepad hold RB 0.35; sleep 2.5; done
|
||||||
|
sleep 1.5
|
||||||
|
screenshot /tmp/hdr.png >/dev/null 2>&1
|
||||||
|
convert /tmp/hdr.png -crop 420x50+130+148 +repage "$OUT/hdr.png"
|
||||||
|
echo "$OUT/hdr.png"
|
||||||
21
tools/re-capture/wait_title.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Poll the framebuffer for the "PRESS (A) BUTTON" title screen (the green A glyph
|
||||||
|
# at ~625,618) and tap A the instant it appears — the title auto-returns to the
|
||||||
|
# attract loop after a few seconds, which is why a human-paced tap misses it.
|
||||||
|
set -u
|
||||||
|
export HOME=/sylph-home/re
|
||||||
|
TMP=/tmp/title-probe.png
|
||||||
|
deadline=$(( SECONDS + ${1:-900} ))
|
||||||
|
while [ $SECONDS -lt $deadline ]; do
|
||||||
|
if screenshot "$TMP" >/dev/null 2>&1; then
|
||||||
|
read -r r g b < <(convert "$TMP" -format "%[fx:int(255*p{625,618}.r)] %[fx:int(255*p{625,618}.g)] %[fx:int(255*p{625,618}.b)]" info:)
|
||||||
|
if [ "$g" -gt 130 ] && [ $((g - r)) -gt 45 ] && [ $((g - b)) -gt 45 ]; then
|
||||||
|
echo "TITLE detected (rgb $r,$g,$b) at ${SECONDS}s — tapping A"
|
||||||
|
vgamepad tap A 200
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "TIMEOUT: title not seen"
|
||||||
|
exit 1
|
||||||