[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;
/// 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<()> {
let arc = PakArchive::open(pak).with_context(|| format!("opening {}", pak.display()))?;
println!(
@@ -752,7 +734,7 @@ fn cmd_pak_list(pak: &Path, idxd_only: bool) -> Result<()> {
}
let (detail, name) = if is_idxd {
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),
}
} else {

View File

@@ -183,6 +183,27 @@ impl IdxdObject {
}
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

View File

@@ -130,6 +130,9 @@ pub struct PakRow {
pub format: String,
/// Short identity (`ID=…` / `Name=…`), empty for non-IDXD entries.
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.
pub detail: Option<PakDetail>,
/// 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,
format: "(not decoded)".into(),
identity: String::new(),
name: None,
detail: None,
content: PakContent::None,
});
@@ -808,7 +812,7 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
Ok(payload) => {
decoded_budget += payload.len();
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) {
Ok(obj) => {
let fields = obj
@@ -818,6 +822,7 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
.collect();
(
obj.identity(),
obj.recover_toc_path(e.name_hash),
Some(PakDetail {
schema_hash: obj.schema_hash,
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 {
(String::new(), None)
(String::new(), None, None)
};
// Presentable content for non-IDXD entries (subtitle/font/png/text).
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(),
format,
identity,
name,
detail,
content,
});
@@ -852,6 +858,7 @@ fn build_pak_rows(index: &[u8], data: Vec<u8>) -> Result<Vec<PakRow>, String> {
size: e.comp_size as usize,
format: "(read error)".into(),
identity: err.to_string(),
name: None,
detail: None,
content: PakContent::None,
}),

View File

@@ -374,7 +374,7 @@ fn draw_viewer_ui(
// No file selected — show welcome / RE notes
egui::CentralPanel::default().show(ctx, |ui| {
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.collapsing("Getting Started", |ui| {
@@ -385,37 +385,48 @@ fn draw_viewer_ui(
ui.code(" xdvdfs unpack sylpheed.iso ./assets/");
ui.code(" File → Open extracted folder…");
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.label("Document your findings here as you RE the formats.");
ui.separator();
egui::Grid::new("re_notes").striped(true).show(ui, |ui| {
ui.strong("Extension");
ui.collapsing("Reverse-engineered formats", |ui| {
let done = egui::Color32::LIGHT_GREEN;
let partial = egui::Color32::from_rgb(150, 210, 255);
let todo = egui::Color32::YELLOW;
egui::Grid::new("re_notes").striped(true).num_columns(3).show(ui, |ui| {
ui.strong("Format");
ui.strong("Status");
ui.strong("Notes");
ui.end_row();
ui.label(".xpr");
ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Partial");
ui.label("XPR2 container + DXT1/3/5 textures");
ui.end_row();
ui.label("?");
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
ui.label("Mesh format — need to identify extension");
ui.end_row();
ui.label("?");
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
ui.label("Audio format — likely XWB wave banks");
ui.end_row();
ui.label("?");
ui.colored_label(egui::Color32::YELLOW, "⏳ TODO");
ui.label("Mission/level data — format unknown");
ui.end_row();
let mut row = |ui: &mut egui::Ui, fmt: &str, c, status: &str, notes: &str| {
ui.label(fmt);
ui.colored_label(c, status);
ui.label(notes);
ui.end_row();
};
row(ui, "IPFB .pak/.pNN", done, "✓ done",
"Archive TOC + name-hash recovered (paths resolved)");
row(ui, "IDXD objects", partial, "◑ most",
"Reflective ship/weapon/effect defs; some fields defaulted in-code");
row(ui, "XBG7 mesh", done, "✓ done",
"3D models — position + normal + UV from vertex decl");
row(ui, "XPR2 texture", done, "✓ done",
"A8R8G8B8 / DXT1/3/5, de-tiled; 2D + cubemaps");
row(ui, "T8aD / RATC / LSTA", partial, "◑ most",
"2D UI textures, bundles, sprite lists (colours unverified)");
row(ui, "IXUD subtitles", done, "✓ done",
"Localized movie subtitle cue tables");
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")
.show(&mut cols[0], |ui| {
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} {}",
row.hash,
row.format,
row_kind(row)
row.hash, row.format, label
))
.monospace();
if row.name.is_some() {
text = text.color(egui::Color32::LIGHT_GREEN);
}
if ui
.add(egui::SelectableLabel::new(*selected == Some(i), text))
.clicked()
@@ -536,6 +551,10 @@ fn draw_idxd_detail(ui: &mut egui::Ui, row: &crate::iso_loader::PakRow) {
row.identity.as_str()
};
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!("{} bytes hash {:08x}", row.size, row.hash));
ui.separator();