Commit Graph

49 Commits

Author SHA1 Message Date
sylph-decoder
7c5a4a6961 fix: unbreak the merge -- ComposeOptions grew at, the Explorer's literal didn't
Some checks failed
CI / Native — linux (pull_request) Failing after 34m16s
CI / WASM — Web (pull_request) Successful in 34m28s
CI / Formatting (pull_request) Failing after 1m17s
`cargo build -p sylpheed-cli` after resolving the merge's one real conflict
(a rustfmt-only clash) was not "the merge is resolved" -- it only checked the
crate whose conflict I'd just looked at. `cargo check --workspace --all-targets`
finds the actual break: sylpheed-viewer's `compose_screen` builds a
`ComposeOptions` struct literal, and this branch added a field
(`at: Option<u32>`, for posing a composite mid-animation) that main's Explorer
code never had to name because every prior caller used `..Default::default()`
or the field didn't exist yet. Neither side's diff touched the other's line,
so nothing in the conflict pointed at it.

`at: None` is the only correct value here -- the doc comment on the field
already says so ("`None` keeps the settled composite, which is what every
existing caller wants"), and the Explorer's screen view has no notion of
mid-animation posing to wire up. This restores its exact prior behavior; nothing
about the Explorer's data source, static-only rule, or design changes.

PR #23 is red on this. Fixes it in place rather than opening a second PR, since
it is the same unit -- a merge I performed that did not actually leave a
building tree.
2026-09-11 20:22:52 +00:00
ff984e0350 Merge pull request 'style: rustfmt sweep — 774 hunks across 154 files → 0 (#12)' (#18) from chore/rustfmt-sweep into main
All checks were successful
CI / Native — linux (push) Successful in 32m27s
CI / WASM — Web (push) Successful in 29m19s
CI / Formatting (push) Successful in 40s
Reviewed-on: #18
2026-09-08 20:09:53 +00:00
c4c914ff59 style: rustfmt sweep -- 774 hunks across 154 files -> 0
Some checks failed
CI / Native — linux (pull_request) Successful in 32m7s
CI / WASM — Web (pull_request) Failing after 8m3s
CI / Formatting (pull_request) Successful in 50s
`cargo fmt --all -- --check` has failed on every run in this repository's
history, identically on `main` and on every branch. This is #12.

Mechanical: `cargo fmt --all`, nothing else. 154 files, all `.rs`, no other
extension touched. `cargo check --workspace` exits 0 afterwards, so nothing
changed semantically.

ON THE ORDERING, WHICH WAS THE REAL QUESTION.

HANDOFF-2026-09-06 section 7 warns this is the expensive fix: a whole-tree
reformat before #7 and #8 return "would put a conflict in every file of 861
commits and make the reviews those items exist to enable unreadable".

That is measurably too pessimistic, and it had been reasoned rather than
tested. Measured here by three-way merging a rustfmt'd `main` against both
unmerged branches, file by file:

  file/branch pairs tested   32
  merges CLEAN               28
  merges CONFLICTING          4   (8 conflict hunks total)

    sylpheed-cli/src/main.rs      1 hunk
    sylpheed-export/src/check.rs  1
    sylpheed-export/src/screen.rs 4
    sylpheed-export/src/video.rs  2

All four are against `auto/frame-blend-draw-path` only;
`auto/port-p6-audio` does not conflict anywhere. The earlier framing --
154 dirty files, 133 that cannot collide, 21 that can, the collision set
carrying 147 of 774 hunks (19%) -- reproduces exactly. What it did not say
is that most of the 21 still merge cleanly, because rustfmt's edits and the
branches' edits rarely land on the same lines.

So the cost of sweeping now is 4 files and 8 hunks for one branch, against
a check that is otherwise red forever. Deliberately NOT folded into the
WASM PR: 154 reformatted files would make that one unreviewable.

Closes #12
2026-09-08 20:07:01 +02:00
7285a566dd fix(wasm): select the bin target, so trunk emits a real bundle
Some checks failed
CI / Native — linux (pull_request) Successful in 32m46s
CI / WASM — Web (pull_request) Failing after 23m39s
CI / Formatting (pull_request) Failing after 48s
`trunk build --release` reached the asset pipeline for the first time and
failed there:

  found more than one target artifact: ["sylpheed_viewer", "sylpheed-viewer"]

The crate declares both a [[bin]] `sylpheed-viewer` (src/main.rs) and a
[lib] `sylpheed_viewer` cdylib (src/lib.rs), and the index.html link named
neither, so trunk refused to guess.

Trunk's error offers two ways out and THEY ARE NOT EQUIVALENT. Measured,
both exiting 0:

  data-target-name="sylpheed_viewer"   1_478 bytes,        1 app symbol
  data-bin="sylpheed-viewer"          21_298_268 bytes, 2_998 app symbols

Selecting the lib "succeeds" while linking nothing, because there is no
wasm entry point in it -- no wasm-bindgen dependency, no import, no
`#[wasm_bindgen(start)]`. The linker drops the whole app and trunk emits an
empty module. That would have turned this job GREEN on a bundle that cannot
start, which is worse than the red it replaced.

`main()` is a valid wasm entry: it calls `sylpheed_viewer::run()` and its
only native-specific code is already `#[cfg(not(target_arch = "wasm32"))]`.
With the bin selected, trunk injects a real init -- `import init`, an
integrity-checked module preload, `__wbindgen_start`, and the
`TrunkApplicationStarted` event.

The lib.rs docs claimed this file was the WASM entry point "called from
`wasm_bindgen` init on the web". Nothing ever called it. That comment is
what made the lib look like the right target, so it is corrected here
rather than left to mislead the next reader.

Verified locally with trunk 0.21.7 on x86_64. The exit code does not
distinguish these two cases -- only the artifact does.

Refs #11
2026-09-08 18:55:05 +02:00
sylph-pi
a2e4dab0ab fix(viewer,cli,export): clear the remaining 30 clippy lints
Some checks failed
CI / Native — linux (pull_request) Successful in 51m51s
CI / WASM — Web (pull_request) Failing after 12m2s
CI / Formatting (pull_request) Failing after 1m45s
`cargo clippy --workspace -- -D warnings` now exits 0. `cargo test
--workspace` still reports 207 passed, 0 failed, 14 ignored across 30
suites — identical to runs 203 and 204, so none of this changed behaviour.

The workspace total was 73, not the 48 run 204 reported. `-D warnings`
turns a lint into a hard compile error, so `sylpheed-formats` failing
stopped its dependents from ever being built: `sylpheed-viewer` (14) and
`sylpheed-cli` (11) had never been linted by anyone. Clearing formats in
5c35a34 is what made them visible.

  formats  43 -> 0   (5c35a34)
  viewer   14 -> 0
  cli      11 -> 0
  export    5 -> 0

Collision surface, measured rather than assumed. Every viewer file
carrying a lint is byte-identical on both `auto/frame-blend-draw-path`
(495 commits) and `auto/port-p6-audio` (366). All eleven cli sites fall
outside every hunk either branch touches. 68 of the 73 sites could not
collide with anything.

The five that can are all in `sylpheed-export`, and three of those are
real:

  main.rs:278   `&out` -> `out`, inside frame-blend's hunk -278,12
  main.rs:318   `&out` -> `out`, inside port-p6-audio's hunk -303,44
  audio.rs:113  an added `#[allow]` in a file frame-blend DELETES

Each is one line. Resolving the first two means taking the branch's
version and re-applying a borrow removal; the third resolves to the
deletion. Flagged here so neither branch owner meets them cold.

Judgement calls, all stated at the site rather than suppressed globally:

* Three `too_many_arguments` in the viewer are false positives.
  `draw_viewer_ui`, `poll_loader_channel` and `apply_pak` are Bevy
  systems — every parameter is a `Res`/`ResMut`/`EventWriter` the
  scheduler injects, so the count is the framework's dependency list and
  cannot be reduced without a `SystemParam` struct.
* `cmd_screen_render` (cli, 8/7) is a plain function, so that one is real
  if mild; its arguments are the subcommand's flags.
* Two `dead_code` fields in export are serde schema fields. They model
  what the on-disc JSON accepts; deleting them would quietly change that.
* `iso_loader.rs` gains a `FrameRx` alias for the ffmpeg frame channel,
  which is what "very complex type" was asking for.

A site-local `#[allow]` with a reason is a decision recorded where it
applies: one lint, one function, and any new violation elsewhere still
fails the build. That is not the shape PROTOCOL.md forbids.

Closes #13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-05 17:47:50 +02:00
MechaCat02
d1685d67c9 viewer: show where a cutscene's voice actually is, and let you hear it
Some checks failed
CI / Native — ubuntu-latest (push) Failing after 8m13s
CI / Native — macos-latest (push) Has been cancelled
CI / Native — windows-latest (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / WASM — Web (push) Has been cancelled
The Cutscenes window printed the voice token as text and offered no way to play
it, which left the most confusing thing on the disc invisible.

The movie voices are one continuous XMA stream chunked into VOICE_*.slb entries
whose boundaries do NOT match the cutscene cues, so the bank named after a movie
need not hold that movie's audio. Measured, on the retail disc:

  ADV     region 433930240..437044592  inside VOICE_ADV.slb        name honest
  S00A    region 452798464..455499120  inside VOICE_S00A.slb       name honest
  RT01A   region 437044592..437345648  inside VOICE_ADV.slb        NAME LIES

RT01A's voice sits in bytes belonging to the entry named after the intro movie.
A viewer that played the name-matched bank would be confidently wrong for
exactly the cutscenes where it matters, and would look right on the two that are
easiest to check.

So the window now shows BOTH locations -- the named bank with its byte range,
and the resolved region -- and states plainly whether the name is honest,
highlighting it when it is not. Play routes through the movie form of
RequestAudio, which resolves the region rather than reading the bank.

Static data only: sound.pak and tables.pak, both on the disc.
2026-08-29 16:21:34 +02:00
Sylpheed RE agent
8b6dbcfead formats: move media assembly out of the viewer, where it could not be reused
The trickiest reading on the disc lived in the Bevy viewer: resolving a
cutscene's voice to a continuous byte REGION of the sound stream, because the
movie voices are one XMA stream chunked into VOICE_*.slb entries whose
boundaries do not match the cues -- a cue routinely spans two chunks, so a .slb
need not hold the track its name claims.

That put the logic most likely to be re-derived incorrectly in the crate least
likely to be reused. The Godot port's exporter needs the same answers, and there
must be one implementation of them.

New `sylpheed_formats::media` owns every case where the bytes of one playable
thing are not one archive entry: segment-spanning reads, multi-sub-wave banks,
and the voice-region resolution. Callers supply bytes through a `DiscSource`
trait, so the viewer keeps its ISO/directory abstraction and a headless consumer
gets `DirectorySource` for free.

The seam is deliberate: this module returns XMA RIFFs, not PCM. Decoding means
shelling out to FFmpeg, which is native-only and a policy decision for the
consumer -- everything up to "here are the bytes that belong together" is disc
knowledge, everything after it is a codec choice.

The four moved functions were previously untested; `tests/media_disc.rs` now
pins them, including the negative the corpus paid for -- an unbound movie must
stay unvoiced rather than borrow a neighbour's clip, which was tried and played
the WRONG recording.

The algorithm is unchanged, moved verbatim (same window sizes, same fallbacks).
The new disc tests pass; the broader audio suite was not re-run in this pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:49:33 +02:00
Sylpheed RE agent
77cd58202b viewer: a Cutscenes browser -- the manifest was invisible plumbing
`movie_manifest` has been parsed since the movie-voice work and rendered
nowhere: it resolved a voice bank and that was all. So the only way to find a
cutscene was to hunt `.wmv` files in the ISO tree, where nothing tells you which
mission a file belongs to, whether it has subtitles, or what is said in it.

View ▸ Cutscenes lists all 104 manifest slots with mission/phase, kind, movie,
subtitle track, voice token and telop, and -- the part that needed no new
parsing, only a route -- resolves the captions to a readable TRANSCRIPT with a
language selector. Subtitles were previously burned into the video during
playback and reachable no other way.

Three negatives are shown rather than smoothed over:

* 5 manifest-bound movies have no `.wmv` (logo1-4 and an encoder test clip).
  They are marked and get no Play button instead of one that would fail.
* 9 of 101 movies resolve no English transcript.
* the `.prt` telop overlay is named by the manifest and we have no parser, so
  the reference is shown labelled "not decoded" rather than omitted.

`cutscene_catalog_binds_movies_and_transcripts` pins all of it against the disc
-- 104/101/99/99/22, the exact absent-movie list, 92 transcripts -- because a
browser that quietly dropped these would look complete and be wrong. The counts
independently reproduce docs/re/movie-subtitle-link.md.

Play routes through the normal FileSelected path, so the existing video player
handles it exactly as it would from the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:47:46 +02:00
Sylpheed RE agent
306a8a5661 viewer: open the whole sound bank, not just the voice half
The library enumerator kept only names containing VOICE or \Briefing\, and read
eng\sounds.tbl unconditionally. So the Explorer could reach 4382 of the 9519
banks in sound.pak: no music, no jingles, no sound effects, and no Japanese
voice at all -- roughly half the disc's audio had no route to the UI.

`slb::list_audio_entries` now returns every named bank with the category its
path implies (Music / Jingles / Sound effects / Radio / Dialogue / Movie voice /
Briefing). `list_voice_clips` is that, restricted to the spoken categories, so
its existing test still guards the old behaviour. The 36 root banks carry no
language component and appear whichever table is read; the window gets an
English/Japanese switch that re-reads the other sounds.tbl, since the table name
IS the selector.

Two defects the decode found, both recorded in
docs/re/structures/sound-pak-contents.md:

* `Static.slb` -- the SFX bank -- declares 616768 bytes more than sound.p04
  holds. Not our extraction: p04 matches the ISO's own directory record, and a
  sweep of every pak on the disc finds this one entry over-running and no other.
  It is the highest-offset entry, so its comp_size is an allocation size. A
  short read is now allowed for the tail entry ONLY; any other overrun stays an
  error, because clamping it would hide real damage behind a half-decoded asset.
  The bank went from unreadable to 514 s of audio.

* the left-channel downmix was applied to everything. Right for voice (mono
  content however stored), wrong for music (a real stereo mix, half of it
  discarded). The caller now decides from the category.

35 of the 36 shared banks decode; JNGL_001 does not, and says so in the player
instead of the panel silently closing. Its payload is not a whole number of XMA1
packets from any known data offset, so it is likely not a plain headerless
stream -- written up rather than papered over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:28:20 +02:00
Sylpheed RE agent
fb70511242 viewer: make the two screen presenters agree, and expose the compose options
The UI Screens window enumerated with `is_build` while the PAK browser's
inline preview composes anything `parse_build` accepts. So the browser drew
screens this window flatly refused to list -- most visibly `palogo`, the
publisher splash, which declares its sprites directly and has no `.rat`
layout child.

`compose_screen` now gates on `is_composable`, the documented superset (every
`is_build` bundle passes it), so anything the list offers is drawable and the
two presenters share one rule. A "Fragments" toggle widens the enumeration to
it as well, off by default: the extra ~1786 bundles are mostly two-element
fragments (a button beside its glow) that would bury the real screens. It
re-scans, which renumbers the display ordinal -- harmless, because the pak
ENTRY index is the locator and the stale catalog is cleared.

The toggle sets a `rescan` flag rather than self-sending RequestScreenCatalog:
a system that both reads and writes one event type is a B0002 panic at
startup, which is how the Save browser broke.

Also plumbs the last two ComposeOptions the CLI had and the UI did not --
`black backdrop` (what a framebuffer capture must be compared against) and
`primitives` (decoded, but paint order unsolved; hover text says so).

Verified: `screen render --all --build 13 GP_TITLE.pak` draws the SQUARE ENIX
splash, 2/3 elements, the third being the .prm quad. Workspace builds, viewer
reaches steady state, disc-gated suite 20/20 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:14:23 +02:00
Sylpheed RE agent
47e4e43310 viewer: stop the UI Screens browser loading forever
Three compounding causes, found by tracing every place a `loading` flag is set
against every place it is cleared.

THE COST. `compose_screen` inflated EVERY entry in the pak and held every build
in memory at once, purely to index into the result -- then did it again on every
checkbox click, re-opening the ISO from scratch each time. But the catalog
already records each build's pak ENTRY INDEX, so the locator was there all
along: compose now reads exactly one entry. `RequestScreenCompose` carries
`entry` (the locator) beside `build` (the display ordinal).

`build_screen_catalog` had no budget at all, while the pak browser has capped
exactly this work since it was written. It now skips oversized entries and stops
at a ceiling.

That ceiling is 384 MB, not the pak browser's 64 MB, and the difference is the
point: GP_HANGAR_ARSENAL inflates past 160 MB and holds ~390 builds, so a 64 MB
cap would have quietly hidden most of them -- trading a hang for a wrong answer.
When the ceiling IS hit the pak is marked `truncated` and the UI says so.

THE LATCH. `poll_loader_channel` treated a disconnected channel exactly like an
empty one, so if a worker died every in-flight spinner stayed up for the life of
the process -- and the `if loading { return }` guard at the top of each handler
then refused every retry. Disconnect now clears the flags and reports it.

Verified: the workspace builds, the viewer reaches steady state, and one
composite still draws 11/11 elements of the tutorial pause menu.
2026-08-28 15:59:00 +02:00
Sylpheed RE agent
6ce1a87d49 re: rebuild game_data on the IDXD record table — 966 misses and 596 flattened reads
Measured first, over GP_MAIN_GAME_E.pak, comparing every named-field read the
six struct loaders performed against the record table: 4435 reads, 2872 agreed,
**966 returned None for a field that has a value**, **596 flattened a field that
several records carry**, 1 was wrong (a weapon whose TargetType is the empty
string read back as the neighbouring token "Skip"). The prior report of
4453/2887/974/591/1 is the same picture; the small differences are definitional
(I count a read as flattened only when the records disagree).

Every read now goes through IdxdObject::record, and the types say where a value
comes from:

* Weapon = the `Weapon` record (launcher) + the `Shell` record (projectile).
  Both carry an ID and a Name and — with `ShellWake` — an `Interval`, which the
  flat reader merged; they are separate fields now. Power/Velocity/ranges/
  LifeTime are Shell fields, which is why 427 weapon reads used to miss.
* CraftUnit/Vessel = `Generic` (hull) + `Maneuver` (flight model) +
  `StructureCount` (counts) + `Shield`, plus a new `hardpoints: Vec<Hardpoint>`
  — one entry per Turret_/Bridge_/Thruster_/Hatch_/ShieldGenerator_ record, each
  with its own HP. A flat HP could only ever be one of them.
* PlayerConfig = `Player`, plus `phases: Vec<PlayerPhase>` (SpaceSize/SupplyRange
  are per Phase_N) and `score: ByDifficulty<ScoreRules>` (MainMissionBonus is per
  Score_<difficulty>; the flat answer was the Easy one).
* Character faces come from the `Faces` record's field names (identical output to
  the old token scrape, 0 of 68 objects differ — now by construction).
* Stage = `StageResource` + `phases: Vec<StagePhase>`, and the packages it names.
* The `fields: BTreeMap` on every struct became `records: RecordSet`, which keeps
  the record boundary; `RecordSet::everywhere(field)` answers "which record".

The token-scraping loaders move too, and this is where the old reader was worst:

* Arsenal: options are the positional fields of the STANDARD_<slot> records. The
  scrape returned 16 nose options of which 8 were field keys and pilot names, and
  47 for arm3 of which 38 were junk, while missing Mine_B2A and No_Equipment.
  Now 8/12/9/9, all real weapons.
* Squadron: one record per squadron, members are Count*4 positional slots
  (unit, message set, n, pilot) — 1160 squadrons with ids and 2295 member tuples,
  against 28 idless squadrons and 47 members before. Agrees exactly with the
  independent Python decode in docs/re/structures/unit-group-table.md.
* DemoMessage: 11775 lines against 10263, every one with a speaker, a portrait,
  a delivery mode and a voice token, from fixed positional slots.
* PilotRoster: assignments are the records the `UNITS` record names, so each one
  now carries its unit id, its loadout and the player marker.
* UnitRoster: the roster is the field *names* of the single `EnumUnit` record.
* load_weapons selects on the records (Weapon + Shell) rather than on token[0],
  whose first byte is often a stray pool byte ("#Weapon", "%Weapon"). Same 131
  objects, no heuristic. GP_HANGAR_ARSENAL.pak holds none of them — the module
  doc's claim that player weapons live there was wrong.

schema:: constants keep their names and values but are documented for what they
are: record 0's name hash (PLAYER = Difficulty_Easy, UNIT = Maneuver, VESSEL =
Bridge_000, MESSAGE = Message_000), not a schema id.

Two things the migration exposes and does not fix, flagged in the docs instead:
load_units' bucket is 43 Type=Craft + 46 Type=Vessel objects (new `unit_type`
field lets a caller separate them), and StructureCount.TurretCount is not the
number of Turret_* records (the player's craft says 4 and has 63).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
2026-08-26 01:42:53 +00:00
Sylpheed RE agent
18947218b4 formats: a UI element scales about its pivot, not its keyframe corner
The compositor read a keyframe as `top-left = (X,Y)`, `size = decoded · scale`
and ignored the declared pivot. That is right at 100 %, which is every element
the format was ever checked against — the pause menu, the ARSENAL chip ruler —
and wrong for every element that is scaled.

Measured against a framebuffer capture of Canary on the title screen.
`GP_TITLE.pak` build 7 element 13 is `ptbase2.t32`: 640x360, pivot (320,180),
one keyframe at (320,180) with scale 200 %. From the corner that is a 1280x720
rect at 320..1600 x 180..900 — a quarter-screen slab with the top-left quadrant
bare. Anchored at the pivot it is (0,0)..(1280,720), and the capture shows the
background art reaching all four edges. Normalised cross-correlation of the
composite against the capture, searched over +-40 px, peaks at (0,0): 0.90 on
the planet limb, 0.72 on the lower-left ship.

`ptcopyright.t32` calibrates the other half: unscaled, 694x20 at (293,655), and
the capture's glyph run is x 295..986 / y 700..718 once the 45 px of window
chrome is taken off. So the keyframe really is the top-left at 1:1.

Disc-wide this moves 865 of 5 130 resting placements. The pause menu's own
`pgpeff01` glow stops hanging off the menu frame to the bottom-right and
centres on it.

`ComposeOptions::backdrop` comes with it: the default dim slate stands in for
the PRMD dim-quad behind an in-mission screen, but comparing against a
framebuffer needs the black the game actually composites over, so
`screen render --black` can ask for it.
2026-08-18 16:20:26 +00:00
MechaCat02
d921ed2602 viewer: fix a startup panic in the Save File browser (Bevy B0002)
draw_save_ui held both an EventReader<RequestSaveOpen> (to know when the View
menu opened it) and an EventWriter<RequestSaveOpen> (so its own "Open savedata…"
button could re-trigger the file dialog). Bevy rejects a system that accesses
one event type both ways, and does so at schedule-validation time -- so the app
panicked on startup, before any window content.

The button now sets `SaveBrowser::request_open` and handle_save_open_request
treats that flag as equivalent to the event, with no path (dialog).

Audited every system in the viewer for the same shape; this was the only one.
Verified by running the binary to steady state rather than by compiling alone,
since a param conflict is invisible to the type checker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 21:34:10 +02:00
MechaCat02
af0796c835 viewer: UI Screens and Save File browsers (View menu)
Two new floating browsers, both fed only by static data — the disc for screens,
a savedata file for saves — following the existing Game Data / Ships pattern
(request event → worker thread → IsoLoaderMsg → draw system).

UI Screens
  Pick a screen pak and one of its builds (a build = one context × language
  variant; GP_PAUSE_MENU holds six) and see the screen reassembled at 1280×720
  beside the element list it was derived from, so a wrong placement shows up as
  a wrong picture and the row next to it says why. Per-element visibility
  toggles, focused-state and loop-animation toggles, and an inspector showing
  each element's sprite, parent link, kind flags, pivot and keyframe count.

  Compositing decodes ~30 T8aD sprites, which is far too slow for the UI
  thread, so it runs on a worker under the same generation gate the XPR path
  uses — a toggle mid-decode discards the stale result rather than racing it.

  The panel deliberately surfaces two things it would be easy to hide: sprites
  that named a child the bundle does not hold, and builds recovered through the
  .rat fallback (where elements without a record are simply absent).

Save File
  Opens a savedata file (it lives in the emulator's content tree, not on the
  disc, so this is a file dialog rather than a disc path). Shows the container,
  the GHAD block with every field coloured by its actual confidence, the
  Arsenal develop blob, the per-stage SHAB records, and the header summary.

  Three things the panel states rather than glosses: the byte-identical
  round-trip, which is the check the whole layout rests on; that SHAB records
  are per-stage results and NOT the UI's save slots; and that the header
  summary is what the Details panel reads, so a payload edit that leaves it
  stale proves nothing about the field that was edited. Fields tested and
  refuted (+36, +56) are shown as refuted rather than dropped.

Also gates draw_game_data_ui / draw_ships_ui at their registration site. Both
are #[cfg(not(wasm32))] but were registered unconditionally, so the wasm leg of
`just ci` failed on an undefined name. NOTE: that leg still does not build, for
a pre-existing and unrelated reason — the workspace pins tokio with features =
["full"], which pulls mio, which refuses to compile for wasm32. Fixing that
means restructuring tokio's features per target and is left alone here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 19:15:15 +02:00
MechaCat02
dc96bace6f formats: consolidate ui_layout onto the declaration table; add a savegame parser
Two independent lines landed a `.rat` reading and neither was the whole
picture, so this merges them into one module and fixes what the merge exposed.

ui_layout — the screen is the BUNDLE, not the set of .rat records
------------------------------------------------------------------
`feat/ui-layout-preview` parsed `.rat` records; the autopilot stack documented
the RATC header and probed it in `examples/screen_layout.rs` but never landed a
library module. The `.rat`-only reading structurally cannot see an element that
has no record -- the `eff*` frame corners, the `deli*` dividers, `msg` -- which
is exactly what the committed real-vs-rebuilt capture shows missing. Rebuilt
around the header:

  * element declaration table at 0x20 (60-byte entries: name, parent index at
    +32, kind flags, pivot) = the back-to-front draw list;
  * the placement region after it = per-element keyframe groups.

Verified against the disc, each against a fact the docs state independently:
`pgpeff02a` -> parent 3 = `pgpeff02`; `pgp_ttrl_btn10` rests at (546,288); the
pause buttons sit at 268/337/407/478, the documented 70 px pitch; the Arsenal
carries X = -516. The tutorial PAUSE menu now composites 11/11 elements and
matches the real screen more closely than the earlier rebuild did.

Three defects found while validating, none of which any test would have caught:

  * the keyframe block is 40 bytes with X/Y/time at +28/+32/+36 and an
    alpha-ramping ARGB at +0 -- the fade, previously unread;
  * a group's data stops 4 bytes short of its last block's time slot, so that
    word is the NEXT group's element index. Reading it produced times like
    1869640736 and silently corrupted the max-dwell pick. Last-frame time is
    now `None`;
  * the `.rat` sprite-name field is not 16 bytes. Capping it there truncated
    `pgp_ttrl_title.t32` to `pgp_ttrl_title.t`, which resolved against nothing
    and dropped 4 of 11 tutorial elements from the composite.

Max-dwell also needed a tie-break: on equal gaps take the LATER frame, or
`pgpmsg` reports the y=645 fly-through instead of the y=605 it settles at.

savegame -- a Rust port of tools/re-capture/savegame.py
------------------------------------------------------
GDHA container, zlib payload, chunk stream (GDAA / phase / GHAD 122 B / 16x20 B
SHAB / trailer). Every GHAD word carries its own confidence rather than the
block being presented as solved: 6 named, 2 recorded as REFUTED (+36, +56 were
tested as difficulty and as stage and are neither), 7 still unknown.

Tested against the three real saves committed under docs/re/captures -- no disc
and no emulator needed. The load-bearing assertion is the byte-identical
round-trip; the develop differential is asserted as a property (spending 4000 P
moves +24 and not its twin +28, steps the clear ratio, and moves exactly two
blob entries), and the header summary is checked to agree with the payload it
mirrors -- the trap that makes the Details panel a bad oracle.

CLI: `screen list|info|render` and `save info`, so both are checkable headlessly
in the same spirit as `mesh render`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:59:27 +02:00
MechaCat02
6d5b13e395 Merge remote-tracking branch 'origin/archive/local-work-pre-movie-sync-2026-07-20'
# Conflicts:
#	crates/sylpheed-viewer/src/iso_loader.rs
2026-08-17 18:13:14 +02:00
MechaCat02
d5784ab0fc Merge remote-tracking branch 'origin/feat/ui-layout-preview' 2026-08-17 18:11:49 +02:00
26f1bdf6fd re: exact anchor offsets (GameMesh.vbuf_offset) -- correcting yesterday's defect table
The first read of the capture-truth table located our resources by searching the
container for their leading vertices, which reads much worse than reality: the
same leading run occurs at several offsets in one container. GameMesh now carries
the offset the anchor scan actually chose, so the comparison is exact -- 4 of the
ship's drawn buffers are anchored correctly, 2 are the twin collapse, and 2 are
real mis-anchors of a size we do decode (brg 51 verts, eng_02_l 44).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 02:04:55 +00:00
9bc8c2d694 viewer: preview reassembled UI screens in the PAK browser
When an opened GP_*.pak RATC entry is a .rat build, compose the screen
(ui_layout::compose_build) and show it atop the RATC detail, labelled
'UI screen', with the sprite children below. Reuses the existing pak-open +
T8aD-decode path — no new browser/threading. Entry summaries mark UI builds.

cargo check -p sylpheed-viewer: clean.
2026-07-29 20:33:29 +02:00
MechaCat02
ea32e77e45 ship: engine placement CONFIRMED by 43-instance capture; exhaust markers added
capture_verify example: clusters every part's ship-relative transform across
the new multi-snapshot, multi-instance F10 captures (5 snapshots, 43 e106
instances, all angles). Verdict: the dominant clusters match the static
assembly to ~1 unit on EVERY part — including BOTH engine nacelles at
(+-131, -133, -131) — so the "engines inside the hull" appearance is the
game's own placement: the engine geometry sits recessed in the aft hull, and
the visible "thrusters" in-game are exhaust FX drawn at the GN_Jet/GN_SJet
frames (Z ~ -570, past the stern).

To close that perception gap the viewer now draws simple exhaust cones at the
game's own jet frames (ship::exhaust_frames; part of the external-parts
toggle). The jet frames flip Z, so the cone apex is authored at +Z and lands
trailing aft — verified in the offline render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 19:44:12 +02:00
MechaCat02
31f2637e6c viewer,ship: external parts ON by default + Ships catalog = composite ships only
Fixes the two things the user saw in the Ships browser:
- "Can't find the thrusters": show_external defaulted OFF, hiding the engine
  rig / bridge / turrets entirely. Externals are EXACT since the node-TRS fix,
  so the toggle now defaults ON (relabelled; off = bare hull bodies).
