Expose per-world smoke readiness to mods

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
2026-11-02 22:11:51 +03:00
parent 2095590807
commit 86b1014a92
8 changed files with 183 additions and 6 deletions
+2 -1
View File
@@ -316,7 +316,8 @@ impl Game<'_> {
// What the mod is told is what is drawn: HD on, the smoke pass possible, and this frame's
// fog pass (an Overworld sky, above water) running.
let on = self.mod_hd().is_some() && drawn;
self.mods.mods.set_smoke_active(on);
let enabled = self.mod_hd().is_some() && horizon_client::gfx::hd_smoke_enabled();
self.mods.mods.set_smoke_frame(enabled, on);
let Some((before, now)) = self.mods.mods.smoke_clock().filter(|_| on) else {
return (Vec::new(), 0.0);
};
+7
View File
@@ -44,6 +44,13 @@ pub use horizon::mods::{
browser, chat, command, game, hud, input, log, recipes, render, storage, ui,
};
/// Whether the last accepted smoke submission may replace the mod's fallback for the
/// current main view. Re-check every frame and after a generation change. This reports
/// a prepared renderer path, not proof that individual segments appeared on the GPU.
pub fn smoke_can_replace_fallback(status: &render::SmokeState) -> bool {
status.available && status.enabled && status.request_accepted && status.backend_ready
}
/// The game's calls into a mod. Only the hooks [`Registration`] asks for
/// are called; the others may stay as their defaults.
pub trait Mod: Sized + 'static {
+26
View File
@@ -983,6 +983,32 @@ interface render {
/// HD renderer draws them; when it does not (the effects preset, HD smoke
/// off), the mod draws its own smoke.
set-smoke: func(clock: f64, segments: list<smoke-segment>) -> bool;
/// State of volumetric smoke, as of the last prepared main-view frame (API level 26).
/// `available` means this host implements the API, not that the backend is ready.
/// `enabled` means HD and smoke are enabled; `backend-ready` means that frame's
/// HD smoke/fog path was scheduled. It does not guarantee GPU submission, a visible
/// pixel, or inclusion of every segment (culling and budgets still apply).
/// `request-accepted` means this mod called set-smoke in this generation and its
/// sanitized submission was stored; it is not a rendering acknowledgement.
/// `generation` changes when the host observes a world/seed transition or restarts
/// the mod runtime. Until a frame is prepared after a transition, keep fallbacks.
record smoke-state {
generation: u64,
available: bool,
enabled: bool,
request-accepted: bool,
backend-ready: bool,
reason: smoke-reason,
}
enum smoke-reason {
awaiting-frame,
disabled,
path-unavailable,
ready,
}
/// The calling mod's submission and the last main-view smoke path state.
smoke-status: func() -> smoke-state;
/// A directional light with a visible beam (AirDef fork): a searchlight, a
/// headlight. The HD lighting lights the surfaces and flying things inside
/// its cone (smoothly from the inner to the outer angle, down to nothing at
+4 -2
View File
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
pub const LEVEL: u32 = 25;
pub const LEVEL: u32 = 26;
/// Every function the game offers mods, as `interface.function`, with its
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
pub const FUNCTIONS: [(&str, u32); 91] = [
pub const FUNCTIONS: [(&str, u32); 92] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -120,6 +120,7 @@ pub const FUNCTIONS: [(&str, u32); 91] = [
("render.canvas-open", 24),
// AirDef fork: the open world's seed, so a new world is not an old one of the same name.
("game.world-seed", 25),
("render.smoke-status", 26),
];
/// What the game's program carries so the launcher can read its level
@@ -246,6 +247,7 @@ mod tests {
#[test]
fn a_mod_needs_its_newest_function() {
assert_eq!(needed(["log.write", "hud.set-panel"]), (0, None));
assert_eq!(needed(["render.set-smoke", "render.smoke-status"]), (26, Some("render.smoke-status")));
assert_eq!(
needed(["log.write", "render.set-minimap", "hud.clear"]),
(1, Some("render.set-minimap"))
+57 -2
View File
@@ -315,6 +315,10 @@ pub struct ModelTable {
resolve: Option<ModelResolver>,
/// The renderer draws the mods' smoke (`render.set-smoke` says so to the mod).
smoke_active: bool,
smoke_enabled: bool,
smoke_frame_seen: bool,
smoke_generation: u64,
smoke_world: Option<(String, Option<i64>)>,
/// The renderer draws the mods' spot lights and their beams (`render.set-spot-lights`).
spot_active: bool,
/// `render.capture-start`: the request the game has not taken yet.
@@ -563,6 +567,7 @@ struct State {
/// `render.set-smoke` (AirDef fork): the segments, the clock of this call and of the one before.
smoke: Vec<SmokeSegment>,
smoke_clock: (f64, f64),
smoke_request_generation: Option<u64>,
input: InputState,
browser: BrowserState,
/// Capability `storage`'s folder; made on first `set`.
@@ -820,6 +825,7 @@ impl Mods {
tree_generation: 0,
smoke: Vec::new(),
smoke_clock: (0.0, 0.0),
smoke_request_generation: None,
input: InputState::default(),
browser: BrowserState::default(),
data_dir,
@@ -903,6 +909,29 @@ impl Mods {
}
/// What mods read until the next snapshot.
pub fn set_snapshot(&mut self, snapshot: Snapshot) {
let world = snapshot.world.as_ref().map(|w| (w.clone(), snapshot.world_seed));
let mut changed = false;
if let Ok(mut t) = self.model_table.lock()
&& (t.smoke_generation == 0 || t.smoke_world != world)
{
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
t.smoke_generation = GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
t.smoke_world = world;
t.smoke_active = false;
t.smoke_enabled = false;
t.smoke_frame_seen = false;
changed = true;
}
if changed {
for entry in &mut self.entries {
if let Some(run) = &mut entry.run {
let state = run.store.data_mut();
state.smoke.clear();
state.smoke_clock = (0.0, 0.0);
state.smoke_request_generation = None;
}
}
}
self.snapshot = Arc::new(snapshot);
}
@@ -1554,8 +1583,14 @@ impl Mods {
}
/// Tells the mods whether the renderer draws their smoke (the answer of `render.set-smoke`).
pub fn set_smoke_active(&mut self, on: bool) {
self.set_smoke_frame(on, on);
}
/// State of the last prepared main-view frame; this is not GPU completion.
pub fn set_smoke_frame(&mut self, enabled: bool, ready: bool) {
if let Ok(mut t) = self.model_table.lock() {
t.smoke_active = on;
t.smoke_frame_seen = t.smoke_world.is_some();
t.smoke_enabled = enabled && t.smoke_frame_seen;
t.smoke_active = ready && t.smoke_enabled;
}
}
/// The camera the first running mod asked the screen's world from, if any.
@@ -2745,6 +2780,11 @@ impl render::Host for State {
} else {
0.0
};
if !lighting.hd && let Ok(mut t) = self.model_table.lock() {
// Do not tell a caller to hide its fallback while a disabled request waits for a frame.
t.smoke_enabled = false;
t.smoke_active = false;
}
self.lighting = Some(lighting);
}
fn set_smoke(&mut self, clock: f64, segments: Vec<SmokeSegment>) -> bool {
@@ -2776,7 +2816,22 @@ impl render::Host for State {
s
})
.collect();
self.model_table.lock().is_ok_and(|t| t.smoke_active)
let generation = self.model_table.lock().ok().map(|t| (t.smoke_generation, t.smoke_active));
self.smoke_request_generation = generation.map(|g| g.0);
generation.is_some_and(|g| g.1)
}
fn smoke_status(&mut self) -> render::SmokeState {
let Ok(t) = self.model_table.lock() else {
return render::SmokeState { generation: 0, available: false, enabled: false, request_accepted: false,
backend_ready: false, reason: render::SmokeReason::AwaitingFrame };
};
let reason = if !t.smoke_frame_seen { render::SmokeReason::AwaitingFrame }
else if !t.smoke_enabled { render::SmokeReason::Disabled }
else if !t.smoke_active { render::SmokeReason::PathUnavailable }
else { render::SmokeReason::Ready };
render::SmokeState { generation: t.smoke_generation, available: true, enabled: t.smoke_enabled,
request_accepted: self.smoke_request_generation == Some(t.smoke_generation),
backend_ready: t.smoke_active, reason }
}
fn set_time_override(&mut self, day_time: Option<u64>) {
self.time_override = day_time.map(|t| t.min(1 << 40));
+48 -1
View File
@@ -110,6 +110,53 @@ fn player() -> Snapshot {
}
}
/// The SDK/WIT call is exercised by a real guest against the host's frame and world state.
#[test]
fn smoke_handshake_keeps_fallback_until_the_current_world_is_ready() {
let wasm = Package::read(&mods_dir().join("horizon_mods_test_ui_guest.hmod")).unwrap().wasm.unwrap();
let functions = horizon_mods::api_level::imports(&wasm).unwrap();
assert_eq!(horizon_mods::api_level::needed(functions.iter().map(String::as_str)), (26, Some("render.smoke-status")));
let mut mods = load("horizon_mods_test_ui_guest.hmod");
let snapshot = |world: &str| Snapshot { world: Some(world.into()), world_seed: Some(17), ..Snapshot::default() };
mods.set_snapshot(snapshot("world:a"));
let status = |mods: &mut Mods| {
assert!(mods.command("smoke_status"));
mods.drain().into_iter().find_map(|o| match o { Output::Chat { text, .. } => Some(text), _ => None }).unwrap()
};
let first = status(&mut mods);
let generation = first.split_whitespace().next().unwrap().to_string();
assert!(first.ends_with("true false false false SmokeReason::AwaitingFrame false"), "{first}");
assert!(mods.command("smoke_send"));
let _ = mods.drain();
assert_eq!(mods.smoke().count(), 1);
assert_eq!(mods.smoke_clock(), Some((0.0, 10.0)));
let queued = status(&mut mods);
assert!(queued.ends_with("true false true false SmokeReason::AwaitingFrame false"), "{queued}");
mods.set_smoke_frame(true, false);
let no_path = status(&mut mods);
assert!(no_path.ends_with("true true true false SmokeReason::PathUnavailable false"), "{no_path}");
mods.set_smoke_frame(true, true);
let ready = status(&mut mods);
assert!(ready.ends_with("true true true true SmokeReason::Ready true"), "{ready}");
assert!(mods.command("smoke_disable"));
let _ = mods.drain();
assert!(status(&mut mods).ends_with("true false true false SmokeReason::Disabled false"));
mods.set_snapshot(snapshot("world:b"));
assert_eq!(mods.smoke().count(), 0, "old world's smoke must not be rendered");
assert_eq!(mods.smoke_clock(), None, "old world's smoke clock must not survive");
let changed = status(&mut mods);
assert_ne!(changed.split_whitespace().next().unwrap(), generation);
assert!(changed.ends_with("true false false false SmokeReason::AwaitingFrame false"), "{changed}");
mods.set_smoke_frame(true, true);
assert!(status(&mut mods).ends_with("true true false true SmokeReason::Ready false"));
assert!(mods.command("smoke_send"));
let _ = mods.drain();
assert_eq!(mods.smoke().count(), 1);
assert_eq!(mods.smoke_clock(), Some((0.0, 10.0)), "new world starts a fresh clock");
mods.set_smoke_frame(false, false);
assert!(status(&mut mods).ends_with("true false true false SmokeReason::Disabled false"));
}
/// Capability `command` (level 19): a mod gives its command a tree and the lists it suggests from;
/// a tree for a command it did not register is ignored; changing a list changes the generation.
#[test]
@@ -286,7 +333,7 @@ experimental = ["ui", "render", "input", "recipes"]
needed(imports(&wasm).unwrap().iter().map(String::as_str)).0
};
assert_eq!(level("coords_hud.hmod"), 0);
// The UI guest draws far terrain (`render.set-far-terrain`).
// The UI guest calls the level-26 smoke handshake.
assert_eq!(level("horizon_mods_test_ui_guest.hmod"), LEVEL);
}
@@ -105,6 +105,9 @@ impl Mod for Guest {
"ui_close",
"render_hud",
"render_clear",
"smoke_status",
"smoke_send",
"smoke_disable",
"render_shake",
"input_claim",
"input_poll",
@@ -153,6 +156,19 @@ impl Mod for Guest {
"ui_close" => ui::close_screen(),
"render_hud" => render::set_hud(&shapes()),
"render_clear" => render::set_hud(&[]),
"smoke_status" => {
let s = render::smoke_status();
chat::show(&format!("{} {} {} {} {} {:?} {}", s.generation, s.available, s.enabled,
s.request_accepted, s.backend_ready, s.reason, horizon_mod_sdk::smoke_can_replace_fallback(&s)));
}
"smoke_send" => {
render::set_smoke(10.0, &[render::SmokeSegment {
id: 1, ax: 1.0, ay: 2.0, az: 3.0, bx: 1.0, by: 2.0, bz: 3.0,
born: 0.0, life: 100.0, radius_start: 1.0, radius_end: 1.0,
density: 1.0, heat: 0.0, soot: 0.5, rise: 0.0, clear: 0.0,
}]);
}
"smoke_disable" => render::set_lighting(render::Lighting { hd: false, exposure: 1.0, bloom: 0.0 }),
// An FPS-counter-like box: as wide as its text plus 4.
"corner" => {
let text = "60 fps";
+23
View File
@@ -765,6 +765,28 @@ A piece of smoke or exhaust trail (AirDef fork): a tube from `a` to `b` (equal e
| `rise` | `f32` | Blocks per second it floats up (negative sinks). |
| `clear` | `f32` | Radius in blocks of the clear tunnel it leaves in fog and cloud, filling back in over `life` (0 none). |
### enum `smoke-reason`
| Case | |
| --- | --- |
| `awaiting-frame` | |
| `disabled` | |
| `path-unavailable` | |
| `ready` | |
### record `smoke-state`
State of volumetric smoke, as of the last prepared main-view frame (API level 26). `available` means this host implements the API, not that the backend is ready. `enabled` means HD and smoke are enabled; `backend-ready` means that frame's HD smoke/fog path was scheduled. It does not guarantee GPU submission, a visible pixel, or inclusion of every segment (culling and budgets still apply). `request-accepted` means this mod called set-smoke in this generation and its sanitized submission was stored; it is not a rendering acknowledgement. `generation` changes when the host observes a world/seed transition or restarts the mod runtime. Until a frame is prepared after a transition, keep fallbacks.
| Field | Type | |
| --- | --- | --- |
| `generation` | `u64` | |
| `available` | `bool` | |
| `enabled` | `bool` | |
| `request-accepted` | `bool` | |
| `backend-ready` | `bool` | |
| `reason` | `smoke-reason` | |
### record `spot-light`
A directional light with a visible beam (AirDef fork): a searchlight, a headlight. The HD lighting lights the surfaces and flying things inside its cone (smoothly from the inner to the outer angle, down to nothing at `range`) and draws the beam as a translucent volume lit from inside: it scatters in the air (more in fog and smoke, a faint artistic one in clear air), the world hides it where it is in front, and the camera may stand inside it. The light does not look for walls: send `range` as far as the beam is free (a cast along the axis) so it stops where the land stops it.
@@ -868,6 +890,7 @@ Where a capture is.
| `set-lighting` | `func(lighting: lighting)` | Switches the HD lighting on or off for this mod's world, with its exposure and bloom; off, the world is drawn as the game draws it. |
| `set-time-override` | `func(day-time: option<u64>)` | Shows the world at this time of day (AirDef fork): the sky, the sun and moon, the sky light and the fog, as `day-time` ticks (24000 a day, as `clock`). Only what the player sees changes: the world's own clock, its saves and its rules do not. None shows the world's own time. |
| `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. |
| `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. |