[viewer] Surface recovered TOC names in the PAK browser + refresh RE panel

- Move the IDXD->TOC-path resolver into sylpheed-formats as
  IdxdObject::recover_toc_path() so the CLI `pak list` and the GUI pack
  browser share one implementation (behaviour-preserving; CLI still
  resolves 308/1004 on GP_MAIN_GAME_E).
- PAK browser now shows each entry's recovered original path (via the
  name-hash) in green, in both the entry list and the IDXD detail heading.
- Replace the stale welcome "RE Notes" table (mesh/audio "unknown") with an
  accurate reverse-engineered-formats status grid + correct authorship.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-13 20:42:40 +02:00
parent d23339a3aa
commit 390c67cb61
4 changed files with 81 additions and 52 deletions

View File

@@ -707,24 +707,6 @@ fn decode_to_rgba8(tex: &sylpheed_formats::texture::X360Texture) -> Result<Vec<u
use sylpheed_formats::pak::inner_format_label as inner_label; 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
/// candidates for [`sylpheed_formats::hash::recover_toc_name`].
fn idxd_toc_name(obj: &IdxdObject, name_hash: u32) -> Option<String> {
let mut cands: Vec<&str> = Vec::new();
for key in ["ID", "Name", "Model"] {
if let Some(v) = obj.get_raw(key) {
cands.push(v);
}
}
for t in obj.tokens() {
if t.contains('_') || t.len() >= 5 {
cands.push(t.as_str());
}
}
sylpheed_formats::hash::recover_toc_name(name_hash, &cands)
}
fn cmd_pak_list(pak: &Path, idxd_only: bool) -> Result<()> { fn cmd_pak_list(pak: &Path, idxd_only: bool) -> Result<()> {
let arc = PakArchive::open(pak).with_context(|| format!("opening {}", pak.display()))?; let arc = PakArchive::open(pak).with_context(|| format!("opening {}", pak.display()))?;
println!( println!(
@@ -752,7 +734,7 @@ fn cmd_pak_list(pak: &Path, idxd_only: bool) -> Result<()> {
} }
let (detail, name) = if is_idxd { let (detail, name) = if is_idxd {
match IdxdObject::parse(&payload) { match IdxdObject::parse(&payload) {
Ok(o) => (o.identity(), idxd_toc_name(&o, e.name_hash)), Ok(o) => (o.identity(), o.recover_toc_path(e.name_hash)),
Err(_) => (String::new(), None), Err(_) => (String::new(), None),
} }
} else { } else {

View File

@@ -183,6 +183,27 @@ impl IdxdObject {
} }
format!("schema {:08x}", self.schema_hash) format!("schema {:08x}", self.schema_hash)
} }
/// Recover this entry's original TOC path (e.g. `unit\rou_f001.tbl`) from its
/// identity/pool tokens by re-hashing candidates against `name_hash` with the
/// known path schemes. Returns `None` when no scheme reproduces the hash.
///
/// Shared by the CLI `pak list` and the GUI pack browser so both surface the
/// same recovered names. See [`crate::hash::recover_toc_name`].
pub fn recover_toc_path(&self, name_hash: u32) -> Option<String> {
let mut cands: Vec<&str> = Vec::new();
for key in ["ID", "Name", "Model"] {
if let Some(v) = self.get_raw(key) {
cands.push(v);
}
}
for t in self.tokens() {
if t.contains('_') || t.len() >= 5 {
cands.push(t.as_str());
}
}
crate::hash::recover_toc_name(name_hash, &cands)
}
} }
/// Extract the trailing string pool. Finds the smallest offset whose suffix is /// Extract the trailing string pool. Finds the smallest offset whose suffix is

View File

