From 54bd4739d6b437efeb34156d2715adad5911358b Mon Sep 17 00:00:00 2001 From: ccoventry <17183194+ccoventry@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:38:42 -0600 Subject: [PATCH 1/5] Batch manifests record their highlights, and Studio catches up on planned ones Each manifest now records the payload's highlight ids, source demos, project file, capture mode and AGR rate. batch_catch_up checks a still-planned batch's takes on disk at startup (or once the game closes) and rewrites the manifest as unwatched. Part of #545 Co-Authored-By: Claude Opus 5.5 --- studio/src-tauri/src/batch_catch_up.rs | 233 ++++++++++++++++++++++++ studio/src-tauri/src/capture_manager.rs | 50 ++++- studio/src-tauri/src/lib.rs | 2 + studio/src-tauri/src/manifest_file.rs | 93 +++++++++- 4 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 studio/src-tauri/src/batch_catch_up.rs diff --git a/studio/src-tauri/src/batch_catch_up.rs b/studio/src-tauri/src/batch_catch_up.rs new file mode 100644 index 000000000..3d8e296c3 --- /dev/null +++ b/studio/src-tauri/src/batch_catch_up.rs @@ -0,0 +1,233 @@ +// batch_catch_up.rs +// Catching up on a capture batch Studio stopped watching (#545, part 2). +// +// A batch keeps running after Studio closes (the hook holds the schedule), but +// the end-of-batch take check that marks highlights Captured ran in Studio. +// Such a batch leaves its manifest at outcome "planned". On the next start, +// Studio checks those batches' takes on disk the same way a live batch does at +// its end and hands the frontend what it found; `batch_catch_up.js` marks the +// highlights. The manifest is then rewritten as "unwatched", so a batch is +// caught up on once. +// +// While a game is running, a planned batch may still be going, so the check +// waits until no game is left. Reconnecting to that game's events pipe to +// resume live progress is not done: the batch's progress state lived in the +// Studio that closed. + +use crate::capture_manager::{ + CaptureManifest, VerifiedBlock, record_capture_settings, verify_capture_takes_settling, +}; +use native::log_markdown; +use serde::Serialize; +use std::path::Path; +use std::time::Duration; +use tauri::Emitter; + +/// The outcome a caught-up batch's manifest is rewritten with. +const OUTCOME_UNWATCHED: &str = "unwatched"; + +/// How often the wait for a running game to close looks again. Only a +/// process staying alive is watched, so it need not be quick. +const GAME_POLL: Duration = Duration::from_millis(2000); + +/// The event the deferred check sends once the game has closed. +pub const CATCH_UP_EVENT: &str = "batch_catch_up"; + +/// One caught-up batch, for the frontend to map back to highlights. +#[derive(Debug, Clone, Serialize)] +pub struct CaughtUpBatch { + pub session_id: String, + /// The project file the batch was started from, when it had one. + pub project_path: Option, + /// Index-aligned with the batch's payload, as each block's + /// `source_streak_indices` count. + pub streak_uids: Vec, + pub streak_sources: Vec, + pub blocks: Vec, +} + +/// What the startup check found. +#[derive(Debug, Clone, Serialize)] +pub struct CatchUpStart { + /// Batches checked now. + pub batches: Vec, + /// A game was running, so the remaining batches are checked once it + /// closes and arrive as a [`CATCH_UP_EVENT`]. + pub waiting_for_game: bool, +} + +/// Planned manifests in `dir` that can be mapped back to highlights. One +/// written before the highlight ids existed cannot, and is left alone. +fn catchable_in(dir: &Path) -> Vec { + crate::manifest_file::read_planned_in(dir) + .into_iter() + .filter(|m| !m.streak_uids.is_empty() && !m.blocks.is_empty()) + .collect() +} + +/// Checks `manifest`'s takes on disk, records the capture settings beside +/// them as a watched batch does, and rewrites its manifest as unwatched. +fn catch_up_one(dir: &Path, manifest: CaptureManifest) -> CaughtUpBatch { + // The batch ended long ago: no take is still being flushed. + let blocks = verify_capture_takes_settling(&manifest, None); + let captured = blocks.iter().filter(|b| b.captured).count(); + log_markdown(&format!( + "[batch-catch-up] session {} ran while Studio was closed: {}/{} takes on disk", + manifest.session_id, + captured, + blocks.len() + )); + record_capture_settings(&manifest, &blocks); + crate::manifest_file::write_in(dir, &manifest, OUTCOME_UNWATCHED, Some(&blocks)); + CaughtUpBatch { + session_id: manifest.session_id, + project_path: manifest.project_path, + streak_uids: manifest.streak_uids, + streak_sources: manifest.streak_sources, + blocks, + } +} + +/// Catches up on the planned batches in `dir`; with `only`, just those +/// sessions. +fn catch_up_in(dir: &Path, only: Option<&[String]>) -> Vec { + catchable_in(dir) + .into_iter() + .filter(|m| only.is_none_or(|ids| ids.contains(&m.session_id))) + .map(|m| catch_up_one(dir, m)) + .collect() +} + +/// Run once when Studio starts, before any batch of its own. With no game +/// running, every planned batch is checked now. With one running, the +/// sessions planned at this moment are checked once no game is left, so a +/// batch this Studio starts in the meantime is never mistaken for one. +#[tauri::command] +pub async fn catch_up_unwatched_batches(app: tauri::AppHandle) -> Result { + crate::messages::flatten_spawn_blocking(tokio::task::spawn_blocking(move || { + let dir = crate::manifest_file::manifests_dir(); + if crate::capture_manager::running_game_pids().is_empty() { + return Ok(CatchUpStart { + batches: catch_up_in(&dir, None), + waiting_for_game: false, + }); + } + let ids: Vec = catchable_in(&dir) + .into_iter() + .map(|m| m.session_id) + .collect(); + if ids.is_empty() { + return Ok(CatchUpStart { + batches: Vec::new(), + waiting_for_game: false, + }); + } + log_markdown(&format!( + "[batch-catch-up] {} batch(es) ran while Studio was closed; checking their takes once the game closes", + ids.len() + )); + std::thread::spawn(move || { + while !crate::capture_manager::running_game_pids().is_empty() { + std::thread::sleep(GAME_POLL); + } + let batches = catch_up_in(&dir, Some(&ids)); + if !batches.is_empty() { + let _ = app.emit(CATCH_UP_EVENT, &batches); + } + }); + Ok(CatchUpStart { + batches: Vec::new(), + waiting_for_game: true, + }) + })) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::Scratch; + use native::patch::CaptureBlock; + + fn block(take_folder: &Path, index: usize, sources: Vec) -> CaptureBlock { + CaptureBlock { + demo_name: "dodstudio_chain_01".to_string(), + block_index: index, + drive_index: 0, + take_folder: take_folder.to_path_buf(), + take_key: format!("s/chain_01_b{index}"), + source_streak_indices: sources, + start_tick: 0, + end_tick: 100, + record_start_tick: 0, + record_stop_tick: 100, + } + } + + fn manifest(session_id: &str, blocks: Vec, uids: &[&str]) -> CaptureManifest { + CaptureManifest { + session_id: session_id.to_string(), + blocks, + capture_fps: 0, + capture_mode: String::new(), + agr_fps: 0, + streak_uids: uids.iter().map(|s| s.to_string()).collect(), + streak_sources: uids.iter().map(|_| "a.dem".to_string()).collect(), + project_path: Some("p.json".to_string()), + } + } + + #[test] + fn a_planned_batch_is_checked_on_disk_once() { + let manifests = Scratch::new("catch_up_manifests"); + let takes = Scratch::new("catch_up_takes"); + let done = takes.path().join("b0"); + std::fs::create_dir_all(&done).unwrap(); + std::fs::write(done.join("00000.tga"), b"x").unwrap(); + let missing = takes.path().join("b1"); + + let m = manifest( + "session_a", + vec![block(&done, 0, vec![0, 1]), block(&missing, 1, vec![2])], + &["u0", "u1", "u2"], + ); + crate::manifest_file::write_in(&manifests, &m, "planned", None).unwrap(); + + let batches = catch_up_in(&manifests, None); + assert_eq!(batches.len(), 1); + let b = &batches[0]; + assert_eq!(b.session_id, "session_a"); + assert_eq!(b.project_path.as_deref(), Some("p.json")); + assert_eq!(b.streak_uids, vec!["u0", "u1", "u2"]); + let captured: Vec = b.blocks.iter().map(|v| v.captured).collect(); + assert_eq!(captured, vec![true, false]); + + // Rewritten as unwatched, so the next start leaves it alone. + assert!(catch_up_in(&manifests, None).is_empty()); + } + + #[test] + fn only_the_named_sessions_and_mappable_manifests_are_caught_up() { + let manifests = Scratch::new("catch_up_only"); + let takes = Scratch::new("catch_up_only_takes"); + let folder = takes.path().join("b0"); + for id in ["session_a", "session_b"] { + let m = manifest(id, vec![block(&folder, 0, vec![0])], &["u0"]); + crate::manifest_file::write_in(&manifests, &m, "planned", None).unwrap(); + } + // From before the highlight ids: nothing to map it back to. + let old = manifest("session_old", vec![block(&folder, 0, vec![0])], &[]); + crate::manifest_file::write_in(&manifests, &old, "planned", None).unwrap(); + + let ids = vec!["session_b".to_string()]; + let batches = catch_up_in(&manifests, Some(&ids)); + assert_eq!(batches.len(), 1); + assert_eq!(batches[0].session_id, "session_b"); + + let left: Vec = crate::manifest_file::read_planned_in(&manifests) + .into_iter() + .map(|m| m.session_id) + .collect(); + assert_eq!(left, vec!["session_a", "session_old"]); + } +} diff --git a/studio/src-tauri/src/capture_manager.rs b/studio/src-tauri/src/capture_manager.rs index dc3a6be3f..d04ba8f36 100644 --- a/studio/src-tauri/src/capture_manager.rs +++ b/studio/src-tauri/src/capture_manager.rs @@ -184,6 +184,13 @@ pub struct CapturePayload { pub keep_game_behind: bool, /// Highlight streaks to capture. pub streaks: Vec, + /// Each streak's durable id (`take_index.js`'s `streakUid`), index-aligned + /// with `streaks`, recorded in the manifest (#545). + #[serde(default)] + pub streak_uids: Vec, + /// The project file the batch was started from, when it had one. + #[serde(default)] + pub project_path: Option, /// Pre-roll added before each streak (seconds). Converted → ticks at 100 Hz. pub pre_roll_seconds: f32, /// Post-roll padding after each streak (seconds). @@ -726,6 +733,18 @@ pub struct CaptureManifest { /// The rate an AGR batch recorded at; unused for the movie modes. #[serde(default)] pub agr_fps: i32, + /// Each payload highlight's durable id (`take_index.js`'s `streakUid`), + /// index-aligned with the payload's `streaks`, so a batch Studio stopped + /// watching can still be mapped back to its highlights after a restart + /// (#545, `batch_catch_up`). Empty in manifests from before it existed. + #[serde(default)] + pub streak_uids: Vec, + /// Each payload highlight's source demo, index-aligned the same way. + #[serde(default)] + pub streak_sources: Vec, + /// The project file the batch was started from, when it had one. + #[serde(default)] + pub project_path: Option, } /// One block's post-batch verdict, checked against what's actually on disk. @@ -782,6 +801,15 @@ fn take_folder_has_content(path: &Path) -> bool { /// silently marking nothing; "renderable" reuses Render Studio's own admission /// predicate so the two views can't disagree without saying so. fn verify_capture_takes(manifest: &CaptureManifest) -> Vec { + verify_capture_takes_settling(manifest, Some(std::time::Duration::from_millis(1500))) +} + +/// [`verify_capture_takes`], with the re-check of misses after `settle`, or +/// none: a batch caught up on after a restart (#545) ended long ago. +pub(crate) fn verify_capture_takes_settling( + manifest: &CaptureManifest, + settle: Option, +) -> Vec { let mut verified: Vec = manifest .blocks .iter() @@ -799,8 +827,10 @@ fn verify_capture_takes(manifest: &CaptureManifest) -> Vec { // HLAE is force-killed the moment the exit trigger appears, so a take can // still be mid-flush when we first look. Re-check only the misses once. - if verified.iter().any(|v| !v.captured) { - std::thread::sleep(std::time::Duration::from_millis(1500)); + if let Some(settle) = settle + && verified.iter().any(|v| !v.captured) + { + std::thread::sleep(settle); for (v, block) in verified.iter_mut().zip(manifest.blocks.iter()) { if !v.captured { v.captured = take_folder_has_content(&block.take_folder); @@ -832,7 +862,7 @@ fn verify_capture_takes(manifest: &CaptureManifest) -> Vec { /// /// Best-effort throughout: a capture that succeeded must never be reported as /// failed because a metadata file could not be written. -fn record_capture_settings(manifest: &CaptureManifest, blocks: &[VerifiedBlock]) { +pub(crate) fn record_capture_settings(manifest: &CaptureManifest, blocks: &[VerifiedBlock]) { if manifest.capture_fps <= 0 { return; } @@ -1013,6 +1043,15 @@ pub async fn start_capture_batch_impl( *slot = Some(patcher_config.clone()); } + // For the manifest (#545): which highlight each payload index is. + let streak_uids = payload.streak_uids.clone(); + let streak_sources: Vec = payload + .streaks + .iter() + .map(|s| s.source_demo.clone()) + .collect(); + let project_path = payload.project_path.clone().filter(|p| !p.is_empty()); + // ── Convert IPC streaks → native CaptureStreak ──────────────────────────── let raw_streaks: Vec = payload .streaks @@ -1114,6 +1153,9 @@ pub async fn start_capture_batch_impl( capture_fps: patcher_config.capture_fps, capture_mode: patcher_config.capture_mode.to_str_id().to_string(), agr_fps: patcher_config.effective_agr_fps(), + streak_uids, + streak_sources, + project_path, }; // On disk as well (#19), so a batch that goes wrong leaves a record // behind after the process is gone. Rewritten with outcomes at the end. @@ -3513,6 +3555,8 @@ mod tests { hook_runs_batch: false, keep_game_behind: true, streaks: Vec::new(), + streak_uids: Vec::new(), + project_path: None, pre_roll_seconds: 2.0, post_roll_seconds: 0.6, capture_directories: vec!["D:/capture".to_string()], diff --git a/studio/src-tauri/src/lib.rs b/studio/src-tauri/src/lib.rs index e6737f8d7..5f4d285c2 100644 --- a/studio/src-tauri/src/lib.rs +++ b/studio/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod audit_manager; +mod batch_catch_up; mod batch_eta; mod blender_manager; mod capture_manager; @@ -732,6 +733,7 @@ pub fn run() { launch_standalone_game, read_cfg_commands, check_engine_processes, + batch_catch_up::catch_up_unwatched_batches, kill_engine_processes, capture_manager::check_running_game, capture_manager::close_running_game, diff --git a/studio/src-tauri/src/manifest_file.rs b/studio/src-tauri/src/manifest_file.rs index efc47b90b..f72d62d3c 100644 --- a/studio/src-tauri/src/manifest_file.rs +++ b/studio/src-tauri/src/manifest_file.rs @@ -9,12 +9,14 @@ // Written twice per batch: once when the batch is planned (`outcome: // "planned"`, no verdicts), and again when it ends (`"complete"` or // `"cancelled"`, with each block's verification verdict). A batch that dies in -// between leaves the planned file, which is itself the useful signal. +// between leaves the planned file, which is itself the useful signal. Since +// #545 it is also how Studio catches up on a batch that ran while it was +// closed (`batch_catch_up`), which rewrites the file as `"unwatched"`. // // Best-effort throughout: a failed write is logged and never fails a batch. use crate::capture_manager::{CaptureManifest, VerifiedBlock}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; /// Manifests kept; older ones are deleted when a new one is written. @@ -23,12 +25,20 @@ const KEEP_MANIFESTS: usize = 50; #[derive(Serialize)] struct ManifestFile<'a> { session_id: &'a str, - /// "planned", "complete" or "cancelled". + /// "planned", "complete", "cancelled" or "unwatched". outcome: &'a str, /// Seconds since the Unix epoch. written_at_unix: u64, capture_fps: i32, + capture_mode: &'a str, + agr_fps: i32, blocks: &'a [native::patch::CaptureBlock], + /// Which highlight each payload index is, so a batch Studio stopped + /// watching can be caught up on after a restart (#545). + streak_uids: &'a [String], + streak_sources: &'a [String], + #[serde(skip_serializing_if = "Option::is_none")] + project_path: Option<&'a str>, /// Present once the batch has ended. #[serde(skip_serializing_if = "Option::is_none")] verified: Option<&'a [VerifiedBlock]>, @@ -70,7 +80,7 @@ pub fn write(manifest: &CaptureManifest, outcome: &str, verified: Option<&[Verif write_in(&manifests_dir(), manifest, outcome, verified); } -fn write_in( +pub(crate) fn write_in( dir: &Path, manifest: &CaptureManifest, outcome: &str, @@ -81,7 +91,12 @@ fn write_in( outcome, written_at_unix: unix_now(), capture_fps: manifest.capture_fps, + capture_mode: &manifest.capture_mode, + agr_fps: manifest.agr_fps, blocks: &manifest.blocks, + streak_uids: &manifest.streak_uids, + streak_sources: &manifest.streak_sources, + project_path: manifest.project_path.as_deref(), verified, }; let result = (|| -> std::io::Result { @@ -109,6 +124,33 @@ fn write_in( } } +/// A manifest file read back: its outcome and the batch it planned. +#[derive(Deserialize)] +struct ManifestRecord { + outcome: String, + #[serde(flatten)] + manifest: CaptureManifest, +} + +/// Every manifest in `dir` whose batch never reached an outcome: still +/// "planned", so Studio stopped watching it before it ended (#545). A file +/// that does not parse is skipped; these are best-effort records. +pub fn read_planned_in(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut planned: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x == "json")) + .filter_map(|p| serde_json::from_slice::(&std::fs::read(p).ok()?).ok()) + .filter(|r| r.outcome == "planned") + .map(|r| r.manifest) + .collect(); + planned.sort_by(|a, b| a.session_id.cmp(&b.session_id)); + planned +} + /// Deletes all but the `keep` most recently modified `.json` files in `dir`. fn prune(dir: &Path, keep: usize) { let Ok(entries) = std::fs::read_dir(dir) else { @@ -140,9 +182,52 @@ mod tests { capture_fps: 300, capture_mode: String::new(), agr_fps: 0, + streak_uids: Vec::new(), + streak_sources: Vec::new(), + project_path: None, } } + #[test] + fn only_planned_manifests_are_read_back_with_what_they_recorded() { + let dir = Scratch::new("manifest_file_read_planned"); + let mut open = manifest("session_open"); + open.streak_uids = vec!["a.dem#1#10#20".to_string()]; + open.streak_sources = vec!["a.dem".to_string()]; + open.project_path = Some("p.json".to_string()); + open.capture_mode = "agr".to_string(); + open.agr_fps = 60; + write_in(&dir, &open, "planned", None).expect("written"); + write_in(&dir, &manifest("session_done"), "complete", Some(&[])).expect("written"); + std::fs::write(dir.path().join("broken.json"), "{ not json").unwrap(); + + let planned = read_planned_in(&dir); + assert_eq!(planned.len(), 1); + let m = &planned[0]; + assert_eq!(m.session_id, "session_open"); + assert_eq!(m.streak_uids, open.streak_uids); + assert_eq!(m.streak_sources, open.streak_sources); + assert_eq!(m.project_path.as_deref(), Some("p.json")); + assert_eq!( + (m.capture_mode.as_str(), m.agr_fps, m.capture_fps), + ("agr", 60, 300) + ); + } + + #[test] + fn a_manifest_from_before_the_highlight_ids_still_reads() { + let dir = Scratch::new("manifest_file_read_old"); + std::fs::write( + dir.path().join("session_old.json"), + r#"{"session_id":"session_old","outcome":"planned","written_at_unix":1,"capture_fps":300,"blocks":[]}"#, + ) + .unwrap(); + let planned = read_planned_in(&dir); + assert_eq!(planned.len(), 1); + assert!(planned[0].streak_uids.is_empty()); + assert!(planned[0].project_path.is_none()); + } + #[test] fn the_session_id_becomes_a_safe_file_name() { assert_eq!( From 9b4f75b69e8b0dcc2d4b5080d416aff3a7af2704 Mon Sep 17 00:00:00 2001 From: ccoventry <17183194+ccoventry@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:38:42 -0600 Subject: [PATCH 2/5] Mark the clips of a batch that ran while Studio was closed The capture payload sends each highlight's id and the open project. batch_catch_up.js marks the caught-up highlights Captured, in the project file the batch came from or in the open queue. Part of #545 Co-Authored-By: Claude Opus 5.5 --- studio/src/batch_catch_up.js | 143 ++++++++++++++++++++++++++++++ studio/src/batch_catch_up.test.js | 141 +++++++++++++++++++++++++++++ studio/src/capture_pane.js | 4 + studio/src/ipc_bridge.js | 19 ++++ studio/src/main.js | 13 ++- studio/src/project_session.js | 9 +- studio/src/strings.js | 10 ++- 7 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 studio/src/batch_catch_up.js create mode 100644 studio/src/batch_catch_up.test.js diff --git a/studio/src/batch_catch_up.js b/studio/src/batch_catch_up.js new file mode 100644 index 000000000..2fe6f5139 --- /dev/null +++ b/studio/src/batch_catch_up.js @@ -0,0 +1,143 @@ +// batch_catch_up.js +// +// Marks the highlights of a capture batch that ran while Studio was closed +// (#545). The backend (`batch_catch_up.rs`) checks such a batch's takes on +// disk when Studio starts, or once the game closes if one is still running; +// this maps each take back to its highlights through the ids the batch's +// manifest recorded and marks them Captured, as capture_pane.js does at the +// end of a batch it watched. +// +// A batch started from a project file is marked in that file, unless it is +// the project open right now: then it is marked in the open queue, which +// leaves it unsaved like any other status change. A batch with no project is +// marked in the open queue, which matches only if those demos are loaded. + +import { listen } from '@tauri-apps/api/event'; +import { catchUpUnwatchedBatches, readProjectFile, writeTextFile } from './ipc_bridge.js'; +import { HIGHLIGHT_STATUS } from './status_colors.js'; +import { streakUid, recordTake, setVerifiedStatus } from './take_index.js'; +import { showToast } from './toast.js'; +import { STRINGS } from './strings.js'; + +/** The event the backend sends once a running game has closed. */ +export const CATCH_UP_EVENT = 'batch_catch_up'; + +/** + * Each captured block of `batch` as `{ takeKey, uids }`, the highlights it + * covers. A block can cover several highlights (overlapping ones are + * recorded as one take). + */ +export function capturedTakes(batch) { + const uids = batch?.streak_uids || []; + return (batch?.blocks || []) + .filter((block) => block.captured) + .map((block) => ({ + takeKey: block.take_key, + uids: (block.source_streak_indices || []).map((i) => uids[i]).filter(Boolean), + })) + .filter((take) => take.uids.length > 0); +} + +/** + * Marks `batch`'s captured highlights in `demos` and records its takes in + * `takeIndex`, both in place. Status only moves forward, with the same rule + * as a watched batch: a hand-set Rendered is overruled, a verified one is + * kept. Returns `{ captured, total, marked, changed }`. + */ +export function applyCaughtUpBatch(batch, demos, takeIndex) { + const takes = capturedTakes(batch); + const wanted = new Set(); + takes.forEach(({ takeKey, uids }) => { + if (takeKey) recordTake(takeIndex, takeKey, uids); + uids.forEach((uid) => wanted.add(uid)); + }); + + let marked = 0; + let changed = takes.length > 0; + (demos || []).forEach((demo) => { + (demo.streaks || []).forEach((streak) => { + if (!wanted.has(streakUid(demo.path, streak))) return; + if (streak.status === HIGHLIGHT_STATUS.RENDERED && !streak.statusByHand) return; + if (streak.statusByHand) changed = true; + if (setVerifiedStatus(streak, HIGHLIGHT_STATUS.CAPTURED)) marked += 1; + }); + }); + + const total = (batch?.blocks || []).length; + const captured = (batch?.blocks || []).filter((b) => b.captured).length; + return { captured, total, marked, changed: changed || marked > 0 }; +} + +function toastKind(result) { + return result.captured < result.total ? 'info' : 'success'; +} + +function fileName(path) { + return path.split(/[\\/]/).pop() || path; +} + +const defaultIo = { + catchUp: catchUpUnwatchedBatches, + readProject: readProjectFile, + writeProject: writeTextFile, + listen, + toast: showToast, +}; + +/** + * @param {object} app What this needs from main.js: getDemos() the queue, + * getTakeIndex(), getSessionPath() the open project file or null, and + * onStatusChange() to re-render and mark the project unsaved. `io` swaps + * the IPC and toasts out in tests. + */ +export function createBatchCatchUp({ getDemos, getTakeIndex, getSessionPath, onStatusChange, io = defaultIo }) { + /** Marks `batch` in the project file it came from. Returns a note for the toast. */ + async function applyToProjectFile(batch, path) { + try { + const data = JSON.parse(await io.readProject(path)); + data.takeIndex = data.takeIndex || {}; + const result = applyCaughtUpBatch(batch, data.demos || [], data.takeIndex); + if (result.changed) await io.writeProject(path, JSON.stringify(data, null, 2)); + return { result, note: result.changed ? ` ${STRINGS.BATCH_CATCH_UP.projectUpdated(fileName(path))}` : '' }; + } catch (err) { + return { + result: applyCaughtUpBatch(batch, [], {}), + note: ` ${STRINGS.BATCH_CATCH_UP.projectNotUpdated(fileName(path), err)}`, + failed: true, + }; + } + } + + async function applyBatch(batch) { + const project = batch.project_path; + if (project && project !== getSessionPath()) { + const { result, note, failed } = await applyToProjectFile(batch, project); + io.toast(`${STRINGS.BATCH_CATCH_UP.caughtUp(result.captured, result.total, result.marked)}${note}`, + failed ? 'error' : toastKind(result)); + return; + } + const result = applyCaughtUpBatch(batch, getDemos(), getTakeIndex()); + if (result.changed) onStatusChange(); + io.toast(STRINGS.BATCH_CATCH_UP.caughtUp(result.captured, result.total, result.marked), toastKind(result)); + } + + async function applyBatches(batches) { + for (const batch of batches || []) { + await applyBatch(batch); + } + } + + /** Run once at startup, before any batch of this Studio's own. */ + async function start() { + io.listen(CATCH_UP_EVENT, (event) => applyBatches(event.payload)) + .catch((err) => console.error(`Failed to register ${CATCH_UP_EVENT} listener:`, err)); + try { + const found = await io.catchUp(); + await applyBatches(found?.batches); + } catch (err) { + io.toast(STRINGS.BATCH_CATCH_UP.checkFailed(err), 'error'); + } + } + + return { start, applyBatches }; +} diff --git a/studio/src/batch_catch_up.test.js b/studio/src/batch_catch_up.test.js new file mode 100644 index 000000000..c1d3489ee --- /dev/null +++ b/studio/src/batch_catch_up.test.js @@ -0,0 +1,141 @@ +import { describe, it, expect, vi } from 'vitest'; +import { capturedTakes, applyCaughtUpBatch, createBatchCatchUp } from './batch_catch_up.js'; +import { streakUid } from './take_index.js'; +import { HIGHLIGHT_STATUS } from './status_colors.js'; + +const streak = (player, first, last, extra = {}) => ({ + player_index: player, + kills: [[first], [last]], + start_tick: first, + end_tick: last, + ...extra, +}); + +const demos = () => [ + { path: 'a.dem', streaks: [streak(1, 10, 20), streak(2, 30, 40), streak(3, 50, 60)] }, + { path: 'b.dem', streaks: [streak(1, 70, 80)] }, +]; + +const uid = (path, s) => streakUid(path, { ...s }); + +// A batch over a.dem's first two highlights (merged into one take) and +// b.dem's, whose take is missing. +function batch(d, extra = {}) { + return { + session_id: 'session_x', + project_path: null, + streak_uids: [uid('a.dem', d[0].streaks[0]), uid('a.dem', d[0].streaks[1]), uid('b.dem', d[1].streaks[0])], + blocks: [ + { take_key: 'session_x/chain_01_b0', captured: true, source_streak_indices: [0, 1] }, + { take_key: 'session_x/chain_02_b0', captured: false, source_streak_indices: [2] }, + ], + ...extra, + }; +} + +describe('capturedTakes', () => { + it('maps each captured block to the highlights it covers', () => { + const d = demos(); + expect(capturedTakes(batch(d))).toEqual([ + { takeKey: 'session_x/chain_01_b0', uids: [uid('a.dem', d[0].streaks[0]), uid('a.dem', d[0].streaks[1])] }, + ]); + }); + + it('skips a block whose indices are out of range', () => { + const b = { streak_uids: ['u0'], blocks: [{ take_key: 'k', captured: true, source_streak_indices: [5] }] }; + expect(capturedTakes(b)).toEqual([]); + }); +}); + +describe('applyCaughtUpBatch', () => { + it('marks the captured highlights and records the take', () => { + const d = demos(); + const takeIndex = {}; + const result = applyCaughtUpBatch(batch(d), d, takeIndex); + expect(result).toEqual({ captured: 1, total: 2, marked: 2, changed: true }); + expect(d[0].streaks.map((s) => s.status)).toEqual([HIGHLIGHT_STATUS.CAPTURED, HIGHLIGHT_STATUS.CAPTURED, undefined]); + expect(d[1].streaks[0].status).toBeUndefined(); + expect(Object.keys(takeIndex)).toEqual(['session_x/chain_01_b0']); + }); + + it('keeps a verified Rendered and overrules a hand-set one', () => { + const d = demos(); + d[0].streaks[0].status = HIGHLIGHT_STATUS.RENDERED; + d[0].streaks[1].status = HIGHLIGHT_STATUS.RENDERED; + d[0].streaks[1].statusByHand = true; + applyCaughtUpBatch(batch(d), d, {}); + expect(d[0].streaks[0].status).toBe(HIGHLIGHT_STATUS.RENDERED); + expect(d[0].streaks[1].status).toBe(HIGHLIGHT_STATUS.CAPTURED); + expect(d[0].streaks[1].statusByHand).toBeUndefined(); + }); +}); + +function fakeIo(overrides = {}) { + return { + catchUp: vi.fn(async () => ({ batches: [], waiting_for_game: false })), + readProject: vi.fn(), + writeProject: vi.fn(async () => {}), + listen: vi.fn(async () => () => {}), + toast: vi.fn(), + ...overrides, + }; +} + +describe('createBatchCatchUp', () => { + it('marks a batch with no project in the open queue', async () => { + const d = demos(); + const onStatusChange = vi.fn(); + const io = fakeIo({ catchUp: vi.fn(async () => ({ batches: [batch(d)], waiting_for_game: false })) }); + const takeIndex = {}; + const c = createBatchCatchUp({ getDemos: () => d, getTakeIndex: () => takeIndex, getSessionPath: () => null, onStatusChange, io }); + await c.start(); + expect(d[0].streaks[0].status).toBe(HIGHLIGHT_STATUS.CAPTURED); + expect(onStatusChange).toHaveBeenCalledTimes(1); + expect(io.toast).toHaveBeenCalledWith(expect.stringContaining('1 of 2 clips'), 'info'); + expect(io.listen).toHaveBeenCalledWith('batch_catch_up', expect.any(Function)); + }); + + it('marks a batch in its project file when that project is not open', async () => { + const saved = demos(); + const file = JSON.stringify({ version: '0.12.0', demos: saved }); + const io = fakeIo({ readProject: vi.fn(async () => file) }); + const onStatusChange = vi.fn(); + const c = createBatchCatchUp({ getDemos: () => [], getTakeIndex: () => ({}), getSessionPath: () => null, onStatusChange, io }); + await c.applyBatches([batch(saved, { project_path: 'C:/projects/match.json' })]); + + expect(io.readProject).toHaveBeenCalledWith('C:/projects/match.json'); + const [path, written] = io.writeProject.mock.calls[0]; + expect(path).toBe('C:/projects/match.json'); + const data = JSON.parse(written); + expect(data.demos[0].streaks[0].status).toBe(HIGHLIGHT_STATUS.CAPTURED); + expect(Object.keys(data.takeIndex)).toEqual(['session_x/chain_01_b0']); + expect(onStatusChange).not.toHaveBeenCalled(); + expect(io.toast).toHaveBeenCalledWith(expect.stringContaining('match.json'), 'info'); + }); + + it('marks the open queue when the batch came from the open project', async () => { + const d = demos(); + const io = fakeIo(); + const onStatusChange = vi.fn(); + const c = createBatchCatchUp({ getDemos: () => d, getTakeIndex: () => ({}), getSessionPath: () => 'p.json', onStatusChange, io }); + await c.applyBatches([batch(d, { project_path: 'p.json' })]); + expect(io.readProject).not.toHaveBeenCalled(); + expect(d[0].streaks[1].status).toBe(HIGHLIGHT_STATUS.CAPTURED); + expect(onStatusChange).toHaveBeenCalled(); + }); + + it('says so when the project file cannot be read', async () => { + const io = fakeIo({ readProject: vi.fn(async () => { throw new Error('gone'); }) }); + const c = createBatchCatchUp({ getDemos: () => [], getTakeIndex: () => ({}), getSessionPath: () => null, onStatusChange: vi.fn(), io }); + await c.applyBatches([batch(demos(), { project_path: 'gone.json' })]); + expect(io.writeProject).not.toHaveBeenCalled(); + expect(io.toast).toHaveBeenCalledWith(expect.stringContaining('Could not mark them in the project gone.json'), 'error'); + }); + + it('toasts when the check itself fails', async () => { + const io = fakeIo({ catchUp: vi.fn(async () => { throw 'boom'; }) }); + const c = createBatchCatchUp({ getDemos: () => [], getTakeIndex: () => ({}), getSessionPath: () => null, onStatusChange: vi.fn(), io }); + await c.start(); + expect(io.toast).toHaveBeenCalledWith(expect.stringContaining('boom'), 'error'); + }); +}); diff --git a/studio/src/capture_pane.js b/studio/src/capture_pane.js index 01bad6ab8..0a6bcff5f 100644 --- a/studio/src/capture_pane.js +++ b/studio/src/capture_pane.js @@ -1330,6 +1330,10 @@ export function initCaptureUI(getState, onSettingsChange, onStatusChange, getTak hook_runs_batch: hookRunsBatchVal, keep_game_behind: keepGameBehindVal, streaks: selectedStreaks, + // Recorded in the batch's manifest, so a batch that outlives Studio can + // still be mapped back to these highlights when it reopens (#545). + streak_uids: selectedStreaks.map((streak, i) => streakUid(selectedDemoPaths[i], streak)), + project_path: state.projectPath || null, pre_roll_seconds: preRollVal, post_roll_seconds: postRollVal, capture_directories: outputDrivePool, diff --git a/studio/src/ipc_bridge.js b/studio/src/ipc_bridge.js index d863d171d..ca445dc3a 100644 --- a/studio/src/ipc_bridge.js +++ b/studio/src/ipc_bridge.js @@ -796,6 +796,25 @@ export async function writeTextFile(path, contents) { }); } +/** Checks the takes of capture batches that ran while Studio was closed + * (#545). Resolves to `{ batches, waiting_for_game }`. */ +export async function catchUpUnwatchedBatches() { + return invoke("catch_up_unwatched_batches") + .catch((err) => { + console.error("IPC Execution Error (catch_up_unwatched_batches):", err); + throw err; + }); +} + +/** A project file's text, for a change made without opening it (#545). */ +export async function readProjectFile(path) { + return invoke("load_project_session", { path }) + .catch((err) => { + console.error("IPC Execution Error (load_project_session, catch-up):", err); + throw err; + }); +} + export async function scanDemoFolders(root) { return invoke("scan_demo_folders", { root: root ?? null }) .catch((err) => { diff --git a/studio/src/main.js b/studio/src/main.js index 4a561bc4b..961625518 100644 --- a/studio/src/main.js +++ b/studio/src/main.js @@ -14,6 +14,7 @@ import { initRollFloors } from './roll_floors.js'; import { renderDetailView, initDetailPane } from './detail_pane.js'; import { initCaptureUI, getCommandsState, hydrateCommandsState, applyCommandsState, refreshLaunchGuard, refreshInitCommandWarnings, isCaptureRunning } from './capture_pane.js'; import { confirmCloseDuringBatch } from './batch_close_prompt.js'; +import { createBatchCatchUp } from './batch_catch_up.js'; import { initRenderUI, checkRenderRecoveryOnStartup, finishedRenderOutputs } from './render_pane.js'; import { initFinishClips } from './finish_clips.js'; import { initAuditorPane } from './auditor_pane.js'; @@ -106,6 +107,7 @@ window.addEventListener("DOMContentLoaded", async () => { saveProjectSession, needsSavePrompt, requestUnsavedChangesConfirmation, + getSessionPath, } = createProjectSession({ getDemos: () => currentScannedDemos, replaceDemos: (demos) => replaceScannedDemos(demos), @@ -478,7 +480,8 @@ window.addEventListener("DOMContentLoaded", async () => { initCaptureUI(() => ({ scanPaths, targetDrives, - currentScannedDemos + currentScannedDemos, + projectPath: getSessionPath(), }), persistAppSettings, onHighlightStatusChange, () => takeIndex, updateExportPoolIndicator, captureDemosReady); initCommandProfiles({ getLists: getCommandsState, applyLists: applyCommandsState, onChange: persistAppSettings }); @@ -515,6 +518,14 @@ window.addEventListener("DOMContentLoaded", async () => { checkObsOrphanOnStartup(); + // A batch that ran while Studio was closed (#545): mark its finished clips. + createBatchCatchUp({ + getDemos: () => currentScannedDemos, + getTakeIndex: () => takeIndex, + getSessionPath, + onStatusChange: onHighlightStatusChange, + }).start(); + // Flush any not-yet-persisted settings edit before the window actually // closes. list_editor.js (Init/Custom Commands, numeric fields) only // writes to disk on 'change' (blur/Enter), not every keystroke — closing diff --git a/studio/src/project_session.js b/studio/src/project_session.js index 7a447737f..e6e648bbd 100644 --- a/studio/src/project_session.js +++ b/studio/src/project_session.js @@ -286,5 +286,12 @@ export function createProjectSession({ }); } - return { markProjectDirty, saveProjectSession, needsSavePrompt, requestUnsavedChangesConfirmation }; + return { + markProjectDirty, + saveProjectSession, + needsSavePrompt, + requestUnsavedChangesConfirmation, + // The open project file, or null for an unsaved queue. + getSessionPath: () => currentSessionPath, + }; } diff --git a/studio/src/strings.js b/studio/src/strings.js index 32683bedb..95f3f6bfe 100644 --- a/studio/src/strings.js +++ b/studio/src/strings.js @@ -1158,12 +1158,20 @@ export const STRINGS = { // ── Closing Studio while a capture batch runs (batch_close_prompt.js, #545) ── BATCH_CLOSE_MODAL: { TITLE: 'Capture batch running', - MESSAGE: 'A capture batch is still running. If you close DoD Studio, the game keeps capturing on its own, but Studio won’t check the takes or mark them Captured, and the game stays open when the batch ends. To stop the batch instead, use Cancel Batch first.', + MESSAGE: 'A capture batch is still running. If you close DoD Studio, the game keeps capturing on its own. Studio checks the takes and marks them Captured the next time it opens. To stop the batch instead, use Cancel Batch first.', LOCAL_BUILD_NOTE: 'This is a local build started from npm run tauri dev: closing it closes the game too, and the batch stops where it is.', CLOSE_BUTTON: 'Close DoD Studio', KEEP_OPEN_BUTTON: 'Keep Studio open', }, + // ── A batch that ran while Studio was closed (batch_catch_up.js, #545) ── + BATCH_CATCH_UP: { + caughtUp: (captured, total, marked) => `A capture batch finished while DoD Studio was closed: ${captured} of ${total} clips are on disk.${marked > 0 ? ` ${marked} highlight(s) marked Captured.` : ''}`, + projectUpdated: (name) => `Marked them in the project ${name}.`, + projectNotUpdated: (name, err) => `Could not mark them in the project ${name}: ${err}`, + checkFailed: (err) => `Could not check the clips of a batch that ran while DoD Studio was closed: ${err}`, + }, + // ── main.js: sessions, settings dialogs, scan status, Clear actions ───── // Map library warnings. A demo names the map it was recorded on and stamps // that map's build alongside it, so "missing" and "wrong build" are different From 600e720631008ee28793d1d5dfc8090ffbb77c75 Mon Sep 17 00:00:00 2001 From: ccoventry <17183194+ccoventry@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:29:32 -0600 Subject: [PATCH 3/5] Closing Studio while a batch is starting waits for the start to reach the game After Start, Studio sends the batch's start to the game ~5 s after the hook's pipe answers. Closed in that window, the game sat at its main menu with no batch, while the close prompt said it keeps capturing. The capture loop now records where the start stands (BatchStart). On close, a start still on its way is waited for (up to 30 s, with a countdown and Keep Studio open). Once it is sent the usual prompt shows; if it never gets there, or the game hasn't launched yet, the prompt says the batch hasn't started and Cancel batch and close cancels it. desktop-studio's tests no longer write into the real activity log: every Scratch points DOD_STUDIO_LOG_DIR at a temp folder. Co-Authored-By: Claude Opus 5.5 --- native/src/capture_engine.rs | 10 ++ native/src/game_handoff.rs | 82 ++++++++++++++ studio/src-tauri/src/batch_close.rs | 137 +++++++++++++++++++++++ studio/src-tauri/src/capture_manager.rs | 9 ++ studio/src-tauri/src/lib.rs | 2 + studio/src-tauri/src/test_support.rs | 29 +++++ studio/src/batch_close_prompt.js | 112 +++++++++++++++++-- studio/src/batch_close_prompt.test.js | 141 +++++++++++++++++++++--- studio/src/ipc_bridge.js | 11 ++ studio/src/strings.js | 8 ++ studio/src/themed_confirm.js | 19 +++- 11 files changed, 534 insertions(+), 26 deletions(-) create mode 100644 studio/src-tauri/src/batch_close.rs diff --git a/native/src/capture_engine.rs b/native/src/capture_engine.rs index 201808937..84cdd95ad 100644 --- a/native/src/capture_engine.rs +++ b/native/src/capture_engine.rs @@ -282,6 +282,9 @@ pub fn spawn_capture_engine( // `jobs` is then empty: nothing is patched into `dod/`, the game starts on // no demo, and the file is handed to it once it takes commands. runtime_batch: Option, + // Where the batch's start in the game stands, read by Studio's close + // prompt (#545). Studio sets it to `Preparing` when the batch starts. + batch_start: Arc, ) { std::thread::Builder::new() .name("capture_engine".into()) @@ -758,6 +761,11 @@ pub fn spawn_capture_engine( } }; log_markdown(&format!("[HLAE] Spawned (PID {})", child.id())); + batch_start.set(if handoff.is_some() { + crate::game_handoff::StartState::Pending + } else { + crate::game_handoff::StartState::NotNeeded + }); // The HLAE launcher process (`_child` above) injects into `hl.exe` and then // exits on its own under `-noGui -autoStart` — that is expected handoff @@ -881,12 +889,14 @@ pub fn spawn_capture_engine( Handoff::Waiting | Handoff::Sent => {} Handoff::JustSent(pid) => { foreground_lock.release(); + batch_start.set(crate::game_handoff::StartState::Sent); log_markdown(&format!( "[HLAE] The game is up (PID {pid}); started the batch: `{}`", handoff.lines.join("; ") )) } Handoff::Failed(why) => { + batch_start.set(crate::game_handoff::StartState::Failed); log_markdown(&format!("[HLAE] {why}")); failure_reason = Some(why); kill_hl_exe_and_wait(&mut sys); diff --git a/native/src/game_handoff.rs b/native/src/game_handoff.rs index bb60f4cca..990a6e1b1 100644 --- a/native/src/game_handoff.rs +++ b/native/src/game_handoff.rs @@ -11,10 +11,72 @@ //! afterwards; only when the map loads matters. Started over the pipe, the //! demo loads after the configs have run. +use std::sync::atomic::{AtomicU8, Ordering}; use std::time::{Duration, Instant}; use crate::sys::game_remote::{Sent, send_console_commands}; +/// Where a batch's start in the game stands, for Studio's close prompt +/// (#545): closing Studio before the start is sent leaves the game at its +/// main menu with no batch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartState { + /// The game has not been launched yet (the batch is being prepared). + Preparing, + /// The game is launched and the start waits for its pipe. + Pending, + /// The start was sent: the batch runs in the game. + Sent, + /// The batch starts from the launch line, so it runs once launched. + NotNeeded, + /// The start could not be sent; the batch is ending. + Failed, +} + +impl StartState { + /// The name the frontend gets. + pub fn as_str(self) -> &'static str { + match self { + Self::Preparing => "preparing", + Self::Pending => "pending", + Self::Sent => "sent", + Self::NotNeeded => "not_needed", + Self::Failed => "failed", + } + } + + /// Whether the batch runs in the game without Studio. + pub fn runs_without_studio(self) -> bool { + matches!(self, Self::Sent | Self::NotNeeded) + } +} + +/// A [`StartState`] shared between the capture loop and Studio's commands. +#[derive(Debug)] +pub struct BatchStart(AtomicU8); + +impl Default for BatchStart { + fn default() -> Self { + Self(AtomicU8::new(StartState::Preparing as u8)) + } +} + +impl BatchStart { + pub fn set(&self, state: StartState) { + self.0.store(state as u8, Ordering::SeqCst); + } + + pub fn get(&self) -> StartState { + match self.0.load(Ordering::SeqCst) { + x if x == StartState::Pending as u8 => StartState::Pending, + x if x == StartState::Sent as u8 => StartState::Sent, + x if x == StartState::NotNeeded as u8 => StartState::NotNeeded, + x if x == StartState::Failed as u8 => StartState::Failed, + _ => StartState::Preparing, + } + } +} + /// How long a launched game has to open its command pipe. const PIPE_WAIT: Duration = Duration::from_secs(120); /// After the pipe answers, how long before the lines are sent: the pipe opens @@ -168,6 +230,26 @@ mod tests { assert!(lines.is_empty()); } + #[test] + fn a_batch_start_reads_back_what_was_set() { + let start = BatchStart::default(); + assert_eq!(start.get(), StartState::Preparing); + for state in [ + StartState::Pending, + StartState::Sent, + StartState::NotNeeded, + StartState::Failed, + StartState::Preparing, + ] { + start.set(state); + assert_eq!(start.get(), state); + } + assert!(StartState::Sent.runs_without_studio()); + assert!(StartState::NotNeeded.runs_without_studio()); + assert!(!StartState::Pending.runs_without_studio()); + assert!(!StartState::Preparing.runs_without_studio()); + } + #[test] fn a_game_that_never_answers_waits_then_fails() { let mut handoff = GameHandoff::new(vec!["playdemo x".into()]); diff --git a/studio/src-tauri/src/batch_close.rs b/studio/src-tauri/src/batch_close.rs new file mode 100644 index 000000000..e7699796f --- /dev/null +++ b/studio/src-tauri/src/batch_close.rs @@ -0,0 +1,137 @@ +// batch_close.rs +// What Studio's close prompt needs to know about a running batch (#545). +// +// After Start, Studio launches the game, waits for the hook's pipe and sends +// the batch's start about 5 s later (`native::game_handoff`). Closed in that +// window, Studio would leave the game at its main menu with no batch. So on +// close, `batch_close_prompt.js` waits here, a bounded time, for the start to +// reach the game: then the batch runs without Studio and the next start's +// catch-up marks it. If it never gets there, the prompt says the batch has not +// started and closing cancels it. + +use native::game_handoff::{BatchStart, StartState}; +use std::time::{Duration, Instant}; + +use crate::capture_manager::CaptureManager; + +/// How often the wait looks again: the user is waiting on a closing window. +const POLL: Duration = Duration::from_millis(100); +/// The longest a close waits, whatever the frontend asks for. +const MAX_WAIT: Duration = Duration::from_secs(60); + +/// Waits while the start is still on its way to the game (`Pending`), up to +/// `timeout`, and returns where it stands then. Stops early once `running` +/// says the batch is over (it ended or was cancelled). +fn wait_for_start( + start: &BatchStart, + timeout: Duration, + poll: Duration, + running: impl Fn() -> bool, +) -> StartState { + let began = Instant::now(); + loop { + let state = start.get(); + if state != StartState::Pending || !running() || began.elapsed() >= timeout { + return state; + } + std::thread::sleep(poll); + } +} + +/// The running batch's start, once it is no longer on its way or +/// `timeout_ms` has passed: "preparing", "pending", "sent", "not_needed" or +/// "failed", or "not_running" when no batch runs. +#[tauri::command] +pub async fn wait_for_batch_start( + state: tauri::State<'_, CaptureManager>, + timeout_ms: u64, +) -> Result { + if !state.is_running() { + return Ok("not_running".to_string()); + } + let start = std::sync::Arc::clone(&state.batch_start); + let is_running = std::sync::Arc::clone(&state.is_running); + let cancel = std::sync::Arc::clone(&state.cancel_token); + let timeout = Duration::from_millis(timeout_ms).min(MAX_WAIT); + crate::messages::flatten_spawn_blocking(tokio::task::spawn_blocking(move || { + let running = || { + *is_running.lock().unwrap_or_else(|p| p.into_inner()) + && !cancel.load(std::sync::atomic::Ordering::Relaxed) + }; + let state = wait_for_start(&start, timeout, POLL, running); + if state == StartState::Pending { + native::log_markdown(&format!( + "[capture] Closing: the batch's start had not reached the game after {} s", + timeout.as_secs() + )); + } + Ok(state.as_str().to_string()) + })) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + const FAST: Duration = Duration::from_millis(5); + + #[test] + fn a_start_already_sent_or_not_needed_answers_at_once() { + for state in [ + StartState::Sent, + StartState::NotNeeded, + StartState::Preparing, + ] { + let start = BatchStart::default(); + start.set(state); + let began = Instant::now(); + assert_eq!( + wait_for_start(&start, Duration::from_secs(10), FAST, || true), + state + ); + assert!(began.elapsed() < Duration::from_secs(1)); + } + } + + #[test] + fn a_pending_start_is_waited_for_until_it_is_sent() { + let start = Arc::new(BatchStart::default()); + start.set(StartState::Pending); + let sender = Arc::clone(&start); + let handle = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(50)); + sender.set(StartState::Sent); + }); + assert_eq!( + wait_for_start(&start, Duration::from_secs(10), FAST, || true), + StartState::Sent + ); + handle.join().unwrap(); + } + + #[test] + fn a_start_that_never_comes_gives_up_at_the_timeout() { + let start = BatchStart::default(); + start.set(StartState::Pending); + let began = Instant::now(); + assert_eq!( + wait_for_start(&start, Duration::from_millis(40), FAST, || true), + StartState::Pending + ); + assert!(began.elapsed() >= Duration::from_millis(40)); + } + + #[test] + fn a_batch_that_ends_stops_the_wait() { + let start = BatchStart::default(); + start.set(StartState::Pending); + let began = Instant::now(); + assert_eq!( + wait_for_start(&start, Duration::from_secs(10), FAST, || false), + StartState::Pending + ); + assert!(began.elapsed() < Duration::from_secs(1)); + } +} diff --git a/studio/src-tauri/src/capture_manager.rs b/studio/src-tauri/src/capture_manager.rs index f1a3199f1..d510be6cb 100644 --- a/studio/src-tauri/src/capture_manager.rs +++ b/studio/src-tauri/src/capture_manager.rs @@ -405,6 +405,9 @@ pub struct CaptureManager { /// Recording blocks the most recent batch planned, used to verify takes /// against disk once the batch ends. pub last_manifest: Arc>>, + /// Whether the running batch's start has reached the game, for the + /// close prompt (#545). Reset to `Preparing` when a batch starts. + pub batch_start: Arc, } impl CaptureManager { @@ -414,6 +417,7 @@ impl CaptureManager { cancel_token: Arc::new(std::sync::atomic::AtomicBool::new(false)), last_config: Arc::new(Mutex::new(None)), last_manifest: Arc::new(Mutex::new(None)), + batch_start: Arc::new(native::game_handoff::BatchStart::default()), } } @@ -1036,6 +1040,9 @@ pub async fn start_capture_batch_impl( manager .cancel_token .store(false, std::sync::atomic::Ordering::Relaxed); + manager + .batch_start + .set(native::game_handoff::StartState::Preparing); // ── Build PatcherConfig from the IPC payload ─────────────────────────────── let mut patcher_config = config_from_payload(&payload); @@ -1083,6 +1090,7 @@ pub async fn start_capture_batch_impl( // ── Clone Arcs into the worker closure ──────────────────────────────────── let is_running_arc = Arc::clone(&manager.is_running); let cancel_token_arc = Arc::clone(&manager.cancel_token); + let batch_start_arc = Arc::clone(&manager.batch_start); let manifest_arc = Arc::clone(&manager.last_manifest); let hlae_path = PathBuf::from(&patcher_config.hlae_path); let hl_path = PathBuf::from(&patcher_config.game_path); @@ -1618,6 +1626,7 @@ pub async fn start_capture_batch_impl( drive_headroom, obs_take_folders, runtime_batch, + batch_start_arc, ); }); diff --git a/studio/src-tauri/src/lib.rs b/studio/src-tauri/src/lib.rs index d45e661bb..90544b058 100644 --- a/studio/src-tauri/src/lib.rs +++ b/studio/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod audit_manager; mod batch_catch_up; +mod batch_close; mod batch_eta; mod blender_manager; mod capture_manager; @@ -847,6 +848,7 @@ pub fn run() { read_cfg_commands, check_engine_processes, batch_catch_up::catch_up_unwatched_batches, + batch_close::wait_for_batch_start, kill_engine_processes, capture_manager::check_running_game, capture_manager::close_running_game, diff --git a/studio/src-tauri/src/test_support.rs b/studio/src-tauri/src/test_support.rs index b0d526bda..a90ba6ef0 100644 --- a/studio/src-tauri/src/test_support.rs +++ b/studio/src-tauri/src/test_support.rs @@ -30,6 +30,24 @@ use std::path::{Path, PathBuf}; +/// Points `native::log_markdown` away from the user's real activity log for +/// this test binary. `native` redirects its own tests with `cfg(test)`, but +/// that flag is off in a library another crate links, so this crate's tests +/// wrote lines like `[batch-catch-up] session session_a …` into the real +/// `dod-studio\logs` (#760). `DOD_STUDIO_LOG_DIR` is the redirect `native` +/// reads for exactly this case. Every [`Scratch`] calls it, since a test that +/// touches the disk is one that runs code which logs. +pub(crate) fn quiet_activity_log() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + let dir = std::env::temp_dir().join("dod_studio_test_logs"); + // SAFETY: Rust's std serialises its own environment reads and + // writes, and nothing in this test binary reads the environment + // from C while it runs. + unsafe { std::env::set_var("DOD_STUDIO_LOG_DIR", dir) }; + }); +} + /// A temporary directory that empties itself on the way in and removes itself /// on the way out. /// @@ -52,6 +70,7 @@ impl Scratch { /// Takes over an arbitrary path, for the handful of tests that need an /// exact one. Creates it if it does not exist. pub(crate) fn at(path: PathBuf) -> Self { + quiet_activity_log(); // Clearing on entry stays, and is not redundant with the `Drop`: it is // what makes a directory left behind by a *previous* build -- one from // before this guard existed, or from a hard-killed test process -- @@ -65,6 +84,7 @@ impl Scratch { /// name nothing exists at. Still removed on drop, in case the code under /// test creates it. pub(crate) fn absent(tag: impl std::fmt::Display) -> Self { + quiet_activity_log(); let path = std::env::temp_dir().join(format!("dod_{tag}_{}", std::process::id())); let _ = std::fs::remove_dir_all(&path); Self { path } @@ -105,6 +125,15 @@ impl AsRef for Scratch { mod tests { use super::*; + #[test] + fn a_test_with_a_scratch_logs_away_from_the_real_activity_log() { + let _dir = Scratch::new("test_support_quiet_log"); + assert_eq!( + native::activity_log_dir(), + std::env::temp_dir().join("dod_studio_test_logs") + ); + } + #[test] fn a_scratch_directory_is_created_and_then_removed() { let path; diff --git a/studio/src/batch_close_prompt.js b/studio/src/batch_close_prompt.js index 811a3bec0..67efd80c3 100644 --- a/studio/src/batch_close_prompt.js +++ b/studio/src/batch_close_prompt.js @@ -1,29 +1,123 @@ // batch_close_prompt.js // Asks before DoD Studio closes while a capture batch is running (#545, // part 1). The game keeps capturing without Studio -- the schedule is already -// in the patched demos -- but nothing checks the takes or closes the game at -// the end, and a local `tauri dev` build takes the game down with it (cargo's -// job object). Stopping the batch stays Cancel Batch's job. +// in the patched demos, or the hook runs it -- but nothing checks the takes +// at the end until Studio next opens, and a local `tauri dev` build takes the +// game down with it (cargo's job object). Stopping the batch stays Cancel +// Batch's job. +// +// The game only keeps capturing once the batch's start has reached it: after +// Start, Studio launches the game, waits for the hook's pipe and sends the +// start about 5 s later. Closed before that, the game would sit at its main +// menu. So a close in that window waits for the start first (bounded), and if +// it never gets there, says the batch has not started and cancels it. -import { themedConfirm } from './themed_confirm.js'; +import { themedConfirm, dismissThemedConfirm, setThemedConfirmMessage } from './themed_confirm.js'; +import { waitForBatchStart, cancelCaptureBatch } from './ipc_bridge.js'; import { STRINGS } from './strings.js'; +/** How long a close waits for the batch's start to reach the game. The + * pipe opens early in the game's start-up, so this clears a slow one. */ +export const START_WAIT_MS = 30000; +/** How long a close waits for a cancelled batch to stop. */ +export const CANCEL_WAIT_MS = 15000; + /** The prompt's text: the note about local builds only where it applies. */ export function batchCloseMessage(isLocalBuild) { const s = STRINGS.BATCH_CLOSE_MODAL; return isLocalBuild ? `${s.MESSAGE}\n\n${s.LOCAL_BUILD_NOTE}` : s.MESSAGE; } +/** Whether a start state from `waitForStart` means the game runs the batch + * without Studio. */ +export function batchRunsWithoutStudio(state) { + return state === 'sent' || state === 'not_needed'; +} + +const KEPT_OPEN = Symbol('kept open'); + +/** + * Shows a wait in the themed dialog while `work` runs, counting down from + * `ms`. Resolves to `work`'s result, or `KEPT_OPEN` when the user picks Keep + * Studio open first (only offered with `canKeepOpen`). + */ +async function whileWaiting(work, { ms, message, canKeepOpen }) { + const s = STRINGS.BATCH_CLOSE_MODAL; + const began = Date.now(); + const text = () => (typeof message === 'function' + ? message(Math.max(0, Math.ceil((ms - (Date.now() - began)) / 1000))) + : message); + const clicked = themedConfirm(text(), { + title: s.WAIT_TITLE, + cancelLabel: s.KEEP_OPEN_BUTTON, + hideConfirm: true, + hideCancel: !canKeepOpen, + }).then(() => KEPT_OPEN); + const tick = setInterval(() => setThemedConfirmMessage(text()), 1000); + try { + return await Promise.race([clicked, work()]); + } finally { + clearInterval(tick); + dismissThemedConfirm(false); + } +} + +/** Resolves once `isRunning()` is false, or after `ms`. */ +async function untilStopped(isRunning, ms) { + const began = Date.now(); + while (isRunning() && Date.now() - began < ms) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + /** * Whether the window may close. True at once when no batch is running; - * otherwise asks, and only "Close DoD Studio" says yes. + * otherwise asks, and only "Close DoD Studio" (or, before the batch has + * started in the game, "Cancel batch and close") says yes. + * + * `waitForStart(ms)` resolves to where the batch's start stands once it is + * no longer on its way to the game, or `ms` has passed (ipc_bridge's + * waitForBatchStart). `cancelBatch()` asks the running batch to stop. */ -export async function confirmCloseDuringBatch({ isRunning, isLocalBuild }) { +export async function confirmCloseDuringBatch({ + isRunning, isLocalBuild, waitForStart = waitForBatchStart, cancelBatch = cancelCaptureBatch, +}) { if (!isRunning()) return true; const s = STRINGS.BATCH_CLOSE_MODAL; - return themedConfirm(batchCloseMessage(await isLocalBuild()), { - title: s.TITLE, - confirmLabel: s.CLOSE_BUTTON, + + // Where the start stands now; only a start still on its way is waited for. + // If Studio can't tell, it asks as it always did rather than cancel a + // batch that may well be running. + let state = await waitForStart(0).catch(() => 'sent'); + if (state === 'pending') { + const waited = await whileWaiting( + () => waitForStart(START_WAIT_MS).catch(() => 'pending'), + { ms: START_WAIT_MS, message: s.starting, canKeepOpen: true }, + ); + if (waited === KEPT_OPEN) return false; + state = waited; + } + if (state === 'not_running' || !isRunning()) return true; + + if (batchRunsWithoutStudio(state)) { + return themedConfirm(batchCloseMessage(await isLocalBuild()), { + title: s.TITLE, + confirmLabel: s.CLOSE_BUTTON, + cancelLabel: s.KEEP_OPEN_BUTTON, + }); + } + + const close = await themedConfirm(s.NOT_STARTED_MESSAGE, { + title: s.NOT_STARTED_TITLE, + confirmLabel: s.CANCEL_AND_CLOSE_BUTTON, cancelLabel: s.KEEP_OPEN_BUTTON, }); + if (!close) return false; + // Cancelled, the batch closes the game and records itself as cancelled, + // so the next start has nothing to catch up on. + await whileWaiting(async () => { + await cancelBatch().catch(() => {}); + await untilStopped(isRunning, CANCEL_WAIT_MS); + }, { ms: CANCEL_WAIT_MS, message: s.CANCELLING, canKeepOpen: false }); + return true; } diff --git a/studio/src/batch_close_prompt.test.js b/studio/src/batch_close_prompt.test.js index eb4c18f9c..b20e686c5 100644 --- a/studio/src/batch_close_prompt.test.js +++ b/studio/src/batch_close_prompt.test.js @@ -1,29 +1,140 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; -vi.mock('./themed_confirm.js', () => ({ themedConfirm: vi.fn(async () => false) })); +// The themed dialog, stood in for: a question answers with the next of +// `answers`; a wait (no confirm button) stays open until the code dismisses +// it, or answers Keep Studio open at once with `keepOpenDuringWait`. +const dialog = vi.hoisted(() => ({ answers: [], calls: [], pending: null, keepOpenDuringWait: false })); +vi.mock('./themed_confirm.js', () => ({ + themedConfirm: vi.fn((message, opts) => new Promise((resolve) => { + dialog.calls.push({ message, opts }); + if (opts.hideConfirm) { + dialog.pending = resolve; + if (dialog.keepOpenDuringWait) resolve(false); + return; + } + resolve(dialog.answers.shift()); + })), + dismissThemedConfirm: vi.fn((result) => { + dialog.pending?.(result); + dialog.pending = null; + }), + setThemedConfirmMessage: vi.fn(), +})); +vi.mock('./ipc_bridge.js', () => ({ waitForBatchStart: vi.fn(), cancelCaptureBatch: vi.fn() })); -import { batchCloseMessage, confirmCloseDuringBatch } from './batch_close_prompt.js'; -import { themedConfirm } from './themed_confirm.js'; +import { batchCloseMessage, confirmCloseDuringBatch, START_WAIT_MS } from './batch_close_prompt.js'; import { STRINGS } from './strings.js'; +const s = STRINGS.BATCH_CLOSE_MODAL; + +/** A running batch whose start answers `states` in turn (the first for the + * look without waiting, the second after the wait). */ +function batch(states, { cancelStops = true } = {}) { + let running = true; + const waitForStart = vi.fn(async () => { + const next = states.shift(); + if (next instanceof Error) throw next; + return next === undefined ? new Promise(() => {}) : next; + }); + const cancelBatch = vi.fn(async () => { if (cancelStops) running = false; }); + return { + waitForStart, + cancelBatch, + args: { isRunning: () => running, isLocalBuild: async () => false, waitForStart, cancelBatch }, + }; +} + +const titles = () => dialog.calls.map((c) => c.opts.title); + describe('closing Studio during a batch (#545)', () => { + beforeEach(() => { + dialog.answers = []; + dialog.calls = []; + dialog.pending = null; + dialog.keepOpenDuringWait = false; + }); + it('closes without asking when no batch is running', async () => { - const ok = await confirmCloseDuringBatch({ isRunning: () => false, isLocalBuild: async () => false }); + const b = batch([]); + const ok = await confirmCloseDuringBatch({ ...b.args, isRunning: () => false }); expect(ok).toBe(true); - expect(themedConfirm).not.toHaveBeenCalled(); + expect(dialog.calls).toEqual([]); + expect(b.waitForStart).not.toHaveBeenCalled(); + }); + + it('asks while a started batch runs, and Keep Studio open keeps it open', async () => { + dialog.answers = [false]; + const b = batch(['sent']); + expect(await confirmCloseDuringBatch(b.args)).toBe(false); + expect(dialog.calls).toHaveLength(1); + expect(dialog.calls[0].message).toBe(s.MESSAGE); + expect(dialog.calls[0].opts.confirmLabel).toBe(s.CLOSE_BUTTON); + expect(b.cancelBatch).not.toHaveBeenCalled(); + }); + + it('a batch started from the launch line needs no wait either', async () => { + dialog.answers = [true]; + const b = batch(['not_needed']); + expect(await confirmCloseDuringBatch(b.args)).toBe(true); + expect(titles()).toEqual([s.TITLE]); + expect(b.waitForStart).toHaveBeenCalledTimes(1); + }); + + it('waits for a start still on its way, then asks as for a running batch', async () => { + dialog.answers = [true]; + const b = batch(['pending', 'sent']); + expect(await confirmCloseDuringBatch(b.args)).toBe(true); + expect(b.waitForStart.mock.calls).toEqual([[0], [START_WAIT_MS]]); + expect(titles()).toEqual([s.WAIT_TITLE, s.TITLE]); + expect(dialog.calls[0].opts.hideConfirm).toBe(true); + expect(dialog.calls[0].message).toBe(s.starting(START_WAIT_MS / 1000)); + expect(b.cancelBatch).not.toHaveBeenCalled(); + }); + + it('Keep Studio open during the wait keeps it open and asks nothing more', async () => { + dialog.keepOpenDuringWait = true; + const b = batch(['pending']); + expect(await confirmCloseDuringBatch(b.args)).toBe(false); + expect(titles()).toEqual([s.WAIT_TITLE]); + expect(b.cancelBatch).not.toHaveBeenCalled(); + }); + + it('a start that never reaches the game: says so, and closing cancels the batch', async () => { + dialog.answers = [true]; + const b = batch(['pending', 'pending']); + expect(await confirmCloseDuringBatch(b.args)).toBe(true); + expect(titles()).toEqual([s.WAIT_TITLE, s.NOT_STARTED_TITLE, s.WAIT_TITLE]); + expect(dialog.calls[1].message).toBe(s.NOT_STARTED_MESSAGE); + expect(dialog.calls[1].opts.confirmLabel).toBe(s.CANCEL_AND_CLOSE_BUTTON); + expect(dialog.calls[2].message).toBe(s.CANCELLING); + expect(dialog.calls[2].opts.hideCancel).toBe(true); + expect(b.cancelBatch).toHaveBeenCalledTimes(1); + }); + + it('a batch still being prepared has not started: no wait, same question', async () => { + dialog.answers = [false]; + const b = batch(['preparing']); + expect(await confirmCloseDuringBatch(b.args)).toBe(false); + expect(titles()).toEqual([s.NOT_STARTED_TITLE]); + expect(b.cancelBatch).not.toHaveBeenCalled(); + }); + + it('a batch that ends during the wait closes without asking', async () => { + const b = batch(['pending', 'not_running']); + expect(await confirmCloseDuringBatch(b.args)).toBe(true); + expect(titles()).toEqual([s.WAIT_TITLE]); }); - it('asks while a batch runs, and Keep Studio open keeps it open', async () => { - const ok = await confirmCloseDuringBatch({ isRunning: () => true, isLocalBuild: async () => false }); - expect(ok).toBe(false); - expect(themedConfirm).toHaveBeenCalledWith( - STRINGS.BATCH_CLOSE_MODAL.MESSAGE, - expect.objectContaining({ confirmLabel: STRINGS.BATCH_CLOSE_MODAL.CLOSE_BUTTON }), - ); + it('when Studio cannot tell, it asks as before and cancels nothing', async () => { + dialog.answers = [true]; + const b = batch([new Error('ipc down')]); + expect(await confirmCloseDuringBatch(b.args)).toBe(true); + expect(titles()).toEqual([s.TITLE]); + expect(b.cancelBatch).not.toHaveBeenCalled(); }); it('warns a local build that closing takes the game with it', () => { - expect(batchCloseMessage(true)).toContain(STRINGS.BATCH_CLOSE_MODAL.LOCAL_BUILD_NOTE); - expect(batchCloseMessage(false)).not.toContain(STRINGS.BATCH_CLOSE_MODAL.LOCAL_BUILD_NOTE); + expect(batchCloseMessage(true)).toContain(s.LOCAL_BUILD_NOTE); + expect(batchCloseMessage(false)).not.toContain(s.LOCAL_BUILD_NOTE); }); }); diff --git a/studio/src/ipc_bridge.js b/studio/src/ipc_bridge.js index 39f385b00..eea405943 100644 --- a/studio/src/ipc_bridge.js +++ b/studio/src/ipc_bridge.js @@ -1003,6 +1003,17 @@ export async function catchUpUnwatchedBatches() { }); } +/** Where the running batch's start stands once it is no longer on its way + * to the game, or `timeoutMs` has passed (#545): 'preparing', 'pending', + * 'sent', 'not_needed', 'failed' or 'not_running'. */ +export async function waitForBatchStart(timeoutMs) { + return invoke("wait_for_batch_start", { timeoutMs }) + .catch((err) => { + console.error("IPC Execution Error (wait_for_batch_start):", err); + throw err; + }); +} + /** A project file's text, for a change made without opening it (#545). */ export async function readProjectFile(path) { return invoke("load_project_session", { path }) diff --git a/studio/src/strings.js b/studio/src/strings.js index 8697284db..b6c118f30 100644 --- a/studio/src/strings.js +++ b/studio/src/strings.js @@ -1313,6 +1313,14 @@ export const STRINGS = { LOCAL_BUILD_NOTE: 'This is a local build started from npm run tauri dev: closing it closes the game too, and the batch stops where it is.', CLOSE_BUTTON: 'Close DoD Studio', KEEP_OPEN_BUTTON: 'Keep Studio open', + // The batch's start has not reached the game yet (#545): Studio waits for + // it before asking, so the game does not sit at its main menu. + WAIT_TITLE: 'Closing DoD Studio', + starting: (secondsLeft) => `The game is still starting. Studio is starting the batch in it first, so it can keep capturing after Studio closes… (up to ${secondsLeft} s)`, + NOT_STARTED_TITLE: 'Capture batch not started', + NOT_STARTED_MESSAGE: 'The batch has not started in the game yet, so the game cannot capture it on its own. Closing DoD Studio cancels the batch and closes the game.', + CANCEL_AND_CLOSE_BUTTON: 'Cancel batch and close', + CANCELLING: 'Cancelling the batch and closing the game…', }, // ── A batch that ran while Studio was closed (batch_catch_up.js, #545) ── diff --git a/studio/src/themed_confirm.js b/studio/src/themed_confirm.js index 75bb718b3..7237dba85 100644 --- a/studio/src/themed_confirm.js +++ b/studio/src/themed_confirm.js @@ -50,7 +50,7 @@ function resolveAndClose(result) { * `focusConfirm`: the confirm button takes focus, so Enter picks it; only for * a question whose yes loses nothing. */ -export function themedConfirm(message, { title, confirmLabel, cancelLabel, details, footer, hideCancel = false, focusConfirm = false } = {}) { +export function themedConfirm(message, { title, confirmLabel, cancelLabel, details, footer, hideCancel = false, hideConfirm = false, focusConfirm = false } = {}) { if (titleEl) titleEl.textContent = title || STRINGS.THEMED_CONFIRM_MODAL.TITLE_DEFAULT; if (messageEl) messageEl.textContent = message; if (detailsEl) { @@ -76,7 +76,11 @@ export function themedConfirm(message, { title, confirmLabel, cancelLabel, detai footerEl.textContent = footer || ''; footerEl.style.display = footer ? '' : 'none'; } - if (okBtn) okBtn.textContent = confirmLabel || STRINGS.THEMED_CONFIRM_MODAL.CONFIRM_BUTTON; + if (okBtn) { + okBtn.textContent = confirmLabel || STRINGS.THEMED_CONFIRM_MODAL.CONFIRM_BUTTON; + // `hideConfirm`: a wait the code ends (dismissThemedConfirm), not a question. + okBtn.style.display = hideConfirm ? 'none' : ''; + } if (cancelBtn) { cancelBtn.textContent = cancelLabel || STRINGS.THEMED_CONFIRM_MODAL.CANCEL_BUTTON; // `hideCancel`: a notice with one button, not a question. @@ -86,3 +90,14 @@ export function themedConfirm(message, { title, confirmLabel, cancelLabel, detai if (focusConfirm) okBtn?.focus(); return new Promise((resolve) => { pendingResolve = resolve; }); } + +/** Replaces the open dialog's message, for a wait that counts down. */ +export function setThemedConfirmMessage(message) { + if (messageEl) messageEl.textContent = message; +} + +/** Closes the open dialog from code, answering it with `result`: for a + * wait that ends on its own. Does nothing when no dialog is open. */ +export function dismissThemedConfirm(result) { + if (pendingResolve) resolveAndClose(result); +} From 55bcb15a6d404758397b6c653ef7bf73f73f989a Mon Sep 17 00:00:00 2001 From: ccoventry <17183194+ccoventry@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:49:45 -0600 Subject: [PATCH 4/5] Closing Studio: a second close while the first asks or waits opens nothing The close prompt's wait and question share one dialog. Clicking the title bar's X again during the wait opened a second wait over the first; when the first wait ended, its dismiss answered the second, whose own dismiss then closed the first's question, and Studio stayed open with no answer given. A second close now answers no at once while the first is open. Co-Authored-By: Claude Opus 5.5 --- studio/src/batch_close_prompt.js | 21 +++++++++++++++++++-- studio/src/batch_close_prompt.test.js | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/studio/src/batch_close_prompt.js b/studio/src/batch_close_prompt.js index 67efd80c3..37dec56f1 100644 --- a/studio/src/batch_close_prompt.js +++ b/studio/src/batch_close_prompt.js @@ -78,11 +78,28 @@ async function untilStopped(isRunning, ms) { * `waitForStart(ms)` resolves to where the batch's start stands once it is * no longer on its way to the game, or `ms` has passed (ipc_bridge's * waitForBatchStart). `cancelBatch()` asks the running batch to stop. + * + * A second close while the first still asks or waits (the title bar's X + * clicked again) answers no at once and opens nothing: the dialog is one + * element, and a second one over it took the first's answer, so the end of + * one wait dismissed the other's question and Studio stayed open. */ -export async function confirmCloseDuringBatch({ +export async function confirmCloseDuringBatch(args) { + if (!args.isRunning()) return true; + if (asking) return false; + asking = true; + try { + return await askBeforeClose(args); + } finally { + asking = false; + } +} + +let asking = false; + +async function askBeforeClose({ isRunning, isLocalBuild, waitForStart = waitForBatchStart, cancelBatch = cancelCaptureBatch, }) { - if (!isRunning()) return true; const s = STRINGS.BATCH_CLOSE_MODAL; // Where the start stands now; only a start still on its way is waited for. diff --git a/studio/src/batch_close_prompt.test.js b/studio/src/batch_close_prompt.test.js index b20e686c5..847c2c4db 100644 --- a/studio/src/batch_close_prompt.test.js +++ b/studio/src/batch_close_prompt.test.js @@ -133,6 +133,26 @@ describe('closing Studio during a batch (#545)', () => { expect(b.cancelBatch).not.toHaveBeenCalled(); }); + it('a second close during the wait opens nothing, and the first still asks', async () => { + dialog.answers = [true]; + let sendStart; + const b = batch(['pending']); + b.waitForStart.mockImplementationOnce(async () => 'pending'); + b.waitForStart.mockImplementationOnce(() => new Promise((resolve) => { sendStart = resolve; })); + const first = confirmCloseDuringBatch(b.args); + await vi.waitFor(() => expect(titles()).toEqual([s.WAIT_TITLE])); + expect(await confirmCloseDuringBatch(b.args)).toBe(false); + expect(titles()).toEqual([s.WAIT_TITLE]); + sendStart('sent'); + expect(await first).toBe(true); + expect(titles()).toEqual([s.WAIT_TITLE, s.TITLE]); + // Once answered, a later close asks again. + dialog.answers = [false]; + b.waitForStart.mockImplementationOnce(async () => 'sent'); + expect(await confirmCloseDuringBatch(b.args)).toBe(false); + expect(titles()).toEqual([s.WAIT_TITLE, s.TITLE, s.TITLE]); + }); + it('warns a local build that closing takes the game with it', () => { expect(batchCloseMessage(true)).toContain(s.LOCAL_BUILD_NOTE); expect(batchCloseMessage(false)).not.toContain(s.LOCAL_BUILD_NOTE); From 092f8961069b9e20645509fcd536196e040c45a9 Mon Sep 17 00:00:00 2001 From: ccoventry <17183194+ccoventry@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:51:45 -0600 Subject: [PATCH 5/5] Close prompt: Cancel batch and close is red, since yes throws the batch away (#880) Co-Authored-By: Claude Opus 5.5 --- studio/src/batch_close_prompt.js | 2 ++ studio/src/batch_close_prompt.test.js | 3 +++ 2 files changed, 5 insertions(+) diff --git a/studio/src/batch_close_prompt.js b/studio/src/batch_close_prompt.js index 37dec56f1..e8cb841ad 100644 --- a/studio/src/batch_close_prompt.js +++ b/studio/src/batch_close_prompt.js @@ -128,6 +128,8 @@ async function askBeforeClose({ title: s.NOT_STARTED_TITLE, confirmLabel: s.CANCEL_AND_CLOSE_BUTTON, cancelLabel: s.KEEP_OPEN_BUTTON, + // Red: yes cancels the batch, so its captures never happen (#880). + destructive: true, }); if (!close) return false; // Cancelled, the batch closes the game and records itself as cancelled, diff --git a/studio/src/batch_close_prompt.test.js b/studio/src/batch_close_prompt.test.js index 847c2c4db..c2193d1e2 100644 --- a/studio/src/batch_close_prompt.test.js +++ b/studio/src/batch_close_prompt.test.js @@ -69,6 +69,8 @@ describe('closing Studio during a batch (#545)', () => { expect(dialog.calls).toHaveLength(1); expect(dialog.calls[0].message).toBe(s.MESSAGE); expect(dialog.calls[0].opts.confirmLabel).toBe(s.CLOSE_BUTTON); + // The batch carries on without Studio: nothing is thrown away. + expect(dialog.calls[0].opts.destructive).toBeFalsy(); expect(b.cancelBatch).not.toHaveBeenCalled(); }); @@ -106,6 +108,7 @@ describe('closing Studio during a batch (#545)', () => { expect(titles()).toEqual([s.WAIT_TITLE, s.NOT_STARTED_TITLE, s.WAIT_TITLE]); expect(dialog.calls[1].message).toBe(s.NOT_STARTED_MESSAGE); expect(dialog.calls[1].opts.confirmLabel).toBe(s.CANCEL_AND_CLOSE_BUTTON); + expect(dialog.calls[1].opts.destructive).toBe(true); expect(dialog.calls[2].message).toBe(s.CANCELLING); expect(dialog.calls[2].opts.hideCancel).toBe(true); expect(b.cancelBatch).toHaveBeenCalledTimes(1);