From 77cd58202baad0114884dd9eb92b5e7aabe3c82a Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Fri, 28 Aug 2026 16:47:46 +0200 Subject: [PATCH] viewer: a Cutscenes browser -- the manifest was invisible plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `movie_manifest` has been parsed since the movie-voice work and rendered nowhere: it resolved a voice bank and that was all. So the only way to find a cutscene was to hunt `.wmv` files in the ISO tree, where nothing tells you which mission a file belongs to, whether it has subtitles, or what is said in it. View ▸ Cutscenes lists all 104 manifest slots with mission/phase, kind, movie, subtitle track, voice token and telop, and -- the part that needed no new parsing, only a route -- resolves the captions to a readable TRANSCRIPT with a language selector. Subtitles were previously burned into the video during playback and reachable no other way. Three negatives are shown rather than smoothed over: * 5 manifest-bound movies have no `.wmv` (logo1-4 and an encoder test clip). They are marked and get no Play button instead of one that would fail. * 9 of 101 movies resolve no English transcript. * the `.prt` telop overlay is named by the manifest and we have no parser, so the reference is shown labelled "not decoded" rather than omitted. `cutscene_catalog_binds_movies_and_transcripts` pins all of it against the disc -- 104/101/99/99/22, the exact absent-movie list, 92 transcripts -- because a browser that quietly dropped these would look complete and be wrong. The counts independently reproduce docs/re/movie-subtitle-link.md. Play routes through the normal FileSelected path, so the existing video player handles it exactly as it would from the tree. Co-Authored-By: Claude Opus 5 --- crates/sylpheed-formats/src/movie_subtitle.rs | 4 +- .../tests/movie_manifest_disc.rs | 59 ++++ crates/sylpheed-viewer/src/iso_loader.rs | 196 +++++++++++++- crates/sylpheed-viewer/src/ui.rs | 253 +++++++++++++++++- 4 files changed, 503 insertions(+), 9 deletions(-) diff --git a/crates/sylpheed-formats/src/movie_subtitle.rs b/crates/sylpheed-formats/src/movie_subtitle.rs index dcc03b3..7a0e112 100644 --- a/crates/sylpheed-formats/src/movie_subtitle.rs +++ b/crates/sylpheed-formats/src/movie_subtitle.rs @@ -25,8 +25,10 @@ use crate::pak::PakArchive; /// Subtitle language. `pak_code` selects `dat/movie/.pak`; `game_code` /// selects `dat/GP_MAIN_GAME_.pak` (the caption text). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SubLang { + /// Default only because the disc's own default is English. + #[default] English, Japanese, German, diff --git a/crates/sylpheed-formats/tests/movie_manifest_disc.rs b/crates/sylpheed-formats/tests/movie_manifest_disc.rs index c8f149f..bad81ba 100644 --- a/crates/sylpheed-formats/tests/movie_manifest_disc.rs +++ b/crates/sylpheed-formats/tests/movie_manifest_disc.rs @@ -167,3 +167,62 @@ fn manifest_slot_and_movie_counts() { // Every id names a real record, so nothing dangles. assert!(entries.iter().all(|e| !e.slot.is_empty() && !e.movie.is_empty())); } + +/// Everything the Cutscenes browser shows, asserted against the disc. +/// +/// The window's value is that it answers "which cutscenes exist, which mission +/// is each one in, and can I read it without playing it" — so the test checks +/// exactly those three, including the **negative**: five manifest-bound movies +/// have no `.wmv`, and nine more have no English transcript. A browser that +/// quietly omitted them would look complete and be wrong, so the counts are +/// pinned here rather than left to the eye. +#[test] +fn cutscene_catalog_binds_movies_and_transcripts() { + let Some(root) = disc_root() else { + eprintln!("SKIP: set SYLPHEED_DISC"); + return; + }; + use sylpheed_formats::movie_subtitle as ms; + let (manifest, _) = load_manifest_and_sounds(&root); + let rows = movie_manifest::parse(&manifest); + assert_eq!(rows.len(), 104, "manifest slots"); + + let movies: std::collections::BTreeSet<&str> = + rows.iter().map(|r| r.movie.as_str()).collect(); + assert_eq!(movies.len(), 101, "distinct movies"); + assert_eq!(rows.iter().filter(|r| r.subtitle.is_some()).count(), 99); + assert_eq!(rows.iter().filter(|r| r.voice_token.is_some()).count(), 99); + assert_eq!(rows.iter().filter(|r| r.telop.is_some()).count(), 22); + + // Bound-but-absent: the four boot logos and one encoder test clip are named + // by the manifest and are not on the disc. The browser marks these in red + // rather than offering a Play button that would fail. + let on_disc: std::collections::BTreeSet = std::fs::read_dir(root.join("dat/movie")) + .unwrap() + .filter_map(|e| { + let p = e.ok()?.path(); + (p.extension()?.to_str()? == "wmv") + .then(|| p.file_stem()?.to_str().map(|s| s.to_ascii_lowercase()))? + }) + .collect(); + let mut absent: Vec<&str> = movies + .iter() + .copied() + .filter(|m| !on_disc.contains(&m.to_ascii_lowercase())) + .collect(); + absent.sort_unstable(); + assert_eq!( + absent, + ["SYLPH_HD720p_8M-CBR_2ch", "logo1", "logo2", "logo3", "logo4"], + "manifest-bound movies with no .wmv on the disc" + ); + + // Transcripts, which is what makes a cutscene readable without playback. + let lang_pak = PakArchive::open(root.join("dat/movie/eng.pak")).unwrap(); + let text_pak = PakArchive::open(root.join("dat/GP_MAIN_GAME_E.pak")).unwrap(); + let resolved = movies + .iter() + .filter(|m| !ms::load(m, &lang_pak, &text_pak).is_empty()) + .count(); + assert_eq!(resolved, 92, "movies with an English transcript"); +} diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index b1827be..45cac70 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -799,6 +799,56 @@ pub struct RequestScreenCompose { pub hidden: Vec, } +// ── Cutscenes (View ▸ Cutscenes) ───────────────────────────────────────────── + +/// One row of the cutscene manifest, flattened for display. +/// +/// The manifest has been parsed since the movie-voice work but was never shown: +/// it was plumbing that resolved a voice bank and nothing else, so the only way +/// to find a cutscene was to hunt `.wmv` files in the ISO tree — with no way to +/// know which mission a file belongs to, or whether it has subtitles at all. +#[derive(Clone)] +pub struct CutsceneRow { + /// Manifest slot name, e.g. `STAGE10_PHASE01`. + pub slot: String, + /// `System` / `Intro` / `Phase` / `PhaseEnd` / `Supply`. + pub kind: &'static str, + pub mission: Option, + pub phase: Option, + /// Movie basename, e.g. `S02A`. The file is `dat/movie/.wmv`. + pub movie: String, + pub voice_token: Option, + pub subtitle: Option, + /// On-screen text overlay (`.prt`). Bound by the manifest; we have no `.prt` + /// parser, so the reference is shown and the content is not. + pub telop: Option, + /// Whether `dat/movie/.wmv` is actually on the disc. + pub present: bool, +} + +/// State of the cutscene browser. +#[derive(Resource, Default)] +pub struct CutsceneBrowser { + pub open: bool, + pub loading: bool, + pub loaded: bool, + pub filter: String, + pub rows: Vec, + pub selected: Option, + /// Subtitle language for the transcript pane. + pub lang: sylpheed_formats::movie_subtitle::SubLang, + /// Resolved captions for `selected`, newest request wins. + pub cues: Vec, + pub cues_loading: bool, + pub cues_generation: u64, + /// Set by the window to ask for the selected row's transcript. + pub want_cues: bool, +} + +/// Ask the loader to read the cutscene manifest from `tables.pak`. +#[derive(Event, Default)] +pub struct RequestCutscenes; + // ── Save files (View ▸ Save File) ──────────────────────────────────────────── /// One GHAD field, flattened for display with its confidence. @@ -962,6 +1012,13 @@ enum IsoLoaderMsg { generation: u64, wav: Option<(PathBuf, f32)>, }, + /// The cutscene manifest, flattened for the browser. + CutscenesLoaded(Vec), + /// One cutscene's resolved captions. `generation` drops a stale result. + CutsceneCuesLoaded { + generation: u64, + cues: Vec, + }, /// The sound-bank library enumerated from `\sounds.tbl`. AudioLibraryLoaded { entries: Vec, @@ -1197,6 +1254,7 @@ impl Plugin for IsoLoaderPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -1205,6 +1263,7 @@ impl Plugin for IsoLoaderPlugin { .add_event::() .add_event::() .add_event::() + .add_event::() .add_event::() .add_event::() .add_event::() @@ -1239,11 +1298,13 @@ impl Plugin for IsoLoaderPlugin { handle_audio_request, advance_audio_playback, handle_audio_library_request, + // Nested: Bevy's system tuples cap at 20 elements, and + // the chain is one ordered sequence either way. + (handle_cutscene_request, handle_cutscene_cues_request).chain(), handle_game_data_request, handle_ship_catalog_request, handle_ship_render_request, - handle_screen_catalog_request, - handle_screen_compose_request, + (handle_screen_catalog_request, handle_screen_compose_request).chain(), handle_save_open_request, ) .chain() @@ -2097,6 +2158,7 @@ fn poll_loader_channel( mut mvoice: ResMut, mut audio_preview: ResMut, mut audio_lib: ResMut, + mut cutscenes: ResMut, mut game_data: ResMut, mut ships: ResMut, mut screens: ResMut, @@ -2205,6 +2267,23 @@ fn poll_loader_channel( } } } + Ok(IsoLoaderMsg::CutscenesLoaded(rows)) => { + cutscenes.loading = false; + // The manifest always names ~104 slots, so an empty result means + // the read failed — leave `loaded=false` so re-opening retries. + if rows.is_empty() { + cutscenes.loaded = false; + } else { + cutscenes.rows = rows; + cutscenes.loaded = true; + } + } + Ok(IsoLoaderMsg::CutsceneCuesLoaded { generation, cues }) => { + if generation == cutscenes.cues_generation { + cutscenes.cues_loading = false; + cutscenes.cues = cues; + } + } Ok(IsoLoaderMsg::AudioLibraryLoaded { entries }) => { audio_lib.loading = false; // sounds.tbl always names thousands of banks, so an empty result @@ -2280,6 +2359,8 @@ fn poll_loader_channel( game_data.loading = false; ships.loading = false; audio_lib.loading = false; + cutscenes.loading = false; + cutscenes.cues_loading = false; iso_state.error = Some("the asset loader stopped responding".into()); break; } @@ -4820,6 +4901,117 @@ fn build_ship_model( } } +/// Handles a [`RequestCutscenes`]: reads the cutscene manifest out of +/// `tables.pak` and checks each movie against the disc, off-thread. +#[cfg(not(target_arch = "wasm32"))] +fn handle_cutscene_request( + mut events: EventReader, + iso_state: Res, + browser: Res, + channels: Res, + mut cut: ResMut, +) { + if events.read().next().is_none() { + return; + } + if cut.loaded || cut.loading { + return; + } + cut.loading = true; + let source = iso_state.source_kind.clone(); + let files = browser.files.clone(); + let sender = channels.sender.clone(); + std::thread::spawn(move || { + let rows = build_cutscene_catalog(&source, &files); + let _ = sender.send(IsoLoaderMsg::CutscenesLoaded(rows)); + }); +} + +#[cfg(not(target_arch = "wasm32"))] +fn build_cutscene_catalog(source: &SourceKind, files: &[String]) -> Vec { + use sylpheed_formats::movie_manifest::{self, MovieKind}; + let Ok(tpak) = read_pak_archive_blocking(source, "dat/tables.pak") else { + return Vec::new(); + }; + let Some(manifest) = tpak + .entries() + .iter() + .find_map(|e| tpak.read(e).ok().filter(|b| movie_manifest::is_manifest(b))) + else { + return Vec::new(); + }; + // Which `.wmv` are actually on the disc, lowercased for the comparison. + let on_disc: std::collections::BTreeSet = files + .iter() + .filter_map(|f| { + let l = f.to_ascii_lowercase(); + l.strip_suffix(".wmv") + .and_then(|s| s.rsplit('/').next()) + .map(str::to_string) + }) + .collect(); + movie_manifest::parse(&manifest) + .into_iter() + .map(|e| CutsceneRow { + kind: match e.kind { + MovieKind::System => "System", + MovieKind::Intro => "Intro", + MovieKind::Phase => "Phase", + MovieKind::PhaseEnd => "Phase end", + MovieKind::Supply => "Supply", + }, + present: on_disc.contains(&e.movie.to_ascii_lowercase()), + slot: e.slot, + mission: e.mission, + phase: e.phase, + movie: e.movie, + voice_token: e.voice_token, + subtitle: e.subtitle, + telop: e.telop, + }) + .collect() +} + +/// Resolves the selected cutscene's captions to text, so a transcript can be +/// read **without playing the video** — which was the only route before. +#[cfg(not(target_arch = "wasm32"))] +fn handle_cutscene_cues_request( + iso_state: Res, + channels: Res, + mut cut: ResMut, +) { + if !cut.want_cues { + return; + } + cut.want_cues = false; + let Some(row) = cut.selected.and_then(|i| cut.rows.get(i)) else { + return; + }; + let movie = row.movie.clone(); + cut.cues_generation = cut.cues_generation.wrapping_add(1); + cut.cues_loading = true; + cut.cues.clear(); + let generation = cut.cues_generation; + let lang = cut.lang; + let source = iso_state.source_kind.clone(); + let sender = channels.sender.clone(); + std::thread::spawn(move || { + let cues = (|| -> Result, String> { + let lang_pak = + read_pak_archive_blocking(&source, &format!("dat/movie/{}.pak", lang.pak_code()))?; + let text_pak = read_pak_archive_blocking( + &source, + &format!("dat/GP_MAIN_GAME_{}.pak", lang.game_code()), + )?; + Ok(sylpheed_formats::movie_subtitle::load( + &movie, &lang_pak, &text_pak, + )) + })() + .unwrap_or_default(); + let _ = sender.send(IsoLoaderMsg::CutsceneCuesLoaded { generation, cues }); + }); +} + /// Handles a [`RequestAudioLibrary`]: reads `tables.pak`'s `\sounds.tbl` /// and enumerates every named sound bank off-thread. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index 398a328..eb4e238 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -10,12 +10,12 @@ use bevy::prelude::*; use bevy_egui::{egui, EguiContexts}; use crate::iso_loader::{ - AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview, - MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir, - RequestOpenIso, RequestSaveOpen, RequestScreenCatalog, RequestScreenCompose, - RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestAudioLibrary, SaveBrowser, - ScreenBrowser, ShipBrowser, SkyboxPreview, TextPreview, TexturePreview, - VideoPreview, AudioLibrary, IsoLoaderSystemSet, + AudioLibrary, AudioPreview, CutsceneBrowser, FileInfo, FileSelected, GameCategory, GameData, + ImageRgba, IsoLoaderSystemSet, IsoState, ModelPreview, MovieSubtitles, MovieVoice, PakContent, + PakView, RequestAudio, RequestAudioLibrary, RequestCutscenes, RequestGameData, RequestOpenDir, + RequestOpenIso, RequestSaveOpen, RequestScreenCatalog, RequestScreenCompose, RequestShipCatalog, + RequestShipRender, RequestSubtitles, SaveBrowser, ScreenBrowser, ShipBrowser, SkyboxPreview, + TextPreview, TexturePreview, VideoPreview, }; use crate::ViewerState; use sylpheed_formats::SubLang; @@ -37,6 +37,7 @@ impl Plugin for ViewerUiPlugin { app.add_systems(Update, draw_ships_ui.after(IsoLoaderSystemSet)); app.add_systems(Update, draw_screens_ui.after(IsoLoaderSystemSet)); app.add_systems(Update, draw_save_ui.after(IsoLoaderSystemSet)); + app.add_systems(Update, draw_cutscenes_ui.after(IsoLoaderSystemSet)); } } } @@ -153,6 +154,7 @@ struct UiEvents<'w> { ships: EventWriter<'w, RequestShipCatalog>, screens: EventWriter<'w, RequestScreenCatalog>, save: EventWriter<'w, RequestSaveOpen>, + cutscenes: EventWriter<'w, RequestCutscenes>, } fn draw_viewer_ui( @@ -228,6 +230,12 @@ fn draw_viewer_ui( events.screens.send_default(); ui.close_menu(); } + if ui.button("🎬 Cutscenes…").clicked() { + // The manifest binds every cutscene slot to its movie, + // subtitle track, voice token and telop overlay. + events.cutscenes.send_default(); + ui.close_menu(); + } if ui.button("💾 Save File…").clicked() { // A save is not on the disc — it lives in the emulator's // content tree, so this opens a file dialog. @@ -2391,3 +2399,236 @@ fn draw_save_ui( } saves.open &= open; } + +// ── Cutscene browser (View ▸ Cutscenes) ────────────────────────────────────── + +/// The cutscene catalog: every slot the manifest binds, with its movie, +/// subtitle track, voice token and telop overlay — and a transcript pane that +/// resolves the captions to text **without playing the video**. +/// +/// Before this the manifest was invisible plumbing: it resolved a voice bank and +/// was never rendered, so a cutscene could only be found by hunting `.wmv` in +/// the ISO tree, where nothing says which mission a file belongs to. +#[cfg(not(target_arch = "wasm32"))] +fn draw_cutscenes_ui( + mut contexts: EguiContexts, + mut cut: ResMut, + mut requests: EventReader, + mut file_selected: EventWriter, + mut browser: ResMut, +) { + if requests.read().next().is_some() { + cut.open = true; + } + if !cut.open { + return; + } + let ctx = contexts.ctx_mut().clone(); + let mut open = true; + let mut pick: Option = None; + let mut play: Option = None; + + egui::Window::new("🎬 Cutscenes") + .default_width(880.0) + .default_height(560.0) + .open(&mut open) + .show(&ctx, |ui| { + if cut.loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Reading the cutscene manifest…"); + }); + ctx.request_repaint(); + return; + } + if !cut.loaded { + ui.label("Open a game source first (File ▸ Open…)."); + return; + } + + ui.horizontal(|ui| { + ui.label("🔍"); + ui.text_edit_singleline(&mut cut.filter); + if !cut.filter.is_empty() && ui.small_button("✖").clicked() { + cut.filter.clear(); + } + ui.separator(); + ui.label("Subtitles:"); + let before = cut.lang; + egui::ComboBox::from_id_salt("cutscene_lang") + .selected_text(cut.lang.label()) + .show_ui(ui, |ui| { + for l in sylpheed_formats::movie_subtitle::SubLang::ALL { + ui.selectable_value(&mut cut.lang, l, l.label()); + } + }); + if cut.lang != before && cut.selected.is_some() { + cut.want_cues = true; + } + }); + let missing = cut.rows.iter().filter(|r| !r.present).count(); + ui.label( + egui::RichText::new(format!( + "{} slots · {} distinct movies{}", + cut.rows.len(), + cut.rows + .iter() + .map(|r| r.movie.as_str()) + .collect::>() + .len(), + if missing > 0 { + format!(" · ⚠ {missing} bound to a movie not on the disc") + } else { + String::new() + } + )) + .weak() + .small(), + ); + ui.separator(); + + let filter = cut.filter.to_lowercase(); + egui::SidePanel::left("cutscene_list") + .resizable(true) + .default_width(330.0) + .show_inside(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + egui::Grid::new("cutscene_grid") + .striped(true) + .num_columns(2) + .show(ui, |ui| { + for (i, r) in cut.rows.iter().enumerate() { + let hay = format!("{} {} {}", r.slot, r.movie, r.kind) + .to_lowercase(); + if !filter.is_empty() && !hay.contains(&filter) { + continue; + } + let label = match (r.mission, r.phase) { + (Some(m), Some(p)) => format!("S{m:02} ph{p} {}", r.movie), + (Some(m), None) => format!("S{m:02} {}", r.movie), + _ => format!(" {}", r.movie), + }; + let mut text = egui::RichText::new(label).monospace(); + if !r.present { + text = text.color(egui::Color32::from_rgb(224, 86, 122)); + } + if ui + .selectable_label(cut.selected == Some(i), text) + .on_hover_text(&r.slot) + .clicked() + { + pick = Some(i); + } + ui.label( + egui::RichText::new(r.kind).weak().small(), + ); + ui.end_row(); + } + }); + }); + }); + + egui::CentralPanel::default().show_inside(ui, |ui| { + let Some(row) = cut.selected.and_then(|i| cut.rows.get(i)) else { + ui.label("Pick a cutscene on the left."); + return; + }; + ui.horizontal(|ui| { + ui.heading(&row.slot); + if row.present { + if ui.button("▶ Play").clicked() { + play = Some(format!("dat/movie/{}.wmv", row.movie)); + } + } else { + ui.colored_label( + egui::Color32::from_rgb(224, 86, 122), + "⚠ not on the disc", + ); + } + }); + egui::Grid::new("cutscene_detail") + .num_columns(2) + .striped(true) + .show(ui, |ui| { + let mut kv = |k: &str, v: String| { + ui.label(egui::RichText::new(k).weak().small()); + ui.label(egui::RichText::new(v).monospace()); + ui.end_row(); + }; + kv("kind", row.kind.to_string()); + kv("movie", format!("dat/movie/{}.wmv", row.movie)); + if let (Some(m), Some(p)) = (row.mission, row.phase) { + kv("mission", format!("S{m:02}, phase {p}")); + } else if let Some(m) = row.mission { + kv("mission", format!("S{m:02}")); + } + kv( + "subtitle", + row.subtitle.clone().unwrap_or_else(|| "—".into()), + ); + kv( + "voice track", + row.voice_token.clone().unwrap_or_else(|| "—".into()), + ); + // The .prt overlay is bound here but we have no parser, + // so name the reference and say the content is not read. + kv( + "telop (.prt)", + match &row.telop { + Some(t) => format!("{t} (not decoded)"), + None => "—".into(), + }, + ); + }); + ui.separator(); + ui.label(egui::RichText::new("Transcript").strong()); + if cut.cues_loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("resolving captions…"); + }); + ctx.request_repaint(); + } else if cut.cues.is_empty() { + ui.label( + egui::RichText::new( + "no captions resolved for this movie in this language", + ) + .weak(), + ); + } else { + egui::ScrollArea::vertical() + .id_salt("cue_scroll") + .show(ui, |ui| { + egui::Grid::new("cue_grid").num_columns(2).striped(true).show( + ui, + |ui| { + for c in &cut.cues { + ui.label( + egui::RichText::new(fmt_time(c.start)) + .monospace() + .weak() + .small(), + ); + ui.label(&c.text); + ui.end_row(); + } + }, + ); + }); + } + }); + }); + + if let Some(i) = pick { + cut.selected = Some(i); + cut.want_cues = true; + } + if let Some(path) = play { + // Route through the normal file-open path, so the existing video player + // handles it exactly as it would from the tree. + browser.loading = true; + browser.selected = browser.files.iter().position(|f| f.eq_ignore_ascii_case(&path)); + file_selected.send(FileSelected(path)); + } + cut.open = open; +}