Expose scoped acoustics and capture capability states

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
2026-11-03 00:15:33 +03:00
parent 86b1014a92
commit f4c1131d37
9 changed files with 518 additions and 66 deletions
+4
View File
@@ -1346,6 +1346,10 @@ impl Audio {
}
/// Whether acoustics are on and a sound is playing in the world: the game then builds the
/// geometry for [`Audio::tick_acoustics`].
/// Whether acoustics can feed a live output or the offline film mixer.
pub fn acoustics_output_ready(&self) -> bool {
self.backend.live || self.backend.offline
}
pub fn acoustics_wanted(&self) -> bool {
self.acoustics.is_some()
&& self.backend.mixer.lock().is_ok_and(|m| {
+3 -1
View File
@@ -56,7 +56,8 @@ impl Game<'_> {
table[i] = if full { v } else { v * spr_occlusion::NON_FULL };
}
let (table, reflect) = (Arc::new(table), Arc::new(reflect));
self.mods.acoustic.table = Some((epoch, facts.states.len(), table.clone(), reflect.clone()));
self.mods.acoustic.table =
Some((epoch, facts.states.len(), table.clone(), reflect.clone()));
(table, reflect)
}
@@ -126,5 +127,6 @@ impl Game<'_> {
};
let geometry: Arc<dyn Geometry> = self.acoustic_snapshot();
self.audio.tick_acoustics(Some(&geometry), &changes);
self.mods_acoustics_prepared();
}
}
+29 -6
View File
@@ -227,7 +227,12 @@ const RELOAD_CHECK: Duration = Duration::from_secs(1);
fn mod_trees(mods: &Mods) -> Vec<(String, Vec<ModNode>)> {
mods.command_trees()
.iter()
.map(|t| (t.name.to_owned(), mod_nodes(t.nodes, |l| t.list(l).to_vec())))
.map(|t| {
(
t.name.to_owned(),
mod_nodes(t.nodes, |l| t.list(l).to_vec()),
)
})
.collect()
}
@@ -371,6 +376,7 @@ impl Game<'_> {
pub(super) fn mods_film_quality(&self) -> bool {
false
}
pub(super) fn mods_acoustics_prepared(&mut self) {}
pub(super) fn mods_film_report(&mut self, _report: &super::film::FilmReport) {}
pub(super) fn mods_main_view_id(&self) -> Option<u32> {
None
@@ -600,7 +606,11 @@ impl Game<'_> {
.then(|| self.mods.world_id.clone())
.flatten(),
world_seed: (self.player.is_some()
&& self.mods.world_id.as_deref().is_some_and(|w| w.starts_with("world:")))
&& self
.mods
.world_id
.as_deref()
.is_some_and(|w| w.starts_with("world:")))
.then_some(self.level.seed),
}
}
@@ -741,8 +751,11 @@ impl Game<'_> {
.map(|s| s.0)
})
});
self.mods.mods.set_source_generation(self.audio.mod_sources.epoch);
self.mods
.mods
.set_source_generation(self.audio.mod_sources.epoch);
self.mods.mods.set_acoustics(self.audio.acoustics.is_some());
self.mods.mods.set_acoustics_output_ready(self.audio.acoustics_output_ready());
self.mods.mods.tick(self.ticks);
self.refresh_mod_trees();
self.update_mod_models();
@@ -821,7 +834,9 @@ impl Game<'_> {
loop_from: p.loop_from,
loop_to: p.loop_to,
};
self.mods.mods.set_source_generation(self.audio.mod_sources.epoch);
self.mods
.mods
.set_source_generation(self.audio.mod_sources.epoch);
let changes: Vec<Change> = self
.mods
.mods
@@ -886,6 +901,9 @@ impl Game<'_> {
pub(super) fn mods_film_quality(&self) -> bool {
self.mods.mods.film_quality()
}
pub(super) fn mods_acoustics_prepared(&mut self) {
self.mods.mods.set_acoustics_prepared();
}
pub(super) fn mods_film_report(&mut self, r: &super::film::FilmReport) {
use super::film::Phase;
use horizon_mods::{CapturePhase, CaptureState};
@@ -919,8 +937,11 @@ impl Game<'_> {
let snapshot = self.mod_snapshot();
self.mods.mods.set_snapshot(snapshot);
}
self.mods.mods.set_source_generation(self.audio.mod_sources.epoch);
self.mods
.mods
.set_source_generation(self.audio.mod_sources.epoch);
self.mods.mods.set_acoustics(self.audio.acoustics.is_some());
self.mods.mods.set_acoustics_output_ready(self.audio.acoustics_output_ready());
self.mods.mods.frame(elapsed.as_secs_f32());
self.canvas_frame();
self.show_mod_output();
@@ -1658,7 +1679,9 @@ mod tree_tests {
node("load", None, vec![2], false),
node(
"name",
Some(TreeNodeKind::Value(TreeValueKind::Names("scenarios".into()))),
Some(TreeNodeKind::Value(TreeValueKind::Names(
"scenarios".into(),
))),
vec![],
true,
),
+30
View File
@@ -1009,6 +1009,36 @@ interface render {
}
/// The calling mod's submission and the last main-view smoke path state.
smoke-status: func() -> smoke-state;
/// API level 27: state for the calling mod and current world generation.
/// Accepted means the request was stored, not that output was produced.
/// Prepared is a host-side processing milestone, not audible or GPU completion.
record capability-state {
generation: u64,
available: bool,
enabled: bool,
request-accepted: bool,
prepared: bool,
reason: capability-reason,
}
enum capability-reason {
awaiting-world,
disabled,
not-requested,
awaiting-backend,
failed,
ready,
}
/// 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.
acoustics-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;
/// 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
+8 -3
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 = 26;
pub const LEVEL: u32 = 27;
/// 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); 92] = [
pub const FUNCTIONS: [(&str, u32); 94] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -121,6 +121,8 @@ pub const FUNCTIONS: [(&str, u32); 92] = [
// 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),
("render.acoustics-status", 27),
("render.capture-owner-status", 27),
];
/// What the game's program carries so the launcher can read its level
@@ -247,7 +249,10 @@ 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(["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"))
+211 -32
View File
@@ -82,14 +82,13 @@ use render_bindings::horizon::mods::input;
pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, ItemUse};
/// What capability `render` asks the game to draw (WIT `interface render`).
pub use render_bindings::horizon::mods::render::{
CameraView, CaptureOptions, CapturePhase, CaptureState, Changes as WorldChanges,
Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate, ItemShape,
Lighting as ModLighting, Minimap as ModMinimap, ModelInstance, ModelInstanceId, ParticleSpawn,
Point as RenderPoint, PointLight, Policy as WorldPolicy, QuadShape, Ray as WorldRay,
RayAnswer as WorldRayAnswer, Rect as RenderRect, Section as WorldSection, SectionFill, Shape,
SmokeSegment, SourceOp, SourceOpKind, SpotLight, SpriteShape, TextShape, Verdict as WorldVerdict, ViewShape,
Zoom as ModZoom,
CanvasButton, CanvasEvent, CanvasScroll, TerrainShape,
CameraView, CanvasButton, CanvasEvent, CanvasScroll, CaptureOptions, CapturePhase,
CaptureState, Changes as WorldChanges, Corner as RenderCorner, FarTerrain as ModFarTerrain,
FillShape, FrameRate, ItemShape, Lighting as ModLighting, Minimap as ModMinimap, ModelInstance,
ModelInstanceId, ParticleSpawn, Point as RenderPoint, PointLight, Policy as WorldPolicy,
QuadShape, Ray as WorldRay, RayAnswer as WorldRayAnswer, Rect as RenderRect,
Section as WorldSection, SectionFill, Shape, SmokeSegment, SourceOp, SourceOpKind, SpotLight,
SpriteShape, TerrainShape, TextShape, Verdict as WorldVerdict, ViewShape, Zoom as ModZoom,
};
use render_bindings::{RenderHost, horizon::mods::render};
/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items.
@@ -339,6 +338,9 @@ pub struct ModelTable {
source_generation: u32,
/// `render.acoustics`: the game muffles sounds itself.
acoustics: bool,
acoustics_prepared: bool,
acoustics_output_ready: bool,
capture_owner: Option<(u64, u64)>,
}
/// The ids of block-change logs: never reused in a run of the game.
@@ -568,6 +570,7 @@ struct State {
smoke: Vec<SmokeSegment>,
smoke_clock: (f64, f64),
smoke_request_generation: Option<u64>,
acoustics_request: Option<(u64, bool)>,
input: InputState,
browser: BrowserState,
/// Capability `storage`'s folder; made on first `set`.
@@ -826,6 +829,7 @@ impl Mods {
smoke: Vec::new(),
smoke_clock: (0.0, 0.0),
smoke_request_generation: None,
acoustics_request: None,
input: InputState::default(),
browser: BrowserState::default(),
data_dir,
@@ -909,7 +913,10 @@ 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 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)
@@ -920,6 +927,8 @@ impl Mods {
t.smoke_active = false;
t.smoke_enabled = false;
t.smoke_frame_seen = false;
t.acoustics_prepared = false;
t.capture_owner = None;
changed = true;
}
if changed {
@@ -1421,7 +1430,11 @@ impl Mods {
return;
}
let same = |a: &CanvasEvent, b: &CanvasEvent| {
matches!((a, b), (CanvasEvent::Moved(_), CanvasEvent::Moved(_)) | (CanvasEvent::Resized(_), CanvasEvent::Resized(_)))
matches!(
(a, b),
(CanvasEvent::Moved(_), CanvasEvent::Moved(_))
| (CanvasEvent::Resized(_), CanvasEvent::Resized(_))
)
};
if let Some(last) = c.events.back_mut()
&& same(last, &event)
@@ -1494,6 +1507,25 @@ impl Mods {
pub fn set_acoustics(&mut self, on: bool) {
if let Ok(mut t) = self.model_table.lock() {
t.acoustics = on;
if !on {
t.acoustics_prepared = false;
}
}
}
/// Reports whether a live or offline audio mixer can receive the result.
pub fn set_acoustics_output_ready(&mut self, ready: bool) {
if let Ok(mut t) = self.model_table.lock() {
t.acoustics_output_ready = ready;
if !ready {
t.acoustics_prepared = false;
}
}
}
/// Records that the game processed an acoustics geometry snapshot.
pub fn set_acoustics_prepared(&mut self) {
if let Ok(mut t) = self.model_table.lock() {
t.acoustics_prepared =
t.acoustics && t.acoustics_output_ready && t.smoke_world.is_some();
}
}
/// The source changes every running mod made since the last call, tagged with the
@@ -1656,7 +1688,11 @@ impl Mods {
pub fn take_chunk_requests(&mut self) -> Vec<(i32, i32)> {
self.entries
.iter_mut()
.filter_map(|e| Some(std::mem::take(&mut e.run.as_mut()?.store.data_mut().chunk_requests)))
.filter_map(|e| {
Some(std::mem::take(
&mut e.run.as_mut()?.store.data_mut().chunk_requests,
))
})
.flatten()
.collect()
}
@@ -2356,6 +2392,46 @@ fn clip_shapes(shapes: Vec<Shape>) -> Vec<Shape> {
.collect()
}
fn unavailable_capability() -> render::CapabilityState {
render::CapabilityState {
generation: 0,
available: false,
enabled: false,
request_accepted: false,
prepared: false,
reason: render::CapabilityReason::AwaitingBackend,
}
}
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;
let phase = t.capture_state.as_ref().map(|s| s.phase);
let prepared = accepted
&& matches!(
phase,
Some(CapturePhase::Recording | CapturePhase::Encoding | CapturePhase::Done)
);
let reason = if t.smoke_world.is_none() {
render::CapabilityReason::AwaitingWorld
} else if !accepted {
render::CapabilityReason::NotRequested
} else if matches!(phase, Some(CapturePhase::Failed)) {
render::CapabilityReason::Failed
} else if !prepared {
render::CapabilityReason::AwaitingBackend
} else {
render::CapabilityReason::Ready
};
render::CapabilityState {
generation: t.smoke_generation,
available: true,
enabled: t.smoke_world.is_some(),
request_accepted: accepted,
prepared,
reason,
}
}
impl render::Host for State {
fn set_hud(&mut self, shapes: Vec<Shape>) {
self.shapes = clip_shapes(shapes)
@@ -2378,14 +2454,19 @@ impl render::Host for State {
Shape::Sprite(s) => s.sprite = clip(std::mem::take(&mut s.sprite)),
Shape::Item(i) => {
i.item = clip(std::mem::take(&mut i.item));
i.scale = if i.scale.is_finite() { i.scale.clamp(0.0, 16.0) } else { 1.0 };
i.scale = if i.scale.is_finite() {
i.scale.clamp(0.0, 16.0)
} else {
1.0
};
}
Shape::Text(t) => {
t.text = clip(std::mem::take(&mut t.text));
t.color &= 0xFF_FFFF;
}
Shape::Terrain(t) => {
t.blocks_per_texel = t.blocks_per_texel.clamp(1, 64).next_power_of_two().min(64);
t.blocks_per_texel =
t.blocks_per_texel.clamp(1, 64).next_power_of_two().min(64);
}
_ => {}
}
@@ -2694,6 +2775,7 @@ impl render::Host for State {
if running {
return Err("a capture is already running".into());
}
t.capture_owner = Some((self.run_serial, t.smoke_generation));
t.capture_request = Some(options);
// Until the game answers, the request is the state.
t.capture_state = Some(CaptureState {
@@ -2741,9 +2823,8 @@ impl render::Host for State {
let Ok(mut t) = self.model_table.lock() else {
return 0;
};
*t.world_log.get_or_insert_with(|| {
WORLD_LOG_IDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
})
*t.world_log
.get_or_insert_with(|| WORLD_LOG_IDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
}
fn world_log_stop(&mut self, id: u32) {
if let Ok(mut t) = self.model_table.lock()
@@ -2780,7 +2861,9 @@ impl render::Host for State {
} else {
0.0
};
if !lighting.hd && let Ok(mut t) = self.model_table.lock() {
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;
@@ -2816,23 +2899,79 @@ impl render::Host for State {
s
})
.collect();
let generation = self.model_table.lock().ok().map(|t| (t.smoke_generation, 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 };
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,
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 }
backend_ready: t.smoke_active,
reason,
}
}
fn acoustics_status(&mut self) -> render::CapabilityState {
let Ok(t) = self.model_table.lock() else {
return unavailable_capability();
};
let accepted = self
.acoustics_request
.is_some_and(|(generation, _)| generation == t.smoke_generation && generation != 0);
let requested_on = accepted && self.acoustics_request.is_some_and(|(_, on)| on);
let prepared = requested_on && t.acoustics && t.acoustics_prepared;
let reason = if t.smoke_world.is_none() {
render::CapabilityReason::AwaitingWorld
} else if !accepted {
render::CapabilityReason::NotRequested
} else if !requested_on || !t.acoustics {
render::CapabilityReason::Disabled
} else if !prepared {
render::CapabilityReason::AwaitingBackend
} else {
render::CapabilityReason::Ready
};
render::CapabilityState {
generation: t.smoke_generation,
available: true,
enabled: t.acoustics,
request_accepted: accepted,
prepared,
reason,
}
}
fn capture_owner_status(&mut self) -> render::CapabilityState {
let Ok(t) = self.model_table.lock() else {
return unavailable_capability();
};
capture_owner_state(&t, self.run_serial)
}
fn set_time_override(&mut self, day_time: Option<u64>) {
self.time_override = day_time.map(|t| t.min(1 << 40));
}
@@ -2880,6 +3019,11 @@ impl render::Host for State {
})
}
fn set_acoustics(&mut self, on: bool) {
self.acoustics_request = self
.model_table
.lock()
.ok()
.map(|t| (t.smoke_generation, on));
self.out.push(Output::SetAcoustics { on });
}
fn acoustics(&mut self) -> bool {
@@ -2979,9 +3123,13 @@ impl CommandTree<'_> {
}
fn check_word(word: &str, what: &str) -> Result<(), String> {
if word.is_empty() || word.chars().count() > TREE_WORD_CHARS || word.contains(char::is_whitespace)
if word.is_empty()
|| word.chars().count() > TREE_WORD_CHARS
|| word.contains(char::is_whitespace)
{
return Err(format!("{what} {word:?}: one to {TREE_WORD_CHARS} characters, no spaces"));
return Err(format!(
"{what} {word:?}: one to {TREE_WORD_CHARS} characters, no spaces"
));
}
Ok(())
}
@@ -3030,11 +3178,15 @@ pub fn check_tree(nodes: &[TreeNode]) -> Result<(), String> {
match &n.kind {
Some(TreeNodeKind::Value(v)) => check_value(v).map_err(|e| format!("{what}: {e}"))?,
Some(TreeNodeKind::Text) if !n.children.is_empty() => {
return Err(format!("{what}: text is the rest of the line: nothing follows it"));
return Err(format!(
"{what}: text is the rest of the line: nothing follows it"
));
}
Some(TreeNodeKind::Options(o)) => {
if !n.children.is_empty() {
return Err(format!("{what}: options take the rest of the line: nothing follows them"));
return Err(format!(
"{what}: options take the rest of the line: nothing follows them"
));
}
if o.entries.len() > TREE_WORDS {
return Err(format!("{what}: too many options"));
@@ -3061,7 +3213,9 @@ pub fn check_tree(nodes: &[TreeNode]) -> Result<(), String> {
for &c in &n.children {
let c = c as usize;
if c <= i || c >= nodes.len() {
return Err(format!("{what}: child {c} must come after it, within the list"));
return Err(format!(
"{what}: child {c} must come after it, within the list"
));
}
if parent[c].replace(i).is_some() {
return Err(format!("{what}: child {c} has two parents"));
@@ -3075,7 +3229,10 @@ pub fn check_tree(nodes: &[TreeNode]) -> Result<(), String> {
.iter()
.any(|&y| nodes[y as usize].name == nodes[x as usize].name)
{
return Err(format!("node {i}: two children named {:?}", nodes[x as usize].name));
return Err(format!(
"node {i}: two children named {:?}",
nodes[x as usize].name
));
}
}
}
@@ -3231,6 +3388,28 @@ fn storage_key_ok(key: &str) -> bool {
mod tree_tests {
use super::*;
#[test]
fn capture_owner_is_scoped_to_run_and_generation() {
let mut t = ModelTable {
smoke_generation: 8,
smoke_world: Some(("world".into(), Some(1))),
capture_owner: Some((10, 8)),
capture_state: Some(CaptureState {
phase: CapturePhase::Recording,
frames: 1,
width: 16,
height: 16,
file: String::new(),
message: String::new(),
}),
..Default::default()
};
assert!(capture_owner_state(&t, 10).prepared);
assert!(!capture_owner_state(&t, 11).request_accepted);
t.smoke_generation = 9;
assert!(!capture_owner_state(&t, 10).request_accepted);
}
fn lit(name: &str, children: &[u32]) -> TreeNode {
TreeNode {
name: name.into(),
+130 -14
View File
@@ -113,50 +113,162 @@ 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 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")));
assert_eq!(
horizon_mods::api_level::needed(functions.iter().map(String::as_str)),
(27, Some("render.acoustics-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() };
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()
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!(
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}");
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}");
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!(
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");
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}");
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");
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"));
}
/// The real component observes owner-scoped requests, host milestones and world resets.
#[test]
fn capability_handshakes_track_actual_state_and_generation() {
use horizon_mods::{CapturePhase, CaptureState};
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()
};
let status = |mods: &mut Mods| {
assert!(mods.command("cap_status"));
mods.drain()
.into_iter()
.find_map(|o| match o {
Output::Chat { text, .. } => Some(text),
_ => None,
})
.unwrap()
};
mods.set_snapshot(snapshot("a"));
assert!(status(&mut mods).contains("false false false CapabilityReason::NotRequested"));
assert!(mods.command("cap_acoustics on"));
mods.drain();
assert!(status(&mut mods).contains("false true false CapabilityReason::Disabled"));
mods.set_acoustics(true);
assert!(status(&mut mods).contains("true true false CapabilityReason::AwaitingBackend"));
mods.set_acoustics_prepared();
assert!(status(&mut mods).contains("true true false CapabilityReason::AwaitingBackend"));
mods.set_acoustics_output_ready(true);
mods.set_acoustics_prepared();
assert!(status(&mut mods).contains("true true true CapabilityReason::Ready"));
mods.set_acoustics_output_ready(false);
assert!(status(&mut mods).contains("true true false CapabilityReason::AwaitingBackend"));
mods.set_acoustics_output_ready(true);
mods.set_acoustics_prepared();
assert!(mods.command("cap_capture"));
mods.drain();
assert!(status(&mut mods).ends_with("true true false CapabilityReason::AwaitingBackend"));
mods.set_capture_state(CaptureState {
phase: CapturePhase::Recording,
frames: 0,
width: 0,
height: 0,
file: String::new(),
message: String::new(),
});
assert!(status(&mut mods).ends_with("true true true CapabilityReason::Ready"));
mods.set_capture_state(CaptureState {
phase: CapturePhase::Failed,
frames: 0,
width: 0,
height: 0,
file: String::new(),
message: "error".into(),
});
assert!(status(&mut mods).ends_with("true true false CapabilityReason::Failed"));
mods.set_snapshot(snapshot("b"));
let reset = status(&mut mods);
assert!(
reset.contains("true false false CapabilityReason::NotRequested"),
"{reset}"
);
assert!(
reset.ends_with("true false false CapabilityReason::NotRequested"),
"{reset}"
);
}
/// 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]
@@ -165,7 +277,11 @@ fn a_mod_describes_its_commands_arguments() {
let mut mods = load("horizon_mods_test_ui_guest.hmod");
assert_eq!(mods.entries()[0].status, Status::Running);
let trees = mods.command_trees();
assert_eq!(trees.len(), 1, "the unregistered command's tree is left out");
assert_eq!(
trees.len(),
1,
"the unregistered command's tree is left out"
);
let tree = &trees[0];
assert_eq!(tree.name, "ui_screen");
assert_eq!(tree.nodes.len(), 4);
@@ -333,7 +449,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 calls the level-26 smoke handshake.
// The UI guest calls the level-27 capability handshake.
assert_eq!(level("horizon_mods_test_ui_guest.hmod"), LEVEL);
}
+77 -10
View File
@@ -105,6 +105,9 @@ impl Mod for Guest {
"ui_close",
"render_hud",
"render_clear",
"cap_status",
"cap_acoustics",
"cap_capture",
"smoke_status",
"smoke_send",
"smoke_disable",
@@ -156,19 +159,77 @@ impl Mod for Guest {
"ui_close" => ui::close_screen(),
"render_hud" => render::set_hud(&shapes()),
"render_clear" => render::set_hud(&[]),
"cap_status" => {
let a = render::acoustics_status();
let c = render::capture_owner_status();
chat::show(&format!(
"{} {} {} {} {:?} | {} {} {} {} {:?}",
a.generation,
a.enabled,
a.request_accepted,
a.prepared,
a.reason,
c.generation,
c.enabled,
c.request_accepted,
c.prepared,
c.reason
));
}
"cap_acoustics" => render::set_acoustics(args == "on"),
"cap_capture" => {
let _ = render::capture_start(&render::CaptureOptions {
folder: "films".into(),
name: "test".into(),
fps: 30,
width: 0,
height: 0,
crf: 14,
audio: false,
warmup: 0,
});
}
"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)));
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,
}]);
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 }),
"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";
@@ -309,12 +370,18 @@ impl Mod for Guest {
let ops = if name == "source_keep" {
vec![op(render::SourceOpKind::Start)]
} else {
vec![op(render::SourceOpKind::Start), op(render::SourceOpKind::Stop)]
vec![
op(render::SourceOpKind::Start),
op(render::SourceOpKind::Stop),
]
};
let ok = render::sound_sources(g, &ops);
return Ok(Some(format!("{ok}")));
}
"saves" => command::set_suggestions("saves", &args.split(',').map(str::to_owned).collect::<Vec<_>>()),
"saves" => command::set_suggestions(
"saves",
&args.split(',').map(str::to_owned).collect::<Vec<_>>(),
),
"quiet" => command::run_quiet(&args.split(';').map(str::to_owned).collect::<Vec<_>>()),
"quiet_many" => {
let n: usize = args.parse().unwrap_or(0);
+26
View File
@@ -787,6 +787,30 @@ State of volumetric smoke, as of the last prepared main-view frame (API level 26
| `backend-ready` | `bool` | |
| `reason` | `smoke-reason` | |
### enum `capability-reason`
| Case | |
| --- | --- |
| `awaiting-world` | |
| `disabled` | |
| `not-requested` | |
| `awaiting-backend` | |
| `failed` | |
| `ready` | |
### record `capability-state`
API level 27: state for the calling mod and current world generation. Accepted means the request was stored, not that output was produced. Prepared is a host-side processing milestone, not audible or GPU completion.
| Field | Type | |
| --- | --- | --- |
| `generation` | `u64` | |
| `available` | `bool` | |
| `enabled` | `bool` | |
| `request-accepted` | `bool` | |
| `prepared` | `bool` | |
| `reason` | `capability-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.
@@ -891,6 +915,8 @@ Where a capture is.
| `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. |
| `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. |
| `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. |