Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand All @@ -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 = "509cf9b", default-features = false, features = ["capture"] }
rustc-hash = "1.1.0"

# For local development, uncomment to build against local checkouts of fast3d and
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
145 changes: 145 additions & 0 deletions docs/capture.md
Original file line number Diff line number Diff line change
@@ -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 <game-pid>`), 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 `<output>.<pid>-<id>.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.
153 changes: 146 additions & 7 deletions src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -139,13 +141,22 @@ pub struct SurfaceHandoff {

pub struct RenderContext {
renderer: fast3d::Renderer,
frame_serial: u64,
capture_selection: Option<capture::FrameSelection>,
capture_frame: Option<fast3d::capture::CaptureFrame>,
capture_sequence: Option<capture::SequenceRecording>,
deferred_resize: Option<(u32, u32)>,
}

impl RenderContext {
/// Build the Renderer on this thread from the handed-over surface. Replicates fast3d `new()`'s
/// 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,
Expand Down Expand Up @@ -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 },
Expand All @@ -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) => {}
Expand All @@ -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.
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading