Compare commits

...

12 Commits

Author SHA1 Message Date
MechaCat02
b7028471e9 docs(re): add reverse-engineering knowledge base + conventions
Some checks failed
CI / Native — ubuntu-latest (push) Failing after 7m53s
CI / WASM — Web (push) Failing after 6m32s
CI / Formatting (push) Failing after 35s
CI / Native — macos-latest (push) Has been cancelled
CI / Native — windows-latest (push) Has been cancelled
Establish docs/re/ as the spec-side of the clean-room:
- README: confidence tiers (HYPOTHESIS/PROBABLE/CONFIRMED), promotion
  requires NEW independent evidence, append-only evidence logs,
  demote-on-contradiction, and a clean-room firewall (describe behaviour +
  cite addresses; never paste decompiled code). Documents the toolchain
  (sylpheed.db + zq.py, xenia-rs probes, Wine-Canary oracle) and the
  join-by-guest-PC method with the VA-equality caveat.
- Function/structure templates + a seeded INDEX (9 already-reversed formats).
- First entry: texture-color-k8888 — CONFIRMED plain k_8_8_8_8 is linear
  UNORM (no sRGB), channel order still to be measured.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:04:23 +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
10425aba8c feat(formats): name font/image inner formats instead of hex
inner_format_label mapped only IDXD/xml/printable-tag magics; fonts and
images fell through to a bare hex label (00010000, 89504E47, …). Recognize
the known binary signatures and return friendly short tags so the pak
browser's format column reads them: sfnt/true/typ1 → "ttf", OTTO → "otf",
ttcf → "ttc", PNG → "png", plus RIFF/DDS. Still-unidentified magics keep the
hex fallback. Shared by the CLI `pak list` and the GUI browser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:24:26 +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
854fd8dfd3 fix(texture): make BC dword-swap format-aware (don't mangle DXT5A)
The dword-swap that fixes DXT1 is a property of the BC1 COLOUR block, not of
BCn in general. RE on a DXT5A (BC4) mask showed the alpha block is byte-indexed
and wants the endian swap ALONE — dword-swapping it makes it worse. The blanket
swap from the previous commit was therefore breaking the (common) DXT5A/DXN
alpha & normal masks.

Now targeted by format:
 - DXT1            : swap dwords of each 8-byte colour block (verified).
 - DXT2_3 / DXT4_5 : swap only the colour half (bytes 8..16) of each 16-byte
                     block; alpha half endian-only. TENTATIVE — these formats
                     are near-absent in the game assets, so unvalidated against
                     a clear image.
 - DXT5A / DXN     : no dword swap (endian only).

DXT1 export re-verified clean (weapon skins); 16 formats tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:13:37 +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
840db9c549 fix(texture): correct Xenos de-tile + endian + cubemaps + PNG export
Reworks the XPR2 texture path after RE against the retail disc (viewer
previews were scrambled/failed).

- de-tile: replace the naive Morton-over-32×32 approximation with the
  faithful Xenos Tiled2D bank/pipe/macro-tile address formula (ported
  from xenia texture_address.h). Verified correct: the Acheron backdrop
  decodes to a sharp planet with its spiral storm.
- endian: undo the X360 word byte-swap per the fetch-constant endianness
  field (k8in16/k8in32/k16in32) — without it BCn/ARGB data is noise.
- cubemaps: parse TXCM resources (skybox/backdrops), decoding face 0,
  instead of erroring "No TX2D found".
- A8R8G8B8: correct channel order after the k8in32 swap ([A,R,G,B]→RGBA)
  — the Acheron backdrop is now correctly green, not red.
- XPR files are texture PACKS; add XPR_RES_INDEX to select a resource.
- CLI `texture export` now writes real PNGs (texpresso BCn decode +
  image), replacing the stub. Adds texpresso + image deps.
- tests/texture_disc.rs: integration test running the pipeline over real
  disc .xpr files (28/28 parse). RE debug knobs: XPR_NO_DETILE /
  XPR_NO_ENDIAN / XPR_FORCE_ENDIAN.

KNOWN ISSUE: mipmapped DXT1 textures still decode to noise — mip 0 is
not at base_address in the multi-resource packs (localized to a mip
storage-offset quirk; uncompressed + de-tile + endian are all verified).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 21:37:20 +02:00
27 changed files with 4315 additions and 233 deletions

428
Cargo.lock generated
View File

@@ -60,8 +60,8 @@ dependencies = [
"hashbrown 0.15.5",
"paste",
"static_assertions",
"windows",
"windows-core",
"windows 0.58.0",
"windows-core 0.58.0",
]
[[package]]
@@ -112,6 +112,28 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "alsa"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
dependencies = [
"alsa-sys",
"bitflags 2.11.0",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "android-activity"
version = "0.6.1"
@@ -121,10 +143,10 @@ dependencies = [
"android-properties",
"bitflags 2.11.0",
"cc",
"jni",
"jni 0.22.4",
"libc",
"log",
"ndk",
"ndk 0.9.0",
"ndk-context",
"ndk-sys 0.6.0+11769913",
"num_enum",
@@ -1248,7 +1270,25 @@ dependencies = [
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"rustc-hash 1.1.0",
"shlex",
"syn 2.0.117",
]
[[package]]
name = "bindgen"
version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags 2.11.0",
"cexpr",
"clang-sys",
"itertools 0.13.0",
"proc-macro2",
"quote",
"regex",
"rustc-hash 2.1.3",
"shlex",
"syn 2.0.117",
]
@@ -1420,6 +1460,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -1470,6 +1516,12 @@ dependencies = [
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cexpr"
version = "0.6.0"
@@ -1561,6 +1613,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "claxon"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688"
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -1764,6 +1822,49 @@ dependencies = [
"libc",
]
[[package]]
name = "coreaudio-rs"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
dependencies = [
"bitflags 1.3.2",
"core-foundation-sys",
"coreaudio-sys",
]
[[package]]
name = "coreaudio-sys"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953"
dependencies = [
"bindgen 0.72.1",
]
[[package]]
name = "cpal"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa",
"core-foundation-sys",
"coreaudio-rs",
"dasp_sample",
"jni 0.21.1",
"js-sys",
"libc",
"mach2",
"ndk 0.8.0",
"ndk-context",
"oboe",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows 0.54.0",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1830,6 +1931,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "data-encoding"
version = "2.10.0"
@@ -2453,7 +2560,7 @@ dependencies = [
"log",
"presser",
"thiserror 1.0.69",
"windows",
"windows 0.58.0",
]
[[package]]
@@ -2548,6 +2655,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]]
name = "hound"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -2726,6 +2839,22 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
@@ -2845,6 +2974,17 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lewton"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030"
dependencies = [
"byteorder",
"ogg",
"tinyvec",
]
[[package]]
name = "libc"
version = "0.2.183"
@@ -2927,6 +3067,15 @@ dependencies = [
"twox-hash 2.1.2",
]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
@@ -3047,7 +3196,7 @@ dependencies = [
"indexmap",
"log",
"pp-rs",
"rustc-hash",
"rustc-hash 1.1.0",
"spirv",
"termcolor",
"thiserror 1.0.69",
@@ -3068,12 +3217,26 @@ dependencies = [
"once_cell",
"regex",
"regex-syntax",
"rustc-hash",
"rustc-hash 1.1.0",
"thiserror 1.0.69",
"tracing",
"unicode-ident",
]
[[package]]
name = "ndk"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
dependencies = [
"bitflags 2.11.0",
"jni-sys 0.3.1",
"log",
"ndk-sys 0.5.0+25.2.9519653",
"num_enum",
"thiserror 1.0.69",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -3169,6 +3332,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3506,6 +3680,29 @@ dependencies = [
"objc",
]
[[package]]
name = "oboe"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
dependencies = [
"jni 0.21.1",
"ndk 0.8.0",
"ndk-context",
"num-derive",
"num-traits",
"oboe-sys",
]
[[package]]
name = "oboe-sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
dependencies = [
"cc",
]
[[package]]
name = "offset-allocator"
version = "0.2.0"
@@ -3516,6 +3713,15 @@ dependencies = [
"nonmax",
]
[[package]]
name = "ogg"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e"
dependencies = [
"byteorder",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -3554,7 +3760,7 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
dependencies = [
"ttf-parser",
"ttf-parser 0.25.1",
]
[[package]]
@@ -3986,6 +4192,19 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "rodio"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1"
dependencies = [
"claxon",
"cpal",
"hound",
"lewton",
"symphonia",
]
[[package]]
name = "ron"
version = "0.8.1"
@@ -4004,6 +4223,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -4345,8 +4570,10 @@ dependencies = [
"anyhow",
"clap",
"colored",
"image",
"indicatif",
"sylpheed-formats",
"texpresso",
"tokio",
"tracing",
"tracing-subscriber",
@@ -4365,6 +4592,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tracing",
"ttf-parser 0.24.1",
"xdvdfs",
]
@@ -4372,16 +4600,68 @@ dependencies = [
name = "sylpheed-viewer"
version = "0.1.0"
dependencies = [
"ab_glyph",
"bevy",
"bevy_egui",
"futures",
"image",
"rfd",
"rodio",
"sylpheed-formats",
"thiserror 2.0.18",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "symphonia"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
dependencies = [
"lazy_static",
"symphonia-bundle-mp3",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "syn"
version = "1.0.109"
@@ -4437,6 +4717,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "texpresso"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8550677e2259d675a7841cb1403db35f330cc9e58674c8c5caa12dd12c51dc71"
dependencies = [
"libm",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -4683,7 +4972,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528bdd1f0e27b5dd9a4ededf154e824b0532731e4af73bb531de46276e0aab1e"
dependencies = [
"bindgen",
"bindgen 0.70.1",
"cc",
"cfg-if",
"once_cell",
@@ -4721,6 +5010,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "ttf-parser"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a"
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -5089,7 +5384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe985f41e291eecef5e5c0770a18d28390addb03331c043964d9e916453d6f16"
dependencies = [
"core-foundation 0.10.1",
"jni",
"jni 0.22.4",
"log",
"ndk-context",
"objc2 0.6.4",
@@ -5147,7 +5442,7 @@ dependencies = [
"parking_lot",
"profiling",
"raw-window-handle",
"rustc-hash",
"rustc-hash 1.1.0",
"smallvec",
"thiserror 1.0.69",
"wgpu-hal",
@@ -5189,14 +5484,14 @@ dependencies = [
"range-alloc",
"raw-window-handle",
"renderdoc-sys",
"rustc-hash",
"rustc-hash 1.1.0",
"smallvec",
"thiserror 1.0.69",
"wasm-bindgen",
"web-sys",
"wgpu-types",
"windows",
"windows-core",
"windows 0.58.0",
"windows-core 0.58.0",
]
[[package]]
@@ -5219,13 +5514,33 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core 0.54.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core",
"windows-core 0.58.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
@@ -5237,7 +5552,7 @@ checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement",
"windows-interface",
"windows-result",
"windows-result 0.2.0",
"windows-strings",
"windows-targets 0.52.6",
]
@@ -5270,6 +5585,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.2.0"
@@ -5285,10 +5609,19 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result",
"windows-result 0.2.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
@@ -5334,6 +5667,21 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
@@ -5382,6 +5730,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -5400,6 +5754,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
@@ -5418,6 +5778,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
@@ -5448,6 +5814,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
@@ -5466,6 +5838,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
@@ -5484,6 +5862,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
@@ -5502,6 +5886,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
@@ -5542,7 +5932,7 @@ dependencies = [
"js-sys",
"libc",
"memmap2",
"ndk",
"ndk 0.9.0",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",

View File

@@ -42,6 +42,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# ── Native file dialog ────────────────────────────────────────────────────────
rfd = "0.14"
# ── Audio playback (native video player) ──────────────────────────────────────
rodio = "0.20"
# ── CLI ───────────────────────────────────────────────────────────────────────
clap = { version = "4", features = ["derive"] }

View File

@@ -19,3 +19,6 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true }
colored = "2"
indicatif = "0.17"
# Texture PNG export: BCn software decode + image encode.
texpresso = "2"
image = { version = "0.25", default-features = false, features = ["png"] }

View File

@@ -272,6 +272,7 @@ fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
"bin" => label.red().to_string(),
"xpr" => label.green().to_string(),
"dds" => label.green().to_string(),
"txt" => label.cyan().to_string(),
_ => label.yellow().to_string(),
};
@@ -330,73 +331,71 @@ fn cmd_texture_export(file: &Path, output: &Path) -> Result<()> {
let bytes = std::fs::read(file)
.with_context(|| format!("Cannot read {}", file.display()))?;
use sylpheed_formats::texture::{X360Texture, X360TextureFormat};
use sylpheed_formats::texture::X360Texture;
let tex = X360Texture::from_xpr2(&bytes)?;
let rgba = decode_to_rgba8(&tex)
.with_context(|| format!("decoding {:?} texture", tex.format))?;
// For BCn compressed textures we need to software-decompress to RGBA8
// before saving to PNG. This uses the `texpresso` crate.
//
// TODO: add `texpresso = "2"` to Cargo.toml for BCn software decode.
// For now, print a helpful message.
match tex.format {
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
// Raw BGRA8 — can save directly (with channel swizzle)
println!(
"{} TODO: save raw BGRA8 to PNG (add `image` crate)",
"Note:".yellow()
);
}
compressed_format => {
println!(
"{} Format {:?} needs BCn decompression before PNG export.",
"Note:".yellow(), compressed_format
);
println!(
" Add `texpresso` crate and implement decompression in texture.rs"
);
println!(
" Alternatively, use the Bevy viewer to inspect textures visually."
);
}
}
image::save_buffer(
output,
&rgba,
tex.width,
tex.height,
image::ExtendedColorType::Rgba8,
)
.with_context(|| format!("writing PNG {}", output.display()))?;
println!(
" Texture parsed OK: {}×{} {:?}",
tex.width, tex.height, tex.format
"{} {}×{} {:?}{}{}",
"Exported".green().bold(),
tex.width,
tex.height,
tex.format,
if tex.is_cubemap { " (cubemap face 0)" } else { "" },
output.display().to_string().cyan(),
);
Ok(())
}
/// Software-decode a de-tiled `X360Texture` (mip 0) to tightly-packed RGBA8.
///
/// BCn blocks are decompressed with `texpresso`; uncompressed A8R8G8B8/X8R8G8B8
/// is byte-swizzled from the Xenos in-memory BGRA order.
fn decode_to_rgba8(tex: &sylpheed_formats::texture::X360Texture) -> Result<Vec<u8>> {
use sylpheed_formats::texture::X360TextureFormat as F;
let (w, h) = (tex.width as usize, tex.height as usize);
let mut rgba = vec![0u8; w * h * 4];
let bc = |fmt: texpresso::Format, rgba: &mut [u8]| {
fmt.decompress(&tex.data, w, h, rgba);
};
match tex.format {
F::Dxt1 => bc(texpresso::Format::Bc1, &mut rgba),
F::Dxt3 => bc(texpresso::Format::Bc2, &mut rgba),
F::Dxt5 => bc(texpresso::Format::Bc3, &mut rgba),
F::A8R8G8B8 | F::X8R8G8B8 => {
// After the k8in32 endian swap in from_xpr2, k_8_8_8_8 pixels are in
// [A,R,G,B] byte order (verified against the retail Acheron backdrop).
// Emit RGBA. X8 has no meaningful alpha.
let opaque = matches!(tex.format, F::X8R8G8B8);
for (px, out) in tex.data.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
out[0] = px[1]; // R
out[1] = px[2]; // G
out[2] = px[3]; // B
out[3] = if opaque { 0xFF } else { px[0] };
}
}
other => {
anyhow::bail!("PNG export for {other:?} (BC4/BC5) not implemented yet");
}
}
Ok(rgba)
}
// ── pak list ────────────────────────────────────────────────────────────────
/// Best-effort human label for a decompressed entry's inner format.
fn inner_label(payload: &[u8]) -> String {
if payload.len() < 4 {
return "empty".into();
}
let m = &payload[0..4];
if m == b"IDXD" {
"IDXD".into()
} else if payload.starts_with(b"<?xml") {
"xml".into()
} else if m.iter().all(|&b| b.is_ascii_graphic()) {
// Many inner formats are 4-char tags (RATC, T8aD, IXUD, LSTA, ttcf, …).
String::from_utf8_lossy(m).into_owned()
} else {
format!("{:02X}{:02X}{:02X}{:02X}", m[0], m[1], m[2], m[3])
}
}
/// For an IDXD entry, a short identity string (ID / Name / Model if present).
fn idxd_identity(obj: &IdxdObject) -> String {
for key in ["ID", "Name", "Model"] {
if let Some(v) = obj.get_raw(key) {
return format!("{key}={v}");
}
}
format!("schema {:08x}", obj.schema_hash)
}
use sylpheed_formats::pak::inner_format_label as inner_label;
/// Try to recover an IDXD entry's original TOC path from its identity tokens.
/// Uses the entry's ID/Name/Model fields plus identifier-like pool tokens as
@@ -443,7 +442,7 @@ fn cmd_pak_list(pak: &Path, idxd_only: bool) -> Result<()> {
}
let (detail, name) = if is_idxd {
match IdxdObject::parse(&payload) {
Ok(o) => (idxd_identity(&o), idxd_toc_name(&o, e.name_hash)),
Ok(o) => (o.identity(), idxd_toc_name(&o, e.name_hash)),
Err(_) => (String::new(), None),
}
} else {

View File

@@ -10,6 +10,7 @@ authors.workspace = true
xdvdfs = { workspace = true }
binrw = { workspace = true }
flate2 = "1" # zlib/DEFLATE for IPFB "Z1" entries (miniz_oxide backend, WASM-safe)
ttf-parser = { version = "0.24", default-features = false, features = ["std", "opentype-layout"] } # font metadata (OTF/TTF/ttcf), WASM-safe
tokio = { workspace = true }
futures = { workspace = true }
thiserror = { workspace = true }

View File

@@ -0,0 +1,64 @@
//! Font metadata for the embedded UI fonts.
//!
//! The packs carry sfnt fonts (`\x00\x01\x00\x00` TrueType, `OTTO` OpenType/CFF)
//! and TrueType Collections (`ttcf`). We only need light metadata to *present*
//! them — family name, face count, glyph count — which `ttf-parser` extracts
//! safely (malformed input yields `None`, never a panic). The raw bytes are
//! handed to egui separately for a live sample.
use ttf_parser::{fonts_in_collection, name_id, Face};
/// Light font metadata for the viewer.
#[derive(Debug, Clone, PartialEq)]
pub struct FontInfo {
/// "TrueType", "OpenType (CFF)", or "TrueType Collection".
pub kind: &'static str,
/// Family / full name (best-effort; empty if the name table is unreadable).
pub family: String,
/// Number of faces (1 for a plain sfnt, N for a `ttcf` collection).
pub faces: u32,
/// Glyph count of the first face.
pub glyphs: u16,
}
/// Whether `bytes` starts with a recognized font signature.
pub fn is_font(bytes: &[u8]) -> bool {
matches!(
bytes.get(0..4),
Some(b"\x00\x01\x00\x00") | Some(b"OTTO") | Some(b"true") | Some(b"ttcf")
)
}
/// Extract light metadata, or `None` if the font can't be parsed.
pub fn parse_info(bytes: &[u8]) -> Option<FontInfo> {
let is_collection = bytes.get(0..4) == Some(b"ttcf");
let faces = fonts_in_collection(bytes).unwrap_or(1).max(1);
// Face 0 gives the representative family + glyph count.
let face = Face::parse(bytes, 0).ok()?;
let glyphs = face.number_of_glyphs();
let kind = if is_collection {
"TrueType Collection"
} else if bytes.get(0..4) == Some(b"OTTO") {
"OpenType (CFF)"
} else {
"TrueType"
};
// Prefer the typographic/full family name; fall back to any readable name.
let family = face
.names()
.into_iter()
.filter(|n| n.name_id == name_id::FULL_NAME || n.name_id == name_id::FAMILY)
.find_map(|n| n.to_string())
.or_else(|| face.names().into_iter().find_map(|n| n.to_string()))
.unwrap_or_default();
Some(FontInfo {
kind,
family,
faces,
glyphs,
})
}

View File

@@ -171,6 +171,18 @@ impl IdxdObject {
}
out
}
/// A short identity string for the object: the first of `ID` / `Name` /
/// `Model` present, else the schema hash. Shared by the CLI `pak list` and
/// the GUI pack browser.
pub fn identity(&self) -> String {
for key in ["ID", "Name", "Model"] {
if let Some(v) = self.get_raw(key) {
return format!("{key}={v}");
}
}
format!("schema {:08x}", self.schema_hash)
}
}
/// Extract the trailing string pool. Finds the smallest offset whose suffix is

View File

@@ -0,0 +1,187 @@
//! IXUD localized-string / subtitle table reader.
//!
//! `IXUD` entries in the language packs (`dat/movie/<lang>.pak`, the
//! `GP_MAIN_GAME_<lang>` tables, …) hold localized UTF-16**BE** strings. The
//! movie language packs use them as **subtitle cue lists**: a `SUBTITLE` header
//! followed by alternating `(text, timecode)` tokens, e.g.
//!
//! ```text
//! SUBTITLE
//! "Calm down!" 00:09.40-00:11.00
//! "Why are you taking Margras away?" 00:18.50-00:19.70
//! ```
//!
//! Reference-style tracks store a message key (`MSG_DEMO_240`) plus a single
//! start timecode instead of inline text; the actual string then lives in a
//! separate global table (not resolved here).
//!
//! ## Binary layout (as far as needed)
//! ```text
//! 0x00 4 Magic "IXUD"
//! 0x04 4 version (1)
//! 0x08 4 schema/type hash (constant 0x6CC83E70)
//! .. .. record directory { key_hash u32, offset u32, len u32 } × n
//! .. .. UTF-16BE string pool, NUL-terminated entries
//! ```
//! We don't need the directory to *present* the track — decoding the pool and
//! pairing tokens after the `SUBTITLE` header is enough and robust.
/// Magic at the start of every IXUD entry.
pub const IXUD_MAGIC: [u8; 4] = *b"IXUD";
/// One subtitle cue: a start time (seconds), optional end time, and the text
/// (or a `MSG_*` reference key for reference-style tracks).
#[derive(Debug, Clone, PartialEq)]
pub struct Cue {
pub start: f32,
pub end: Option<f32>,
pub text: String,
}
/// A decoded subtitle track.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Subtitle {
pub cues: Vec<Cue>,
}
impl Subtitle {
/// True when the track carries no inline text — every cue is a `MSG_*`
/// reference whose string lives in a separate global table.
pub fn is_reference_only(&self) -> bool {
!self.cues.is_empty() && self.cues.iter().all(|c| c.text.starts_with("MSG_"))
}
}
/// Whether `bytes` is an IXUD entry.
pub fn is_ixud(bytes: &[u8]) -> bool {
bytes.len() >= 4 && bytes[0..4] == IXUD_MAGIC
}
/// Parse an IXUD entry as a subtitle track. Returns `None` if not IXUD.
/// Malformed pools yield as many well-formed cues as can be recovered.
pub fn parse(bytes: &[u8]) -> Option<Subtitle> {
if !is_ixud(bytes) {
return None;
}
// Decode the whole payload as UTF-16BE and split into NUL-terminated,
// printable runs. The binary header/directory decodes to control/CJK-range
// junk that we skip by seeking to the `SUBTITLE` marker.
let tokens = utf16be_tokens(bytes);
let start = tokens
.iter()
.position(|t| t.ends_with("SUBTITLE"))
.map(|i| i + 1)
.unwrap_or(0);
let rest = &tokens[start..];
// Pair (text, timecode). A token that parses as a timecode closes the cue
// opened by the preceding text token; unpaired tokens are skipped so a
// single glitch doesn't desync the rest.
let mut cues = Vec::new();
let mut i = 0;
while i + 1 < rest.len() {
if let Some((s, e)) = parse_timing(&rest[i + 1]) {
cues.push(Cue {
start: s,
end: e,
text: rest[i].clone(),
});
i += 2;
} else {
i += 1;
}
}
Some(Subtitle { cues })
}
/// Split the payload (interpreted as UTF-16BE) into printable, NUL-delimited
/// tokens. Control chars terminate the current token.
fn utf16be_tokens(bytes: &[u8]) -> Vec<String> {
let mut tokens = Vec::new();
let mut cur = String::new();
for pair in bytes.chunks_exact(2) {
let u = u16::from_be_bytes([pair[0], pair[1]]);
match char::from_u32(u as u32) {
Some(c) if !c.is_control() => cur.push(c),
_ => {
if !cur.is_empty() {
tokens.push(std::mem::take(&mut cur));
}
}
}
}
if !cur.is_empty() {
tokens.push(cur);
}
tokens
}
/// Parse `MM:SS.ss` (`" 9:04.40"` style, minutes may be blank/space-padded) or
/// a `start-end` range into seconds.
fn parse_timing(s: &str) -> Option<(f32, Option<f32>)> {
let one = |p: &str| -> Option<f32> {
let (mm, ss) = p.trim().split_once(':')?;
let m: f32 = if mm.trim().is_empty() {
0.0
} else {
mm.trim().parse().ok()?
};
let sec: f32 = ss.trim().parse().ok()?;
Some(m * 60.0 + sec)
};
match s.split_once('-') {
Some((a, b)) => Some((one(a)?, Some(one(b)?))),
None => Some((one(s)?, None)),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a synthetic IXUD payload: an 8-byte header stand-in + UTF-16BE
/// NUL-terminated tokens.
fn synth(tokens: &[&str]) -> Vec<u8> {
let mut b = IXUD_MAGIC.to_vec();
b.extend_from_slice(&[0, 0, 0, 1, 0x6C, 0xC8, 0x3E, 0x70]); // version + schema
for t in tokens {
for u in t.encode_utf16() {
b.extend_from_slice(&u.to_be_bytes());
}
b.extend_from_slice(&[0, 0]); // NUL
}
b
}
#[test]
fn inline_subtitle_track() {
let b = synth(&[
"SUBTITLE",
"Calm down!",
"00:09.40-00:11.00",
"Let go of me!",
"00:13.10-00:14.50",
]);
let sub = parse(&b).unwrap();
assert_eq!(sub.cues.len(), 2);
assert_eq!(sub.cues[0].text, "Calm down!");
assert_eq!(sub.cues[0].start, 9.4);
assert_eq!(sub.cues[0].end, Some(11.0));
assert!(!sub.is_reference_only());
}
#[test]
fn reference_track() {
let b = synth(&["SUBTITLE", "MSG_DEMO_240", "00:01.00", "MSG_DEMO_241", "00:03.20"]);
let sub = parse(&b).unwrap();
assert_eq!(sub.cues.len(), 2);
assert_eq!(sub.cues[0].end, None);
assert!(sub.is_reference_only());
}
#[test]
fn rejects_non_ixud() {
assert!(parse(b"IDXD\0\0\0\0").is_none());
}
}

View File

@@ -25,6 +25,21 @@ pub mod pak;
// IDXD reflective object (ship / weapon / effect / menu definitions) reader
pub mod idxd;
// IXUD localized string / subtitle table reader
pub mod ixud;
// Embedded font (OTF / TTF / ttcf) metadata
pub mod font;
// T8aD 2D UI/HUD texture
pub mod t8ad;
// RATC nested resource bundle
pub mod ratc;
// LSTA sprite list (inline T8aD frames)
pub mod lsta;
// XISO reading is not available in-browser
#[cfg(not(target_arch = "wasm32"))]
pub mod xiso;
@@ -36,7 +51,11 @@ pub mod mesh;
pub mod audio;
/// Re-export the most commonly used types at the crate root.
pub use font::FontInfo;
pub use idxd::{IdxdError, IdxdObject};
pub use ixud::{Cue, Subtitle};
pub use ratc::RatcChild;
pub use t8ad::T8adImage;
pub use pak::{PakArchive, PakEntry, PakError};
pub use texture::{X360Texture, X360TextureFormat};
pub use vfs::{GameAssets, VfsError};

View File

@@ -0,0 +1,78 @@
//! `LSTA` — a sprite list: a header followed by N inline [`T8aD`](crate::t8ad)
//! frames concatenated back-to-back.
//!
//! A `count` lives at `@0x04`, but a few entries disagree with the actual frame
//! count, so we walk by the `T8aD` magic instead (robust) and decode each frame
//! from its slice up to the next frame (or end).
use crate::t8ad::{self, T8adImage, T8AD_MAGIC};
/// Magic at the start of an LSTA sprite list.
pub const LSTA_MAGIC: [u8; 4] = *b"LSTA";
/// Whether `bytes` is an LSTA list.
pub fn is_lsta(bytes: &[u8]) -> bool {
bytes.len() >= 4 && bytes[0..4] == LSTA_MAGIC
}
/// Decode all inline T8aD frames. Frames that don't decode (unsupported T8aD
/// variant) are skipped. Returns `None` only for non-LSTA input.
pub fn parse(bytes: &[u8]) -> Option<Vec<T8adImage>> {
if !is_lsta(bytes) {
return None;
}
// Offsets of each inline T8aD frame (search past the 4-byte magic).
let mut offs = Vec::new();
let mut i = 4;
while i + 4 <= bytes.len() {
if bytes[i..i + 4] == T8AD_MAGIC {
offs.push(i);
i += 4;
} else {
i += 1;
}
}
let mut frames = Vec::with_capacity(offs.len());
for (idx, &off) in offs.iter().enumerate() {
let next = offs.get(idx + 1).copied().unwrap_or(bytes.len());
if let Some(img) = t8ad::parse(&bytes[off..next]) {
frames.push(img);
}
}
Some(frames)
}
#[cfg(test)]
mod tests {
use super::*;
fn t8ad_frame(w: u32, h: u32) -> Vec<u8> {
let mut b = vec![0u8; 64];
b[0..4].copy_from_slice(&T8AD_MAGIC);
b[0x14..0x18].copy_from_slice(&w.to_be_bytes());
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&vec![0x80u8; (w * h * 4) as usize]);
b
}
#[test]
fn walks_inline_frames() {
let mut b = LSTA_MAGIC.to_vec();
b.extend_from_slice(&2u32.to_be_bytes());
b.extend_from_slice(&[0u8; 15]); // header remainder
b.extend_from_slice(&t8ad_frame(4, 4));
b.extend_from_slice(&t8ad_frame(2, 3));
let frames = parse(&b).unwrap();
assert_eq!(frames.len(), 2);
assert_eq!((frames[0].width, frames[0].height), (4, 4));
assert_eq!((frames[1].width, frames[1].height), (2, 3));
}
#[test]
fn rejects_non_lsta() {
assert!(parse(b"T8aD....").is_none());
}
}

View File

@@ -262,6 +262,41 @@ fn be32(b: &[u8], o: usize) -> u32 {
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
/// Best-effort human label for a decompressed entry's inner format, from its
/// first bytes: a known signature (`IDXD`, fonts, `png`, …), a printable 4-char
/// tag (RATC, IXUD, LSTA, …), or a hex fallback for still-unidentified magics.
/// Shared by the CLI `pak list` and the GUI pack browser.
pub fn inner_format_label(payload: &[u8]) -> String {
if payload.len() < 4 {
return "empty".into();
}
let m = &payload[0..4];
// Known binary/text signatures → friendly short tags (so fonts/images don't
// show as bare hex like `00010000` / `89504E47`).
let known = match m {
b"IDXD" => Some("IDXD"),
b"\x89PNG" => Some("png"),
b"\x00\x01\x00\x00" | b"true" | b"typ1" => Some("ttf"), // sfnt TrueType
b"OTTO" => Some("otf"), // OpenType (CFF)
b"ttcf" => Some("ttc"), // TrueType Collection
b"RIFF" => Some("riff"),
b"DDS " => Some("dds"),
_ if payload.starts_with(b"<?xml") => Some("xml"),
_ => None,
};
if let Some(tag) = known {
return tag.into();
}
if m.iter().all(|&b| b.is_ascii_graphic()) {
// Many inner formats are 4-char tags (RATC, T8aD, IXUD, LSTA, …).
String::from_utf8_lossy(m).into_owned()
} else {
format!("{:02X}{:02X}{:02X}{:02X}", m[0], m[1], m[2], m[3])
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -0,0 +1,131 @@
//! `RATC` — a nested resource bundle.
//!
//! After a small header, a RATC holds named children — `foo.t32` (T8aD
//! textures), `foo.rat` (nested RATC), fonts, PNG — each immediately preceded by
//! its ASCII name string. The children are self-locating by their 4-char magic,
//! so we list them by scanning for those magics and pairing each with the name
//! run that precedes it. This is reliable for *listing* (names / types / sizes
//! are literal bytes); decoding a child's pixels is delegated to that child's
//! own parser ([`crate::t8ad`]).
/// A child resource inside a RATC bundle.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RatcChild {
/// Name string preceding the child (e.g. `prselectbtn_g04b.t32`), or empty.
pub name: String,
/// Child magic tag: `T8aD`, `RATC`, `ttcf`, `png`, …
pub kind: String,
/// Byte offset of the child (its magic) within the RATC payload.
pub offset: usize,
/// Byte length of the child, up to the next child (or end of payload).
pub size: usize,
}
/// Magic at the start of a RATC bundle.
pub const RATC_MAGIC: [u8; 4] = *b"RATC";
/// Whether `bytes` is a RATC bundle.
pub fn is_ratc(bytes: &[u8]) -> bool {
bytes.len() >= 4 && bytes[0..4] == RATC_MAGIC
}
/// Recognized child magics and their display tag.
fn child_kind(m: &[u8]) -> Option<&'static str> {
match m {
b"T8aD" => Some("T8aD"),
b"RATC" => Some("RATC"),
b"ttcf" => Some("ttc"),
b"\x89PNG" => Some("png"),
_ => None,
}
}
/// List the children of a RATC bundle (the self-magic at offset 0 is skipped).
pub fn parse(bytes: &[u8]) -> Option<Vec<RatcChild>> {
if !is_ratc(bytes) {
return None;
}
// Offsets of every recognized child magic (skip the self RATC at 0).
let mut offs: Vec<(usize, &'static str)> = Vec::new();
let mut i = 4;
while i + 4 <= bytes.len() {
if let Some(kind) = child_kind(&bytes[i..i + 4]) {
offs.push((i, kind));
i += 4;
} else {
i += 1;
}
}
let mut children = Vec::with_capacity(offs.len());
for (idx, &(off, kind)) in offs.iter().enumerate() {
let next = offs.get(idx + 1).map(|&(o, _)| o).unwrap_or(bytes.len());
children.push(RatcChild {
name: name_before(bytes, off),
kind: kind.to_string(),
offset: off,
size: next.saturating_sub(off),
});
}
Some(children)
}
/// The nearest name string preceding `off`: the *last* printable run (len ≥ 3)
/// in the 96 bytes before the child magic. A few record-header bytes usually sit
/// between the name and the magic, so an exact-adjacency scan isn't enough.
fn name_before(bytes: &[u8], off: usize) -> String {
let start = off.saturating_sub(96);
let window = &bytes[start..off];
let mut best = String::new();
let mut run_start: Option<usize> = None;
let flush = |from: usize, to: usize, best: &mut String| {
if to - from >= 3 {
*best = String::from_utf8_lossy(&window[from..to]).trim().to_string();
}
};
for (i, &c) in window.iter().enumerate() {
if (0x20..=0x7e).contains(&c) {
run_start.get_or_insert(i);
} else if let Some(s) = run_start.take() {
flush(s, i, &mut best);
}
}
if let Some(s) = run_start {
flush(s, window.len(), &mut best);
}
best
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lists_named_children() {
let mut b = RATC_MAGIC.to_vec();
b.extend_from_slice(&[0u8; 28]); // header padding
b.extend_from_slice(b"logo.t32");
let t8_off = b.len();
b.extend_from_slice(b"T8aD");
b.extend_from_slice(&[0u8; 40]); // some child bytes
b.extend_from_slice(b"sub.rat");
let ratc_off = b.len();
b.extend_from_slice(b"RATC");
b.extend_from_slice(&[0u8; 8]);
let kids = parse(&b).unwrap();
assert_eq!(kids.len(), 2);
assert_eq!(kids[0].kind, "T8aD");
assert_eq!(kids[0].name, "logo.t32");
assert_eq!(kids[0].offset, t8_off);
assert_eq!(kids[0].size, ratc_off - t8_off);
assert_eq!(kids[1].kind, "RATC");
assert_eq!(kids[1].name, "sub.rat");
}
#[test]
fn rejects_non_ratc() {
assert!(parse(b"T8aD....").is_none());
}
}

View File

@@ -0,0 +1,134 @@
//! `T8aD` — the game's 2D UI/HUD texture format.
//!
//! A linear (untiled) 32bpp surface stored **A8R8G8B8** (Xbox byte order), with
//! a small fixed-per-variant header:
//!
//! ```text
//! 0x00 4 Magic "T8aD"
//! 0x14 4 width (BE u32)
//! 0x18 4 height (BE u32)
//! 0x1c 4 type field → header size: 1→64 2→84 3→104 4→124 15→344
//! <header> w*h*4 bytes of A8R8G8B8 pixel data, row-major
//! ```
//!
//! Static forensics over the disc showed ~85% of entries decode exactly with
//! this rule; the rest (unknown type field or a `w*h*4` that doesn't fit — most
//! likely DXT / palettized variants) return `None` rather than a wrong image.
//!
//! Keying the header size off the type field (not `len - w*h*4`) makes the
//! decoder **container-safe**: RATC/LSTA hand us a child slice that may have
//! trailing padding before the next child, so we must not infer the header from
//! the slice length.
//!
//! ⚠️ Colour correctness (channel order / endianness / sRGB) is unverified
//! against the running game — see the RE backlog.
/// Magic at the start of every T8aD surface.
pub const T8AD_MAGIC: [u8; 4] = *b"T8aD";
/// A decoded T8aD surface as tightly-packed RGBA8 (row-major, top-left origin).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct T8adImage {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// Whether `bytes` starts with the T8aD magic.
pub fn is_t8ad(bytes: &[u8]) -> bool {
bytes.len() >= 4 && bytes[0..4] == T8AD_MAGIC
}
/// Header size in bytes for a given type field (`@0x1c`), or `None` for an
/// unrecognized variant.
fn header_size(type_field: u32) -> Option<usize> {
match type_field {
1 => Some(64),
2 => Some(84),
3 => Some(104),
4 => Some(124),
15 => Some(344),
_ => None,
}
}
#[inline]
fn be32(b: &[u8], off: usize) -> u32 {
u32::from_be_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
}
/// Decode a T8aD surface from a slice whose first bytes ARE the magic. Returns
/// `None` for non-T8aD input or a variant we can't decode as RGBA (never guesses).
pub fn parse(bytes: &[u8]) -> Option<T8adImage> {
if !is_t8ad(bytes) || bytes.len() < 0x40 {
return None;
}
let width = be32(bytes, 0x14);
let height = be32(bytes, 0x18);
if !(1..=4096).contains(&width) || !(1..=4096).contains(&height) {
return None;
}
let header = header_size(be32(bytes, 0x1c))?;
let n = (width as usize) * (height as usize) * 4;
if bytes.len() < header + n {
return None; // DXT/palettized/short variant — defer, don't misdecode
}
// A8R8G8B8 → RGBA8.
let src = &bytes[header..header + n];
let mut rgba = vec![0u8; n];
for (px, out) in src.chunks_exact(4).zip(rgba.chunks_exact_mut(4)) {
let (a, r, g, b) = (px[0], px[1], px[2], px[3]);
out[0] = r;
out[1] = g;
out[2] = b;
out[3] = a;
}
Some(T8adImage {
width,
height,
rgba,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a synthetic type-1 (header 64) T8aD with a known A8R8G8B8 pattern.
fn synth(w: u32, h: u32) -> Vec<u8> {
let mut b = vec![0u8; 64];
b[0..4].copy_from_slice(&T8AD_MAGIC);
b[0x14..0x18].copy_from_slice(&w.to_be_bytes());
b[0x18..0x1c].copy_from_slice(&h.to_be_bytes());
b[0x1c..0x20].copy_from_slice(&1u32.to_be_bytes()); // type 1 → header 64
for i in 0..(w * h) {
b.extend_from_slice(&[(i & 0xff) as u8, 0x24, 0x63, 0xB2]); // A, R, G, B
}
b
}
#[test]
fn decodes_argb_to_rgba() {
let b = synth(4, 2);
let img = parse(&b).expect("decodes");
assert_eq!((img.width, img.height), (4, 2));
assert_eq!(img.rgba.len(), 4 * 2 * 4);
// pixel 0: A=0,R=0x24,G=0x63,B=0xB2 → RGBA = 24 63 B2 00
assert_eq!(&img.rgba[0..4], &[0x24, 0x63, 0xB2, 0x00]);
// pixel 1: A=1 → alpha byte
assert_eq!(&img.rgba[4..8], &[0x24, 0x63, 0xB2, 0x01]);
}
#[test]
fn rejects_unknown_variant_and_short() {
// type field 7 (unknown) → None
let mut b = synth(2, 2);
b[0x1c..0x20].copy_from_slice(&7u32.to_be_bytes());
assert!(parse(&b).is_none());
// truncated pixel data → None
let b = synth(64, 64);
assert!(parse(&b[..200]).is_none());
assert!(parse(b"IDXD\0\0\0\0").is_none());
}
}

View File

@@ -164,6 +164,11 @@ impl Xpr2ResourceEntry {
pub fn is_texture(&self) -> bool {
&self.type_tag == b"TX2D"
}
/// Cubemap resource (`TXCM`) — 6 faces sharing one GPUFC descriptor.
pub fn is_cubemap(&self) -> bool {
&self.type_tag == b"TXCM"
}
}
// ── Decoded texture ───────────────────────────────────────────────────────────
@@ -179,12 +184,34 @@ pub struct X360Texture {
pub height: u32,
pub format: X360TextureFormat,
pub mip_levels: u32,
/// True when the source resource was a cubemap (`TXCM`). `data` then holds
/// only face 0 (the +X face) decoded as a 2D image — enough for a preview.
pub is_cubemap: bool,
/// De-tiled texture data in linear order.
/// BCn: standard packed block data (DDS layout).
/// Uncompressed: BGRA8 pixel data.
pub data: Vec<u8>,
}
/// A decoded Xbox 360 cubemap (`TXCM`) — a world skybox. The 6 faces are each a
/// de-tiled 2D surface in the same layout as [`X360Texture::data`], in D3D9
/// `D3DCUBEMAP_FACES` order.
#[derive(Debug, Clone)]
pub struct Cubemap {
pub width: u32,
pub height: u32,
pub format: X360TextureFormat,
/// Exactly 6 faces, D3D9 order: +X, -X, +Y, -Y, +Z, -Z.
pub faces: Vec<Vec<u8>>,
}
impl Cubemap {
/// The D3D9 cube-face name for slice `i` (0..6).
pub fn face_label(i: usize) -> &'static str {
["+X", "-X", "+Y", "-Y", "+Z", "-Z"].get(i).copied().unwrap_or("?")
}
}
impl X360Texture {
/// Parse the first TX2D texture from an XPR2 file's raw bytes.
///
@@ -206,10 +233,20 @@ impl X360Texture {
entries.push(Xpr2ResourceEntry::read(&mut cur)?);
}
// Find the first TX2D texture resource
// Select a texture resource. TX2D = 2D texture; TXCM = cubemap
// (skybox / backdrop) — same 52-byte descriptor + GPUFC layout, but the
// pixel section holds 6 faces. For a preview we decode face 0.
// XPR files are frequently PACKS of many textures; XPR_RES_INDEX picks
// the Nth texture resource (default 0) for RE/browse validation.
let want = std::env::var("XPR_RES_INDEX")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(0);
let tex_entry = entries.iter()
.find(|e| e.is_texture())
.filter(|e| e.is_texture() || e.is_cubemap())
.nth(want)
.ok_or(TextureError::NoTextureFound)?;
let is_cubemap = tex_entry.is_cubemap();
// The descriptor is at file offset = data_offset + 0x10 (directory base)
const DIR_BASE: usize = 0x10;
@@ -239,6 +276,11 @@ impl X360Texture {
let format = X360TextureFormat::from_u8(fmt_code)
.ok_or(TextureError::UnsupportedFormat(fmt_code))?;
// GPUFC[1] bits[7:6] = endianness. X360 stores texture words byte-
// swapped; without undoing this, BCn endpoints/indices and ARGB channels
// decode to noise. (fetch-constant `endianness`, xenos.h Endian.)
let endianness = ((gpufc1 >> 6) & 0x3) as u8;
// GPUFC[1] bits[31:12] = base_address (4KB-aligned byte offset into data section)
let base_address = (gpufc1 & 0xFFFFF000) as usize;
@@ -260,23 +302,74 @@ impl X360Texture {
have: bytes.len(),
});
}
let raw_data = &bytes[data_start..];
let linear_data = if is_tiled {
detile(raw_data, width, height, format)?
} else {
// Linear layout — copy only the mip-0 slice
let block_size = format.block_size() as u32;
let bw = ((width + block_size - 1) / block_size).max(1);
let bh = ((height + block_size - 1) / block_size).max(1);
let needed = bw as usize * bh as usize * format.bytes_per_block();
if raw_data.len() < needed {
return Err(TextureError::BufferTooSmall { needed, have: raw_data.len() });
// De-tile + endian-correct the single (face-0) surface.
let data = decode_surface(&bytes[data_start..], width, height, format, is_tiled, endianness)?;
Ok(X360Texture { width, height, format, mip_levels: mip_count, is_cubemap, data })
}
raw_data[..needed].to_vec()
};
Ok(X360Texture { width, height, format, mip_levels: mip_count, data: linear_data })
/// Decode all 6 faces of a cubemap (`TXCM`) resource, or `Ok(None)` if the
/// selected resource is an ordinary 2D texture.
///
/// X360 cubemaps store the 6 faces back-to-back in the pixel section, each a
/// full independently-tiled 2D surface whose stride is the tiled surface size
/// rounded up to a 4 KiB subresource boundary (`kTextureSubresourceAlignmentBytes`).
/// Faces are in D3D9 `D3DCUBEMAP_FACES` order: +X, -X, +Y, -Y, +Z, -Z.
/// (Derived from xenia `texture_util.cc::GetGuestTextureLayout`; verified on
/// `BG_Acheron`: `data_size == 6 × 0x400000` and all 6 faces decode cleanly.)
pub fn cube_faces_from_xpr2(bytes: &[u8]) -> Result<Option<Cubemap>, TextureError> {
use std::io::Cursor;
let mut cur = Cursor::new(bytes);
let header = Xpr2Header::read(&mut cur)?;
let mut entries = Vec::new();
for _ in 0..header.num_resources {
entries.push(Xpr2ResourceEntry::read(&mut cur)?);
}
let want = std::env::var("XPR_RES_INDEX")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(0);
let tex_entry = entries
.iter()
.filter(|e| e.is_texture() || e.is_cubemap())
.nth(want)
.ok_or(TextureError::NoTextureFound)?;
if !tex_entry.is_cubemap() {
return Ok(None); // ordinary 2D texture — use `from_xpr2`
}
const DIR_BASE: usize = 0x10;
let gpufc_base = tex_entry.data_offset as usize + DIR_BASE + 0x18;
if bytes.len() < gpufc_base + 6 * 4 {
return Err(TextureError::BufferTooSmall { needed: gpufc_base + 24, have: bytes.len() });
}
let be_u32 = |o: usize| u32::from_be_bytes(bytes[o..o + 4].try_into().unwrap());
let gpufc0 = be_u32(gpufc_base);
let gpufc1 = be_u32(gpufc_base + 0x04);
let gpufc2 = be_u32(gpufc_base + 0x08);
let fmt_code = (gpufc1 & 0x3F) as u8;
let format = X360TextureFormat::from_u8(fmt_code)
.ok_or(TextureError::UnsupportedFormat(fmt_code))?;
let endianness = ((gpufc1 >> 6) & 0x3) as u8;
let base_address = (gpufc1 & 0xFFFFF000) as usize;
let width = (gpufc2 & 0x1FFF) + 1;
let height = ((gpufc2 >> 13) & 0x1FFF) + 1;
let is_tiled = (gpufc0 >> 31) != 0;
let data_start = header.header_size as usize + base_address;
let stride = tiled_face_stride(width, height, format);
let mut faces = Vec::with_capacity(6);
for f in 0..6 {
let start = data_start + f * stride;
let raw = bytes
.get(start..)
.ok_or(TextureError::BufferTooSmall { needed: start + 1, have: bytes.len() })?;
faces.push(decode_surface(raw, width, height, format, is_tiled, endianness)?);
}
Ok(Some(Cubemap { width, height, format, faces }))
}
/// Parse a texture from already-known parameters + raw tiled data.
@@ -290,19 +383,163 @@ impl X360Texture {
format: X360TextureFormat,
) -> Result<Self, TextureError> {
let linear_data = detile(tiled_data, width, height, format)?;
Ok(X360Texture { width, height, format, mip_levels: 1, data: linear_data })
Ok(X360Texture { width, height, format, mip_levels: 1, is_cubemap: false, data: linear_data })
}
}
/// Apply the Xenos texture endian swap in place, as byte permutations over the
/// data. Xbox 360 stores texture words big-endian; this converts them to the
/// PC-standard little-endian layout that BCn decoders and wgpu expect.
/// `endianness` is the fetch-constant `dword_1` bits[7:6]:
/// 0 = none, 1 = k8in16, 2 = k8in32, 3 = k16in32 (xenia `xenos.h` `Endian`).
pub fn apply_endian_swap(data: &mut [u8], endianness: u8) {
match endianness {
1 => {
// k8in16 — swap the two bytes of each 16-bit half.
for c in data.chunks_exact_mut(2) {
c.swap(0, 1);
}
}
2 => {
// k8in32 — reverse each 32-bit word.
for c in data.chunks_exact_mut(4) {
c.reverse();
}
}
3 => {
// k16in32 — swap the two 16-bit halves of each 32-bit word.
for c in data.chunks_exact_mut(4) {
c.swap(0, 2);
c.swap(1, 3);
}
}
_ => {} // kNone
}
}
/// De-tile (if tiled) + endian-correct one texture surface into linear PC
/// layout. Shared by `from_xpr2` (face 0) and `cube_faces_from_xpr2` (6 faces).
/// The `XPR_NO_DETILE` / `XPR_NO_ENDIAN` / `XPR_FORCE_ENDIAN` / `XPR_NO_BC_DWORD_SWAP`
/// debug knobs are honoured here so both paths behave identically.
fn decode_surface(
raw_data: &[u8],
width: u32,
height: u32,
format: X360TextureFormat,
is_tiled: bool,
endianness: u8,
) -> Result<Vec<u8>, TextureError> {
let force_linear = std::env::var("XPR_NO_DETILE").is_ok();
let mut linear_data = if is_tiled && !force_linear {
detile(raw_data, width, height, format)?
} else {
// Linear layout — copy only the mip-0 slice.
let block_size = format.block_size() as u32;
let bw = ((width + block_size - 1) / block_size).max(1);
let bh = ((height + block_size - 1) / block_size).max(1);
let needed = bw as usize * bh as usize * format.bytes_per_block();
if raw_data.len() < needed {
return Err(TextureError::BufferTooSmall { needed, have: raw_data.len() });
}
raw_data[..needed].to_vec()
};
// Undo the X360 word byte-swap so block/pixel data is PC little-endian.
let endianness = std::env::var("XPR_FORCE_ENDIAN")
.ok()
.and_then(|v| v.trim().parse::<u8>().ok())
.unwrap_or(endianness);
if std::env::var("XPR_NO_ENDIAN").is_err() {
apply_endian_swap(&mut linear_data, endianness);
}
// BC1 colour-block dword swap (see the long note where this was discovered):
// colour endpoints live in the high dword on X360; format-targeted so BC4/BC5
// (byte-indexed alpha/normal blocks) are left as endian-only.
if std::env::var("XPR_NO_BC_DWORD_SWAP").is_err() {
match format {
X360TextureFormat::Dxt1 => swap_bc_block_dwords(&mut linear_data),
X360TextureFormat::Dxt3 | X360TextureFormat::Dxt5 => {
for block in linear_data.chunks_exact_mut(16) {
swap_bc_block_dwords(&mut block[8..16]);
}
}
_ => {}
}
}
Ok(linear_data)
}
/// Byte stride between consecutive cubemap faces: the tiled surface size
/// (`pitch_aligned × height_aligned × bpb`, both padded to 32-block macro tiles)
/// rounded up to the 4 KiB subresource alignment (`kTextureSubresourceAlignmentBytes`).
fn tiled_face_stride(width: u32, height: u32, format: X360TextureFormat) -> usize {
let bs = format.block_size() as u32;
let bw = ((width + bs - 1) / bs).max(1);
let bh = ((height + bs - 1) / bs).max(1);
let pitch_aligned = align_up(bw, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
let height_aligned = align_up(bh, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
let surface = pitch_aligned as usize * height_aligned as usize * format.bytes_per_block();
(surface + 0xFFF) & !0xFFF
}
/// Swap the two 32-bit dwords within each 64-bit unit of `data`, in place.
///
/// Xbox 360 stores the BC1 *colour* block with its two 32-bit words in the
/// opposite order to the PC/DDS layout, putting the colour endpoints ahead of
/// the indices. Apply to a BC1 buffer (or the colour half of a BC2/BC3 block)
/// after the byte-level endian swap. Any trailing bytes that don't fill a full
/// 8-byte group are left untouched.
pub fn swap_bc_block_dwords(data: &mut [u8]) {
for unit in data.chunks_exact_mut(8) {
// [d0 d1 d2 d3 | d4 d5 d6 d7] → [d4 d5 d6 d7 | d0 d1 d2 d3]
let (lo, hi) = unit.split_at_mut(4);
lo.swap_with_slice(hi);
}
}
// ── Core de-tiling algorithm ──────────────────────────────────────────────────
/// De-tile an Xbox 360 GPU texture from tiled to linear (row-major) layout.
/// Macro-tile side in blocks (Xenos tiles are 32×32 *blocks*, where a "block"
/// is one BCn 4×4 group or one uncompressed texel). `texture_address.h`
/// `kTextureTileWidthHeight` / `kMacroTileWidth`.
const MACRO_TILE_BLOCKS: u32 = 32;
/// Storage pitch/height alignment, in blocks (`kStoragePitchHeightAlignmentBlocks`).
const STORAGE_ALIGN_BLOCKS: u32 = 32;
#[inline]
fn align_up(v: u32, a: u32) -> u32 {
(v + a - 1) & !(a - 1)
}
/// Byte offset of block (x, y) within an Xbox 360 2D tiled surface.
///
/// Xbox 360 stores textures in 32×32 texel macro-tiles. Within each
/// macro-tile the DXT blocks (or raw pixels) are in Morton (Z-order) order.
/// This function reverses that ordering for the mip-0 level.
/// Faithful port of xenia-canary `texture_address.h::Tiled2D` + `TiledCombine`
/// (documented Xenos hardware tiling — bank/pipe/macro-tile addressing, NOT a
/// plain Morton curve). `x`/`y` and `pitch_aligned` are in block units;
/// `bpb_log2` is log2(bytes-per-block). Returns a byte offset.
#[inline]
fn tiled_2d_offset(x: i32, y: i32, pitch_aligned: u32, bpb_log2: u32) -> i32 {
let outer_blocks = ((y >> 5) * (pitch_aligned >> 5) as i32 + (x >> 5)) << 6;
let inner_blocks = (((y >> 1) & 0b111) << 3) | (x & 0b111);
let outer_inner_bytes = (outer_blocks | inner_blocks) << bpb_log2;
let bank = (y >> 4) & 0b1;
let pipe = ((x >> 3) & 0b11) ^ (((y >> 3) & 0b1) << 1);
let y_lsb = y & 1;
// TiledCombine: splice bank/pipe/y_lsb bits into the byte address.
((y_lsb << 4) | (pipe << 6) | (bank << 11))
| (outer_inner_bytes & 0b1111)
| (((outer_inner_bytes >> 4) & 0b1) << 5)
| (((outer_inner_bytes >> 5) & 0b111) << 8)
| (outer_inner_bytes >> 8 << 12)
}
/// De-tile an Xbox 360 GPU texture (mip-0) from tiled to linear (row-major)
/// layout, using the exact Xenos address formula.
///
/// Algorithm based on Xenia's `texture_util.cc` `TileTexture()`.
/// `src` must hold the tiled mip-0 surface (its storage pitch/height are
/// rounded up to 32 blocks, so it may be larger than the visible image). The
/// returned buffer is tightly packed linear block data (DDS layout for BCn).
pub fn detile(
src: &[u8],
width: u32,
@@ -311,53 +548,33 @@ pub fn detile(
) -> Result<Vec<u8>, TextureError> {
let block_size = format.block_size() as u32;
let bpb = format.bytes_per_block();
let bpb_log2 = (bpb as u32).trailing_zeros();
// Texture dimensions in blocks
// Visible dimensions in blocks, and the padded storage pitch/height.
let blocks_wide = ((width + block_size - 1) / block_size).max(1);
let blocks_tall = ((height + block_size - 1) / block_size).max(1);
let pitch_aligned = align_up(blocks_wide, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
let height_aligned = align_up(blocks_tall, STORAGE_ALIGN_BLOCKS).max(MACRO_TILE_BLOCKS);
let expected = blocks_wide as usize * blocks_tall as usize * bpb;
if src.len() < expected {
return Err(TextureError::BufferTooSmall { needed: expected, have: src.len() });
// The tiled surface occupies pitch_aligned × height_aligned blocks.
let src_needed = pitch_aligned as usize * height_aligned as usize * bpb;
if src.len() < src_needed {
return Err(TextureError::BufferTooSmall { needed: src_needed, have: src.len() });
}
let mut dst = vec![0u8; expected];
// Xbox 360 macro-tiles are always 32×32 texels → 8×8 blocks for BCn (4-texel blocks)
let macro_tile_blocks = 32 / block_size;
let macro_tiles_wide = (blocks_wide + macro_tile_blocks - 1) / macro_tile_blocks;
let macro_tiles_tall = (blocks_tall + macro_tile_blocks - 1) / macro_tile_blocks;
let blocks_per_macro_tile = (macro_tile_blocks * macro_tile_blocks) as usize;
for macro_y in 0..macro_tiles_tall {
for macro_x in 0..macro_tiles_wide {
let macro_base = ((macro_y * macro_tiles_wide + macro_x) as usize)
* blocks_per_macro_tile
* bpb;
for local in 0..blocks_per_macro_tile as u32 {
// Decode Morton (Z-order) index → (lx, ly) within macro-tile
let (lx, ly) = morton_decode(local);
let block_x = macro_x * macro_tile_blocks + lx;
let block_y = macro_y * macro_tile_blocks + ly;
// Skip blocks outside the actual texture
if block_x >= blocks_wide || block_y >= blocks_tall {
continue;
}
let src_offset = macro_base + local as usize * bpb;
let dst_offset = (block_y * blocks_wide + block_x) as usize * bpb;
let dst_len = blocks_wide as usize * blocks_tall as usize * bpb;
let mut dst = vec![0u8; dst_len];
for by in 0..blocks_tall {
for bx in 0..blocks_wide {
let src_offset = tiled_2d_offset(bx as i32, by as i32, pitch_aligned, bpb_log2) as usize;
let dst_offset = (by * blocks_wide + bx) as usize * bpb;
if src_offset + bpb <= src.len() && dst_offset + bpb <= dst.len() {
dst[dst_offset..dst_offset + bpb]
.copy_from_slice(&src[src_offset..src_offset + bpb]);
}
}
}
}
Ok(dst)
}
@@ -399,11 +616,58 @@ mod tests {
}
#[test]
fn detile_noop_for_1x1_block() {
// A 4×4 DXT1 texture = exactly 1 block = 8 bytes; de-tiling is identity
let src = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04];
fn tiled_offset_origin_is_zero() {
// Block (0,0) always maps to byte offset 0 for any pitch / bpb.
assert_eq!(tiled_2d_offset(0, 0, 32, 3), 0);
assert_eq!(tiled_2d_offset(0, 0, 64, 4), 0);
}
#[test]
fn detile_single_block_reads_offset_zero() {
// A 4×4 DXT1 texture = 1 visible block, but Xenos pads the tiled
// surface to 32×32 blocks (8192 bytes). Block (0,0) sits at offset 0,
// so the de-tiled output equals the first 8 source bytes.
let mut src = vec![0u8; 32 * 32 * 8];
src[..8].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04]);
let result = detile(&src, 4, 4, X360TextureFormat::Dxt1).unwrap();
assert_eq!(result, src);
assert_eq!(result, &src[..8]);
}
#[test]
fn swap_bc_dwords_swaps_each_64bit_half() {
// One 8-byte BC1 block: X360 stores it as [indices][endpoints]; the swap
// must move the endpoint dword to the front so BC decoders find it.
let mut one = vec![0, 1, 2, 3, 4, 5, 6, 7];
swap_bc_block_dwords(&mut one);
assert_eq!(one, vec![4, 5, 6, 7, 0, 1, 2, 3]);
// A 16-byte BC3 block = two independent 8-byte halves; each is swapped.
let mut two: Vec<u8> = (0..16).collect();
swap_bc_block_dwords(&mut two);
assert_eq!(
two,
vec![4, 5, 6, 7, 0, 1, 2, 3, 12, 13, 14, 15, 8, 9, 10, 11]
);
// Applying it twice is the identity (it's its own inverse).
let mut back = two.clone();
swap_bc_block_dwords(&mut back);
assert_eq!(back, (0..16).collect::<Vec<u8>>());
}
#[test]
fn cubemap_face_stride_and_labels() {
// Acheron: 1024×1024 A8R8G8B8 → 1024*1024*4 = 4 MiB, already 4 KiB-aligned.
// Verified against the real file: data_size == 6 × 0x400000.
assert_eq!(
tiled_face_stride(1024, 1024, X360TextureFormat::A8R8G8B8),
0x400000
);
// A tiny surface still occupies a full 32×32-block tile, 4 KiB-aligned.
assert_eq!(tiled_face_stride(4, 4, X360TextureFormat::A8R8G8B8), 0x1000);
assert_eq!(Cubemap::face_label(0), "+X");
assert_eq!(Cubemap::face_label(2), "+Y");
assert_eq!(Cubemap::face_label(5), "-Z");
}
#[test]

View File

@@ -108,7 +108,12 @@ impl GameAssets {
/// Use this to identify unknown file types during reverse engineering.
pub fn identify_format(bytes: &[u8]) -> FileFormat {
if bytes.len() < 4 {
return FileFormat::Unknown;
// Very short files can still be text (e.g. a one-line config).
return if is_probably_text(bytes) {
FileFormat::Text
} else {
FileFormat::Unknown
};
}
match &bytes[..4] {
b"XPR2" => FileFormat::Xpr2Texture,
@@ -116,6 +121,10 @@ pub fn identify_format(bytes: &[u8]) -> FileFormat {
b"XWB\0" => FileFormat::XwbAudio, // Xbox Wave Bank
[0x89, b'P', b'N', b'G'] => FileFormat::Png,
b"DDS " => FileFormat::Dds,
// No binary magic matched — fall back to a content heuristic so text
// configs (`config.ini`, etc.) are recognised even though they have no
// signature.
_ if is_probably_text(bytes) => FileFormat::Text,
_ => FileFormat::Unknown,
}
}
@@ -127,6 +136,8 @@ pub enum FileFormat {
XwbAudio,
Png,
Dds,
/// Plain text (ASCII / UTF-8 / UTF-16) — `.ini` and similar.
Text,
Unknown,
}
@@ -138,7 +149,162 @@ impl FileFormat {
Self::XwbAudio => "xwb",
Self::Png => "png",
Self::Dds => "dds",
Self::Text => "txt",
Self::Unknown => "bin",
}
}
}
// ── Text detection & decoding (pure std, WASM-safe) ───────────────────────────
/// True if `b` is a byte we'd expect inside a text file: common whitespace,
/// printable ASCII, or any high byte (UTF-8 continuation / Latin-1). Mirrors the
/// printable convention used by the IDXD string-pool extractor.
#[inline]
fn is_text_byte(b: u8) -> bool {
matches!(b, b'\t' | b'\n' | b'\r') || (0x20..0x7f).contains(&b) || b >= 0x80
}
/// Heuristic sniff for whether `bytes` is human-readable text.
///
/// Recognises UTF-8 / UTF-16 BOMs outright, BOM-less UTF-16LE by its
/// characteristic odd-position NUL bytes, and otherwise requires a byte sample
/// to be almost entirely text bytes with no embedded NUL (NUL is the strongest
/// binary signal). Decoding is left to [`decode_text`].
pub fn is_probably_text(bytes: &[u8]) -> bool {
if bytes.is_empty() {
return false;
}
// Byte-order marks are unambiguous.
if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) // UTF-8
|| bytes.starts_with(&[0xFF, 0xFE]) // UTF-16LE
|| bytes.starts_with(&[0xFE, 0xFF])
// UTF-16BE
{
return true;
}
// Cap the scan so huge files stay cheap.
let sample = &bytes[..bytes.len().min(4096)];
// BOM-less UTF-16LE: ASCII text encodes as `<char> 0x00 <char> 0x00 …`, so
// roughly half the bytes (the odd positions) are NUL and the even bytes are
// printable.
if sample.len() >= 16 {
let nul_odd = sample.iter().skip(1).step_by(2).filter(|&&b| b == 0).count();
if nul_odd * 2 >= sample.len() / 2 {
let printable_even = sample.iter().step_by(2).filter(|&&b| is_text_byte(b)).count();
if printable_even * 2 >= sample.len() / 2 - 2 {
return true;
}
}
}
// Single-byte (ASCII / UTF-8): an embedded NUL means binary; otherwise
// require the overwhelming majority to be text bytes.
if sample.contains(&0) {
return false;
}
let text = sample.iter().filter(|&&b| is_text_byte(b)).count();
text * 100 >= sample.len() * 95
}
/// Decode `bytes` to a `String`, returning `(text, encoding_label)`.
///
/// Honours UTF-8 / UTF-16LE / UTF-16BE BOMs and BOM-less UTF-16LE, falling back
/// to lossy UTF-8. Never fails — undecodable sequences become U+FFFD. Pure std,
/// so both the viewer and CLI can share it.
pub fn decode_text(bytes: &[u8]) -> (String, &'static str) {
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return (String::from_utf8_lossy(rest).into_owned(), "UTF-8 (BOM)");
}
if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
return (decode_utf16(rest, false), "UTF-16LE (BOM)");
}
if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
return (decode_utf16(rest, true), "UTF-16BE (BOM)");
}
// BOM-less UTF-16LE detection (same signal as `is_probably_text`).
let sample = &bytes[..bytes.len().min(4096)];
if sample.len() >= 16 {
let nul_odd = sample.iter().skip(1).step_by(2).filter(|&&b| b == 0).count();
if nul_odd * 2 >= sample.len() / 2 {
return (decode_utf16(bytes, false), "UTF-16LE");
}
}
(String::from_utf8_lossy(bytes).into_owned(), "UTF-8 / ASCII")
}
fn decode_utf16(bytes: &[u8], big_endian: bool) -> String {
let units: Vec<u16> = bytes
.chunks_exact(2)
.map(|c| {
if big_endian {
u16::from_be_bytes([c[0], c[1]])
} else {
u16::from_le_bytes([c[0], c[1]])
}
})
.collect();
String::from_utf16_lossy(&units)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_config_is_text() {
let ini = b"[Video]\r\nWidth=1280\r\nHeight=720\r\n";
assert!(is_probably_text(ini));
assert_eq!(identify_format(ini), FileFormat::Text);
let (text, enc) = decode_text(ini);
assert!(text.contains("Width=1280"));
assert_eq!(enc, "UTF-8 / ASCII");
}
#[test]
fn magic_takes_precedence_over_text() {
// An XPR2 header happens to start with printable bytes; the magic match
// must win so it's never misfiled as text.
let mut xpr = b"XPR2".to_vec();
xpr.extend_from_slice(&[0u8; 32]);
assert_eq!(identify_format(&xpr), FileFormat::Xpr2Texture);
}
#[test]
fn binary_with_nul_is_not_text() {
let bin = [0u8, 1, 2, 0xFF, 0x00, 0x89, 0x10, 0x00, 0x42];
assert!(!is_probably_text(&bin));
assert_eq!(identify_format(&bin), FileFormat::Unknown);
}
#[test]
fn utf16le_bom_is_text_and_decodes() {
let data = [0xFF, 0xFE, b'H', 0x00, b'i', 0x00];
assert!(is_probably_text(&data));
assert_eq!(identify_format(&data), FileFormat::Text);
let (text, enc) = decode_text(&data);
assert_eq!(text, "Hi");
assert_eq!(enc, "UTF-16LE (BOM)");
}
#[test]
fn bomless_utf16le_detected() {
// "Hello, world!!!!" as UTF-16LE, no BOM (>=16 bytes so the heuristic runs).
let mut data = Vec::new();
for &b in b"Hello, world!!!!" {
data.push(b);
data.push(0);
}
assert!(is_probably_text(&data));
let (text, enc) = decode_text(&data);
assert_eq!(text, "Hello, world!!!!");
assert_eq!(enc, "UTF-16LE");
}
#[test]
fn empty_is_not_text() {
assert!(!is_probably_text(&[]));
}
}

View File

@@ -6,6 +6,7 @@
use std::path::{Path, PathBuf};
use sylpheed_formats::texture::X360Texture;
use sylpheed_formats::{IdxdObject, PakArchive};
/// Locate the extracted disc root, or `None` to skip.
@@ -122,3 +123,139 @@ fn name_lookup_resolves_weapon_tbl() {
let obj = IdxdObject::parse(&bytes).expect("weapon.tbl parses as IDXD");
assert!(obj.count > 0);
}
/// Real T8aD entries decode to sane RGBA surfaces, and a good fraction of the
/// pack's T8aD entries are decodable (the RGBA-variant rule holds broadly).
#[test]
fn hangar_t8ad_textures_decode() {
skip_without_disc!(root);
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
let (mut seen, mut decoded) = (0usize, 0usize);
for e in arc.entries() {
if e.comp_size > 4_000_000 {
continue;
}
let Ok(p) = arc.read(e) else { continue };
if !sylpheed_formats::t8ad::is_t8ad(&p) {
continue;
}
seen += 1;
if let Some(img) = sylpheed_formats::t8ad::parse(&p) {
decoded += 1;
assert!((1..=4096).contains(&img.width) && (1..=4096).contains(&img.height));
assert_eq!(img.rgba.len(), (img.width * img.height * 4) as usize);
}
}
assert!(seen > 100, "expected many T8aD, saw {seen}");
// The RGBA rule should cover the large majority (≈85% disc-wide).
assert!(decoded * 100 >= seen * 70, "decoded {decoded}/{seen}");
}
/// A real LSTA sprite list yields inline T8aD frames.
#[test]
fn lsta_sprite_list_decodes() {
skip_without_disc!(root);
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
let mut found = false;
for e in arc.entries() {
let Ok(p) = arc.read(e) else { continue };
if !sylpheed_formats::lsta::is_lsta(&p) {
continue;
}
if let Some(frames) = sylpheed_formats::lsta::parse(&p) {
if !frames.is_empty() {
found = true;
assert!(frames.iter().all(|f| !f.rgba.is_empty()));
}
}
}
assert!(found, "no decodable LSTA found");
}
/// A real RATC bundle lists named children including a decodable T8aD.
#[test]
fn ratc_bundle_lists_children() {
skip_without_disc!(root);
let arc = PakArchive::open(root.join("dat/GP_HANGAR_ARSENAL.pak")).unwrap();
let mut ok = false;
for e in arc.entries() {
if e.comp_size > 4_000_000 {
continue;
}
let Ok(p) = arc.read(e) else { continue };
if !sylpheed_formats::ratc::is_ratc(&p) {
continue;
}
let Some(kids) = sylpheed_formats::ratc::parse(&p) else { continue };
let has_named = kids.iter().any(|c| c.name.contains('.'));
let has_t8ad = kids
.iter()
.any(|c| c.kind == "T8aD" && sylpheed_formats::t8ad::parse(&p[c.offset..c.offset + c.size]).is_some());
if !kids.is_empty() && has_named && has_t8ad {
ok = true;
break;
}
}
assert!(ok, "no RATC with named children + a decodable T8aD");
}
/// The English movie subtitle track (an `IXUD` entry) decodes to timed cues.
#[test]
fn eng_movie_subtitle_track() {
skip_without_disc!(root);
let arc = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
// S04A's track — the longest inline English one (starts "Calm down!").
let bytes = arc.read_by_hash(0x73d2_2a3b).expect("entry present").unwrap();
let sub = sylpheed_formats::ixud::parse(&bytes).expect("IXUD parses as subtitle");
assert!(sub.cues.len() > 50, "cues={}", sub.cues.len());
assert!(!sub.is_reference_only());
assert_eq!(sub.cues[0].text, "Calm down!");
assert!((sub.cues[0].start - 9.4).abs() < 0.01);
assert_eq!(sub.cues[0].end, Some(11.0));
}
/// The subtitle tracks are genuinely localized: the same entry hash differs
/// between the English and German packs.
#[test]
fn subtitles_are_localized() {
skip_without_disc!(root);
let eng = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
let deu = PakArchive::open(root.join("dat/movie/deu.pak")).unwrap();
let e = eng.read_by_hash(0x73d2_2a3b).unwrap().unwrap();
let d = deu.read_by_hash(0x73d2_2a3b).unwrap().unwrap();
assert_ne!(e, d, "eng/deu subtitle payloads should differ");
}
/// The English movie pack's embedded subtitle font parses to sane metadata.
#[test]
fn eng_movie_font_metadata() {
skip_without_disc!(root);
let arc = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap();
let bytes = arc.read_by_hash(0x5cd0_fca6).expect("entry present").unwrap();
assert!(sylpheed_formats::font::is_font(&bytes));
let info = sylpheed_formats::font::parse_info(&bytes).expect("font parses");
assert!(info.glyphs > 0, "glyphs={}", info.glyphs);
assert!(!info.family.is_empty(), "family should be readable");
}
/// The Acheron backdrop is a TXCM world cubemap: 6 faces, and the cube path's
/// face 0 matches the (validated) 2D `from_xpr2` face-0 decode.
#[test]
fn acheron_world_cubemap_six_faces() {
skip_without_disc!(root);
let bytes = std::fs::read(root.join("hidden/resource3d/BG_Acheron.xpr")).unwrap();
let cube = X360Texture::cube_faces_from_xpr2(&bytes)
.expect("cube decode ok")
.expect("BG_Acheron is a TXCM cubemap");
assert_eq!((cube.width, cube.height), (1024, 1024));
assert_eq!(cube.faces.len(), 6);
// A8R8G8B8 → 4 bytes/texel, de-tiled to width×height.
for face in &cube.faces {
assert_eq!(face.len(), 1024 * 1024 * 4);
}
// Face 0 must equal what the existing 2D path decodes (the green planet).
let face0_2d = X360Texture::from_xpr2(&bytes).unwrap();
assert!(face0_2d.is_cubemap);
assert_eq!(cube.faces[0], face0_2d.data);
}

View File

@@ -0,0 +1,140 @@
//! Integration test: run the XPR2 texture pipeline against REAL `.xpr` files
//! read directly from the retail disc image, reproducing exactly what the
//! viewer's texture-preview path does (`identify_format` → `X360Texture::
//! from_xpr2`). Skipped unless `SYLPHEED_ISO` points at the disc (or the
//! default dev path exists).
//!
//! Run: `cargo test -p sylpheed-formats --test texture_disc -- --ignored --nocapture`
use std::path::PathBuf;
use sylpheed_formats::texture::X360Texture;
use sylpheed_formats::vfs::identify_format;
fn iso_path() -> Option<PathBuf> {
if let Ok(p) = std::env::var("SYLPHEED_ISO") {
let p = PathBuf::from(p);
if p.is_file() {
return Some(p);
}
}
let default = PathBuf::from(
"/home/fabi/RE Project Sylpheed/Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso",
);
default.is_file().then_some(default)
}
#[tokio::test]
#[ignore = "requires the retail ISO — set SYLPHEED_ISO"]
async fn xpr_pipeline_over_disc_sample() {
let Some(iso) = iso_path() else {
eprintln!("SKIP: ISO not found (set SYLPHEED_ISO)");
return;
};
let mut reader = sylpheed_formats::xiso::open_iso(&iso).await.unwrap();
let all = reader.list_all_files().await.unwrap();
let mut xprs: Vec<String> = all
.into_iter()
.filter(|f| f.to_lowercase().ends_with(".xpr"))
.collect();
xprs.sort();
println!("found {} .xpr files", xprs.len());
// Sample across the set so we hit different texture sizes/formats.
let sample: Vec<String> = xprs.iter().step_by((xprs.len() / 24).max(1)).cloned().collect();
let mut ok = 0usize;
let mut fail = 0usize;
let mut by_format: std::collections::BTreeMap<String, usize> = Default::default();
let mut nonxpr = 0usize;
// Optionally dump the sampled .xpr bytes so the CLI can export them to PNG
// for visual de-tiling validation: `DUMP_XPR_DIR=/tmp/xpr cargo test …`.
let dump_dir = std::env::var("DUMP_XPR_DIR").ok();
if let Some(d) = &dump_dir {
std::fs::create_dir_all(d).unwrap();
}
for path in &sample {
let bytes = reader.read_file(path).await.unwrap();
if let Some(d) = &dump_dir {
let base = path.rsplit('/').next().unwrap();
std::fs::write(format!("{d}/{base}"), &bytes).unwrap();
}
let fmt = identify_format(&bytes);
let magic: String = bytes
.iter()
.take(4)
.map(|b| if b.is_ascii_graphic() { *b as char } else { '.' })
.collect();
if fmt != sylpheed_formats::vfs::FileFormat::Xpr2Texture {
nonxpr += 1;
println!(" [{path}] NOT XPR2 (magic {magic:?}, {} bytes)", bytes.len());
continue;
}
match X360Texture::from_xpr2(&bytes) {
Ok(t) => {
ok += 1;
*by_format.entry(format!("{:?}", t.format)).or_default() += 1;
// Sanity: does the decoded data length match the descriptor
// dimensions (what the GPU upload will require)?
let bs = t.format.block_size() as u32;
let bw = ((t.width + bs - 1) / bs).max(1) as usize;
let bh = ((t.height + bs - 1) / bs).max(1) as usize;
let need = bw * bh * t.format.bytes_per_block();
let size_ok = if need == t.data.len() { "ok" } else { "MISMATCH" };
println!(
" [{path}] {:?} {}x{} mips={} data={} need={} {size_ok}",
t.format, t.width, t.height, t.mip_levels, t.data.len(), need
);
}
Err(e) => {
fail += 1;
println!(" [{path}] from_xpr2 FAILED: {e}");
dump_xpr2_structure(&bytes);
}
}
}
println!("\nSUMMARY: ok={ok} fail={fail} non-xpr2={nonxpr} formats={by_format:?}");
}
/// Dump the XPR2 header + resource directory the way `from_xpr2` reads it,
/// so we can see why a file's TX2D scan comes up empty.
fn dump_xpr2_structure(bytes: &[u8]) {
let be = |o: usize| u32::from_be_bytes(bytes[o..o + 4].try_into().unwrap());
let magic: String = bytes[..4].iter().map(|b| *b as char).collect();
println!(
" hdr magic={magic:?} header_size=0x{:X} data_size=0x{:X} num_resources={}",
be(0x04),
be(0x08),
be(0x0C)
);
let n = be(0x0C).min(16); // guard against garbage counts
for i in 0..n {
let base = 0x10 + i as usize * 16;
if base + 16 > bytes.len() {
break;
}
let tag: String = bytes[base..base + 4]
.iter()
.map(|b| if b.is_ascii_graphic() { *b as char } else { '.' })
.collect();
println!(
" res[{i}] tag={tag:?} data_off=0x{:X} desc_size=0x{:X} name_off=0x{:X}",
be(base + 4),
be(base + 8),
be(base + 12)
);
}
// First 48 bytes hex for orientation.
let hx: String = bytes
.iter()
.take(48)
.map(|b| format!("{b:02X} "))
.collect();
println!(" hex[0..48] {hx}");
}

View File

@@ -50,3 +50,9 @@ dev = ["bevy/dynamic_linking"]
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tracing-subscriber = { workspace = true }
rfd = { workspace = true }
# Audio for the WMV video player (pulls cpal → alsa on Linux).
rodio = { workspace = true }
# PNG decode for pak image entries (pure Rust; Bevy already pulls it in).
image = { version = "0.25", default-features = false, features = ["png"] }
# Off-thread rasterization of embedded-font samples (avoids egui's global fonts).
ab_glyph = "0.2"

View File

@@ -93,8 +93,28 @@ impl AssetLoader for Xpr2TextureLoader {
pub fn x360_texture_to_bevy_image(tex: X360Texture) -> Result<Image, Xpr2LoadError> {
let format = x360_format_to_wgpu(&tex.format)?;
// Uncompressed k_8_8_8_8: after `from_xpr2`'s k8in32 endian swap the bytes
// are in [A,R,G,B] order (verified against the retail Acheron backdrop).
// wgpu has no ARGB format, so reorder to [R,G,B,A] and upload as Rgba8
// (see `x360_format_to_wgpu`). BCn data is already GPU-ready.
let data = match tex.format {
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
let opaque = matches!(tex.format, X360TextureFormat::X8R8G8B8);
let mut out = tex.data;
for px in out.chunks_exact_mut(4) {
let (a, r, g, b) = (px[0], px[1], px[2], px[3]);
px[0] = r;
px[1] = g;
px[2] = b;
px[3] = if opaque { 0xFF } else { a };
}
out
}
_ => tex.data,
};
Ok(Image {
data: tex.data,
data,
texture_descriptor: TextureDescriptor {
label: None,
size: Extent3d {
@@ -132,9 +152,9 @@ fn x360_format_to_wgpu(format: &X360TextureFormat) -> Result<TextureFormat, Xpr2
X360TextureFormat::Dxn => TextureFormat::Bc5RgUnorm,
// DXT5A / BC4 — single-channel (gloss, specular, luminance maps)
X360TextureFormat::Dxt5A => TextureFormat::Bc4RUnorm,
// Uncompressed ARGB — Xbox 360 stores as BGRA in memory
// Uncompressed — reordered to [R,G,B,A] by `x360_texture_to_bevy_image`.
X360TextureFormat::A8R8G8B8 | X360TextureFormat::X8R8G8B8 => {
TextureFormat::Bgra8UnormSrgb
TextureFormat::Rgba8UnormSrgb
}
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -28,32 +28,15 @@ pub mod ui;
/// Global viewer state, shared across all UI and rendering systems.
#[derive(Resource)]
pub struct ViewerState {
/// Which type of asset is currently being browsed / previewed.
pub current_mode: ViewMode,
/// Whether to render meshes in wireframe mode (toggle with F2).
pub wireframe: bool,
/// Whether the plain-text viewer wraps long lines.
pub text_wrap: bool,
}
impl Default for ViewerState {
fn default() -> Self {
Self {
current_mode: ViewMode::Textures,
wireframe: false,
Self { text_wrap: true }
}
}
}
/// The active view / asset type being inspected.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ViewMode {
/// XPR2 texture preview (Milestone 1).
#[default]
Textures,
/// 3D mesh viewer (Milestone 2+).
Mesh,
/// Audio waveform / playback (Milestone 2+).
Audio,
}
// ── App builder ──────────────────────────────────────────────────────────────

View File

@@ -10,10 +10,10 @@ use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{
FileInfo, FileSelected, IsoState, RequestOpenDir, RequestOpenIso, TexturePreview,
IsoLoaderSystemSet,
FileInfo, FileSelected, ImageRgba, IsoState, PakContent, PakView, RequestOpenDir,
RequestOpenIso, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, IsoLoaderSystemSet,
};
use crate::{ViewerState, ViewMode};
use crate::ViewerState;
pub struct ViewerUiPlugin;
@@ -31,6 +31,96 @@ pub struct FileBrowserState {
pub files: Vec<String>,
pub selected: Option<usize>,
pub filter: String,
/// True from the moment a file is clicked until its content is applied —
/// drives the "Loading…" indicator so the click registers immediately.
pub loading: bool,
}
// ── Directory tree ────────────────────────────────────────────────────────────
/// One directory node in the ephemeral file tree. Rebuilt from the flat path
/// list each frame; leaves carry their original index into `browser.files` so
/// selection stays stable regardless of tree shape or filtering.
#[derive(Default)]
struct TreeDir {
/// Child directories, keyed by name (`BTreeMap` = stable alphabetical order).
dirs: std::collections::BTreeMap<String, TreeDir>,
/// Leaf files: (display name, original index into `browser.files`).
files: Vec<(String, usize)>,
}
/// True for an IPFB data segment (`foo.p00`, `foo.p01`, …). These are the pack's
/// data body, not standalone files — the `.pak` index is what the user opens, so
/// they're hidden from the tree (their content loads when the `.pak` is opened).
fn is_pak_segment(leaf: &str) -> bool {
matches!(leaf.rsplit_once('.'), Some((_, ext))
if ext.len() == 3
&& ext.as_bytes()[0] == b'p'
&& ext.as_bytes()[1].is_ascii_digit()
&& ext.as_bytes()[2].is_ascii_digit())
}
/// Split a full path into (directory components, leaf name).
fn split_for_tree(full: &str) -> (Vec<String>, String) {
let mut comps: Vec<String> = full.split('/').map(str::to_string).collect();
let leaf = comps.pop().unwrap_or_default();
(comps, leaf)
}
/// Build the directory tree, keeping only leaves whose full path contains
/// `filter` (case-insensitive; empty filter keeps everything). `.pNN` data
/// segments are skipped — only their `.pak` index is shown.
fn build_tree(files: &[String], filter: &str) -> TreeDir {
let filter = filter.to_lowercase();
let mut root = TreeDir::default();
for (idx, full) in files.iter().enumerate() {
if !filter.is_empty() && !full.to_lowercase().contains(&filter) {
continue;
}
let (dirs, leaf) = split_for_tree(full);
if is_pak_segment(&leaf) {
continue;
}
let mut node = &mut root;
for comp in dirs {
node = node.dirs.entry(comp).or_default();
}
node.files.push((leaf, idx));
}
root
}
/// Render a directory node recursively: sub-directories (collapsible) first,
/// then leaf files as selectable labels.
#[allow(clippy::too_many_arguments)]
fn render_dir(
ui: &mut egui::Ui,
node: &TreeDir,
force_open: bool,
selected: &mut Option<usize>,
loading: &mut bool,
events: &mut EventWriter<FileSelected>,
files: &[String],
) {
for (name, child) in &node.dirs {
egui::CollapsingHeader::new(format!("📁 {name}"))
.id_salt(name)
.default_open(force_open)
.show(ui, |ui| {
render_dir(ui, child, force_open, selected, loading, events, files);
});
}
for (leaf, idx) in &node.files {
let is_selected = *selected == Some(*idx);
if ui
.add(egui::SelectableLabel::new(is_selected, leaf.as_str()))
.clicked()
{
*selected = Some(*idx);
*loading = true; // show the spinner until the content is applied
events.send(FileSelected(files[*idx].clone()));
}
}
}
fn draw_viewer_ui(
@@ -39,6 +129,10 @@ fn draw_viewer_ui(
mut browser: ResMut<FileBrowserState>,
iso_state: Res<IsoState>,
preview: Res<TexturePreview>,
text_preview: Res<TextPreview>,
mut pak_view: ResMut<PakView>,
skybox: Res<SkyboxPreview>,
mut video: ResMut<VideoPreview>,
file_info: Res<FileInfo>,
mut open_iso_events: EventWriter<RequestOpenIso>,
mut open_dir_events: EventWriter<RequestOpenDir>,
@@ -72,29 +166,6 @@ fn draw_viewer_ui(
);
});
ui.menu_button("View", |ui| {
if ui
.selectable_label(viewer.current_mode == ViewMode::Textures, "Textures")
.clicked()
{
viewer.current_mode = ViewMode::Textures;
}
if ui
.selectable_label(viewer.current_mode == ViewMode::Mesh, "Meshes")
.clicked()
{
viewer.current_mode = ViewMode::Mesh;
}
if ui
.selectable_label(viewer.current_mode == ViewMode::Audio, "Audio")
.clicked()
{
viewer.current_mode = ViewMode::Audio;
}
ui.separator();
ui.checkbox(&mut viewer.wireframe, "Wireframe (F2)");
});
ui.menu_button("Help", |ui| {
if ui.button("Controls…").clicked() {
// TODO: controls popup
@@ -138,53 +209,101 @@ fn draw_viewer_ui(
or File → Open extracted folder.",
);
} else {
let filter = browser.filter.to_lowercase();
let filtered: Vec<(usize, String)> = browser
.files
.iter()
.enumerate()
.filter(|(_, f)| {
filter.is_empty() || f.to_lowercase().contains(&filter)
})
.map(|(i, f)| (i, f.clone()))
.collect();
for (idx, file) in &filtered {
let is_selected = browser.selected == Some(*idx);
let label =
egui::SelectableLabel::new(is_selected, file.as_str());
if ui.add(label).clicked() {
browser.selected = Some(*idx);
file_selected_events.send(FileSelected(file.clone()));
}
}
// Build a directory tree from the flat path list. Cheap to
// rebuild every frame (a few hundred short paths); cloning
// `files` first sidesteps borrowing `browser` immutably and
// mutably (selected) at the same time.
let files = browser.files.clone();
let tree = build_tree(&files, &browser.filter);
let force_open = !browser.filter.is_empty();
let FileBrowserState {
selected, loading, ..
} = &mut *browser;
render_dir(
ui,
&tree,
force_open,
selected,
loading,
&mut file_selected_events,
&files,
);
}
});
});
// ── Bottom panel: status bar ──────────────────────────────────────────
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
ui.horizontal(|ui| {
// `horizontal_wrapped` so the bar wraps instead of overflowing the
// window on narrow widths; the controls hint is right-aligned and only
// the source's final path component is shown (full path on hover).
ui.horizontal_wrapped(|ui| {
if let Some(label) = &iso_state.source_label {
ui.label(label);
let short = label.rsplit(['/', '\\']).next().unwrap_or(label);
ui.label(short).on_hover_text(label);
ui.separator();
}
match viewer.current_mode {
ViewMode::Textures => ui.label("Mode: Texture Viewer"),
ViewMode::Mesh => ui.label("Mode: Mesh Viewer"),
ViewMode::Audio => ui.label("Mode: Audio Viewer"),
};
ui.separator();
ui.label(format!("{} files", browser.files.len()));
ui.separator();
ui.label("LMB: orbit | RMB: pan | Scroll: zoom | R: reset");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label("LMB orbit · RMB pan · scroll zoom · R reset");
});
});
});
// ── Central area ──────────────────────────────────────────────────────
if browser.selected.is_some() {
egui::CentralPanel::default().show(ctx, |ui| {
if let Some(egui_id) = preview.egui_id {
if browser.loading {
// A file was just clicked — show immediate feedback while the
// background read + decode runs, instead of the previous item.
let name = browser
.selected
.and_then(|i| browser.files.get(i))
.map(|p| p.rsplit('/').next().unwrap_or(p))
.unwrap_or("");
ui.centered_and_justified(|ui| {
ui.horizontal(|ui| {
ui.spinner();
ui.label(format!("Loading {name}"));
});
});
} else if video.active {
draw_video_player(ui, &mut video);
} else if !skybox.faces.is_empty() {
draw_skybox_grid(ui, &skybox);
} else if pak_view.loaded {
draw_pak_browser(ui, &mut pak_view);
} else if let Some(text) = &text_preview.content {
// ── Plain-text viewer ──
ui.horizontal(|ui| {
ui.heading(&file_info.name);
ui.separator();
ui.label(&text_preview.encoding);
ui.separator();
ui.checkbox(&mut viewer.text_wrap, "Wrap");
if text_preview.truncated {
ui.separator();
ui.colored_label(egui::Color32::YELLOW, "(truncated)");
}
});
ui.separator();
egui::ScrollArea::both().show(ui, |ui| {
// Read-only: `&str` is a non-editable TextBuffer, and
// `.interactive(false)` keeps it selectable/copyable.
let mut buf = text.as_str();
let mut edit = egui::TextEdit::multiline(&mut buf)
.font(egui::TextStyle::Monospace)
.code_editor()
.desired_width(f32::INFINITY)
.interactive(false);
if !viewer.text_wrap {
// Don't wrap: let ScrollArea scroll horizontally.
edit = edit.clip_text(false);
}
ui.add(edit);
});
} else if let Some(egui_id) = preview.egui_id {
// Texture preview
ui.heading(&file_info.name);
ui.label(&preview.format_info);
@@ -278,3 +397,549 @@ fn draw_viewer_ui(
});
}
}
// ── Data pack (IPFB / IDXD) browser ───────────────────────────────────────────
/// Master-detail view for an open `.pak`: entry list on the left, the selected
/// entry's content on the right (IDXD table, subtitle cues, font sample, image,
/// or text — whatever `classify_content` decoded off-thread).
fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) {
ui.horizontal(|ui| {
ui.heading(&pak.name);
ui.separator();
ui.label(format!("{} entries", pak.rows.len()));
});
if let Some(err) = &pak.error {
ui.separator();
ui.colored_label(egui::Color32::RED, format!("{err}"));
return;
}
ui.separator();
// Destructure so the columns can read `rows` while mutating the selection +
// the egui-side caches — without cloning the (now heavy) rows each frame.
let PakView {
rows,
selected,
img_tex,
..
} = pak;
let mut new_selection = *selected;
ui.columns(2, |cols| {
// Left — entry list.
egui::ScrollArea::vertical()
.id_salt("pak_list")
.show(&mut cols[0], |ui| {
for (i, row) in rows.iter().enumerate() {
let text = egui::RichText::new(format!(
"{:08x} {:<7} {}",
row.hash,
row.format,
row_kind(row)
))
.monospace();
if ui
.add(egui::SelectableLabel::new(*selected == Some(i), text))
.clicked()
{
new_selection = Some(i);
}
}
});
// Right — detail of the selected entry, dispatched on decoded content.
egui::ScrollArea::vertical()
.id_salt("pak_detail")
.show(&mut cols[1], |ui| match selected.and_then(|i| rows.get(i)) {
None => {
ui.label("Select an entry to inspect.");
}
Some(row) => match &row.content {
PakContent::Subtitle(sub) => draw_subtitle_detail(ui, row, sub),
PakContent::Font { info, sample } => {
draw_font_detail(ui, row, info, sample.as_ref(), img_tex)
}
PakContent::Png(img) => draw_png_detail(ui, row, img, img_tex),
PakContent::T8ad(img) => draw_t8ad_detail(ui, row, img, img_tex),
PakContent::Lsta(frames) => draw_lsta_detail(ui, row, frames, img_tex),
PakContent::Ratc(children) => draw_ratc_detail(ui, row, children, img_tex),
PakContent::Text { text, encoding } => {
draw_text_detail(ui, row, text, encoding)
}
PakContent::None => draw_idxd_detail(ui, row),
},
});
});
*selected = new_selection;
}
/// Short kind hint for the entry list (subtitle/font/image/text), falling back
/// to the IDXD identity.
fn row_kind(row: &crate::iso_loader::PakRow) -> String {
match &row.content {
PakContent::Subtitle(s) => format!("subtitle · {} cues", s.cues.len()),
PakContent::Font { info, .. } => {
if info.family.is_empty() {
"font".into()
} else {
info.family.clone()
}
}
PakContent::Png(img) => format!("PNG {}×{}", img.width, img.height),
PakContent::T8ad(img) => format!("T8aD {}×{}", img.width, img.height),
PakContent::Lsta(f) => format!("LSTA · {} sprite(s)", f.len()),
PakContent::Ratc(c) => format!("RATC · {} item(s)", c.len()),
PakContent::Text { .. } => "text".into(),
PakContent::None => row.identity.clone(),
}
}
/// `mm:ss.SS` timestamp for subtitle cues.
fn fmt_ts(secs: f32) -> String {
let s = secs.max(0.0);
format!("{:02}:{:05.2}", (s / 60.0) as u32, s % 60.0)
}
/// IDXD object detail (or bare format/size for other un-presentable entries).
fn draw_idxd_detail(ui: &mut egui::Ui, row: &crate::iso_loader::PakRow) {
match &row.detail {
Some(d) => {
let title = if row.identity.is_empty() {
row.format.as_str()
} else {
row.identity.as_str()
};
ui.heading(title);
ui.label(format!("schema 0x{:08x} count {}", d.schema_hash, d.count));
ui.label(format!("{} bytes hash {:08x}", row.size, row.hash));
ui.separator();
if d.fields.is_empty() {
ui.label("(no explicit-value fields)");
} else {
egui::Grid::new("pak_fields")
.striped(true)
.num_columns(2)
.show(ui, |ui| {
for (k, v) in &d.fields {
ui.label(k);
ui.monospace(v);
ui.end_row();
}
});
}
}
None => {
ui.heading(&row.format);
ui.label(format!("{} bytes hash {:08x}", row.size, row.hash));
ui.separator();
ui.label("No decoded preview for this format yet.");
}
}
}
/// IXUD subtitle track: a timecode + text cue table.
fn draw_subtitle_detail(
ui: &mut egui::Ui,
row: &crate::iso_loader::PakRow,
sub: &sylpheed_formats::ixud::Subtitle,
) {
ui.heading("Subtitle track");
let note = if sub.is_reference_only() {
" · reference-only (MSG_* keys)"
} else {
""
};
ui.label(format!(
"IXUD · {} cues · hash {:08x}{note}",
sub.cues.len(),
row.hash
));
ui.separator();
if sub.cues.is_empty() {
ui.label("(empty — no cues)");
return;
}
egui::Grid::new("subtitle_cues")
.striped(true)
.num_columns(2)
.show(ui, |ui| {
ui.strong("Time");
ui.strong("Text");
ui.end_row();
for c in &sub.cues {
let t = match c.end {
Some(e) => format!("{}{}", fmt_ts(c.start), fmt_ts(e)),
None => fmt_ts(c.start),
};
ui.monospace(t);
ui.label(&c.text);
ui.end_row();
}
});
}
/// Per-entry egui texture cache: `(entry hash, one texture per shown image)`.
type ImgCache = Option<(u32, Vec<egui::TextureHandle>)>;
/// Ensure `cache` holds egui textures for `imgs` under `hash`, rebuilding when
/// the selected entry changes. All image content comes from off-thread decodes;
/// this never touches egui's global font system.
fn ensure_textures(ui: &egui::Ui, hash: u32, imgs: &[&ImageRgba], cache: &mut ImgCache) {
if cache.as_ref().map(|(h, _)| *h) != Some(hash) {
let texs = imgs
.iter()
.enumerate()
.map(|(i, img)| {
let color = egui::ColorImage::from_rgba_unmultiplied(
[img.width as usize, img.height as usize],
&img.rgba,
);
ui.ctx().load_texture(
format!("pak_img_{hash:08x}_{i}"),
color,
egui::TextureOptions::LINEAR,
)
})
.collect();
*cache = Some((hash, texs));
}
}
/// Cache + draw a single `ImageRgba`, scaled to fit the available width (capped
/// by `max_scale`). Shared by the PNG / T8aD / font-sample views.
fn show_cached_image(
ui: &mut egui::Ui,
hash: u32,
img: &ImageRgba,
cache: &mut ImgCache,
max_scale: f32,
) {
ensure_textures(ui, hash, &[img], cache);
if let Some(tex) = cache.as_ref().and_then(|(_, t)| t.first()) {
let scale = (ui.available_width() / img.width.max(1) as f32).min(max_scale);
let (dw, dh) = (img.width as f32 * scale, img.height as f32 * scale);
ui.add(egui::Image::new(egui::load::SizedTexture::new(
tex.id(),
[dw, dh],
)));
}
}
/// Embedded font: metadata + a pre-rasterized sample line (rendered off-thread
/// with `ab_glyph`, shown as an image — no global egui font install).
fn draw_font_detail(
ui: &mut egui::Ui,
row: &crate::iso_loader::PakRow,
info: &sylpheed_formats::FontInfo,
sample: Option<&ImageRgba>,
img_tex: &mut ImgCache,
) {
ui.heading(if info.family.is_empty() {
"Font"
} else {
&info.family
});
ui.label(format!(
"{} · {} face(s) · {} glyphs · hash {:08x}",
info.kind, info.faces, info.glyphs, row.hash
));
ui.separator();
match sample {
Some(img) => {
ui.label("Sample:");
show_cached_image(ui, row.hash, img, img_tex, 1.0);
}
None => {
ui.label("(no sample — font collection or unsupported outlines)");
}
}
}
/// Decoded PNG image, shown via a cached egui texture.
fn draw_png_detail(
ui: &mut egui::Ui,
row: &crate::iso_loader::PakRow,
img: &ImageRgba,
img_tex: &mut ImgCache,
) {
ui.heading(format!("PNG image {}×{}", img.width, img.height));
ui.label(format!("hash {:08x}", row.hash));
ui.separator();
show_cached_image(ui, row.hash, img, img_tex, 2.0);
}
/// A decoded T8aD 2D texture. Colours are not yet verified against the game.
fn draw_t8ad_detail(
ui: &mut egui::Ui,
row: &crate::iso_loader::PakRow,
img: &ImageRgba,
img_tex: &mut ImgCache,
) {
ui.heading(format!("T8aD 2D texture {}×{}", img.width, img.height));
ui.horizontal(|ui| {
ui.label(format!("hash {:08x}", row.hash));
ui.separator();
ui.weak("colours unverified");
});
ui.separator();
show_cached_image(ui, row.hash, img, img_tex, 2.0);
}
/// An LSTA sprite list — the inline T8aD frames in a grid.
fn draw_lsta_detail(
ui: &mut egui::Ui,
row: &crate::iso_loader::PakRow,
frames: &[ImageRgba],
img_tex: &mut ImgCache,
) {
ui.horizontal(|ui| {
ui.heading("LSTA sprite list");
ui.separator();
ui.label(format!("{} sprite(s)", frames.len()));
ui.separator();
ui.weak("colours unverified");
});
ui.separator();
let refs: Vec<&ImageRgba> = frames.iter().collect();
ensure_textures(ui, row.hash, &refs, img_tex);
const CELL: f32 = 150.0;
egui::Grid::new("lsta_sprites")
.num_columns(3)
.spacing([10.0, 10.0])
.show(ui, |ui| {
for (i, frame) in frames.iter().enumerate() {
if let Some(tex) = img_tex.as_ref().and_then(|(_, t)| t.get(i)) {
let aspect = frame.height as f32 / frame.width.max(1) as f32;
ui.add(egui::Image::new(egui::load::SizedTexture::new(
tex.id(),
[CELL, CELL * aspect],
)));
}
if (i + 1) % 3 == 0 {
ui.end_row();
}
}
});
}
/// A RATC bundle — a list of its named children, with thumbnails for the
/// decoded T8aD ones.
fn draw_ratc_detail(
ui: &mut egui::Ui,
row: &crate::iso_loader::PakRow,
children: &[crate::iso_loader::RatcEntry],
img_tex: &mut ImgCache,
) {
ui.horizontal(|ui| {
ui.heading("RATC bundle");
ui.separator();
ui.label(format!("{} item(s)", children.len()));
ui.separator();
ui.weak("colours unverified");
});
ui.separator();
let refs: Vec<&ImageRgba> = children.iter().filter_map(|c| c.image.as_ref()).collect();
ensure_textures(ui, row.hash, &refs, img_tex);
let mut img_i = 0;
for c in children {
ui.horizontal(|ui| {
if c.image.is_some() {
if let Some(tex) = img_tex.as_ref().and_then(|(_, t)| t.get(img_i)) {
let sz = tex.size_vec2();
let scale = (48.0 / sz.y.max(1.0)).min(1.0);
ui.add(egui::Image::new(egui::load::SizedTexture::new(
tex.id(),
[sz.x * scale, sz.y * scale],
)));
}
img_i += 1;
}
ui.vertical(|ui| {
let name = if c.name.is_empty() { "(unnamed)" } else { &c.name };
ui.strong(name);
ui.weak(format!("{} · {} bytes", c.kind, c.size));
});
});
ui.separator();
}
}
/// Plain-text / XML entry in a read-only monospace box.
fn draw_text_detail(
ui: &mut egui::Ui,
row: &crate::iso_loader::PakRow,
text: &str,
encoding: &str,
) {
ui.horizontal(|ui| {
ui.heading(&row.format);
ui.separator();
ui.label(encoding);
ui.separator();
ui.label(format!("{} bytes", row.size));
});
ui.separator();
let mut buf = text;
ui.add(
egui::TextEdit::multiline(&mut buf)
.font(egui::TextStyle::Monospace)
.code_editor()
.desired_width(f32::INFINITY)
.interactive(false),
);
}
// ── Video player (WMV cutscenes) ──────────────────────────────────────────────
/// Format seconds as `mm:ss`.
fn fmt_time(secs: f32) -> String {
let s = secs.max(0.0) as u32;
format!("{:02}:{:02}", s / 60, s % 60)
}
/// Central-panel video player: the current frame plus a transport bar
/// (play/pause, seekable timeline, volume). Also supports click-to-toggle and
/// keyboard shortcuts (space = play/pause, ←/→ = skip 10 s). Playback state
/// lives in `VideoPreview`; the decode/audio engine reacts to it in
/// `advance_video_playback`.
fn draw_video_player(ui: &mut egui::Ui, video: &mut VideoPreview) {
// ── Keyboard shortcuts (consumed so focused widgets don't also act). ──
let (mut toggle_play, mut skip) = (false, 0.0_f32);
ui.input_mut(|i| {
if i.consume_key(egui::Modifiers::NONE, egui::Key::Space) {
toggle_play = true;
}
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight) {
skip += 10.0;
}
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft) {
skip -= 10.0;
}
});
if toggle_play {
video.playing = !video.playing;
}
if skip != 0.0 {
let t = (video.position + skip).clamp(0.0, video.duration);
video.position = t;
video.scrub_request = Some(t); // instant preview via the grabber…
video.seek_request = Some(t); // …then the stream restarts here
}
ui.horizontal(|ui| {
ui.heading(&video.name);
ui.separator();
ui.label(format!("{}×{} · wmv3 / wmapro", video.width, video.height));
if !video.has_audio {
ui.separator();
ui.colored_label(egui::Color32::YELLOW, "no audio");
}
});
ui.separator();
// Reserve room for the transport row so the frame never pushes it off-screen.
const CONTROLS_H: f32 = 34.0;
let avail = ui.available_size();
let frame_h = (avail.y - CONTROLS_H).max(0.0);
let (w, h) = (video.width.max(1) as f32, video.height.max(1) as f32);
let scale = (avail.x / w).min(frame_h / h);
let (disp_w, disp_h) = (w * scale, h * scale);
if let Some(egui_id) = video.egui_id {
ui.allocate_ui(egui::vec2(avail.x, frame_h), |ui| {
ui.vertical_centered(|ui| {
// Click the frame to toggle play/pause.
let resp = ui.add(
egui::Image::new(egui::load::SizedTexture::new(egui_id, [disp_w, disp_h]))
.sense(egui::Sense::click()),
);
if resp.clicked() {
video.playing = !video.playing;
}
});
});
}
// ── Transport bar ──
ui.horizontal(|ui| {
if ui.button(if video.playing { "" } else { "" }).clicked() {
video.playing = !video.playing;
}
ui.label(fmt_time(video.position));
// Timeline fills the space left after reserving room for the total-time
// label and the volume control on the right. Dragging scrubs live (the
// grabber shows the frame under the knob); release commits the seek.
let reserved = 170.0;
let tl_width = (ui.available_width() - reserved).max(80.0);
ui.spacing_mut().slider_width = tl_width;
let mut pos = video.position;
let resp =
ui.add(egui::Slider::new(&mut pos, 0.0..=video.duration.max(0.1)).show_value(false));
if resp.changed() {
video.position = pos; // knob + label follow immediately
video.scrub_request = Some(pos);
if resp.dragged() {
video.scrubbing = true;
} else {
// A click on the track (no drag) → commit right away.
video.seek_request = Some(pos);
video.scrubbing = false;
}
}
if resp.drag_stopped() {
video.seek_request = Some(pos);
video.scrubbing = false;
}
ui.label(fmt_time(video.duration));
ui.separator();
ui.label("🔊");
ui.spacing_mut().slider_width = 90.0;
ui.add_enabled(
video.has_audio,
egui::Slider::new(&mut video.volume, 0.0..=1.0).show_value(false),
);
});
}
// ── World cubemap (skybox) viewer ─────────────────────────────────────────────
/// Show a world cubemap's 6 faces in a labelled grid (D3D9 order). A true
/// interactive skybox is a follow-up — the face data + order are validated, but
/// the wgpu cube-sampling handedness needs visual confirmation first.
fn draw_skybox_grid(ui: &mut egui::Ui, skybox: &SkyboxPreview) {
ui.horizontal(|ui| {
ui.heading("World cubemap");
ui.separator();
ui.label(&skybox.info);
});
ui.label("6 cube faces, D3D9 order (+X X +Y Y +Z Z).");
ui.separator();
egui::ScrollArea::both().show(ui, |ui| {
const CELL: f32 = 240.0;
egui::Grid::new("cube_faces")
.num_columns(3)
.spacing([10.0, 10.0])
.show(ui, |ui| {
for (i, face) in skybox.faces.iter().enumerate() {
ui.vertical(|ui| {
ui.strong(face.label);
let aspect = face.height as f32 / face.width.max(1) as f32;
ui.add(egui::Image::new(egui::load::SizedTexture::new(
face.egui_id,
[CELL, CELL * aspect],
)));
});
if (i + 1) % 3 == 0 {
ui.end_row();
}
}
});
});
}

29
docs/re/INDEX.md Normal file
View File

@@ -0,0 +1,29 @@
# RE knowledge index
Confidence: ✅ `CONFIRMED` · 🟡 `PROBABLE` · ❔ `HYPOTHESIS`. See [README](README.md).
Formats we've already reversed are, for now, **documented by their parser + disc round-trip
tests** (the executable spec) rather than a prose file — the "Spec" column points there.
Promote to a prose `structures/…md` file when a format needs behavioural notes beyond layout.
## Data structures / formats
| Format | Conf. | Spec (parser + tests) | Notes |
|--------|-------|-----------------------|-------|
| IPFB `.pak` archive | ✅ | `sylpheed-formats/src/pak.rs` + `tests/pak_idxd_disc.rs` | header + 12-byte TOC, Z1/zlib payloads |
| name-hash (TOC keys) | ✅ | `sylpheed-formats/src/hash.rs` | Barrett-reduction hash; recovers original paths |
| IDXD object/table | ✅ | `sylpheed-formats/src/idxd.rs` | self-describing; ship/weapon stats verified vs known values |
| XPR2 texture + cubemap | 🟡 | `sylpheed-formats/src/texture.rs` | de-tile + A8R8G8B8; **colours unverified** (dynamic item) |
| T8aD 2D texture | 🟡 | `sylpheed-formats/src/t8ad.rs` | ~85% decode; **colours unverified**; ~15% variants deferred |
| RATC bundle | 🟡 | `sylpheed-formats/src/ratc.rs` | child listing confirmed; one level deep |
| LSTA sprite list | 🟡 | `sylpheed-formats/src/lsta.rs` | inline T8aD frames |
| IXUD subtitle | 🟡 | `sylpheed-formats/src/ixud.rs` | timed cues; **movie↔track link unknown** (dynamic item) |
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
## Functions / code paths
_None documented yet — populated during the dynamic-RE phase._
| Function | Conf. | Reimpl. | Summary |
|----------|-------|---------|---------|
| — | — | — | — |

88
docs/re/README.md Normal file
View File

@@ -0,0 +1,88 @@
# Reverse-engineering knowledge base
This directory is the **spec-side** of the clean-room: it records what the original
*Project Sylpheed* binary **does** (behaviour) and how its **data is laid out**, so that
the Rust port can be implemented **from these specs** without re-deriving anything and
without ever copying original code.
It exists to answer one question fast: *"do we already know how X works, and how sure are we?"*
---
## The one rule that matters
> **Never document a claim more confidently than the evidence supports, and never
> paste original code here.**
A wrong-but-confident note is worse than no note: someone builds on it and the bug hides
for weeks. Every entry therefore carries an explicit **confidence** and its **evidence**.
This mirrors the project method — *measure the oracle, never infer; refute before believing.*
### Clean-room firewall
- ✅ Allowed: behaviour descriptions, field offsets/types, formulas, state machines,
observed input→output pairs, and **references** to the original by address
(`sub_821B68C0`) or to `xenia-rs/sylpheed.db`.
- ❌ Forbidden here and in `crates/`: pasted decompiled C/C++ or verbatim disassembled
function bodies presented as the thing to reimplement. Cite the address; describe the
behaviour in your own words. Disassembly is a tool for *understanding*, not a source to copy.
---
## Confidence levels
| Level | Meaning | Bar to reach it |
|-------|---------|-----------------|
| `CONFIRMED` | Behaviour verified against ground truth. | ≥2 independent observations **or** one observation cross-checked against an oracle (canary framebuffer, a known-correct value, a second code path). |
| `PROBABLE` | Strong single-source inference. | One clean observation, or an unambiguous static read of the disassembly. |
| `HYPOTHESIS` | Educated guess, not yet tested. | Anything else. Must say what would confirm/refute it. |
**Promotion requires new evidence, not re-reading the old evidence.** A `HYPOTHESIS` that
"looks right again" is still a `HYPOTHESIS`. Only an *independent* check promotes it.
If evidence later contradicts an entry, **demote it and record the contradiction** — do
not silently edit the conclusion.
---
## When to document
- **Right after** a function/structure crosses from `HYPOTHESIS` to at least `PROBABLE`
before moving to the next code path, so the knowledge isn't lost or re-derived.
- **Whenever confidence changes** (up or down) — append to the Evidence log, don't overwrite.
- **Not** while it's still a pure guess with no evidence — a one-liner in the relevant
backlog/plan is enough until there's something to stand on.
## What to document
- **Functions/code paths** → `docs/re/functions/<name>.md` (one file per function or tight cluster).
- **Data structures / formats** → `docs/re/structures/<name>.md`.
- Keep the index in [`INDEX.md`](INDEX.md) (one line each: name · confidence · one-line summary).
Use the templates: [`_TEMPLATE.function.md`](_TEMPLATE.function.md),
[`_TEMPLATE.structure.md`](_TEMPLATE.structure.md).
---
## How we find and confirm code paths (the toolchain)
Everything joins on the **guest virtual address (PC)** — code addresses are fixed by the
XEX load, identical across our emulator and canary.
- **Static (cheap, try first):** `xenia-rs/sylpheed.db` (DuckDB: 25 481 functions, xrefs,
strings, vtables, imports). Query with `xenia-rs/zq.py``zq.py grep <str>`,
`zq.py xref <addr>`, `zq.py dis <lo> <hi>`, `zq.py fn <pc>`. Entry points are usually a
**string** (`zq.py grep MSG_DEMO`) or an **import** (movie/XMA API) xref'd back to the loader.
- **Dynamic (when static is ambiguous):** run `xenia-rs` with its probe suite —
`--pc-probe` / `--audit-pc-probe-hex` (fires at block entry), `--mem-watch` (mid-block
reads/writes of a VA), `--lr-trace` (call/return chains), `--trace-instructions`,
`--dump-addr` (read guest memory). These already exist; prefer them over hacking canary.
- **Oracle (correctness ground truth):** canary — the **Wine cross-build**
`xenia-canary/build-cross/bin/Windows/Debug/xenia_canary.exe` (the native Linux ELF
**crashes / does not run** — do not use it). This is the only emulator that reaches the
in-game menu; our `xenia-rs` never got past the intro video. Use canary to *observe output*
(capture its framebuffer for texture colours), not usually to instrument code — though its
`build-cross` toolchain does compile, so small C++ probes + rebuild are possible when needed.
Run **muted, one emulator process at a time**, point it at the real ISO (not the symlink).
> ⚠️ **VA-equality caveat:** join **code** by PC (fixed), but **never** assume a data VA
> holds the same bytes across emulators — allocators differ. Compare data by content/layout.

View File

@@ -0,0 +1,23 @@
# `<function name / sub_XXXXXXXX>`
- **Address:** `0x________` (in `sylpheed.db`; `zq.py fn 0x________`)
- **Confidence:** `HYPOTHESIS | PROBABLE | CONFIRMED`
- **Reimplemented in:** `crates/…/…rs::fn` — or *not yet*
- **Related:** other RE entries, `[[memory-slug]]`
## What it does
One-paragraph behavioural summary, in your own words. No pasted disassembly.
## Signature / calling convention
Inputs (registers/args + meaning), outputs, side effects (memory it writes, globals, imports it calls).
## Behaviour
Step-by-step of the observable behaviour — the *spec* to implement from. Formulas, branches,
state transitions. Cite addresses for detail (`the loop at 0x…`), don't transcribe code.
## Evidence log (append-only; newest last)
- `YYYY-MM-DD`*how we know*: static disasm read / `--lr-trace` from 0x… / canary output /
N observations. What it established. → confidence set to `…`.
## Open questions / what would raise confidence
The specific check that would confirm or refute the current claim.

View File

@@ -0,0 +1,29 @@
# `<structure / format name>`
- **Confidence:** `HYPOTHESIS | PROBABLE | CONFIRMED`
- **Parser in:** `crates/sylpheed-formats/src/…rs` — or *not yet*
- **Seen in:** which pak / asset / memory region
- **Related:** other RE entries, `[[memory-slug]]`
## Layout
```text
offset size type name notes
0x00 4 magic "____"
0x__ 4 u32be …
```
Endianness, alignment, variable-length rules, how child/section sizes are derived.
## Field semantics
What each field *means* and its observed value range. Distinguish **confirmed** fields from
**guessed** ones explicitly (a guessed field with a plausible value is still a guess).
## Coverage / limits
What fraction of real instances this decodes; which variants are deferred and why.
## Evidence log (append-only; newest last)
- `YYYY-MM-DD` — how established (forensic scan over N disc entries / round-trip test /
oracle comparison). → confidence `…`.
## Open questions / what would raise confidence
e.g. "colours unverified until compared against canary's framebuffer."

View File

@@ -0,0 +1,38 @@
# Texture colour interpretation — `k_8_8_8_8` (32bpp UI/HUD textures)
- **Confidence:** mixed — see per-claim tags below.
- **Parser in:** `sylpheed-formats/src/t8ad.rs`, `src/texture.rs` (XPR2)
- **Applies to:** T8aD 2D UI textures, XPR2 32bpp surfaces — the "colours look a bit odd" concern.
- **Related:** [[reborn-dynamic-re-plan]], INDEX rows T8aD / XPR2.
## What this resolves
Whether our decoded 32bpp textures use the right **sRGB-vs-linear** interpretation and the right
**channel order**. Ground truth = Canary's Vulkan texture cache, which is the code that actually
produces correct on-screen colour.
## Findings
### ✅ CONFIRMED — plain `k_8_8_8_8` is LINEAR (UNORM), never sRGB
Canary's host-format table maps plain `k_8_8_8_8``VK_FORMAT_R8G8B8A8_UNORM` with a straight
32-bit copy load shader (`kLoadShaderIndex32bpb`) and an **identity** format swizzle
(`XE_GPU_TEXTURE_SWIZZLE_RGBA`). There is **no** `..._SRGB` host format anywhere in the table;
gamma is a separate explicit path (`k_8_8_8_8_GAMMA_EDRAM` only). Therefore a plain 32bpp texture
is sampled as **raw UNORM bytes — apply no sRGB/gamma decode.**
*Implication:* if the viewer/engine applies an sRGB→linear (or linear→sRGB) step to these, that is
wrong. Show the bytes as-is (raw RGBA8), gamma only where the game explicitly requests it.
### ❔ HYPOTHESIS — exact source **channel order** (needs measurement, do not infer)
Our decoders currently assume on-disk **A8R8G8B8** and reorder `[a,r,g,b] → [r,g,b,a]`. Canary
proves the *host* target is R8G8B8A8 identity-swizzled, but the *guest→host* byte order also depends
on the texture's runtime **endian** field (e.g. `8in32`), which is set by the game at fetch time and
**cannot be read statically** from the format table. So whether our reorder matches is unverified.
*What would confirm/refute it:* one **measured** decoded texel — either capture Canary's
framebuffer over a texture with distinct R≠G≠B pixels of known colour, or patch Canary's already-
present-but-unwired `TextureDump` (`src/xenia/gpu/texture_dump.cc`) to dump the decoded texture and
diff bytes against our decode. **Measure, don't guess** — a symmetric colour (white logo) can't
disambiguate channel order.
## Evidence log
- `2026-07-11` — static read of Canary `src/xenia/gpu/vulkan/vulkan_texture_cache.cc` host-format
table + confirmed no `R8G8B8A8_SRGB` entry exists. → sRGB/UNORM claim `CONFIRMED`; channel-order
claim remains `HYPOTHESIS` pending a measured texel.