diff --git a/crates/horizon-client/src/bin/horizon-ctl/commands.rs b/crates/horizon-client/src/bin/horizon-ctl/commands.rs
index f549a162af..af02779d78 100644
--- a/crates/horizon-client/src/bin/horizon-ctl/commands.rs
+++ b/crates/horizon-client/src/bin/horizon-ctl/commands.rs
@@ -589,8 +589,8 @@ pub const COMMANDS: &[Cmd] = &[
["horizon-ctl trace /tmp/frame.json 10", "horizon-ctl trace stop"], &[], mcp: false, local: false),
cmd!("screenshot-sequence", Scripting, "screenshot-sequence FRAMES STEP_MS PATH|cancel", "draw a shader pack's first FRAMES frames on a set clock and write the last as a PNG",
"Needs HORIZON_STILL and a frozen world (docs/agent-control.md). The pack starts over, frame n's clock is n frames and (n - 1) × STEP_MS milliseconds, the PNG is written as screenshot PATH writes it, and the reply comes once it is. Relative paths are taken from horizon-ctl's directory. cancel stops a running sequence.",
- ["horizon-ctl screenshot-sequence 64 50 frame64.png", "horizon-ctl screenshot-sequence cancel"],
- &[p("frames", Ty::Str, "frames to draw, 1 to 10000, or cancel"), opt("step_ms", Ty::Int, "milliseconds between frames on the pack's clock"), opt("path", Ty::Rest, "the PNG to write")], mcp: false, local: false),
+ ["horizon-ctl screenshot-sequence 64 50 frame64.png", "horizon-ctl screenshot-sequence 64 16.666667 frame64.png", "horizon-ctl screenshot-sequence cancel"],
+ &[p("frames", Ty::Str, "frames to draw, 1 to 10000, or cancel"), opt("step_ms", Ty::Num, "milliseconds between frames on the pack's clock, more than 0 (16.666667 for 60 fps)"), opt("path", Ty::Rest, "the PNG to write")], mcp: false, local: false),
cmd!("render-capture", Scripting, "render-capture on|off|DIR", "keep CPU copies of the meshes the renderer draws (on), or write the next frame's scene to DIR for the scene_replay example",
"on meshes every section and far tile again so the copies are complete (HORIZON_RENDER_CAPTURE=1 keeps them from the start). DIR gets the camera, frame inputs, settings and every resident near section and far tile mesh; entities, particles, weather, the hand and the HUD are left out. See docs/graphics.md.",
["horizon-ctl render-capture on", "horizon-ctl render-capture /tmp/scene"], &[], mcp: false, local: false),
diff --git a/crates/horizon-client/src/game/screenshot_sequence.rs b/crates/horizon-client/src/game/screenshot_sequence.rs
index d2029e52ad..ccc7f69d08 100644
--- a/crates/horizon-client/src/game/screenshot_sequence.rs
+++ b/crates/horizon-client/src/game/screenshot_sequence.rs
@@ -17,7 +17,7 @@ const READ_ONLY: [&str; 5] = ["screenshot-sequence", "hello", "help", "status",
pub(super) struct Sequence {
frames: u32,
- step_ms: u32,
+ step_ms: f64,
/// The frame drawn next, from 1.
next: u32,
path: PathBuf,
@@ -34,7 +34,7 @@ impl Sequence {
}
/// `FRAMES STEP_MS PATH`; the path is the rest of the line and may hold spaces.
-fn parse(args: &str) -> Result<(u32, u32, PathBuf)> {
+fn parse(args: &str) -> Result<(u32, f64, PathBuf)> {
let usage = "screenshot-sequence FRAMES STEP_MS PATH, or screenshot-sequence cancel";
let (frames, rest) = args.trim().split_once(char::is_whitespace).ok_or(usage)?;
let (step_ms, path) = rest
@@ -42,12 +42,12 @@ fn parse(args: &str) -> Result<(u32, u32, PathBuf)> {
.split_once(char::is_whitespace)
.ok_or(usage)?;
let frames: u32 = frames.parse().map_err(|_| usage)?;
- let step_ms: u32 = step_ms.parse().map_err(|_| usage)?;
+ let step_ms: f64 = step_ms.parse().map_err(|_| usage)?;
if !(1..=MAX_FRAMES).contains(&frames) {
return Err(format!("FRAMES must be 1 to {MAX_FRAMES}").into());
}
- if step_ms == 0 {
- return Err("STEP_MS must be more than 0".into());
+ if !step_ms.is_finite() || step_ms <= 0.0 {
+ return Err("STEP_MS must be a number of milliseconds more than 0".into());
}
Ok((frames, step_ms, path.trim().into()))
}
@@ -296,14 +296,18 @@ mod tests {
fn the_path_is_the_rest_of_the_line_and_the_counts_are_bounded() {
assert_eq!(
parse(" 64 50 /tmp/a b.png ").unwrap(),
- (64, 50, PathBuf::from("/tmp/a b.png"))
+ (64, 50.0, PathBuf::from("/tmp/a b.png"))
);
assert_eq!(parse("10000 1 x.png").unwrap().0, 10_000);
+ assert_eq!(parse("64 16.666667 x.png").unwrap().1, 16.666667);
for bad in [
"0 50 x.png",
"10001 50 x.png",
"4 0 x.png",
"4 -1 x.png",
+ "4 0.0 x.png",
+ "4 NaN x.png",
+ "4 inf x.png",
"4 50",
"x 50 a",
] {
diff --git a/docs/agent-control.md b/docs/agent-control.md
index d2c13027fe..169cc98130 100644
--- a/docs/agent-control.md
+++ b/docs/agent-control.md
@@ -409,13 +409,13 @@ of the frame ([#8750](https://github.com/horizon-rust/horizon/issues/8750)).
FRAMES STEP_MS PATH`: the pack starts over (its buffers, images, shadow maps, smoothed values and
custom uniforms, or a Horizon pack's `history` and eased values) and draws FRAMES frames on a set
clock, frame n at n frames and (n - 1) × STEP_MS milliseconds (0 since the frame before on frame
- 1, STEP_MS after). The last is written as `screenshot PATH` writes it (with its `-hdr.png` under
- HDR output), and the reply, once it is, names the pack and the last frame's clock. Without a pack
- it draws the frames all the same. It needs the world frozen, far terrain (LOD Distance), TAA,
- frame generation and the shadow cache off and chunk fade 0, and says so otherwise. While it runs,
- commands that would change the scene are refused, a change to the camera, the world, the
- settings, the pack or the window's size ends it with an error, `screenshot-sequence cancel` stops
- it and `quit` quits.
+ 1, STEP_MS after; a decimal, `16.666667` for 60 fps). The last is written as `screenshot PATH`
+ writes it (with its `-hdr.png` under HDR output), and the reply, once it is, names the pack and
+ the last frame's clock. Without a pack it draws the frames all the same. It needs the world
+ frozen, far terrain (LOD Distance), TAA, frame generation and the shadow cache off and chunk fade
+ 0, and says so otherwise. While it runs, commands that would change the scene are refused, a
+ change to the camera, the world, the settings, the pack or the window's size ends it with an
+ error, `screenshot-sequence cancel` stops it and `quit` quits.
- Not covered: temporal AA and frame generation (they build on earlier frames: turn them off),
custom entity model animations, native mods, and what eases on the wall clock for a moment
(toasts, the boss bar's fill, chunks fading in, the spyglass): wait for them to settle. Frame
diff --git a/docs/api/shaders.md b/docs/api/shaders.md
index 5f09e960bb..7e69cda264 100644
--- a/docs/api/shaders.md
+++ b/docs/api/shaders.md
@@ -189,10 +189,10 @@ refused.
`horizon-ctl screenshot-sequence FRAMES STEP_MS PATH`, under `HORIZON_STILL`, starts the running pack over and draws
its first FRAMES frames on a set clock: frame n has `frame_index` n, `time` (n - 1) × STEP_MS / 1000 and `delta_time`
-STEP_MS / 1000 (0 on frame 1), and `history` and the eased values start as in a pack just loaded. The last frame is
-written as a PNG, the same bytes every run on the same build, GPU and driver. Packs in the `shaders/` format get the
-same (`frameCounter`, `frameTimeCounter`, `frameTime`). How to use it:
-[the guide](../shaders.md#testing-a-pack-frame-by-frame).
+STEP_MS / 1000 (0 on frame 1; STEP_MS may be a decimal, `16.666667` for 60 fps), and `history` and the eased values
+start as in a pack just loaded. The last frame is written as a PNG, the same bytes every run on the same build, GPU
+and driver. Packs in the `shaders/` format get the same (`frameCounter`, `frameTimeCounter`, `frameTime`). How to use
+it: [the guide](../shaders.md#testing-a-pack-frame-by-frame).
## Guide, reference and examples
diff --git a/docs/horizon-ctl-commands.md b/docs/horizon-ctl-commands.md
index 4c9bdf755c..2d3eabccee 100644
--- a/docs/horizon-ctl-commands.md
+++ b/docs/horizon-ctl-commands.md
@@ -144,7 +144,7 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch
| `stats [reset] [raw]` | `stats` | Frame-time summary since the last reset | `reset` (boolean): start counting again
`raw` (boolean): every frame time too (frame_ms, cpu_ms, gpu_ms) |
| `gpu-passes [on\|off]` | CLI only | Time Horizon's own render passes on the GPU, shown in F3 and in stats and bench | `mode` (on, off): on or off; without it, only reports |
| `trace [FILE [SECONDS] \| stop]` | CLI only | Write every thread's timed spans to FILE as a timeline that ui.perfetto.dev opens, for SECONDS or until stop | none |
-| `screenshot-sequence FRAMES STEP_MS PATH\|cancel` | CLI only | Draw a shader pack's first FRAMES frames on a set clock and write the last as a PNG | `frames` (string, required): frames to draw, 1 to 10000, or cancel
`step_ms` (integer): milliseconds between frames on the pack's clock
`path` (text): the PNG to write |
+| `screenshot-sequence FRAMES STEP_MS PATH\|cancel` | CLI only | Draw a shader pack's first FRAMES frames on a set clock and write the last as a PNG | `frames` (string, required): frames to draw, 1 to 10000, or cancel
`step_ms` (number): milliseconds between frames on the pack's clock, more than 0 (16.666667 for 60 fps)
`path` (text): the PNG to write |
| `render-capture on\|off\|DIR` | CLI only | Keep CPU copies of the meshes the renderer draws (on), or write the next frame's scene to DIR for the scene_replay example | none |
| `fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]` | CLI only | Fly the camera along a line, then reply with the frame summary | none |
| `record-start [NAME] [--no-world]` | `record_start` | Record input, state and the world for a bug repro | `name` (string): folder name after the timestamp (default session)
`--no-world` (boolean): do not copy the world (a server session) |
diff --git a/docs/shaders.md b/docs/shaders.md
index 297db06ccf..3bf0d22842 100644
--- a/docs/shaders.md
+++ b/docs/shaders.md
@@ -73,7 +73,8 @@ horizon-ctl screenshot-sequence 64 50 frame64.png
starts the pack over as if just loaded (black `history`, eased values from the start) and draws 64 frames 50 ms
apart on its clock: frame n has `frame_index` n, `time` (n - 1) × 0.05 and `delta_time` 0.05 (0 on frame 1). The
-64th is written to `frame64.png` (and `frame64-hdr.png` with HDR output on) before the reply. The same build, GPU
+64th is written to `frame64.png` (and `frame64-hdr.png` with HDR output on) before the reply. STEP_MS may be a
+decimal: `16.666667` steps a 60 fps clock. The same build, GPU
and driver write the same bytes every run, so two versions of a pack compare by a pixel diff. `screenshot-sequence
cancel` stops one.