- "Some parts very far away": families without a composite scene graph (fighter
  morph sets like f002 whose detached parts are authored far from the origin,
  shared turret/prop part families) fell into the stack-at-origin fallback and
  rendered as piles with stray pieces. ShipModel now carries has_composite and
  the catalog lists only real assembled ships.

Offline audit across ALL stages (ship_audit example): the only far-away
outliers were composite-less f002 morph parts (now filtered); every capital
ship assembles coherently (e108 places its 7 turret instances, f105 its
mirrored shield generators). All primary composites are single-key tracks —
multikey exists only in animation composites (e901 attacks, missile-open),
which assemble_ship does not select.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 19:15:34 +02:00
MechaCat02
737b61242e ship: STATIC assembly is now EXACT — node-TRS off-by-one cracked via the capture
The runtime capture served its true purpose: as the answer key that exposed the
static encoding. The XBG7 node joint table (node+0x44) holds NINE keyframe
channels [TX TY TZ RY RX RZ SX SY SZ] behind EIGHT pointer slots shifted one
track forward: channel k+1 = f64@(ptr[k]+8), and channel 0 -- TX, which no
pointer names -- sits at ptr[0]-0x48 (tracks are 0x50-byte records, value at
+8). The old 8-slot read took TY as X, RY as Y (usually 0 -- why both hulls
stacked on the centreline) and never saw TX at all (the +-264 hull offsets).
Euler order is Ry(ch3)*Rx(ch4)*Rz(ch5) with angles applied directly, pinned by
the captured engine nacelle Rx(-15)*Rz(30) decomposition. mesh.rs gains
read_trs9/node_rotation; scene_world_nodes and node_transforms both fixed
(saber fin overrides unaffected).

