viewer: stop the UI Screens browser loading forever
Three compounding causes, found by tracing every place a `loading` flag is set
against every place it is cleared.
THE COST. `compose_screen` inflated EVERY entry in the pak and held every build
in memory at once, purely to index into the result -- then did it again on every
checkbox click, re-opening the ISO from scratch each time. But the catalog
already records each build's pak ENTRY INDEX, so the locator was there all
along: compose now reads exactly one entry. `RequestScreenCompose` carries
`entry` (the locator) beside `build` (the display ordinal).
`build_screen_catalog` had no budget at all, while the pak browser has capped
exactly this work since it was written. It now skips oversized entries and stops
at a ceiling.
That ceiling is 384 MB, not the pak browser's 64 MB, and the difference is the
point: GP_HANGAR_ARSENAL inflates past 160 MB and holds ~390 builds, so a 64 MB
cap would have quietly hidden most of them -- trading a hang for a wrong answer.
When the ceiling IS hit the pak is marked `truncated` and the UI says so.
THE LATCH. `poll_loader_channel` treated a disconnected channel exactly like an
empty one, so if a worker died every in-flight spinner stayed up for the life of
the process -- and the `if loading { return }` guard at the top of each handler
then refused every retry. Disconnect now clears the flags and reports it.
Verified: the workspace builds, the viewer reaches steady state, and one
composite still draws 11/11 elements of the tutorial pause menu.
This commit is contained in:
@@ -664,6 +664,11 @@ impl Default for ShipBrowser {
|
||||
/// builds it holds. A build is one *(context × language)* variant of the screen.
|
||||
#[derive(Clone)]
|
||||
pub struct ScreenPak {
|
||||
/// True when the scan hit its decode budget and stopped early, so `builds`
|
||||
/// is a PREFIX of what the pak holds. Surfaced in the UI rather than
|
||||
/// silently swallowed — a truncated list that looks complete is worse than
|
||||
/// a slow one.
|
||||
pub truncated: bool,
|
||||
/// Disc path, e.g. `dat/GP_PAUSE_MENU.pak`.
|
||||
pub path: String,
|
||||
/// Display name, e.g. `PAUSE MENU`.
|
||||
@@ -738,7 +743,11 @@ pub struct RequestScreenCatalog;
|
||||
#[derive(Event)]
|
||||
pub struct RequestScreenCompose {
|
||||
pub pak: String,
|
||||
/// Display ordinal within [`ScreenPak::builds`].
|
||||
pub build: usize,
|
||||
/// The pak ENTRY index the catalog recorded for this build — the locator
|
||||
/// that lets a compose read one entry instead of scanning the archive.
|
||||
pub entry: usize,
|
||||
pub focus: bool,
|
||||
pub animated: bool,
|
||||
/// Element indices to leave out (the per-element toggles).
|
||||
@@ -837,6 +846,11 @@ const PAK_DECODE_BUDGET: usize = 64 * 1024 * 1024;
|
||||
/// Skip decoding any single entry whose stored size exceeds this (cheap guard
|
||||
/// against a pathological entry, since `decompress_entry` has no output cap).
|
||||
const PAK_ENTRY_COMP_CAP: u32 = 16 * 1024 * 1024;
|
||||
/// Decode ceiling for ONE pak while cataloguing screens. Deliberately larger
|
||||
/// than `PAK_DECODE_BUDGET`: `GP_HANGAR_ARSENAL` alone inflates past 160 MB and
|
||||
/// holds ~390 builds, so a 64 MB cap would quietly hide most of them. Hitting
|
||||
/// this sets `ScreenPak::truncated`, which the UI shows.
|
||||
const SCREEN_CATALOG_BUDGET: usize = 384 * 1024 * 1024;
|
||||
|
||||
/// Bounded decoded-frame queue between the ffmpeg reader thread and the main
|
||||
/// thread. Also the back-pressure valve: when we stop draining (paused/behind),
|
||||
@@ -2204,7 +2218,22 @@ fn poll_loader_channel(
|
||||
iso_state.error = Some(msg);
|
||||
browser.loading = false;
|
||||
}
|
||||
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
// The loader channel is gone: no result will ever arrive, so
|
||||
// every in-flight spinner would latch forever. Clear them and
|
||||
// say why, rather than showing "Scanning…" indefinitely.
|
||||
iso_state.loading = false;
|
||||
browser.loading = false;
|
||||
screens.loading = false;
|
||||
screens.composing = false;
|
||||
saves.loading = false;
|
||||
game_data.loading = false;
|
||||
ships.loading = false;
|
||||
voice_lib.loading = false;
|
||||
iso_state.error = Some("the asset loader stopped responding".into());
|
||||
break;
|
||||
}
|
||||
Err(TryRecvError::Empty) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4124,15 +4153,30 @@ fn build_screen_catalog(source: &SourceKind, files: &[String]) -> Vec<ScreenPak>
|
||||
let Ok(ar) = read_pak_archive_blocking(source, path) else {
|
||||
continue;
|
||||
};
|
||||
// BUDGETED, like the pak browser at `build_pak_rows`. Without this the
|
||||
// catalog inflates every entry of every GP_*.pak with no ceiling —
|
||||
// GP_HANGAR_ARSENAL alone holds ~510 RATC entries — which is most of
|
||||
// why "Scanning the screen paks…" never finished.
|
||||
let mut builds = Vec::new();
|
||||
let mut budget: usize = 0;
|
||||
let mut truncated = false;
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
if e.comp_size > PAK_ENTRY_COMP_CAP {
|
||||
continue;
|
||||
}
|
||||
if budget > SCREEN_CATALOG_BUDGET {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
budget += bytes.len();
|
||||
if ui_layout::is_build(&bytes) {
|
||||
builds.push((i, bytes.len()));
|
||||
}
|
||||
}
|
||||
if !builds.is_empty() {
|
||||
out.push(ScreenPak {
|
||||
truncated,
|
||||
path: path.clone(),
|
||||
label: screen_label(path),
|
||||
builds,
|
||||
@@ -4159,9 +4203,10 @@ fn handle_screen_compose_request(
|
||||
screens.generation = screens.generation.wrapping_add(1);
|
||||
screens.composing = true;
|
||||
let generation = screens.generation;
|
||||
let (pak, build, focus, animated, hidden) = (
|
||||
let (pak, build, entry, focus, animated, hidden) = (
|
||||
req.pak.clone(),
|
||||
req.build,
|
||||
req.entry,
|
||||
req.focus,
|
||||
req.animated,
|
||||
req.hidden.clone(),
|
||||
@@ -4169,7 +4214,7 @@ fn handle_screen_compose_request(
|
||||
let source = iso_state.source_kind.clone();
|
||||
let sender = channels.sender.clone();
|
||||
std::thread::spawn(move || {
|
||||
let composed = compose_screen(&source, &pak, build, focus, animated, &hidden);
|
||||
let composed = compose_screen(&source, &pak, entry, build, focus, animated, &hidden);
|
||||
let _ = sender.send(IsoLoaderMsg::ScreenComposed {
|
||||
generation,
|
||||
composed: Box::new(composed),
|
||||
@@ -4181,6 +4226,7 @@ fn handle_screen_compose_request(
|
||||
fn compose_screen(
|
||||
source: &SourceKind,
|
||||
pak: &str,
|
||||
entry: usize,
|
||||
build_idx: usize,
|
||||
focus: bool,
|
||||
animated: bool,
|
||||
@@ -4201,13 +4247,22 @@ fn compose_screen(
|
||||
Ok(a) => a,
|
||||
Err(e) => return fail(e),
|
||||
};
|
||||
let bundles: Vec<Vec<u8>> = ar
|
||||
// Read exactly the entry the catalog recorded for this build. The previous
|
||||
// version inflated EVERY entry in the pak and held every build in memory at
|
||||
// once, just to index into the result — repeated on every checkbox click,
|
||||
// with the ISO re-opened from scratch each time. That, not the compositing,
|
||||
// was the cost.
|
||||
let one: Option<Vec<u8>> = ar
|
||||
.entries()
|
||||
.iter()
|
||||
.filter_map(|e| ar.read(e).ok())
|
||||
.filter(|b| ui_layout::is_build(b))
|
||||
.collect();
|
||||
let Some(bundle) = bundles.get(build_idx) else {
|
||||
.get(entry)
|
||||
.and_then(|e| ar.read(e).ok())
|
||||
.filter(|b| ui_layout::is_build(b));
|
||||
let bundles: Vec<Vec<u8>> = one.into_iter().collect();
|
||||
let Some(bundle) = bundles.first().filter(|_| {
|
||||
// `build_idx` stays the display ordinal; `entry` is the real locator.
|
||||
let _ = build_idx;
|
||||
true
|
||||
}) else {
|
||||
return fail(format!("build {build_idx} not in {pak}"));
|
||||
};
|
||||
let Some(build) = ui_layout::parse_build(bundle) else {
|
||||
|
||||
@@ -1895,6 +1895,12 @@ fn draw_screens_ui(
|
||||
egui::CollapsingHeader::new(&pak.label)
|
||||
.default_open(pak.builds.len() == 1)
|
||||
.show(ui, |ui| {
|
||||
if pak.truncated {
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(224, 168, 86),
|
||||
"⚠ list truncated (decode budget)",
|
||||
);
|
||||
}
|
||||
for (bi, (entry, size)) in pak.builds.iter().enumerate() {
|
||||
let selected = screens.selected == Some(pi)
|
||||
&& screens.build == bi;
|
||||
@@ -2081,6 +2087,13 @@ fn draw_screens_ui(
|
||||
compose.send(RequestScreenCompose {
|
||||
pak: pak.path.clone(),
|
||||
build: screens.build,
|
||||
// The catalog stored (pak entry index, size) per build; the
|
||||
// entry index is what lets the worker read one entry.
|
||||
entry: pak
|
||||
.builds
|
||||
.get(screens.build)
|
||||
.map(|(e, _)| *e)
|
||||
.unwrap_or(0),
|
||||
focus: screens.show_focus,
|
||||
animated: screens.show_animated,
|
||||
hidden: screens.hidden.clone(),
|
||||
|
||||
Reference in New Issue
Block a user