@@ -130,6 +130,9 @@ pub struct PakRow {
pub format: String, pub format: String,
/// Short identity (`ID=…` / `Name=…`), empty for non-IDXD entries. /// Short identity (`ID=…` / `Name=…`), empty for non-IDXD entries.
pub identity: String, pub identity: String,
/// Recovered original TOC path (e.g. `unit\rou_f001.tbl`) via the name-hash,
/// when a known scheme reproduces `hash`. `None` = unresolved (~70% of entries).
pub name: Option<String>,
/// Parsed IDXD detail for the property table, when the entry is an object. /// Parsed IDXD detail for the property table, when the entry is an object.
pub detail: Option<PakDetail>, pub detail: Option<PakDetail>,
/// Decoded presentable payload for the detail pane (subtitle / font / image /// Decoded presentable payload for the detail pane (subtitle / font / image
@@ -799,6 +802,7 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
size: e.comp_size as usize, size: e.comp_size as usize,
format: "(not decoded)".into(), format: "(not decoded)".into(),
identity: String::new(), identity: String::new(),
name: None,
detail: None, detail: None,
content: PakContent::None, content: PakContent::None,
}); });
@@ -808,7 +812,7 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
Ok(payload) => { Ok(payload) => {
decoded_budget += payload.len(); decoded_budget += payload.len();
let format = inner_format_label(&payload); let format = inner_format_label(&payload);
let (identity, detail) = if IdxdObject::is_idxd(&payload) { let (identity, name, detail) = if IdxdObject::is_idxd(&payload) {
match IdxdObject::parse(&payload) { match IdxdObject::parse(&payload) {
Ok(obj) => { Ok(obj) => {
let fields = obj let fields = obj
@@ -818,6 +822,7 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
.collect(); .collect();
( (
obj.identity(), obj.identity(),
obj.recover_toc_path(e.name_hash),
Some(PakDetail { Some(PakDetail {
schema_hash: obj.schema_hash, schema_hash: obj.schema_hash,
count: obj.count, count: obj.count,
@@ -825,10 +830,10 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
}), }),
) )
} }
Err(_) => (String::new(), None), Err(_) => (String::new(), None, None),
} }
} else { } else {
(String::new(), None) (String::new(), None, None)
}; };
// Presentable content for non-IDXD entries (subtitle/font/png/text). // Presentable content for non-IDXD entries (subtitle/font/png/text).
let content = if detail.is_some() { let content = if detail.is_some() {
@@ -842,6 +847,7 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
size: payload.len(), size: payload.len(),
format, format,
identity, identity,
name,
detail, detail,
content, content,
}); });
@@ -852,6 +858,7 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
size: e.comp_size as usize, size: e.comp_size as usize,
format: "(read error)".into(), format: "(read error)".into(),
identity: err.to_string(), identity: err.to_string(),
name: None,
detail: None, detail: None,
content: PakContent::None, content: PakContent::None,
}), }),

View File

@@ -374,7 +374,7 @@ fn draw_viewer_ui(
// No file selected — show welcome / RE notes // No file selected — show welcome / RE notes
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Project Sylpheed: Arc of Deception"); ui.heading("Project Sylpheed: Arc of Deception");
ui.label("Asset Viewer — Milestone 1"); ui.label("Asset Viewer · Game Arts / SETA · Square Enix (Xbox 360)");
ui.separator(); ui.separator();
ui.collapsing("Getting Started", |ui| { ui.collapsing("Getting Started", |ui| {
@@ -385,37 +385,48 @@ fn draw_viewer_ui(
ui.code(" xdvdfs unpack sylpheed.iso ./assets/"); ui.code(" xdvdfs unpack sylpheed.iso ./assets/");
ui.code(" File → Open extracted folder…"); ui.code(" File → Open extracted folder…");
ui.separator(); ui.separator();
ui.label("Click a .xpr file in the left panel to preview the texture."); ui.label(
"Open a .pak to browse its entries, a .xpr for textures, \
an .xbg mesh for the 3D preview, or a movie .wmv to play it.",
);
}); });
ui.collapsing("Reverse Engineering Notes", |ui| { ui.collapsing("Reverse-engineered formats", |ui| {
ui.label("Document your findings here as you RE the formats."); let done = egui::Color32::LIGHT_GREEN;
ui.separator(); let partial = egui::Color32::from_rgb(150, 210, 255);
egui::Grid::new("re_notes").striped(true).show(ui, |ui| { let todo = egui::Color32::YELLOW;
ui.strong("Extension"); egui::Grid::new("re_notes").striped(true).num_columns(3).show(ui, |ui| {
ui.strong("Format");
ui.strong("Status"); ui.strong("Status");
ui.strong("Notes"); ui.strong("Notes");
ui.end_row(); ui.end_row();
ui.label(".xpr"); let mut row = |ui: &mut egui::Ui, fmt: &str, c, status: &str, notes: &str| {
ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Partial"); ui.label(fmt);
ui.label("XPR2 container + DXT1/3/5 textures"); ui.colored_label(c, status);
ui.end_row(); ui.label(notes);
ui.end_row();
ui.label("?"); };
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO"); row(ui, "IPFB .pak/.pNN", done, "✓ done",
ui.label("Mesh format — need to identify extension"); "Archive TOC + name-hash recovered (paths resolved)");
ui.end_row(); row(ui, "IDXD objects", partial, "◑ most",
"Reflective ship/weapon/effect defs; some fields defaulted in-code");
ui.label("?"); row(ui, "XBG7 mesh", done, "✓ done",
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO"); "3D models — position + normal + UV from vertex decl");
ui.label("Audio format — likely XWB wave banks"); row(ui, "XPR2 texture", done, "✓ done",
ui.end_row(); "A8R8G8B8 / DXT1/3/5, de-tiled; 2D + cubemaps");
row(ui, "T8aD / RATC / LSTA", partial, "◑ most",
ui.label("?"); "2D UI textures, bundles, sprite lists (colours unverified)");
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO"); row(ui, "IXUD subtitles", done, "✓ done",
ui.label("Mission/level data — format unknown"); "Localized movie subtitle cue tables");
ui.end_row(); row(ui, "Font (OTF/TTF/ttcf)", done, "✓ done",
"Embedded subtitle fonts — metadata + glyph sample");
row(ui, "WMV cutscene", done, "✓ done",
"wmv3 / wmapro playback with transport + subtitles");
row(ui, "XISO disc", done, "✓ done",
"XDVDFS filesystem — direct ISO browsing");
row(ui, "XMA audio", todo, "⏳ wip",
"Xbox 360 XMA → PCM (scaffold)");
}); });
}); });
}); });
@@ -456,13 +467,17 @@ fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) {
.id_salt("pak_list") .id_salt("pak_list")
.show(&mut cols[0], |ui| { .show(&mut cols[0], |ui| {
for (i, row) in rows.iter().enumerate() { for (i, row) in rows.iter().enumerate() {
let text = egui::RichText::new(format!( // Prefer the recovered TOC path (name-hash) over the generic
// kind hint; colour resolved names green so they stand out.
let label = row.name.clone().unwrap_or_else(|| row_kind(row));
let mut text = egui::RichText::new(format!(
"{:08x} {:<7} {}", "{:08x} {:<7} {}",
row.hash, row.hash, row.format, label
row.format,
row_kind(row)
)) ))
.monospace(); .monospace();
if row.name.is_some() {
text = text.color(egui::Color32::LIGHT_GREEN);
}
if ui if ui
.add(egui::SelectableLabel::new(*selected == Some(i), text)) .add(egui::SelectableLabel::new(*selected == Some(i), text))
.clicked() .clicked()
@@ -536,6 +551,10 @@ fn draw_idxd_detail(ui: &mut egui::Ui, row: &crate::iso_loader::PakRow) {
row.identity.as_str() row.identity.as_str()
}; };
ui.heading(title); ui.heading(title);
// Recovered original path (name-hash) — the entry's real TOC key.
if let Some(name) = &row.name {
ui.colored_label(egui::Color32::LIGHT_GREEN, format!("📄 {name}"));
}
ui.label(format!("schema 0x{:08x} count {}", d.schema_hash, d.count)); ui.label(format!("schema 0x{:08x} count {}", d.schema_hash, d.count));
ui.label(format!("{} bytes hash {:08x}", row.size, row.hash)); ui.label(format!("{} bytes hash {:08x}", row.size, row.hash));
ui.separator(); ui.separator();