From ace22ccffbf0d6827b883cf561aa19a7b3fca36a Mon Sep 17 00:00:00 2001 From: David Chavez Date: Tue, 8 Sep 2026 09:07:56 +0200 Subject: [PATCH 1/2] feat: record fast3d capture sequences from renderer reset --- Cargo.toml | 3 +- README.md | 2 + docs/capture.md | 145 +++++++++ src/render.rs | 153 ++++++++- src/render/capture.rs | 707 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1002 insertions(+), 8 deletions(-) create mode 100644 docs/capture.md create mode 100644 src/render/capture.rs diff --git a/Cargo.toml b/Cargo.toml index 1ec76e2..a6d3e7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ network = ["dep:tokio"] [dependencies] anyhow = "1.0.70" +ctrlc = { version = "3.5.2", features = ["termination"] } env_logger = "0.10.0" gilrs = { version = "0.10.2", features = ["serde", "serde-serialize"] } winit = { version = "0.30", features = ["x11", "wayland", "wayland-dlopen", "wayland-csd-adwaita"] } @@ -28,7 +29,7 @@ wgpu = { version = "29" } spin_sleep = "1.1.1" arie = "0.3.0" # Pinned: fast3d may change its API between commits; bump this deliberately and fix up the render path with it. -fast3d = { git = "https://github.com/retrofoundry/fast3d-rs", rev = "44d2d1b", default-features = false } +fast3d = { git = "https://github.com/retrofoundry/fast3d-rs", rev = "44d2d1b", default-features = false, features = ["capture"] } rustc-hash = "1.1.0" # For local development, uncomment to build against local checkouts of fast3d and diff --git a/README.md b/README.md index b46d84f..fe47360 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ For details on each of the features provided please see our [documentation](http For setup and more in-depth information please see our [documentation](https://retrofoundry.github.io/helix/dev/). +For display-list fixtures and continuous recordings, see [fast3d capture](docs/capture.md). + ## Community [![](https://dcbadge.vercel.app/api/server/nGckYNTp4w)](https://discord.gg/nGckYNTp4w) diff --git a/docs/capture.md b/docs/capture.md new file mode 100644 index 0000000..8e9eca9 --- /dev/null +++ b/docs/capture.md @@ -0,0 +1,145 @@ +# fast3d capture + +Set capture variables before launching the game. Helix must be built against a +fast3d revision with `CaptureSequence` and the native capture API. The dependency +enables fast3d's `capture` feature. A statically linked game must be relinked after +rebuilding Helix. + +| Variable | Meaning | +| --- | --- | +| `FAST3D_CAPTURE_DIR` | Directory for individual frame fixtures. Requires `FAST3D_CAPTURE_FRAMES`. | +| `FAST3D_CAPTURE_FRAMES` | Comma-separated renderer serials for individual fixtures, starting at 1. | +| `FAST3D_CAPTURE_SEQUENCE` | Output file for one sequence, for example `/tmp/startup.f3dcap`. Mutually exclusive with `FAST3D_CAPTURE_DIR`. | +| `FAST3D_CAPTURE_WARMUP_FRAMES` | Number of initial sequence frames before observation; defaults to 0. These frames remain in the recording. | +| `FAST3D_CAPTURE_PRESENTATIONS` | Required sequence presentation serials, comma-separated, all greater than the warm-up count. | +| `FAST3D_CAPTURE_REVISION` | Decomp revision in each frame's provenance; defaults to `unknown`. | +| `FAST3D_CAPTURE_SYMBOLS` | Source symbols or scene annotations in provenance; defaults to `unknown (live task)`. | + +Lists accept surrounding whitespace, duplicate serials, and empty comma-separated +elements, including the trailing comma produced by BSD `seq -s,`. Empty lists, +zero, invalid numbers, and invalid combinations cause render-thread failure and +runtime shutdown. Existing capture files are never overwritten. Auxiliary variables +alone do not enable capture. + +## Individual frames + +```sh +FAST3D_CAPTURE_DIR=/tmp/sm64-frames \ +FAST3D_CAPTURE_FRAMES=120,240,360, \ +FAST3D_CAPTURE_REVISION="$(git rev-parse HEAD)" \ +FAST3D_CAPTURE_SYMBOLS='live sm64 graphics tasks' \ +RUST_LOG=helix::render=info \ +./build-cmake/sm64-us +``` + +This writes `frame-000120.f3dcap`, `frame-000240.f3dcap`, and +`frame-000360.f3dcap`, named from `fixture.frame.serial`. Serial 1 is the first +graphics task, including startup before gameplay. PR #33 used zero-based consume +indices, so its selection 120 wrote serial 121; this hook selects the renderer +serial directly. Add one to old selections to capture the same tasks. + +Standalone fixtures still require self-contained frame state. A frame that depends +on prior RDP/TMEM or framebuffer history can fail standalone capture or replay; +use a sequence for those workloads. Capture failures log an error and request +shutdown. A successful write logs `captured frame ...`. + +## Sequences + +```sh +FAST3D_CAPTURE_SEQUENCE=/tmp/sm64-startup.f3dcap \ +FAST3D_CAPTURE_WARMUP_FRAMES=120 \ +FAST3D_CAPTURE_PRESENTATIONS=121,180,240, \ +FAST3D_CAPTURE_REVISION="$(git rev-parse HEAD)" \ +FAST3D_CAPTURE_SYMBOLS='startup through selected gameplay route' \ +RUST_LOG=helix::render=info \ +./build-cmake/sm64-us +``` + +The sequence resets the renderer before the first task and records every frame +through serial 240. It retains frames 1–120 as warm-up and selects 121, 180, and +240 for replay images. Serials are never cropped or renumbered. Each Helix graphics +task is one frame. The dither seed is 0 and depth reset policy is `Never`. + +After serial 240 is presented, `CaptureSequence::finish` validates the recording, +the file is written, and recording stops. The game continues running. Look for +`capture sequence started: ... frames 1..=240` and +`capture sequence complete: wrote 240 frames to ...` in the log. The completed +file uses the version-two sequence container, not a standalone frame payload. + +Sequence startup also logs `capture sequence context initialization #1` with the +process ID. A second sequence context in the same process logs its attempt number +and fails before creating a renderer or recording. It cannot restart at serial 1 +and compete for the same output. Helix's current GUI starts one render context; +resuming an existing window does not recreate it. Restart the process for another +sequence, even after the first finishes. + +Guest memory is copied inside `consume_dl`, before the blocked guest is released. +All sequence renderer operations use the capture wrapper. Presentation uses +`present_last`, without VI registers or guest-memory reads. Renderer resizes are +deferred until recording finishes to keep one extent and avoid invalidating the +sequence. Avoid resizing or minimizing during a recording: a surface presentation +failure stops capture and attempts to save the earlier completed prefix. + +Without a capture output variable, Helix retains the normal renderer operations +and presentation error handling and installs no signal handler. + +## Ending a run early + +Sequence mode attempts to install a `ctrlc` handler with termination support once +per process, including when installation fails. Success logs +`capture shutdown handler installed`. With that handler, Ctrl-C, SIGTERM +(`kill -TERM `), SIGHUP, and normal window close reach Helix's shutdown +path, which joins the render thread after it finishes and writes completed frames. +Allow shutdown to finish; a second forced kill can interrupt serialization. +Failure logs `capture shutdown handler unavailable` and warns that a killed run +will not save an unfinished capture. Recording continues: endpoint and normal +window-close saves remain enabled. The sequence-start message alone does not +promise signal save. `ctrlc::try_set_handler` also rejects existing native signal +handlers and ignored signals, so its "already registered" error does not prove +Helix initialized twice. Existing signal dispositions are not deliberately +overwritten. + +For a prefix ending at serial N, requested presentations greater than N are +removed. If none were reached, N becomes the sole presentation. Warm-up becomes +`min(requested_warmup, N - 1)`. Every frame 1..N remains present. The log says +`capture sequence incomplete` with the actual counts and selection, followed by +`capture sequence partial: wrote N frames ...`. This is a valid partial recording, +not evidence that the requested range completed. If no frame completed, there is +no valid sequence to write; the error says so. + +SIGKILL, process abort, power loss, or a render-thread panic cannot reliably save +an unfinished recording. `CaptureSequence` keeps frames in memory and only exposes +them through consuming `finish`; it has no snapshot/checkpoint API. This hook saves +on catchable termination and at the requested endpoint. It does not install unsafe +signal handlers or claim crash recovery. Memory grows with the retained prefix, +and serialization needs additional memory. + +Writes use a unique `.-.partial` file in the output directory, +sync its bytes, then atomically publish the final name with a hard link. The +filesystem must support hard links. The final path never exposes a half-written +container. Empty staging files are removed on graceful abandonment; crashes can +leave them behind without blocking retries. Failed writes/publications can leave +nonempty staging files; the error identifies the path. Validate such a file with +the replay tool before using it. A `.partial` file alone is not a successful capture. + +## Replay and validation + +From the fast3d worktree, with its supported toolchain: + +```sh +mkdir -p /tmp/sm64-replay +cargo run -p fast3d --features capture --example replay_capture -- \ + /tmp/sm64-startup.f3dcap /tmp/sm64-replay/startup all +``` + +The replay loader rejects gaps, missing reset history, invalid warm-up/presentation +metadata, and malformed/truncated files. The `all` mode replays depth policies +`persist`, `cimg`, `task`, and `frame`; it writes per-policy logs and selected +serial PNG/RGBA8 files, plus comparisons against persistence. For example, expect +`startup-persist-000121.png`, `startup-persist-000180.png`, +`startup-persist-000240.png`, and `startup-persist.log`. + +Check that the log lists every original frame 1..240, warm-up 120, the three +requested presentations, expected provenance, one native task per frame, and no +missing-memory errors. Review the images against the live route. Container +validation alone does not establish visual correctness. diff --git a/src/render.rs b/src/render.rs index 2a763f2..5a98c68 100644 --- a/src/render.rs +++ b/src/render.rs @@ -6,6 +6,8 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::mpsc::Receiver; use std::thread::JoinHandle; +mod capture; + /// Dedup-then-log diagnostics: a message is logged at most once per process lifetime, keyed on /// `d.kind`'s `Display` string. pub(crate) struct DedupLogSink; @@ -139,6 +141,11 @@ pub struct SurfaceHandoff { pub struct RenderContext { renderer: fast3d::Renderer, + frame_serial: u64, + capture_selection: Option, + capture_frame: Option, + capture_sequence: Option, + deferred_resize: Option<(u32, u32)>, } impl RenderContext { @@ -146,6 +153,10 @@ impl RenderContext { /// device setup verbatim (`with_device` adopts a caller-made device and does not configure the /// surface — spec §6). Panics here are caught by `spawn` (fatal-on-panic). pub fn from_handoff(h: SurfaceHandoff) -> RenderContext { + let selection = capture::Selection::from_env().expect("helix capture configuration"); + if let Some(selection) = &selection { + selection.install_shutdown_handler(); + } let SurfaceHandoff { instance, surface, @@ -180,7 +191,7 @@ impl RenderContext { let config = surface_config(width, height); surface.configure(&device, &config); - let renderer = fast3d::Renderer::with_device( + let mut renderer = fast3d::Renderer::with_device( device, queue, fast3d::PresentTarget::Surface { surface, config }, @@ -193,22 +204,130 @@ impl RenderContext { power_preference: wgpu::PowerPreference::HighPerformance, }, ); - RenderContext { renderer } + let (capture_selection, capture_sequence) = match selection { + Some(capture::Selection::Frames(selection)) => { + log::info!( + "per-frame capture enabled; selections use renderer serials starting at one" + ); + (Some(selection), None) + } + Some(capture::Selection::Sequence(selection)) => ( + None, + Some( + capture::SequenceRecording::begin(&mut renderer, selection) + .expect("helix capture: begin sequence"), + ), + ), + None => (None, None), + }; + RenderContext { + renderer, + frame_serial: 0, + capture_selection, + capture_frame: None, + capture_sequence, + deferred_resize: None, + } } pub fn consume_dl(&mut self, data_ptr: usize) { - self.renderer.set_data_format(data_format()); - self.renderer.begin_frame(); + self.frame_serial += 1; + let capture = if let Some(sequence) = &mut self.capture_sequence { + if let Err(error) = sequence + .recorder + .begin_frame(&mut self.renderer, capture::provenance(self.frame_serial)) + { + log::error!( + "capture sequence begin frame {}: {error}; shutting down", + self.frame_serial + ); + crate::ultra::request_shutdown(); + return; + } + Some( + sequence + .recorder + .frame_mut() + .expect("capture frame just begun"), + ) + } else if self + .capture_selection + .as_ref() + .is_some_and(|selection| selection.contains(self.frame_serial)) + { + self.capture_frame = Some(fast3d::capture::CaptureFrame::begin( + &mut self.renderer, + self.frame_serial, + 0, + capture::provenance(self.frame_serial), + )); + self.capture_frame.as_mut() + } else { + self.renderer.set_data_format(data_format()); + self.renderer.begin_frame(); + None + }; let ram = fast3d::HostRam::new(&[]); // SAFETY: the guest stays blocked until consume_dl returns, so every span the walk // reads remains live and stable in the guest's native layout. - let _ = unsafe { - self.renderer - .process_dl_host(ram, data_ptr as u64, microcode(), &mut DedupLogSink) + let result = unsafe { + if let Some(capture) = capture { + capture + .process_dl_host( + &mut self.renderer, + ram, + data_ptr as u64, + microcode(), + data_format(), + &mut DedupLogSink, + ) + .map(|_| ()) + } else { + let _ = self.renderer.process_dl_host( + ram, + data_ptr as u64, + microcode(), + &mut DedupLogSink, + ); + Ok(()) + } }; + if let Err(error) = result { + log::error!( + "capture frame {}: {error}; shutting down", + self.frame_serial + ); + crate::ultra::request_shutdown(); + } } pub fn present(&mut self) { + if let Some(sequence) = &mut self.capture_sequence { + match sequence.present(&mut self.renderer) { + Ok(true) => self.finish_capture("requested range complete"), + Ok(false) => {} + Err(error) => { + log::error!("capture sequence present: {error}; shutting down"); + self.finish_capture("capture error"); + crate::ultra::request_shutdown(); + } + } + return; + } + if let Some(capture) = self.capture_frame.take() { + let result = capture + .present_last(&mut self.renderer) + .map_err(anyhow::Error::from) + .and_then(|fixture| self.capture_selection.as_ref().unwrap().write(&fixture)); + if let Err(error) = result { + log::error!( + "capture frame {}: {error:#}; shutting down", + self.frame_serial + ); + crate::ultra::request_shutdown(); + } + return; + } match self.renderer.present_last() { Ok(()) => {} Err(fast3d::PresentError::SurfaceLost) => {} @@ -218,9 +337,28 @@ impl RenderContext { pub fn resize(&mut self, width: u32, height: u32) { if let Some((w, h)) = valid_surface_size(width, height) { + if self.capture_sequence.is_some() { + if self.deferred_resize.is_none() { + log::warn!("deferring renderer resize until sequence capture finishes"); + } + self.deferred_resize = Some((w, h)); + return; + } self.renderer.resize(w, h); } } + + fn finish_capture(&mut self, reason: &str) { + if let Some(sequence) = self.capture_sequence.take() { + if let Err(error) = sequence.finish(reason) { + log::error!("save capture sequence: {error:#}; shutting down"); + crate::ultra::request_shutdown(); + } + if let Some((w, h)) = self.deferred_resize.take() { + self.renderer.resize(w, h); + } + } + } } /// Consume/present/resize seam so the loop's dispatch is unit-testable without a GPU. @@ -281,6 +419,7 @@ pub fn spawn(handoff: SurfaceHandoff) -> RenderHandle { let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let mut ctx = RenderContext::from_handoff(handoff); render_loop_on(&mut ctx, rx); + ctx.finish_capture("shutdown"); })); if outcome.is_err() { log::error!("helix render thread failed; shutting down"); diff --git a/src/render/capture.rs b/src/render/capture.rs new file mode 100644 index 0000000..48b2e91 --- /dev/null +++ b/src/render/capture.rs @@ -0,0 +1,707 @@ +use anyhow::{bail, Context, Result}; +use fast3d::capture::{CaptureSequence, Fixture, Provenance}; +use std::ffi::OsString; +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Once; + +pub(super) enum Selection { + Frames(FrameSelection), + Sequence(SequenceSelection), +} + +impl Selection { + pub fn from_env() -> Result> { + let selection = Self::parse( + std::env::var_os("FAST3D_CAPTURE_DIR"), + std::env::var("FAST3D_CAPTURE_FRAMES").ok(), + std::env::var_os("FAST3D_CAPTURE_SEQUENCE"), + std::env::var("FAST3D_CAPTURE_WARMUP_FRAMES").ok(), + std::env::var("FAST3D_CAPTURE_PRESENTATIONS").ok(), + )?; + if let Some(Self::Sequence(sequence)) = &selection { + static CONTEXTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let context = CONTEXTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + log::info!( + "capture sequence context initialization #{context} (pid {}): {}", + std::process::id(), + sequence.path.display() + ); + if context != 1 { + bail!("capture sequence already initialized in this process; refusing to restart recording in a second render context"); + } + } + Ok(selection) + } + + pub fn install_shutdown_handler(&self) { + if matches!(self, Self::Sequence(_)) { + static INSTALL: Once = Once::new(); + INSTALL.call_once(|| { + match ctrlc::try_set_handler(crate::ultra::request_shutdown) { + Ok(()) => log::info!("capture shutdown handler installed: SIGINT/SIGTERM/SIGHUP save a completed prefix; SIGKILL cannot save"), + Err(error) => log::warn!("capture shutdown handler unavailable: {error}; a killed run will not save an unfinished capture; endpoint and normal window-close saves remain enabled"), + } + }); + } + } + + fn parse( + directory: Option, + frames: Option, + sequence: Option, + warmup: Option, + presentations: Option, + ) -> Result> { + match (directory, sequence) { + (Some(_), Some(_)) => { + bail!("FAST3D_CAPTURE_DIR and FAST3D_CAPTURE_SEQUENCE are mutually exclusive") + } + (Some(directory), None) => { + if directory.is_empty() { + bail!("FAST3D_CAPTURE_DIR is empty"); + } + let frames = + frames.context("FAST3D_CAPTURE_FRAMES is required with FAST3D_CAPTURE_DIR")?; + Ok(Some(Self::Frames(FrameSelection { + directory: directory.into(), + frames: parse_serials(&frames)?, + }))) + } + (None, Some(path)) => Ok(Some(Self::Sequence(SequenceSelection::parse( + path, + warmup, + presentations, + )?))), + (None, None) => Ok(None), + } + } +} + +fn parse_serials(value: &str) -> Result> { + let mut serials = value + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| { + let serial = s + .parse::() + .with_context(|| format!("invalid capture frame serial: {s:?}"))?; + if serial == 0 { + bail!("capture frame serials start at one"); + } + Ok(serial) + }) + .collect::>>()?; + if serials.is_empty() { + bail!("capture requires at least one frame serial"); + } + serials.sort_unstable(); + serials.dedup(); + Ok(serials) +} + +pub(super) struct FrameSelection { + directory: PathBuf, + frames: Vec, +} + +impl FrameSelection { + pub fn contains(&self, serial: u64) -> bool { + self.frames.binary_search(&serial).is_ok() + } + + pub fn write(&self, fixture: &Fixture) -> Result<()> { + let path = self + .directory + .join(format!("frame-{:06}.f3dcap", fixture.frame.serial)); + CaptureOutput::create(&path)?.write(&fixture.to_bytes()?)?; + log::info!( + "captured frame {} to {}", + fixture.frame.serial, + path.display() + ); + Ok(()) + } +} + +pub(super) struct SequenceSelection { + path: PathBuf, + warmup_frames: u32, + presentations: Vec, +} + +impl SequenceSelection { + fn parse( + path: OsString, + warmup: Option, + presentations: Option, + ) -> Result { + if path.is_empty() { + bail!("FAST3D_CAPTURE_SEQUENCE is empty"); + } + let warmup_frames = warmup + .as_deref() + .unwrap_or("0") + .trim() + .parse::() + .context("invalid FAST3D_CAPTURE_WARMUP_FRAMES")?; + let presentations = parse_serials( + &presentations + .context("FAST3D_CAPTURE_PRESENTATIONS is required with FAST3D_CAPTURE_SEQUENCE")?, + )?; + if presentations[0] <= u64::from(warmup_frames) { + bail!("FAST3D_CAPTURE_PRESENTATIONS must be after FAST3D_CAPTURE_WARMUP_FRAMES"); + } + Ok(Self { + path: path.into(), + warmup_frames, + presentations, + }) + } + + fn last_serial(&self) -> u64 { + *self.presentations.last().unwrap() + } + + fn prefix(&self, frames: u64) -> Result<(u32, Vec)> { + if frames == 0 { + bail!("no completed frames; no sequence can be written"); + } + let warmup = u64::from(self.warmup_frames).min(frames - 1) as u32; + let mut presentations: Vec<_> = self + .presentations + .iter() + .copied() + .take_while(|&serial| serial <= frames) + .collect(); + if presentations.is_empty() { + presentations.push(frames); + } + Ok((warmup, presentations)) + } +} + +pub(super) struct SequenceRecording { + pub recorder: CaptureSequence, + selection: SequenceSelection, + output: CaptureOutput, + completed_frames: u64, +} + +impl SequenceRecording { + pub fn begin(renderer: &mut fast3d::Renderer, selection: SequenceSelection) -> Result { + let output = CaptureOutput::create(&selection.path)?; + let recorder = CaptureSequence::begin(renderer, 0)?; + log::info!( + "capture sequence started: {} frames 1..={}, warmup {}, presentations {:?}", + selection.path.display(), + selection.last_serial(), + selection.warmup_frames, + selection.presentations + ); + Ok(Self { + recorder, + selection, + output, + completed_frames: 0, + }) + } + + pub fn present(&mut self, renderer: &mut fast3d::Renderer) -> Result { + self.recorder.present_last(renderer)?; + self.completed_frames += 1; + Ok(self.completed_frames == self.selection.last_serial()) + } + + pub fn finish(self, reason: &str) -> Result<()> { + let (warmup, presentations) = self.selection.prefix(self.completed_frames)?; + let complete = self.completed_frames == self.selection.last_serial(); + if !complete { + log::warn!("capture sequence incomplete ({reason}): {}/{} frames; saving warmup {warmup}, presentations {presentations:?}", self.completed_frames, self.selection.last_serial()); + } + let sequence = self.recorder.finish(warmup, presentations)?; + self.output.write(&sequence.to_bytes()?)?; + log::info!( + "capture sequence {}: wrote {} frames to {}", + if complete { "complete" } else { "partial" }, + self.completed_frames, + self.selection.path.display() + ); + Ok(()) + } +} + +struct CaptureOutput { + path: PathBuf, + temporary: PathBuf, + file: File, +} + +impl CaptureOutput { + fn create(path: &Path) -> Result { + if path.try_exists()? { + bail!("capture output already exists: {}", path.display()); + } + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent)?; + } + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let (temporary, file) = loop { + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut temporary = path.as_os_str().to_owned(); + temporary.push(format!(".{}-{id}.partial", std::process::id())); + let temporary = PathBuf::from(temporary); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + { + Ok(file) => break (temporary, file), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(error).with_context(|| { + format!("create capture staging file {}", temporary.display()) + }) + } + } + }; + Ok(Self { + path: path.to_owned(), + temporary, + file, + }) + } + + fn write(mut self, bytes: &[u8]) -> Result<()> { + self.file + .write_all(bytes) + .with_context(|| format!("write capture staging file {}", self.temporary.display()))?; + self.file + .sync_all() + .with_context(|| format!("sync capture staging file {}", self.temporary.display()))?; + // Publish only complete bytes, without replacing a capture from another run. + std::fs::hard_link(&self.temporary, &self.path).with_context(|| { + format!( + "publish capture {}; staging file retained at {}", + self.path.display(), + self.temporary.display() + ) + })?; + if let Err(error) = std::fs::remove_file(&self.temporary) { + log::warn!( + "remove capture staging file {}: {error}", + self.temporary.display() + ); + } + Ok(()) + } +} + +impl Drop for CaptureOutput { + fn drop(&mut self) { + if self + .file + .metadata() + .is_ok_and(|metadata| metadata.len() == 0) + { + let _ = std::fs::remove_file(&self.temporary); + } + } +} + +pub(super) fn provenance(serial: u64) -> Provenance { + Provenance { + decomp_revision: std::env::var("FAST3D_CAPTURE_REVISION") + .unwrap_or_else(|_| "unknown".into()), + source_symbols: std::env::var("FAST3D_CAPTURE_SYMBOLS") + .unwrap_or_else(|_| "unknown (live task)".into()), + command_vector: format!("helix/frame/{serial}"), + synthetic_data: "none; live guest memory".into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch_directory() -> PathBuf { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let directory = std::env::temp_dir().join(format!( + "helix-capture-{}-{}", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + std::fs::create_dir(&directory).unwrap(); + directory + } + + #[cfg(unix)] + fn reset_shutdown_signals_in_child() { + for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP] { + unsafe { + assert_ne!(libc::signal(signal, libc::SIG_DFL), libc::SIG_ERR); + } + } + } + + #[cfg(unix)] + #[test] + fn sequence_termination_requests_runtime_shutdown() { + const CHILD: &str = "HELIX_TEST_CAPTURE_TERMINATION"; + if let Ok(signal) = std::env::var(CHILD) { + reset_shutdown_signals_in_child(); + env_logger::Builder::new() + .filter_level(log::LevelFilter::Info) + .init(); + let selection = Selection::parse( + None, + None, + Some("unused.f3dcap".into()), + None, + Some("1".into()), + ) + .unwrap() + .unwrap(); + std::thread::scope(|scope| { + for _ in 0..8 { + scope.spawn(|| selection.install_shutdown_handler()); + } + }); + assert!(!crate::ultra::shutdown_requested()); + assert!(std::process::Command::new("kill") + .arg(format!("-{signal}")) + .arg(std::process::id().to_string()) + .status() + .unwrap() + .success()); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !crate::ultra::shutdown_requested() { + assert!( + std::time::Instant::now() < deadline, + "signal did not request runtime shutdown" + ); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + return; + } + for signal in ["INT", "TERM", "HUP"] { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "render::capture::tests::sequence_termination_requests_runtime_shutdown", + "--nocapture", + ]) + .env(CHILD, signal) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "{signal}: {}\n{stderr}", + String::from_utf8_lossy(&output.stdout), + ); + assert_eq!( + stderr.matches("capture shutdown handler installed").count(), + 1 + ); + assert!(!stderr.contains("capture shutdown handler unavailable")); + } + } + + #[cfg(unix)] + #[test] + fn shutdown_handler_conflicts_warn_once_and_do_not_stop_capture_startup() { + const CHILD: &str = "HELIX_TEST_CAPTURE_HANDLER_CONFLICT"; + if let Ok(conflict) = std::env::var(CHILD) { + reset_shutdown_signals_in_child(); + env_logger::Builder::new() + .filter_level(log::LevelFilter::Info) + .init(); + let signal = match conflict.as_str() { + "INT" => libc::SIGINT, + "TERM" => libc::SIGTERM, + "HUP" => libc::SIGHUP, + _ => unreachable!(), + }; + unsafe { + assert_ne!(libc::signal(signal, libc::SIG_IGN), libc::SIG_ERR); + } + let selection = Selection::parse( + None, + None, + Some("unused.f3dcap".into()), + None, + Some("1".into()), + ) + .unwrap() + .unwrap(); + selection.install_shutdown_handler(); + assert!(!crate::ultra::shutdown_requested()); + unsafe { + assert_eq!(libc::signal(signal, libc::SIG_DFL), libc::SIG_IGN); + } + selection.install_shutdown_handler(); + unsafe { + assert_eq!(libc::signal(signal, libc::SIG_DFL), libc::SIG_DFL); + } + assert!(!crate::ultra::shutdown_requested()); + return; + } + for conflict in ["INT", "TERM", "HUP"] { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "render::capture::tests::shutdown_handler_conflicts_warn_once_and_do_not_stop_capture_startup", + "--nocapture", + ]) + .env(CHILD, conflict) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "{conflict}: {}\n{stderr}", + String::from_utf8_lossy(&output.stdout), + ); + assert_eq!(stderr.matches("killed run will not save").count(), 1); + assert!(!stderr.contains("capture shutdown handler installed")); + } + } + + #[test] + fn sequence_selection_cannot_be_reinitialized() { + const CHILD: &str = "HELIX_TEST_CAPTURE_SELECTION_OWNER"; + if std::env::var_os(CHILD).is_some() { + let mut selection = Selection::from_env().unwrap(); + assert!(selection.is_some()); + for _ in 0..2 { + let Err(error) = Selection::from_env() else { + panic!("a second context must not start another sequence"); + }; + assert!(error.to_string().contains("already initialized")); + drop(selection.take()); + } + return; + } + let directory = scratch_directory(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "render::capture::tests::sequence_selection_cannot_be_reinitialized", + "--nocapture", + ]) + .env(CHILD, "1") + .env_remove("FAST3D_CAPTURE_DIR") + .env("FAST3D_CAPTURE_SEQUENCE", directory.join("run.f3dcap")) + .env("FAST3D_CAPTURE_WARMUP_FRAMES", "0") + .env("FAST3D_CAPTURE_PRESENTATIONS", "1") + .output() + .unwrap(); + std::fs::remove_dir_all(directory).unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + + fn fixture(serial: u64) -> Fixture { + use fast3d::capture::{Frame, MemoryLayout, MemorySpan, SourceLayout, Task}; + Fixture { + frame: Frame { + serial, + dither_seed: 0, + config: fast3d::RendererConfig { + clear_policy: fast3d::ClearPolicy::Persist, + resolution_multiplier: 1, + sample_count: 1, + present_mode: wgpu::PresentMode::Fifo, + format: Some(wgpu::TextureFormat::Bgra8Unorm), + power_preference: wgpu::PowerPreference::HighPerformance, + }, + width: 4, + height: 4, + vi: None, + dual_source_blending: false, + }, + tasks: vec![Task { + entry: 0x100, + microcode: fast3d::Microcode::F3d, + data_format: fast3d::DataFormat::Fixed, + order: 0, + source: SourceLayout { + memory: MemoryLayout::IMAGE, + segments: [0; 16], + }, + spans: vec![MemorySpan { + address: 0x100, + bytes: vec![0xb8, 0, 0, 0, 0, 0, 0, 0], + }], + }], + provenance: Provenance { + synthetic_data: "test end-DL".into(), + ..Default::default() + }, + } + } + + #[test] + fn capture_modes_require_an_output_and_cannot_be_combined() { + assert!(Selection::parse( + None, + Some("bad".into()), + None, + Some("bad".into()), + Some("bad".into()) + ) + .unwrap() + .is_none()); + assert!(Selection::parse( + Some("frames".into()), + Some("1".into()), + Some("run.f3dcap".into()), + None, + Some("1".into()) + ) + .is_err()); + assert!(Selection::parse(Some("frames".into()), None, None, None, None).is_err()); + assert!(Selection::parse(Some("".into()), Some("1".into()), None, None, None).is_err()); + } + + #[test] + fn per_frame_output_uses_fixture_serial_and_never_overwrites() { + let directory = scratch_directory(); + let selection = FrameSelection { + directory: directory.clone(), + frames: vec![7], + }; + assert!(selection.contains(7)); + assert!(!selection.contains(6)); + let fixture = fixture(7); + selection.write(&fixture).unwrap(); + let path = directory.join("frame-000007.f3dcap"); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(Fixture::from_bytes(&bytes).unwrap(), fixture); + assert!(selection.write(&fixture).is_err()); + assert_eq!(std::fs::read(path).unwrap(), bytes); + assert_eq!(std::fs::read_dir(&directory).unwrap().count(), 1); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn output_is_unpublished_until_complete_and_refuses_a_competing_writer() { + let directory = scratch_directory(); + let path = directory.join("run.f3dcap"); + let output = CaptureOutput::create(&path).unwrap(); + assert!(!path.exists()); + let competing = CaptureOutput::create(&path).unwrap(); + assert_ne!(output.temporary, competing.temporary); + drop(competing); + std::fs::write(&path, b"another run").unwrap(); + assert!(output.write(b"new capture").is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"another run"); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn abandoned_empty_output_is_cleaned_up_and_can_be_retried() { + let directory = scratch_directory(); + let path = directory.join("run.f3dcap"); + drop(CaptureOutput::create(&path).unwrap()); + assert_eq!(std::fs::read_dir(&directory).unwrap().count(), 0); + CaptureOutput::create(&path) + .unwrap() + .write(b"retry") + .unwrap(); + assert_eq!(std::fs::read(path).unwrap(), b"retry"); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn partial_prefix_writes_a_valid_sequence_with_every_original_serial() { + let directory = scratch_directory(); + let path = directory.join("run.f3dcap"); + let selection = SequenceSelection::parse( + path.clone().into_os_string(), + Some("10".into()), + Some("12,20".into()), + ) + .unwrap(); + let (warmup_frames, presentations) = selection.prefix(4).unwrap(); + let sequence = fast3d::capture::Sequence { + frames: (1..=4).map(fixture).collect(), + warmup_frames, + presentations, + }; + CaptureOutput::create(&path) + .unwrap() + .write(&sequence.to_bytes().unwrap()) + .unwrap(); + let saved = fast3d::capture::Sequence::from_bytes(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!( + saved + .frames + .iter() + .map(|f| f.frame.serial) + .collect::>(), + vec![1, 2, 3, 4] + ); + assert_eq!(saved.warmup_frames, 3); + assert_eq!(saved.presentations, vec![4]); + assert_eq!(std::fs::read_dir(&directory).unwrap().count(), 1); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn serial_lists_accept_bsd_seq_trailing_comma() { + assert_eq!(parse_serials(" 1, 5,,19,5, \n").unwrap(), vec![1, 5, 19]); + for invalid in ["", ", ,", "0", "-1", "1,no", "18446744073709551616"] { + assert!(parse_serials(invalid).is_err(), "{invalid:?}"); + } + } + + #[test] + fn sequence_selection_requires_presentations_after_warmup() { + let selection = SequenceSelection::parse( + "run.f3dcap".into(), + Some("10".into()), + Some("20,11,".into()), + ) + .unwrap(); + assert_eq!(selection.warmup_frames, 10); + assert_eq!(selection.presentations, vec![11, 20]); + assert_eq!(selection.last_serial(), 20); + for (path, warmup, presentations) in [ + ("", None, Some("1")), + ("run.f3dcap", Some("-1"), Some("1")), + ("run.f3dcap", Some("4294967296"), Some("1")), + ("run.f3dcap", Some("10"), Some("10,11")), + ("run.f3dcap", None, None), + ] { + assert!(SequenceSelection::parse( + path.into(), + warmup.map(str::to_owned), + presentations.map(str::to_owned) + ) + .is_err()); + } + } + + #[test] + fn interrupted_sequence_retains_reached_selections_or_last_frame() { + let selection = + SequenceSelection::parse("run.f3dcap".into(), Some("10".into()), Some("12,20".into())) + .unwrap(); + assert_eq!(selection.prefix(20).unwrap(), (10, vec![12, 20])); + assert_eq!(selection.prefix(15).unwrap(), (10, vec![12])); + assert_eq!(selection.prefix(11).unwrap(), (10, vec![11])); + assert_eq!(selection.prefix(4).unwrap(), (3, vec![4])); + assert!(selection.prefix(0).is_err()); + } +} From 8f51acd3be6631ecf132cc4a1c44af3809458613 Mon Sep 17 00:00:00 2001 From: David Chavez Date: Tue, 8 Sep 2026 22:50:35 +0200 Subject: [PATCH 2/2] build: bump fast3d to the merged capture-sequence revision --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index a6d3e7a..0f1bc89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ wgpu = { version = "29" } spin_sleep = "1.1.1" arie = "0.3.0" # Pinned: fast3d may change its API between commits; bump this deliberately and fix up the render path with it. -fast3d = { git = "https://github.com/retrofoundry/fast3d-rs", rev = "44d2d1b", default-features = false, features = ["capture"] } +fast3d = { git = "https://github.com/retrofoundry/fast3d-rs", rev = "509cf9b", default-features = false, features = ["capture"] } rustc-hash = "1.1.0" # For local development, uncomment to build against local checkouts of fast3d and