From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 12:37:45 +0300 Subject: [PATCH 01/12] Mod API (AirDef fork): world models, client particles, sounds, ground heights render.set-models draws block models with an interpolated pose per id; render.spawn-particles and render.play-sound act on this client only; render.query-ground / render.ground read the motion-blocking heightmap. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 2 + crates/horizon-client/src/game/mod_models.rs | 197 +++++++++++++++++++ crates/horizon-client/src/game/mods.rs | 24 +++ crates/horizon-mod-sdk/wit/horizon.wit | 58 ++++++ crates/horizon-mods/src/api_level.rs | 10 +- crates/horizon-mods/src/runtime.rs | 116 ++++++++++- 6 files changed, 403 insertions(+), 4 deletions(-) create mode 100644 crates/horizon-client/src/game/mod_models.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1d7bbbbd6..7c342cd86 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -115,6 +115,7 @@ mod mod_input; #[cfg(feature = "mods")] mod mod_recipes; mod mod_render; +mod mod_models; mod mods; mod mount_menu; mod mounts; @@ -8232,6 +8233,7 @@ impl Game<'_> { } } self.mark_entity(out.len(), ""); + self.mod_model_geometry(camera, alpha, &mut out); self.moving_piston_geometry(camera, alpha, &mut out); self.mark_entity(out.len(), "minecraft:tnt"); self.tnt_geometry(camera, alpha, &mut out); diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs new file mode 100644 index 000000000..3c7f9345c --- /dev/null +++ b/crates/horizon-client/src/game/mod_models.rs @@ -0,0 +1,197 @@ +//! AirDef fork: capability `render`'s world models, client particles and +//! sounds. A mod sets its models once a tick (`render.set-models`); each id +//! keeps its last two poses and is drawn between them every frame, so models +//! move smoothly at any frame rate. +use super::Game; +#[cfg(feature = "mods")] +use { + glam::{DVec3, Mat4, Quat, Vec3}, + horizon_client::{ + mesher::Vertex, + particles::{ParticleOptions, ParticleType}, + }, + horizon_core::random::RandomSource, + horizon_mods::ParticleSpawn, + horizon_world::registry::BlockStateId, + std::collections::HashMap, +}; + +/// One model of a mod: where it was a tick ago and where it is now. +#[cfg(feature = "mods")] +struct Pose { + state: BlockStateId, + previous: DVec3, + position: DVec3, + old_rotation: Quat, + rotation: Quat, + old_scale: f32, + scale: f32, + glow: bool, +} + +/// The models the running mods keep, by (mod, id). +#[cfg(feature = "mods")] +#[derive(Default)] +pub(super) struct ModModels { + poses: HashMap<(usize, u32), Pose>, + /// Block states by the text mods name them, parsed once. + states: HashMap>, +} + +/// Full brightness: sky and block light 15, as the entity shader packs them. +#[cfg(feature = "mods")] +const GLOW: u32 = 240 | (240 << 8); + +/// A build without the `mods` feature draws no mod models. +#[cfg(not(feature = "mods"))] +impl Game<'_> { + pub(super) fn mod_model_geometry( + &self, + _camera: glam::DVec3, + _alpha: f64, + _out: &mut Vec, + ) { + } +} + +#[cfg(feature = "mods")] +impl Game<'_> { + /// After the mods' tick: each model's new pose, its last one kept to + /// draw from. Ids no longer set are dropped; new ones start where they + /// are. + pub(super) fn update_mod_models(&mut self) { + let mut seen: Vec<(usize, u32)> = Vec::new(); + let registry = &self.pack.assets.registry; + let models = &mut self.mods.models; + for (owner, m) in self.mods.mods.models() { + let state = *models + .states + .entry(m.model.clone()) + .or_insert_with(|| super::render_control::parse_state(registry, &m.model).ok()); + let Some(state) = state else { + continue; + }; + let key = (owner, m.id); + seen.push(key); + let position = DVec3::new(m.x, m.y, m.z); + let (x, y, z, w) = m.rotation; + let rotation = Quat::from_xyzw(x, y, z, w); + match models.poses.get_mut(&key) { + Some(p) => { + p.previous = p.position; + p.old_rotation = p.rotation; + p.old_scale = p.scale; + p.position = position; + p.rotation = rotation; + p.scale = m.scale; + p.state = state; + p.glow = m.glow; + } + None => { + models.poses.insert( + key, + Pose { + state, + previous: position, + position, + old_rotation: rotation, + rotation, + old_scale: m.scale, + scale: m.scale, + glow: m.glow, + }, + ); + } + } + } + if seen.len() != models.poses.len() { + let seen: std::collections::HashSet<_> = seen.into_iter().collect(); + models.poses.retain(|k, _| seen.contains(k)); + } + } + + /// Draws every mod model between its last two poses, lit by the world + /// (or at full brightness), its faces shaded by its own rotation. + pub(super) fn mod_model_geometry(&self, camera: DVec3, alpha: f64, out: &mut Vec) { + if self.mods.models.poses.is_empty() { + return; + } + let t = alpha.clamp(0.0, 1.0); + for p in self.mods.models.poses.values() { + let at = p.previous.lerp(p.position, t); + // Far models are culled by distance; the view frustum does the rest. + if at.distance_squared(camera) > 512.0 * 512.0 { + continue; + } + let rotation = p.old_rotation.slerp(p.rotation, t as f32); + let scale = p.old_scale + (p.scale - p.old_scale) * t as f32; + if scale <= 0.0 { + continue; + } + let light = if p.glow { + GLOW + } else { + let l = self.world.light( + at.x.floor() as i32, + at.y.floor() as i32, + at.z.floor() as i32, + ); + (u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8) + }; + // The model's unit cube turned and scaled about its centre. + let m = Mat4::from_scale_rotation_translation( + Vec3::splat(scale), + rotation, + (at - camera).as_vec3(), + ) * Mat4::from_translation(Vec3::splat(-0.5)); + self.block_model_vertices(p.state, 0, m, light, true, out); + } + } + + /// `render.spawn-particles`: made on this client at once. + pub(super) fn mod_particles(&mut self, particles: Vec) { + for s in particles { + let Some(ty) = ParticleType::from_name(&s.particle) else { + continue; + }; + let rgb = [ + ((s.color >> 16) & 255) as f32 / 255.0, + ((s.color >> 8) & 255) as f32 / 255.0, + (s.color & 255) as f32 / 255.0, + ]; + let rgb = if s.color == 0 { [1.0; 3] } else { rgb }; + let options = match ty { + ParticleType::Dust => ParticleOptions::dust(rgb, s.scale), + ParticleType::EntityEffect | ParticleType::TintedLeaves | ParticleType::Flash => { + ParticleOptions::colored(ty, [rgb[0], rgb[1], rgb[2], 1.0]) + } + ParticleType::Block + | ParticleType::FallingDust + | ParticleType::DustPillar + | ParticleType::BlockMarker + | ParticleType::Item + | ParticleType::ItemSlime + | ParticleType::ItemSnowball + | ParticleType::ItemCobweb => continue, + _ => ParticleOptions::simple(ty), + }; + let (sx, sy, sz) = s.spread; + let (vx, vy, vz) = s.velocity; + let speed = f64::from(s.speed); + for _ in 0..s.count { + let r = self.particles.random(); + let p = [ + s.x + r.next_gaussian() * f64::from(sx), + s.y + r.next_gaussian() * f64::from(sy), + s.z + r.next_gaussian() * f64::from(sz), + ]; + let v = [ + f64::from(vx) + r.next_gaussian() * speed, + f64::from(vy) + r.next_gaussian() * speed, + f64::from(vz) + r.next_gaussian() * speed, + ]; + self.particles.create(&options, p, v); + } + } + } +} diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 52dc46fe0..3eef96aa3 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -46,6 +46,9 @@ pub(super) struct ModsState { /// Capability `render`'s camera shakes. #[cfg(feature = "mods")] pub(super) shake: super::mod_render::Shake, + /// Capability `render`'s world models (AirDef fork), with their last poses. + #[cfg(feature = "mods")] + pub(super) models: super::mod_models::ModModels, /// `--dev-mod PATH`: always on, reloaded when rebuilt. pub(super) dev: Vec, /// When dev mods were last checked for a rebuild. @@ -596,9 +599,17 @@ impl Game<'_> { return; } self.tick_mod_use(); + for (i, columns) in self.mods.mods.take_ground_queries() { + let ground = columns + .into_iter() + .map(|(x, z)| (x, z, self.rain_height(x, z))) + .collect(); + self.mods.mods.answer_ground(i, ground); + } let snapshot = self.mod_snapshot(); self.mods.mods.set_snapshot(snapshot); self.mods.mods.tick(self.ticks); + self.update_mod_models(); self.show_mod_output(); } /// Restarts the dev mods rebuilt since the last check, once a second. @@ -716,6 +727,19 @@ impl Game<'_> { self.mods.shake.add(strength, seconds); continue; } + Output::Particles { particles } => { + self.mod_particles(particles); + continue; + } + Output::Sound { + event, + at, + volume, + pitch, + } => { + self.audio.play(&event, Some(at), volume, pitch); + continue; + } Output::PlaceRecipe { book_id, all } => { self.mod_place_recipe(book_id, all); continue; diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 0680cce92..89d1906a9 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -429,6 +429,64 @@ interface render { /// Distance setting, when on, wins. One far terrain shows at a time: the /// first loaded mod's. set-far-terrain: func(far-terrain: option); + /// A block model drawn in the world (AirDef fork). + record model-instance { + /// Stays the same while the object lives: the game moves an id + /// smoothly from where the last `set-models` put it. + id: u32, + /// A block state as commands write it (`airdef:shahed`, + /// `minecraft:stone`, `minecraft:furnace[facing=east]`): its block + /// model is drawn. An unknown one draws nothing. + model: string, + /// Where the model's centre is, in blocks. + x: f64, + y: f64, + z: f64, + /// Rotation about the model's centre: a quaternion (x, y, z, w). + rotation: tuple, + /// Uniform scale, 1.0 a block (0 to 64). + scale: f32, + /// Drawn at full brightness, whatever the light where it is. + glow: bool, + } + /// Replaces the mod's models: up to 4096, drawn with the world's + /// entities until replaced. Call it once a tick: the game interpolates + /// each id between its last two positions and rotations every frame. + /// An empty list removes them. + set-models: func(models: list); + /// Particles to spawn on this client (AirDef fork). + record particle-spawn { + /// As `/particle` names the type: `minecraft:flame`, `minecraft:dust`. + particle: string, + x: f64, + y: f64, + z: f64, + /// Gaussian spread round the point, per axis, in blocks. + spread: tuple, + /// Every particle's starting velocity, blocks a tick. + velocity: tuple, + /// Gaussian speed added to it per axis. + speed: f32, + /// 1 to 256. + count: u32, + /// 0xRRGGBB for dust and coloured types (0: white). + color: u32, + /// Dust size (0.01 to 4). + scale: f32, + } + /// Spawns particles on this client only: no command, no server. + /// At most 512 spawns a call. + spawn-particles: func(particles: list); + /// Plays a sound event (`minecraft:entity.generic.explode`, + /// `:`) at a point on this client. + play-sound: func(event: string, x: f64, y: f64, z: f64, volume: f32, pitch: f32); + /// Asks for the ground height of these columns (x, z): at most 512 a + /// tick, answered by `ground` from the next tick on. + query-ground: func(columns: list>); + /// The answers to the last tick's `query-ground`: (x, z, y), y one above + /// the highest block that blocks motion or holds a fluid, as the motion + /// blocking heightmap; -64 where the chunk isn't loaded. + ground: func() -> list>; } /// What the game calls. Only the hooks `init` asks for are ever called. diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs index 045a9346b..b64792eaa 100644 --- a/crates/horizon-mods/src/api_level.rs +++ b/crates/horizon-mods/src/api_level.rs @@ -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 = 7; +pub const LEVEL: u32 = 8; /// 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); 46] = [ +pub const FUNCTIONS: [(&str, u32); 51] = [ ("log.write", 0), ("game.get-player", 0), ("game.get-clock", 0), @@ -60,6 +60,12 @@ pub const FUNCTIONS: [(&str, u32); 46] = [ ("recipes.place", 6), ("recipes.give", 6), ("render.set-far-terrain", 7), + // AirDef fork: world models, client particles and sounds. + ("render.set-models", 8), + ("render.spawn-particles", 8), + ("render.play-sound", 8), + ("render.query-ground", 8), + ("render.ground", 8), ]; /// What the game's program carries so the launcher can read its level diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index cff44722b..317cd4f06 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -71,6 +71,7 @@ mod render_bindings { wasmtime::component::bindgen!({ path: "../horizon-mod-sdk/wit", world: "render-host", + additional_derives: [PartialEq], }); } use render_bindings::horizon::mods::input; @@ -79,8 +80,8 @@ pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, I /// What capability `render` asks the game to draw (WIT `interface render`). pub use render_bindings::horizon::mods::render::{ Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate, ItemShape, - Minimap as ModMinimap, Point as RenderPoint, QuadShape, Rect as RenderRect, Shape, SpriteShape, - TextShape, Zoom as ModZoom, + Minimap as ModMinimap, ModelInstance, ParticleSpawn, Point as RenderPoint, QuadShape, + Rect as RenderRect, Shape, SpriteShape, TextShape, Zoom as ModZoom, }; use render_bindings::{RenderHost, horizon::mods::render}; /// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items. @@ -238,6 +239,15 @@ pub enum Output { CloseScreen { owner: usize }, /// `render.shake`: strength 0 to 1, fading out over `seconds` (≤ 10). Shake { strength: f32, seconds: f32 }, + /// `render.spawn-particles` (AirDef fork): particles to make on this client. + Particles { particles: Vec }, + /// `render.play-sound` (AirDef fork): a sound event at a point on this client. + Sound { + event: String, + at: [f64; 3], + volume: f32, + pitch: f32, + }, /// `recipes.place`: the recipe book's click on this display id, all of /// it when `all`. PlaceRecipe { book_id: i32, all: bool }, @@ -416,6 +426,12 @@ struct State { minimap: Option, /// Capability `render`'s far terrain, when the mod draws one. far_terrain: Option, + /// Capability `render`'s world models (AirDef fork), drawn until replaced. + models: Vec, + /// `render.query-ground`'s columns, answered before the next tick. + ground_queries: Vec<(i32, i32)>, + /// The game's answers to them. + ground: Vec<(i32, i32, i32)>, input: InputState, browser: BrowserState, /// Capability `storage`'s folder; made on first `set`. @@ -619,6 +635,9 @@ impl Mods { zoom: None, minimap: None, far_terrain: None, + models: Vec::new(), + ground_queries: Vec::new(), + ground: Vec::new(), input: InputState::default(), browser: BrowserState::default(), data_dir, @@ -1173,6 +1192,32 @@ impl Mods { .iter() .find_map(|e| e.run.as_ref()?.store.data().minimap) } + /// Every running mod's world models (AirDef fork), with the mod's index + /// so ids of different mods don't meet. + pub fn models(&self) -> impl Iterator { + self.entries.iter().enumerate().flat_map(|(i, e)| { + e.run + .iter() + .flat_map(move |r| r.store.data().models.iter().map(move |m| (i, m))) + }) + } + /// The columns each running mod asked the ground height of, taken. + pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> { + self.entries + .iter_mut() + .enumerate() + .filter_map(|(i, e)| { + let q = std::mem::take(&mut e.run.as_mut()?.store.data_mut().ground_queries); + (!q.is_empty()).then_some((i, q)) + }) + .collect() + } + /// Mod `i`'s answers to its ground queries, for its next calls. + pub fn answer_ground(&mut self, i: usize, ground: Vec<(i32, i32, i32)>) { + if let Some(run) = self.entries.get_mut(i).and_then(|e| e.run.as_mut()) { + run.store.data_mut().ground = ground; + } + } /// The far terrain the first running mod draws (capability `render`). pub fn far_terrain(&self) -> Option { self.entries @@ -1734,6 +1779,12 @@ const MINIMAP_SIZE: (f32, f32) = (16.0, 256.0); const MINIMAP_SCALE: (f32, f32) = (0.5, 4.0); /// `set-far-terrain`'s distance in chunks, as the LOD Distance setting's. const FAR_TERRAIN_DISTANCE: (u32, u32) = (64, 1024); +/// `set-models`' most models a mod keeps, and their largest scale. +const MODELS: usize = 4096; +const MODEL_SCALE: f32 = 64.0; +/// `spawn-particles`' most spawns a call and particles a spawn. +const PARTICLE_SPAWNS: usize = 512; +const PARTICLES_PER_SPAWN: u32 = 256; /// A mod's corner panel: shapes in a `width` × `height` box the game /// places in `corner`, stacked with the other overlays. @@ -1851,6 +1902,67 @@ impl render::Host for State { self.out.push(Output::Shake { strength, seconds }); } } + fn set_models(&mut self, models: Vec) { + let finite = |v: f64| v.is_finite() && v.abs() < 30_000_000.0; + self.models = models + .into_iter() + .filter(|m| finite(m.x) && finite(m.y) && finite(m.z)) + .take(MODELS) + .map(|mut m| { + let (x, y, z, w) = m.rotation; + let len = (x * x + y * y + z * z + w * w).sqrt(); + m.rotation = if len.is_finite() && len > 1e-6 { + (x / len, y / len, z / len, w / len) + } else { + (0.0, 0.0, 0.0, 1.0) + }; + m.scale = if m.scale.is_finite() { + m.scale.clamp(0.0, MODEL_SCALE) + } else { + 1.0 + }; + m.model = clip(m.model); + m + }) + .collect(); + } + fn spawn_particles(&mut self, particles: Vec) { + let particles: Vec = particles + .into_iter() + .filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()) + .take(PARTICLE_SPAWNS) + .map(|mut p| { + p.count = p.count.clamp(1, PARTICLES_PER_SPAWN); + p.particle = clip(p.particle); + p + }) + .collect(); + if !particles.is_empty() { + self.out.push(Output::Particles { particles }); + } + } + fn query_ground(&mut self, columns: Vec<(i32, i32)>) { + let room = 512usize.saturating_sub(self.ground_queries.len()); + self.ground_queries.extend(columns.into_iter().take(room)); + } + fn ground(&mut self) -> Vec<(i32, i32, i32)> { + self.ground.clone() + } + fn play_sound(&mut self, event: String, x: f64, y: f64, z: f64, volume: f32, pitch: f32) { + if !(x.is_finite() && y.is_finite() && z.is_finite()) { + return; + } + let ok = |v: f32, max: f32| if v.is_finite() { v.clamp(0.0, max) } else { 1.0 }; + let sounds = self.out.iter().filter(|o| matches!(o, Output::Sound { .. })).count(); + if sounds < 64 { + self.out.push(Output::Sound { + event: clip(event), + at: [x, y, z], + volume: ok(volume, 16.0), + pitch: ok(pitch, 2.0), + }); + } + } } impl command::Host for State { -- 2.54.0 (Apple Git-157)