Scope capture stops and cancel on world changes

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
2026-11-03 00:22:22 +03:00
parent f4c1131d37
commit c89c78c470
4 changed files with 59 additions and 18 deletions
+3 -2
View File
@@ -1037,7 +1037,7 @@ interface render {
/// Enabled means a world is present, not that the encoder or GPU is ready.
/// Prepared means recording, encoding, or done; inspect capture-status for
/// completion and errors. Other mods cannot claim it. A world transition
/// invalidates ownership, even if the existing capture continues.
/// invalidates ownership and asks the host to cancel the capture.
capture-owner-status: func() -> capability-state;
/// A directional light with a visible beam (AirDef fork): a searchlight, a
/// headlight. The HD lighting lights the surfaces and flying things inside
@@ -1137,7 +1137,8 @@ interface render {
/// capture already running, a bad name, or an invalid size. Only the
/// game's own files are written, under its home.
capture-start: func(options: capture-options) -> result<_, string>;
/// Ends the capture: finishes the file, or (`cancel`) throws it away.
/// Ends this mod run's capture: finishes the file, or (`cancel`) throws it away.
/// A different mod cannot stop it. World transitions cancel mod-owned captures.
capture-stop: func(cancel: bool);
/// The capture's state; `done` and `failed` stay until the next start.
capture-status: func() -> capture-state;
+44 -14
View File
@@ -928,7 +928,11 @@ impl Mods {
t.smoke_enabled = false;
t.smoke_frame_seen = false;
t.acoustics_prepared = false;
t.capture_owner = None;
// A world-bound mod capture must not survive into another world.
// Only mod-owned captures are touched; game/admin captures remain independent.
if t.capture_owner.take().is_some() {
stop_owned_capture(&mut t, true);
}
changed = true;
}
if changed {
@@ -2403,6 +2407,35 @@ fn unavailable_capability() -> render::CapabilityState {
}
}
fn stop_capture_for_run(t: &mut ModelTable, run_serial: u64, cancel: bool) {
if t.capture_owner
.is_some_and(|(serial, _)| serial == run_serial)
{
stop_owned_capture(t, cancel);
}
}
// Called only for the owning run, or by the host on a world transition.
fn stop_owned_capture(t: &mut ModelTable, cancel: bool) {
if t.capture_request.take().is_some() {
t.capture_state = Some(CaptureState {
phase: CapturePhase::Failed,
frames: 0,
width: 0,
height: 0,
file: String::new(),
message: "cancelled".into(),
});
} else if t.capture_state.as_ref().is_some_and(|s| {
matches!(
s.phase,
CapturePhase::Arming | CapturePhase::Recording | CapturePhase::Encoding
)
}) {
t.capture_stop = Some(cancel || t.capture_stop == Some(true));
}
}
fn capture_owner_state(t: &ModelTable, run_serial: u64) -> render::CapabilityState {
let accepted =
t.capture_owner == Some((run_serial, t.smoke_generation)) && t.smoke_generation != 0;
@@ -2790,21 +2823,10 @@ impl render::Host for State {
}
fn capture_stop(&mut self, cancel: bool) {
if let Ok(mut t) = self.model_table.lock() {
// A request the game has not seen is simply withdrawn.
if t.capture_request.take().is_some() {
t.capture_state = Some(CaptureState {
phase: CapturePhase::Failed,
frames: 0,
width: 0,
height: 0,
file: String::new(),
message: "cancelled".into(),
});
} else {
t.capture_stop = Some(cancel || t.capture_stop == Some(true));
}
stop_capture_for_run(&mut t, self.run_serial, cancel);
}
}
fn capture_status(&mut self) -> CaptureState {
self.model_table
.lock()
@@ -3404,6 +3426,14 @@ mod tree_tests {
}),
..Default::default()
};
stop_capture_for_run(&mut t, 11, true);
assert_eq!(t.capture_stop, None);
stop_capture_for_run(&mut t, 10, false);
assert_eq!(t.capture_stop, Some(false));
stop_capture_for_run(&mut t, 11, true);
assert_eq!(t.capture_stop, Some(false));
stop_capture_for_run(&mut t, 10, true);
assert_eq!(t.capture_stop, Some(true));
assert!(capture_owner_state(&t, 10).prepared);
assert!(!capture_owner_state(&t, 11).request_accepted);
t.smoke_generation = 9;
+10
View File
@@ -239,6 +239,7 @@ fn capability_handshakes_track_actual_state_and_generation() {
assert!(mods.command("cap_capture"));
mods.drain();
assert!(status(&mut mods).ends_with("true true false CapabilityReason::AwaitingBackend"));
assert!(mods.take_capture_request().is_some());
mods.set_capture_state(CaptureState {
phase: CapturePhase::Recording,
frames: 0,
@@ -257,7 +258,16 @@ fn capability_handshakes_track_actual_state_and_generation() {
message: "error".into(),
});
assert!(status(&mut mods).ends_with("true true false CapabilityReason::Failed"));
mods.set_capture_state(CaptureState {
phase: CapturePhase::Recording,
frames: 1,
width: 16,
height: 16,
file: String::new(),
message: String::new(),
});
mods.set_snapshot(snapshot("b"));
assert_eq!(mods.take_capture_stop(), Some(true));
let reset = status(&mut mods);
assert!(
reset.contains("true false false CapabilityReason::NotRequested"),
+2 -2
View File
@@ -916,10 +916,10 @@ Where a capture is.
| `set-smoke` | `func(clock: f64, segments: list<smoke-segment>) -> bool` | Replaces the mod's smoke: up to 4096 segments, `clock` being now in ticks (the game shows the age between the last two calls). Returns whether the HD renderer draws them; when it does not (the effects preset, HD smoke off), the mod draws its own smoke. |
| `smoke-status` | `func() -> smoke-state` | The calling mod's submission and the last main-view smoke path state. |
| `acoustics-status` | `func() -> capability-state` | The caller's last set-acoustics request and the last geometry processing step. Enabled reflects the actual audio setting. A silent or headless world may remain awaiting-backend until a positional sound is processed. A later disable or world transition clears the processing milestone. |
| `capture-owner-status` | `func() -> capability-state` | The caller's capture request, scoped to its run and world generation. Enabled means a world is present, not that the encoder or GPU is ready. Prepared means recording, encoding, or done; inspect capture-status for completion and errors. Other mods cannot claim it. A world transition invalidates ownership, even if the existing capture continues. |
| `capture-owner-status` | `func() -> capability-state` | The caller's capture request, scoped to its run and world generation. Enabled means a world is present, not that the encoder or GPU is ready. Prepared means recording, encoding, or done; inspect capture-status for completion and errors. Other mods cannot claim it. A world transition invalidates ownership and asks the host to cancel the capture. |
| `set-spot-lights` | `func(lights: list<spot-light>) -> bool` | Replaces the mod's spot lights: up to 32 (the nearest 16 are drawn), interpolated per id between the last two calls. Returns whether the HD renderer draws them with their beams; when it does not (the effects preset, HD off), the mod draws its own beam if it wants one. |
| `capture-start` | `func(options: capture-options) -> result<_, string>` | Records the screen frame by frame (AirDef fork), at a fixed time step whatever the speed of the machine: while it records, the game clock advances exactly `1 / fps` seconds a frame (ticks, interpolation, the mod's `tick`s and the sounds with it), so a slow frame is a longer wait and not a skipped one. The frames are read back from the GPU and encoded to H.264 MP4 by `ffmpeg` (found on the `PATH`, or named by `HORIZON_FFMPEG`) through a bounded queue, never held in memory; with no `ffmpeg` they are written as a PNG sequence. Returns an error for a capture already running, a bad name, or an invalid size. Only the game's own files are written, under its home. |
| `capture-stop` | `func(cancel: bool)` | Ends the capture: finishes the file, or (`cancel`) throws it away. |
| `capture-stop` | `func(cancel: bool)` | Ends this mod run's capture: finishes the file, or (`cancel`) throws it away. A different mod cannot stop it. World transitions cancel mod-owned captures. |
| `capture-status` | `func() -> capture-state` | The capture's state; `done` and `failed` stay until the next start. |
| `set-film-quality` | `func(on: bool)` | Asks for the best picture the renderer can give (AirDef fork, level 17): the HD world at the full size of the window (no resolution scale, so no upscaling), and the volumetric clouds and fog marched with more steps. Meant for replays and films; false restores the player's settings. A capture turns it on for its duration. |
| `world-log-start` | `func() -> u32` | Starts a log of the world's block changes as the game's client sees them (AirDef fork, level 18): each change keeps its position, the state before and after, and the game tick (the number `tick` is called with). It is bounded (about a million changes): the oldest go first. Returns the log's id, which stays the same while it runs (a second call returns it). Only the world open now has it: a new world (leaving, rejoining) loses it. Not kept when the world runs in the game's own frame (`--in-frame`), where `world-log-range` stays none. |