assemble_ship rewritten fully static and exact:
- every composite-node instance placed (alias duplicates collapsed, real
  instances kept) including cross-id turret mounts (rou_e303_wep_01_root x2)
- the e_rou_<id>_eng cluster rig mounts at GN_Engine_01 (two mirrored nacelles
  +-131 with -+30-degree cant + centre engine) -- world = frame o rig_local
- brg/sld at their exact GN frames (frames were always exact)
- shared-geometry twin pairs at +-TX: X-reflect the instance whose offset
  opposes the geometry's dominant side (viewer reverses winding on det<0)
- squeezed node names resolve (wep02 -> wep_02_01)

Ground-truth gate: ship::tests::static_assembly_matches_runtime_capture asserts
static == the baked e106 capture for all 8 parts (T<1.0, R<0.02) plus both
nacelles, both turrets, and the starboard-hull mirror. Viewer now uses pure
static assembly; ship_capture + the baked table remain as the verification
oracle. Renders show a coherent, bilaterally-symmetric destroyer.

81 formats-lib + all disc tests green; viewer builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 19:00:49 +02:00
MechaCat02
d9442a2106 ship_capture: bake e106 COMPLETE from the 2026-07-26 F10 capture — all 8 parts
The second F10 capture (ship at _m LOD distance) closes e106 entirely: all 8
parts placed including the bridge both earlier captures missed, cross-validating
the doc's 7 known translations to 0.1 units and adding brg_01 at the exact
centreline (0, 153.3, -164.0).

Matching hardened by what the real capture taught us:
- LOD-aware: each part tries every variant vcount (base/_m/_l/_d) — a distant
  ship draws its LOD copies, same local frame.
- Position-validated, SET-based, against the UNION of a part's variants: buffer
  order != decode order, and one draw's vcount equalled the _m count while its
  buffer held the FULL 1633-vert geometry. A coincidental vcount (foreign
  51-vert mesh vs the bridge) is rejected by geometry.
- Runtime mirror handled: twins share one file geometry; the engine uploads the
  starboard copy X-reflected. Mirror-validated draws bake diag(-1,1,1) and the
  viewer reverses triangle winding for det<0 so front faces stay outward.
- Near-axis rotation entries snapped to exact 0/+-1 for a clean table; eng_01
  keeps its genuine 30-degree nacelle rotation.

data/ship_placements.txt now ships e106 (viewer renders via the captured table);
embedded_e106_is_complete locks the baked data. part_pos + capture_match helper
examples added. 10 ship_capture tests; 80 formats-lib tests green; viewer builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 18:26:23 +02:00
MechaCat02
c6ca5ce9e7 ship: bake pipeline — correlator as a tested module + viewer prefers captured table
Promote the F10 ship-capture correlator from an example into a reusable, unit-
tested library module (ship_capture): parse_capture (log -> per-draw rigid
WorldView from the c0..c2 WVP constants), correlate (match parts to draws by
vertex count, express each in the reference part's frame), and a checked-in
placement-table format (serialize_table/parse_table, embedded via
embedded_placement from data/ship_placements.txt).

build_ship_model now prefers a ship's captured placement over the static
assemble_ship when a table entry exists (empty table -> unchanged fallback).
correlate_capture example refactored onto the module + gains --emit to print a
table block. Doc updated: bake plumbing done; remaining is running real F10
captures to populate the table (needs the emulator).

5 new ship_capture tests (parse/normalize/correlate/table round-trip); 76
formats-lib tests + viewer build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:48:00 +02:00
MechaCat02
79c78cc7e1 ship: apply scale + gate approximate external parts behind a toggle
Two changes after user feedback that externals sit near-but-wrong (shield inset
into the hull, thruster floating close):

- Scale: the joint table's slots 5–7 are per-axis scale (a GN_ShieldG frame ships
  at 0.5, GN_Jet FX at 2.4–6.5) and were being ignored, rendering scaled parts at
  the wrong size. ScenePart now carries `s` and applies R·(S·v)+T.
