Report per-run sound source application milestones

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
2026-11-04 16:10:59 +03:00
parent 6230ec7031
commit d8fa77b000
7 changed files with 202 additions and 14 deletions
+6 -4
View File
@@ -837,10 +837,9 @@ impl Game<'_> {
self.mods
.mods
.set_source_generation(self.audio.mod_sources.epoch);
let changes: Vec<Change> = self
.mods
.mods
.take_source_changes()
let pending = self.mods.mods.take_source_changes();
let changed_runs: Vec<u64> = pending.iter().map(|(run, _)| *run).collect();
let changes: Vec<Change> = pending
.into_iter()
.map(|(run, c)| match c {
SourceChange::Start(id, p) => Change::Start((run, id), params(p)),
@@ -853,6 +852,9 @@ impl Game<'_> {
if !idle || !runs.is_empty() {
self.audio.apply_mod_sources(changes, &runs);
}
self.mods
.mods
.source_changes_applied(&changed_runs, self.audio.acoustics_output_ready());
}
/// Restarts the dev mods rebuilt since the last check, once a second.
pub(super) fn reload_dev_mods(&mut self) {
+6
View File
@@ -1039,6 +1039,12 @@ interface render {
/// completion and errors. Other mods cannot claim it. A world transition
/// invalidates ownership and asks the host to cancel the capture.
capture-owner-status: func() -> capability-state;
/// API level 29. State of this run's live persistent sound source changes.
/// Prepared means their changes were applied by the host audio source manager
/// while a live output or offline film mixer was available; it does not mean
/// a sound asset loaded, received a voice, or was audible. Stopping all sources,
/// a source-generation reset, world transition or mod reload invalidates it.
sound-sources-status: func() -> capability-state;
/// API level 28. The calling run's non-empty point-light submission for this world.
/// Enabled means HD was selected for the last main frame, not GPU availability.
/// Prepared means the host processed its cached submissions for an HD main frame,
+3 -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 = 28;
pub const LEVEL: u32 = 29;
/// 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); 95] = [
pub const FUNCTIONS: [(&str, u32); 96] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -124,6 +124,7 @@ pub const FUNCTIONS: [(&str, u32); 95] = [
("render.acoustics-status", 27),
("render.capture-owner-status", 27),
("render.point-lights-status", 28),
("render.sound-sources-status", 29),
];
/// What the game's program carries so the launcher can read its level
+88 -3
View File
@@ -338,6 +338,8 @@ pub struct ModelTable {
time_view: Option<(u32, u64)>,
/// `render.sound-source-generation`: moves on when the game released every source.
source_generation: u32,
source_output_seen: bool,
source_output_ready: bool,
/// `render.acoustics`: the game muffles sounds itself.
acoustics: bool,
acoustics_prepared: bool,
@@ -561,6 +563,9 @@ struct State {
spots: Vec<SpotLight>,
/// `render.sound-sources` (AirDef fork): the changes the game has not taken, and this run's number.
sources: crate::sources::SourceBook,
source_request: Option<(u64, u32)>,
source_taken: bool,
source_prepared: bool,
run_serial: u64,
lighting: Option<ModLighting>,
/// `render.set-time-override` (AirDef fork).
@@ -844,6 +849,9 @@ impl Mods {
point_prepared_generation: None,
spots: Vec::new(),
sources: Default::default(),
source_request: None,
source_taken: false,
source_prepared: false,
run_serial: {
static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
@@ -956,6 +964,8 @@ impl Mods {
t.smoke_frame_seen = false;
t.point_lights_frame_seen = false;
t.point_lights_enabled = false;
t.source_output_seen = false;
t.source_output_ready = false;
t.acoustics_prepared = false;
// A world-bound mod capture must not survive into another world.
// Only mod-owned captures are touched; game/admin captures remain independent.
@@ -968,6 +978,9 @@ impl Mods {
for entry in &mut self.entries {
if let Some(run) = &mut entry.run {
let state = run.store.data_mut();
state.source_request = None;
state.source_taken = false;
state.source_prepared = false;
state.lights.clear();
state.point_request_generation = None;
state.point_cached_generation = None;
@@ -1606,7 +1619,11 @@ impl Mods {
drop(t);
for e in &mut self.entries {
if let Some(r) = e.run.as_mut() {
r.store.data_mut().sources.clear();
let state = r.store.data_mut();
state.sources.clear();
state.source_request = None;
state.source_taken = false;
state.source_prepared = false;
}
}
}
@@ -1643,11 +1660,36 @@ impl Mods {
if let Some(r) = e.run.as_mut() {
let d = r.store.data_mut();
let serial = d.run_serial;
out.extend(d.sources.take().into_iter().map(|c| (serial, c)));
let changes = d.sources.take();
if !changes.is_empty() {
d.source_taken = true;
}
out.extend(changes.into_iter().map(|c| (serial, c)));
}
}
out
}
/// Report the runs whose changes were applied to the host audio source manager.
pub fn source_changes_applied(&mut self, runs: &[u64], output_ready: bool) {
if let Ok(mut t) = self.model_table.lock() {
t.source_output_seen = true;
t.source_output_ready = output_ready && t.smoke_world.is_some();
} else {
return;
}
for entry in &mut self.entries {
if let Some(run) = &mut entry.run {
let state = run.store.data_mut();
if !output_ready {
state.source_prepared = false;
}
if runs.contains(&state.run_serial) && state.source_taken {
state.source_prepared = output_ready;
state.source_taken = false;
}
}
}
}
/// The runs that are alive: the game releases the sources of any other.
pub fn source_runs(&self) -> Vec<u64> {
self.entries
@@ -3102,6 +3144,35 @@ impl render::Host for State {
reason,
}
}
fn sound_sources_status(&mut self) -> render::CapabilityState {
let Ok(t) = self.model_table.lock() else {
return unavailable_capability();
};
let accepted = self.source_request == Some((t.smoke_generation, t.source_generation))
&& t.smoke_generation != 0;
let prepared = accepted && t.source_output_ready && self.source_prepared;
let reason = if t.smoke_world.is_none() {
render::CapabilityReason::AwaitingWorld
} else if !accepted {
render::CapabilityReason::NotRequested
} else if !t.source_output_seen {
render::CapabilityReason::AwaitingBackend
} else if !t.source_output_ready {
render::CapabilityReason::Disabled
} else if !prepared {
render::CapabilityReason::AwaitingBackend
} else {
render::CapabilityReason::Ready
};
render::CapabilityState {
generation: t.smoke_generation,
available: true,
enabled: t.source_output_ready,
request_accepted: accepted,
prepared,
reason,
}
}
fn point_lights_status(&mut self) -> render::CapabilityState {
let Ok(t) = self.model_table.lock() else {
return unavailable_capability();
@@ -3209,6 +3280,7 @@ impl render::Host for State {
return false;
}
use crate::sources::{OPS_PER_CALL, SourceKind, SourceParams};
let mut changed = false;
for (i, o) in ops.into_iter().enumerate() {
let kind = match o.kind {
SourceOpKind::Start => SourceKind::Start,
@@ -3237,7 +3309,20 @@ impl render::Host for State {
)
})
.flatten();
self.sources.apply(kind, o.id, params);
changed |= self.sources.apply(kind, o.id, params);
}
if changed {
self.source_request = if self.sources.running() == 0 {
None
} else {
self.model_table
.lock()
.ok()
.map(|t| (t.smoke_generation, generation))
.filter(|(world, _)| *world != 0)
};
self.source_taken = false;
self.source_prepared = false;
}
true
}
+76 -2
View File
@@ -110,6 +110,80 @@ fn player() -> Snapshot {
}
}
/// The guest submits changes; only the audio apply acknowledgement advances its status.
#[test]
fn sound_sources_status_tracks_apply_output_and_generation() {
let dir = mods_dir().join("source_status_reload");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("guest.hmod");
std::fs::copy(mods_dir().join("horizon_mods_test_ui_guest.hmod"), &path).unwrap();
let mut mods = Mods::load(std::slice::from_ref(&path), |_| true, |_| false);
let snapshot = |world: &str| Snapshot {
world: Some(world.into()),
world_seed: Some(17),
..Snapshot::default()
};
let status = |mods: &mut Mods| {
assert!(mods.command("source_status"));
mods.drain()
.into_iter()
.find_map(|o| match o {
Output::Chat { text, .. } => Some(text),
_ => None,
})
.unwrap()
};
mods.set_snapshot(snapshot("world:a"));
mods.set_source_generation(7);
assert!(mods.command("source_stale"));
assert!(status(&mut mods).ends_with("false false false CapabilityReason::NotRequested"));
assert!(mods.command("source_keep"));
assert!(status(&mut mods).ends_with("false true false CapabilityReason::AwaitingBackend"));
let changes = mods.take_source_changes();
let runs: Vec<_> = changes.iter().map(|(run, _)| *run).collect();
mods.source_changes_applied(&runs, false);
assert!(status(&mut mods).ends_with("false true false CapabilityReason::Disabled"));
mods.source_changes_applied(&[], true);
assert!(status(&mut mods).ends_with("true true false CapabilityReason::AwaitingBackend"));
assert!(mods.command("source_keep"));
let changes = mods.take_source_changes();
let runs: Vec<_> = changes.iter().map(|(run, _)| *run).collect();
mods.source_changes_applied(&runs, true);
assert!(status(&mut mods).ends_with("true true true CapabilityReason::Ready"));
assert!(mods.command("source_stop"));
assert!(status(&mut mods).ends_with("true false false CapabilityReason::NotRequested"));
assert!(mods.command("source_keep"));
mods.set_source_generation(8);
assert!(status(&mut mods).ends_with("true false false CapabilityReason::NotRequested"));
assert!(mods.command("source_keep"));
let changes = mods.take_source_changes();
let runs: Vec<_> = changes.iter().map(|(run, _)| *run).collect();
mods.source_changes_applied(&runs, true);
let old = status(&mut mods)
.split_whitespace()
.next()
.unwrap()
.to_owned();
mods.set_snapshot(snapshot("world:b"));
let next = status(&mut mods);
assert_ne!(next.split_whitespace().next().unwrap(), old);
assert!(next.ends_with("false false false CapabilityReason::NotRequested"));
assert!(mods.command("source_keep"));
let changes = mods.take_source_changes();
let runs: Vec<_> = changes.iter().map(|(run, _)| *run).collect();
mods.source_changes_applied(&runs, true);
assert!(status(&mut mods).ends_with("true true true CapabilityReason::Ready"));
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(5);
std::fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_modified(later)
.unwrap();
assert!(mods.reload_changed(|_| false));
assert!(status(&mut mods).ends_with("true false false CapabilityReason::NotRequested"));
}
/// Exercise the SDK/WIT through a real guest, then the same cache and frame hooks the game calls.
#[test]
fn point_lights_milestones_follow_processing_and_world_generation() {
@@ -195,7 +269,7 @@ fn smoke_handshake_keeps_fallback_until_the_current_world_is_ready() {
let functions = horizon_mods::api_level::imports(&wasm).unwrap();
assert_eq!(
horizon_mods::api_level::needed(functions.iter().map(String::as_str)),
(28, Some("render.point-lights-status"))
(29, Some("render.sound-sources-status"))
);
let mut mods = load("horizon_mods_test_ui_guest.hmod");
let snapshot = |world: &str| Snapshot {
@@ -534,7 +608,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-28 point-light handshake.
// The UI guest calls the level-29 sound-source handshake.
assert_eq!(level("horizon_mods_test_ui_guest.hmod"), LEVEL);
}
+22 -3
View File
@@ -108,6 +108,9 @@ impl Mod for Guest {
"render_hud",
"render_clear",
"cap_status",
"source_status",
"source_stop",
"source_stale",
"point_status",
"point_set",
"point_clear",
@@ -180,6 +183,13 @@ impl Mod for Guest {
}
"render_hud" => render::set_hud(&shapes()),
"render_clear" => render::set_hud(&[]),
"source_status" => {
let s = render::sound_sources_status();
chat::show(&format!(
"{} {} {} {} {:?}",
s.generation, s.enabled, s.request_accepted, s.prepared, s.reason
));
}
"point_status" => {
let s = render::point_lights_status();
chat::show(&format!(
@@ -381,7 +391,7 @@ impl Mod for Guest {
render::set_far_terrain(far);
}
// API level 20: the generation, and a source started and stopped at once.
"source" | "source_keep" => {
"source" | "source_keep" | "source_stop" | "source_stale" => {
let g = render::sound_source_generation();
let _ = render::acoustics();
render::load_chunks(&[(0, 0)]);
@@ -405,15 +415,24 @@ impl Mod for Guest {
loop_from: 0.0,
loop_to: 0.0,
};
let ops = if name == "source_keep" {
let ops = if name == "source_keep" || name == "source_stale" {
vec![op(render::SourceOpKind::Start)]
} else if name == "source_stop" {
vec![op(render::SourceOpKind::Stop)]
} else {
vec![
op(render::SourceOpKind::Start),
op(render::SourceOpKind::Stop),
]
};
let ok = render::sound_sources(g, &ops);
let ok = render::sound_sources(
if name == "source_stale" {
g.wrapping_add(1)
} else {
g
},
&ops,
);
return Ok(Some(format!("{ok}")));
}
"saves" => command::set_suggestions(
+1
View File
@@ -917,6 +917,7 @@ Where a capture is.
| `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 and asks the host to cancel the capture. |
| `sound-sources-status` | `func() -> capability-state` | API level 29. State of this run's live persistent sound source changes. Prepared means their changes were applied by the host audio source manager while a live output or offline film mixer was available; it does not mean a sound asset loaded, received a voice, or was audible. Stopping all sources, a source-generation reset, world transition or mod reload invalidates it. |
| `point-lights-status` | `func() -> capability-state` | API level 28. The calling run's non-empty point-light submission for this world. Enabled means HD was selected for the last main frame, not GPU availability. Prepared means the host processed its cached submissions for an HD main frame, not that every light survived culling or that a GPU produced visible pixels. Empty submissions clear the request; a new submission awaits another frame. |
| `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. |