viewer: a Cutscenes browser -- the manifest was invisible plumbing

`movie_manifest` has been parsed since the movie-voice work and rendered
nowhere: it resolved a voice bank and that was all. So the only way to find a
cutscene was to hunt `.wmv` files in the ISO tree, where nothing tells you which
mission a file belongs to, whether it has subtitles, or what is said in it.

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

Three negatives are shown rather than smoothed over:

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sylpheed RE agent
2026-08-28 16:47:46 +02:00
parent 306a8a5661
commit 77cd58202b
4 changed files with 503 additions and 9 deletions

View File

@@ -25,8 +25,10 @@ use crate::pak::PakArchive;
/// Subtitle language. `pak_code` selects `dat/movie/<code>.pak`; `game_code`
/// selects `dat/GP_MAIN_GAME_<code>.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,

View File

@@ -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<String> = 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");
}

View File

@@ -799,6 +799,56 @@ pub struct RequestScreenCompose {
pub hidden: Vec<bool>,
}
// ── 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<u8>,
pub phase: Option<u8>,
/// Movie basename, e.g. `S02A`. The file is `dat/movie/<movie>.wmv`.
pub movie: String,
pub voice_token: Option<String>,
pub subtitle: Option<String>,
/// 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<String>,
/// Whether `dat/movie/<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<CutsceneRow>,
pub selected: Option<usize>,
/// Subtitle language for the transcript pane.
pub lang: sylpheed_formats::movie_subtitle::SubLang,
/// Resolved captions for `selected`, newest request wins.
pub cues: Vec<sylpheed_formats::SubCue>,
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<CutsceneRow>),
/// One cutscene's resolved captions. `generation` drops a stale result.
CutsceneCuesLoaded {
generation: u64,
cues: Vec<sylpheed_formats::SubCue>,
},
/// The sound-bank library enumerated from `<lang>\sounds.tbl`.
AudioLibraryLoaded {
entries: Vec<sylpheed_formats::slb::AudioEntry>,
@@ -1197,6 +1254,7 @@ impl Plugin for IsoLoaderPlugin {
.init_resource::<MovieVoice>()
.init_resource::<AudioPreview>()
.init_resource::<AudioLibrary>()
.init_resource::<CutsceneBrowser>()
.init_resource::<GameData>()
.init_resource::<ShipBrowser>()
.init_resource::<ScreenBrowser>()
@@ -1205,6 +1263,7 @@ impl Plugin for IsoLoaderPlugin {
.add_event::<RequestVoice>()
.add_event::<RequestAudio>()
.add_event::<RequestAudioLibrary>()
.add_event::<RequestCutscenes>()
.add_event::<RequestGameData>()
.add_event::<RequestShipCatalog>()
.add_event::<RequestShipRender>()
@@ -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<MovieVoice>,
mut audio_preview: ResMut<AudioPreview>,
mut audio_lib: ResMut<AudioLibrary>,
mut cutscenes: ResMut<CutsceneBrowser>,
mut game_data: ResMut<GameData>,
mut ships: ResMut<ShipBrowser>,
mut screens: ResMut<ScreenBrowser>,
@@ -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<RequestCutscenes>,
iso_state: Res<IsoState>,
browser: Res<crate::ui::FileBrowserState>,
channels: Res<IsoChannels>,
mut cut: ResMut<CutsceneBrowser>,
) {
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<CutsceneRow> {
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<String> = 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<IsoState>,
channels: Res<IsoChannels>,
mut cut: ResMut<CutsceneBrowser>,
) {
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<Vec<sylpheed_formats::SubCue>, 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 `<lang>\sounds.tbl`
/// and enumerates every named sound bank off-thread.
#[cfg(not(target_arch = "wasm32"))]

View File

@@ -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<CutsceneBrowser>,
mut requests: EventReader<RequestCutscenes>,
mut file_selected: EventWriter<FileSelected>,
mut browser: ResMut<FileBrowserState>,
) {
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<usize> = None;
let mut play: Option<String> = 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::<std::collections::BTreeSet<_>>()
.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;
}