- External placement is fundamentally approximate: a GN_* frame is the mount
  PIVOT (f105's two GN_ShieldG frames are both on the centreline), while the
  part's outboard/rotational offset lives in a detail sub-rig / runtime code this
  static pass can't recover. So assemble_ship gains `include_external`: the hull
  tier (rou_ nodes) is exact and always drawn; the external tier (bridge / shield
  / engine at GN_ frames) is opt-in via a "Show external parts" checkbox in the
  Ships browser, off by default so the clean, correct hull is the default view.

The module doc is corrected to the scene-graph mechanism (the old "draw parts
untransformed" claim was wrong).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:11:47 +02:00
MechaCat02
a8b5691d1c ship: assemble capital ships from the composite scene graph (real placement)
The first cut drew every part at the origin, overlapping into a mess — the parts'
vertex buffers are authored around the origin, NOT pre-placed. The real world
placement lives in the composite scene-graph resource `e_rou_<id>`, exactly like
node_transforms poses the DeltaSaber's fins within one resource — but here the
nodes reference SEPARATE geometry resources.

Two tiers, matching how the game splits a ship (as the user described):
- Hull — `rou_<id>_*` scene nodes name the hull body resources and carry their
  world transform (bodies that together form the hull, not individually
  destructible). Composed parent∘child down the first-child/next-sibling tree.
- External hardpoints — `GN_*` frame nodes are the Vessel mounts (`GN_Bridge_01`,
  `GN_ShieldG_01`, `GN_Engine_01`, matching the IDXD recipe Frame names). Each
  bridge / shield-generator / engine part is placed at its frame by category+idx —
  the individually-destructible parts.

- mesh: `scene_world_nodes` — walk a composite, emit every `rou_`/`GN_` node with
  its composed world transform (same record layout + TRS convention as
  node_transforms, cross-resource). ScenePart{resource,m,t}+apply.
- ship: `assemble_ship(bytes, id)` — resolve hull nodes to the ship's own parts
  (shared cross-id turrets excluded — they need the per-mount recipe), match
  external parts to GN frames, fall back to a raw draw when a prop has no
  composite. is_base_part now also drops `_dNN`/`_lNN`/`_mNN` LOD copies.
- viewer: build_ship_model runs assemble_ship, decodes only referenced resources,
  bakes each part's world pose into its vertices (one posed copy per placement),
  then assembles via the shared prepare path. RequestShipRender carries the id.

Verified headless: parts now spread into coherent single-ship extents
(e108 185×174×683, f106 703×479×2079, f101 carrier 560×539×1712) instead of
piling at the origin. Turret placement (shared eNNN gun models on many GN_*Gun*
frames) still pending the per-mount recipe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:44:25 +02:00
MechaCat02
720a5fec27 viewer: Ships browser (View ▸ Ships) — assemble & view whole capital ships
Adds a Ships window that scans the stage containers, reconstructs each capital
ship from its XBG7 part family (via sylpheed_formats::ship), and renders the
whole assembled model in the 3D view on click.

- iso_loader: ShipBrowser resource + ShipRow (id, faction, name, parts, stage,
  and linked Vessel stats: HP/size/turrets/bridges/shield gens), RequestShipCatalog
  + RequestShipRender events, ShipCatalogLoaded message. build_ship_catalog scans
  Stage_S01..S30, groups resources into ships, joins Vessel recipes by id, and
  sorts capital ships (Vessel-matched) first. handle_ship_render_request decodes
  only the ship's parts (models_named) and assembles them off-thread, reusing the
  existing XprPrepared → apply_prepared_xpr render + camera-focus path.
- prepare_models gains an `assemble` mode (prepare_ship): parts share ONE recentre
  so each keeps its ship-local offset (bridge fore, engines aft) instead of the
  per-model recentre / thumbnail grid used for a stage browse.
- ui: draw_ships_ui (own system, keeps draw_viewer_ui under the 16-param limit) —
  faction filter chips, name/id search, collapsible per-ship stat cards with an
  "Assemble & view in 3D" button; colour-coded by faction. View ▸ Ships menu item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:16:15 +02:00
MechaCat02
df724b6bcb viewer: Game Data browser (View ▸ Game Data)
Wire the decoded game_data + localization tables into the viewer as a
browsable window. View ▸ Game Data opens a floating panel with category
tabs — Weapons, Craft, Capital Ships, Characters, Missions, Arsenal,
Flights — decoded off-thread from GP_MAIN_GAME_E + GP_HANGAR_ARSENAL.

- Weapons/Craft/Ships: sortable stat grids (power/velocity/range;
  HP/cruise/accel/radar/turrets; HP/length/turrets/bridges/shield-gen),
  with a live name filter.
- Characters: name (localized) + faction (colour-coded) + portrait count.
- Missions: collapsible per stage — location, resolved objectives, fail
  conditions, and the enemy roster with HP.
- Arsenal: the player's nose/arm weapon options in four columns.
- Flights: distinct wingman line-ups.

Its own egui system (keeps draw_viewer_ui under Bevy's 16-param limit);
a RequestGameData event from the menu opens it and lazily decodes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 20:44:40 +02:00
MechaCat02
161359e151 Fix hokyu voice: resolve by subtitle demo-id, not ship category
The 13 manifest-unbound hokyu (resupply) cutscenes reuse 5 recordings
(VOICE_D_450..454). The category guess (ship LS/DS × source carrier/
tanker) was wrong for some. The real selector is the cutscene's subtitle
demo-id: 600→450, 601→451, 602→452, 603→453, 604→454 — derived from the
5 BOUND hokyu (each carries both a demo-id and a VOICETRACK), so it
self-validates. Fixes hokyu_LS_s11A/s15A (→451, were wrongly 450) and
hokyu_LS_s24A/s27A (→silent, no voice cue). User-confirmed all correct.

hokyu_voice_token() builds the demo→token map from the bound hokyu and
looks up the target movie's demo-id; replaces the category fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:48:56 +02:00
MechaCat02
053aa81953 Cutscene voice: resolve movies via continuous-stream cue regions
Cracked how the game maps a movie to its voice track and reimplemented it,
fixing wrong/short voices for RT and hokyu cutscenes.

## Mechanism (RE'd from a Canary file-I/O trace, user-confirmed by ear)

The movie voices are ONE continuous XMA stream, physically chunked into
`<lang>\Movie\VOICE_*.slb` (and `\etc\VOICE_D_*.slb`) sound.pak TOC entries
whose boundaries do NOT align with the cutscene cues. A single cue routinely
spans two `.slb` chunks, so a `.slb` does not necessarily hold the track its
name claims (they are off-by-one vs the cues). Each cue ends at an inline
trailer descriptor `(sound_id:u32be, 0x11, …)` whose id repeats at +0x800.

Resolution chain (all derivable on-disc, no guest-code RE):
  1. movie -> cue token         (ADVERTISE_MOVIE manifest VOICETRACK)
  2. cue token -> sound-id       (master sound registry, tables.pak
                                  a2c8c185=eng / b04238e0=jpn, schema 0x13cb84ba)
  3. sound-id -> [start,end)      scan the stream for trailer(id) and its
                                  predecessor; cue audio = the bytes between,
                                  read continuously across chunk boundaries.

Validated: decoded voice length matches each movie within ~0.4s, including
cross-boundary cues (ADV/RT01A/RT01B/RT01C/S00A/S01A + the 5 bound hokyu).

## Changes

- sylpheed-formats/src/movie_voice.rs (new): registry_voice_ids(),
  find_descriptor(), find_descriptor_before() (gap-tolerant predecessor).
  Unit-tested.
- viewer iso_loader.rs: resolve_movie_voice_region() + decode_voice_region()
  (continuous read via existing read_segment_range + to_xma_riffs). Voice/
  audio requests now region-first; a `\Movie\` token that fails region stays
  SILENT (never plays the wrong off-by-one chunk). Removed the old
  movie_is_demo_based suppress hack.
- Anchor tries subdirs Movie/etc/Voice so hokyu `\etc\VOICE_D_*` resolve too.
- Examples resolve_all/validate_cues/hokyu_final document + validate the pipeline.

## Status

- RT / S / ADV cutscene voices: CORRECT (user-confirmed).
- 5 manifest-bound hokyu (VOICE_D_450-454): CORRECT (user-confirmed).

## OPEN (handoff)

13 of 18 hokyu movies have NO manifest VOICETRACK; the game reuses the 5
recordings across stages via a code rule. `hokyu_fallback_token()` currently
guesses by category (ship LS/DS x source carrier-A/tanker-H: LS/A->450,
LS/H->453, DS/A->452, DS/H->454). USER REPORTS THIS IS SOMETIMES WRONG.
- LS/carrier has two takes (450 from s02A, 451 from s09A); the early-vs-late
  split is unknown — unbound LS/A currently all default to 450.
- Definitive fix = Canary `--phase_a_fileio_only` file.read trace of an unbound
  hokyu (e.g. hokyu_LS_s03A) to see which VOICE_D the game actually streams,
  then encode the real rule. Same method that cracked the RT mapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 06:53:55 +02:00
MechaCat02
eee1607e1b feat(formats,viewer): standalone voice player covers all line categories
list_voice_clips now enumerates every spoken-line category so the standalone audio player covers them all — bound movie voices (\Movie\), in-mission radio (\Voice\, \etc\), and mission briefings (\Briefing\, whose BR<NN>_<MM> names lack "VOICE"); music (BGM_*) stays excluded.

Viewer voice-library fixes: invalidate the library when a new game source loads, and don't latch an empty result as "loaded" (sounds.tbl always has thousands of clips, so empty = a failed read) so re-opening the menu retries.

Voice decode reverted to the first sub-wave (base behaviour): a naive multi-sub-wave concat produced the wrong track for RT movies. The per-sub-wave splitter (to_xma_riffs) is kept + documented for the eventual real fix, which needs ground-truth playback instrumentation to resolve segments-vs-takes-vs-timecode placement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 22:49:09 +02:00
MechaCat02
8ed8c85f18 [WIP] Audio codec probe/detection + CLI audio-info + viewer audio panel
Snapshot of local WIP before rebasing onto the movie/voice remote work.
- audio.rs: AudioCodec enum, AudioInfo::probe (RIFF/WAVE parse, raw-XMA2
  Shannon-entropy heuristic looks_like_raw_xma2), from_wav, riff_data_span.
- cli/main.rs: `audio-info` subcommand (cmd_audio_info).
- viewer ui.rs/iso_loader.rs: draw_audio_detail panel wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:42:48 +02:00
MechaCat02
95de29f290 feat(formats,viewer): movie subtitles, voice decode, and the movie manifest
The movie cutscene subtitle + voice pipeline, driven by the ADVERTISE_MOVIE
manifest (the authoritative movie -> subtitle -> voice index). Also flushes
several sessions of local WIP (async viewer loading, grouped-pool XBG7/hero-ship
decode, drawlog tooling). See docs/HANDOFF-movie-voice-subtitles-2026-07-19.md.

Subtitles (movie_subtitle.rs):
- Full movie->track->text chain; join multi-line captions sharing one timing
  (fixes S13A dropped "Look at it father" line); overlap-safe active_cues();
  Latin-1 accents preserved.

Voice (slb.rs): XACT .slb -> XMA1 RIFF; take the FIRST sub-wave bounded by its
declared data size (fixes S10-S16 alternate-take garble); list_voice_clips.

Manifest (movie_manifest.rs): parse ADVERTISE_MOVIE (0x5B983A08) for the real
movie->voice binding (not always VOICE_<movie>; e.g. hokyu -> VOICE_D_* in etc\).
Resolve the token's sound.pak path via sounds.tbl. DIRECT bindings only — the
demo-id shared-clip fallback for unbound hokyu movies was verified WRONG in-game
and reverted (unbound hokyu stay unvoiced; correct join key is an OPEN problem).

Viewer: manifest-driven voice (movie player toggle + solo button), standalone
"Voice Lines" browser, stacked caption overlay.

Tests: 46 formats-lib + 11 viewer-lib + movie_manifest/movie_subtitle/slb disc
tests; full workspace green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:15:41 +02:00
MechaCat02
578c71a1b9 [formats,viewer] Decode grouped-pool XBG7 models; fix mesh routing + albedo
Revert the mis-guided "triangle STRIP" reading (a937779): XBG7 index
buffers are triangle LISTS (prim=4 in the GPU capture; stored-normal
agreement 1.000 as a list vs ~0.49 as a strip). Reinstate the
winding-consistency gate.

Crack the grouped-pool layout used by the hero ship and ~150 detailed
models. A resource's sub-meshes share one index pool (buffers 4-byte
aligned, in descriptor-marker order) followed by one vertex pool (each
vtx_count*stride, same order); the vertex pool is 4-byte aligned after
the index pool. The whole resource is derived from one anchored pivot:
  ib0 = vb0 - span - pad   (pad in 0..=3, alignment)
  ib[i] = align4(ib[i-1] + idx_count[i-1]*2)
  vb[i] = vb[i-1] + vtx_count[i-1]*stride
vb0 is found via the unit-normal vertex-run scan; the alignment is
confirmed by validating the LARGEST sub-mesh (most reliable), after
which the rest are read/validated. A single marker reduces this to the
existing adjacency anchor, so grouped generalises it.

Results (cross-checked against a Canary GPU draw-log capture of
DeltaSaber_T.xpr):
- DeltaSaber_T f001 = body + 7 detail parts = 8650 tris, every sub-mesh
  0-degenerate / full-coverage / winding-agreement 1.000.
- All 19 previously-declined weapon models now decode (they hit pad 2
  and/or lead with a tiny bracket that broke a markers[0] pivot). Corpus
  audit: 0/146 geometry files fail (was 19 -> flat-texture in the viewer).
- Stages unchanged (single-marker path is byte-identical; grouped falls
  back to the old anchor on failure). 7/7 disc tests green incl. the
  strict stage quality audit; new hero_ship_grouped_pool_decodes test.

Viewer: route by count_xbg7; --only matches an exact model name (so the
neutral pose renders without the mnv*/turn180 animation poses). Fix the
albedo matcher: match a sub-model to its _col map by entity stem
(e007_bdy_01 -> e007_col) instead of a full-name prefix, lifting stage
sub-model texturing from ~25% to ~95% (the rest were flat grey). Colour
correctness (channel order/sRGB) remains a separate dynamic-RE item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 19:54:39 +02:00
MechaCat02
3e1040b472 [texture] Present Canary GPUTEXTUREFORMAT names + metadata; handle XPR5
- Port xenia-canary's complete 64-entry texture-format table (xenos.h enum +
  texture_info_formats.inl) into GPU_FORMATS + gpu_format_name()/desc() and
  X360TextureFormat::gpu_name()/desc().
- Viewer + CLI `texture info` now show the canonical k_… name, bpp and
  compressed flag (e.g. "k_DXT1 (Dxt1) · 4 bpp, compressed"); UnsupportedFormat
  now names the format instead of a bare hex code.
- Detect non-XPR2 containers up front: the game ships one XPR5 file
  (Common.xpr) alongside 165 XPR2 — report UnsupportedContainer("XPR5")
  cleanly instead of a "bad magic" parse error.
- Add a table-integrity test (index==code, spot-checks, decodable-variant
  consistency).

Static-RE scan of the 166 resource3d XPRs: only k_DXT1 (145), k_8_8_8_8 (19),
k_DXT5A (1) are used — all already decoded, so this is presentation, not new
decoders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:09:55 +02:00
MechaCat02
390c67cb61 [viewer] Surface recovered TOC names in the PAK browser + refresh RE panel
- Move the IDXD->TOC-path resolver into sylpheed-formats as
  IdxdObject::recover_toc_path() so the CLI `pak list` and the GUI pack
  browser share one implementation (behaviour-preserving; CLI still
  resolves 308/1004 on GP_MAIN_GAME_E).
- PAK browser now shows each entry's recovered original path (via the
  name-hash) in green, in both the entry list and the IDXD detail heading.
- Replace the stale welcome "RE Notes" table (mesh/audio "unknown") with an
  accurate reverse-engineered-formats status grid + correct authorship.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:42:40 +02:00
MechaCat02
d23339a3aa feat(viewer): stage thumbnail grid, per-model albedo, camera zoom fix
Route stage containers to stage_models (decode both single + stage paths, keep
whichever yields more geometry) — fixes stages showing the dummy-root "black
cube". Present a stage's sub-models as a thumbnail grid: each recentred and
uniformly scaled to a fixed cell so a 1-unit prop and a 3000-unit structure are
equally visible; skybox-plane / stray-anchor models (>20000u) are culled. Each
sub-model is textured by matching its name to the container's `_col` albedo.

Fix the orbit camera: zoom radius was hard-capped at 50u with fixed clip planes,
trapping the camera inside multi-thousand-unit stages. Zoom limits and near/far
planes now scale to the framed model.

Adds docs/HANDOFF-stage-mesh-2026-07-12.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:56:45 +02:00
MechaCat02
ef6e448268 feat(formats,viewer): decode XBG7 meshes + 3D model preview
Reverse-engineer the single-stream XBG7 geometry layout (clean-room: hex
inspection + geometric validation of the retail disc's
hidden/resource3d/*.xpr, no game code copied) and present models as
textured 3D meshes in the explorer.

Format (docs/re/structures/xbg7-mesh.md): XBG7 geometry resources sit
inside XPR2 containers alongside TX2D textures. For ~25 single-stream
models (weapons, simple props) the data section is a sequence of
sub-meshes, each an u16-BE triangle-list index buffer followed (after a
fixed 12-byte header) by a stride-24 vertex buffer whose declaration is
in the descriptor: POSITION f32x3 @0x00, NORMAL f16x4 @0x0C, TEXCOORD
f16x2 @0x14. Sub-mesh (vtx,idx) counts come from descriptor tuples.

The +12 vertex offset is pinned by the recovered normals being exactly
unit-length (align16 lands 4 bytes early and silently corrupts every
field). A safety gate rejects any model whose indices are out of range
or whose mean |normal| is not ~1, declining garbage (Stage_S*
placeholders, the complex multi-stream hero-ship body) rather than
mis-decoding it.

- mesh.rs: Xbg7Model::from_xpr2 -> GameMesh { positions, normals, uvs,
  indices }; standalone f16->f32; unit + real-disc tests (weapon decodes
  to 215v/364t with unit normals + in-range UVs; DeltaSaber body
  declined).
- texture.rs: from_xpr2_index / texture_names so the viewer can pick a
  model's _col albedo map.
- viewer: loose .xpr with decodable XBG7 spawns Bevy meshes (real normals,
  double-sided) textured with the albedo, framed by the orbit camera; the
  central egui panel goes transparent so the 3D scene shows through.

Complex multi-stream body meshes (DeltaSaber f004, other vertex layouts)
remain undecoded and are cleanly declined — next target is dynamic RE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:47:06 +02:00
MechaCat02
e1ca95c0af feat(formats,viewer): decode T8aD textures, RATC bundles, LSTA sprites
Add three static PAK-format decoders in the Bevy-free formats crate and
present them in the explorer:

- t8ad.rs: linear 32bpp A8R8G8B8 2D texture (UI/HUD art). Dims at
  0x14/0x18, header size keyed by the type field @0x1c (container-safe).
  Decodes the ~85% RGBA variants; defers the rest (likely DXT) rather
  than misdecoding.
- ratc.rs: nested resource bundle — lists named children by magic scan.
- lsta.rs: sprite list — walks the inline T8aD frames.

Viewer: PakContent gains T8ad/Lsta/Ratc, decoded off-thread and bounded
by an 8M-texel per-entry cap; detail views show the texture, a sprite
grid, and a child list with thumbnails. Decoded images carry a
"colours unverified" note — channel-order/sRGB stays on the dynamic-RE
backlog.

Tests: 12 new unit + 3 real-disc (T8aD >=70% decode over the hangar
pack, LSTA frames, RATC named children incl. a decodable T8aD).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:49:50 +02:00
MechaCat02
e17bc0216d feat(viewer): present subtitles, fonts, PNG + text in the PAK browser
Decode and present four more PAK item types in the pack browser's detail
pane. All content is decoded off-thread in build_pak_rows and attached to
each PakRow, then rendered with egui's own texture facilities — the Bevy
texture pipeline and egui's global font system are untouched.

formats crate (Bevy-free, reusable by CLI):
- ixud: parse IXUD localized-string tables as subtitle cue lists (UTF-16BE
  pool, SUBTITLE header + (text, timecode) pairs -> Vec<Cue{start,end,text}>).
- font: OTF/TTF/ttcf metadata (family / faces / glyphs) via ttf-parser.

viewer:
- PakRow gains `content: PakContent` (Subtitle | Font | Png | Text | None);
  classify_content fills it for IXUD / fonts / PNG / xml+text within the
  existing decode budget. Font samples are pre-rasterized off-thread with
  ab_glyph into an image (ImageRgba) — ab_glyph returns Option/Result at every
  step, so a bad font yields no sample instead of panicking (the earlier
  egui set_fonts approach crashed the app on atlas rebuild).
- draw_pak_browser dispatches on content: subtitle cue table, font metadata +
  sample image, PNG image, scrollable text; else the existing IDXD detail.
  Borrow-split PakView instead of cloning the (now heavy) rows each frame;
  one hash-keyed egui texture cache serves both PNG and font-sample images.

Subtitle<->movie auto-matching is deferred: movie filenames don't hash to the
IXUD keys and no cross-reference table was found (the link is an internal
MSG_DEMO id), so the movie player gets no subtitle track yet.

Tests: ixud unit tests; disc tests for the real English subtitle track
(cues + timecodes), eng/deu localization, font metadata, and off-thread
rasterization of the real font.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:19:31 +02:00
MechaCat02
91bd2543f4 feat(viewer): WMV cutscene player with scrub, keyboard + audio
Add an in-explorer video player for the dat/movie/*.wmv cutscenes.

Decode via the ffmpeg/ffprobe CLI (no libav linking): video streams from an
`ffmpeg -f rawvideo -pix_fmt rgba` pipe read into fixed w*h*4 frame chunks over
a bounded channel (paused/behind => ffmpeg back-pressures on its pipe); one
reused Image is overwritten in place each tick. Audio is best-effort: the track
is pre-decoded to a temp stereo WAV and played through a rodio Sink (volume /
play / pause / seek-via-skip_duration for free). Native-only under
cfg(not(wasm32)); VideoPreview registers unconditionally so the UI compiles for
wasm. Mirrors the .pak load pipeline (FileSelected -> bg thread ->
IsoLoaderMsg::VideoLoaded -> apply_loaded_video -> advance_video_playback), with
a single free_video teardown wired into every other viewer's reset.

Controls:
- play/pause button, click-on-frame toggle, Space / arrow (+-10s) shortcuts
- seekable timeline with mm:ss labels; volume slider (greyed when no audio)
- live scrubbing: a coalescing one-shot `ffmpeg -ss -frames:v 1` grabber always
  jumps to the knob's latest position, so dragging shows the frame under the
  knob in real time; that grabbed frame also bridges the gap after a commit-seek
  so there's no black flash while the streaming decoder catches up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:31:52 +02:00
MechaCat02
589659bec6 feat(viewer): world cubemap (skybox) viewer — 6-face grid
TXCM XPR2 resources are the game's world skyboxes (BG_Acheron, BG_Hargenteen,
…). The viewer showed only face 0 as a flat 2D image; now it decodes all 6 faces
and shows a labelled grid.

Formats (Bevy-free): X360Texture::cube_faces_from_xpr2() returns a Cubemap with 6
faces in D3D9 order (+X −X +Y −Y +Z −Z), or None for ordinary 2D textures. Face
layout derived from xenia's GetGuestTextureLayout — 6 back-to-back independently-
tiled surfaces, per-face stride = tiled-surface-size aligned to the 4 KiB
subresource boundary (kTextureSubresourceAlignmentBytes). Verified against real
BG_Acheron: data_size == 6 × 0x400000, and all 6 faces decode cleanly (own
Python decode + a disc test asserting 6×4 MiB faces and face 0 == the validated
from_xpr2 green-planet decode). Extracted a shared decode_surface() helper so the
2D and cube paths are byte-identical; from_xpr2 output unchanged (re-verified).

Viewer: new SkyboxPreview resource (6 egui face textures, reusing the existing
per-format x360_texture_to_bevy_image); populated in apply_loaded_texture's TXCM
branch; freed/reset alongside the other previews (factored free_texture/
free_skybox helpers). Central panel gains a skybox branch rendering a 3-column
labelled face grid.

DEFERRED (per "do not guess, else defer"): the interactive 3D skybox. Face data +
D3D9 order are validated, but wgpu cube-sampling handedness can't be confirmed
without eyeballing the GUI — a wrong-oriented skybox is worse than the correct
labelled grid. The grid is the reliable deliverable; the 3D look-around is a
follow-up once orientation is visually confirmed.

(Background agent did the investigation/validation but was blocked from writing
files; implemented here in the main tree from its findings, independently
re-verified.)

23 formats tests + disc cubemap test pass; viewer/CLI build; face-0 export
re-verified as the green planet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:38:07 +02:00
MechaCat02
765e4a573b refactor(viewer): explorer UX cleanups (menu, loading, tree, status bar)
Four small UX fixes to the asset explorer:

1. Remove the no-op "View" menu (Textures/Meshes/Audio toggles + Wireframe
   checkbox) — none were wired to anything. Dropped ViewMode + the current_mode/
   wireframe fields from ViewerState (nothing read them); the central panel
   already dispatches on which preview resource is populated.

2. Loading indicator — clicking a drawer item now flips FileBrowserState.loading
   and the central panel shows a centered spinner + "Loading <name>…" until the
   background read/decode applies, instead of leaving the previous item on screen
   with no feedback. Cleared in poll_loader_channel on FileLoaded/PakLoaded/
   Error/Cancelled.

3. Drawer shows only the .pak index, not its .pNN data segments — the segments
   are the pack's body (loaded when the .pak is opened), so listing them (with no
   preview) was noise. Dropped the virtual "<stem>.pak" folder too; the .pak now
   sits as a normal leaf in its real directory.

4. Bottom status bar no longer overflows — horizontal_wrapped, only the source's
   final path component (full path on hover), file count, and a right-aligned,
   shortened controls hint. Removed the mode label.

Viewer + formats build; all tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:21:38 +02:00
MechaCat02
fbd4550d62 feat(viewer): data pack (.pak / IDXD) browser
Roadmap #3. The explorer can now open IPFB data packs — the game's ship/weapon/
mission definition tables. Selecting a `.pak` shows a master-detail browser:
entry list (hash · inner-format · identity) on the left, the selected IDXD
object's schema + explicit-field property table on the right.

Load path: a `.pak` selection reads the index plus its sibling `.pNN` segments
(ISO reader or extracted dir, probing p00.. until the first gap), assembles via
PakArchive::from_parts, and builds owned PakRow/PakDetail entirely off-thread —
so the UI holds only plain data (no borrow of a PakArchive, WASM-safe). Two
decode caps (64 MiB cumulative, 16 MiB/entry) keep a huge sound.pak from hanging;
over-budget entries show as "(not decoded)".

Wiring mirrors the texture path: new IsoLoaderMsg::PakLoaded → PendingPak staging
→ apply_pak. A shared reset (free GPU handle + clear texture/text/pak previews)
runs in both apply systems, so exactly one viewer is active per selection and the
prior GPU texture is always freed when switching modes.

Reuse over duplication: the CLI's inner_label / idxd_identity move into the
formats crate as pak::inner_format_label and IdxdObject::identity (bodies
verbatim → CLI output unchanged), now shared by CLI and GUI.

PakView registered unconditionally (wasm-safe; population native-only). ViewMode
unchanged — the central panel dispatches on the populated resource. Workspace
builds; 22 formats tests + all crate tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:17:10 +02:00
MechaCat02
2658dd20a6 feat(viewer): directory-tree browser + plain-text viewer
Explorer drawer was a flat list of all 353 disc files and only textures had a
preview. Two additions:

1. Directory tree — the left drawer is now a collapsible `📁` tree, rebuilt each
   frame from the path list (ui.rs: build_tree / split_for_tree / render_dir).
   Filtering narrows leaves and auto-expands matches; `.p00…p04` data segments
   nest under a virtual `<stem>.pak` node. Selection still keys off the original
   file index, so the loader pipeline is untouched.

2. Plain-text viewer — config.ini and any file that sniffs as text now show
   read-only, selectable, monospace content with a Wrap toggle and an encoding
   label. Detection/decoding live in the Bevy-free formats crate:
   vfs::is_probably_text + vfs::decode_text (BOM-aware UTF-8/UTF-16LE/BE, BOM-less
   UTF-16LE heuristic, NUL-reject + 95%-printable fallback) — pure std, WASM-safe,
   unit-tested (6 new tests), and reused by the CLI sniffer (new cyan `txt`).

New TextPreview resource (registered unconditionally so it exists on wasm);
populated in apply_loaded_texture with a 2 MiB char-boundary-safe cap. Also fixes
a latent bug: switching from a texture to a non-texture left stale preview state —
both texture and text state now reset on every selection.

ViewerState gains text_wrap. Formats tests: 22 pass; workspace compiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:05:58 +02:00
MechaCat02
405233e84f fix(texture): crack X360 DXT1 dword-swap; wire correct decode into viewer
DXT1 (108/137 textures in Base.xpr — the bulk of picture assets) decoded to
noise. Root cause found by RE: Xbox 360 BCn blocks store the two 32-bit words
of each 64-bit sub-block in the opposite order to the PC/DDS layout — colour
endpoints live in the HIGH dword, indices in the low one. The endian field
(k8in16) only fixes byte order within the 16-bit words; it does not reorder the
dwords, so without this every DXT texture transposed endpoints/indices → noise.

Isolation that led here:
 - de-tile proven correct for bpb=8 (coherent per-block signature map; the
   linear read is scrambled) — same faithful Xenos Tiled2D as the verified
   bpb=4 ARGB path (green Acheron backdrop).
 - inspecting a smooth region, coherent colour endpoints appeared only in
   bytes[4..8], with the high-entropy indices in bytes[0..4].

Fix: swap_bc_block_dwords() swaps the two dwords within each 64-bit unit after
the byte-level endian swap, for every BCn format. Verified in the real Rust CLI:
weapon skins (rou_f001_wep_*) now decode to clean, recognisable images.

Viewer: DXT1 is fixed transparently (from_xpr2 feeds tex.data straight to the
GPU as Bc1). Also corrected the uncompressed path — post-swap k_8_8_8_8 is
[A,R,G,B]; reorder to [R,G,B,A] and upload as Rgba8UnormSrgb (was Bgra8, wrong).

Knob: XPR_NO_BC_DWORD_SWAP disables the swap for A/B validation.

KNOWN REMAINING: 16-byte blocks (BC2/DXT3, BC3/DXT5) and BC4/BC5 (DXT5A/DXN)
still need their alpha+colour half layout worked out — they decode to noise for
now. DXT1 + uncompressed + cubemaps are correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:08:23 +02:00
MechaCat02
d92c7ddaea feat(viewer): ISO / extracted-dir loading pipeline + native file dialog
WIP: add iso_loader.rs — bridges the async XisoReader / synchronous
GameAssets to Bevy's ECS via background threads + mpsc channels polled
per frame (no main-thread blocking); UI open-iso/open-dir/file-select
events → texture preview. Register IsoLoaderPlugin; wire asset_loader
and the egui UI. Deps: rfd (native file dialog, workspace + viewer),
futures, and bevy tonemapping_luts feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 07:18:45 +02:00
MechaCat02
f8127e73b0 feat: initialise workspace — Milestone 1 asset explorer
Three-crate Cargo workspace structured per PROJECT.md spec:
- crates/sylpheed-formats  — Xbox 360 format parsers (no Bevy)
- crates/sylpheed-viewer   — Bevy 0.15 asset viewer + egui UI
- crates/sylpheed-cli      — CLI tools (extract/list/sniff/texture)

Milestone 1 features:
- XISO disc image reading via xdvdfs 0.8
- XPR2 texture container parsing + Morton de-tiling
- D3DFORMAT → wgpu TextureFormat mapping (DXT1/3/5, DXN, ARGB)
- Custom Bevy AssetLoader for .xpr files
- Orbit camera (LMB orbit, RMB pan, scroll zoom)
- egui file browser + RE notes panel
- CLI: extract / list / sniff / texture info / texture export
- GitHub Actions CI (Linux, macOS, Windows, WASM)
- Trunk WASM build config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 21:04:07 +01:00