Expose scoped acoustics and capture capability states
Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
@@ -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| {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user