Files
airdef/horizon-patches/0001-Mod-API-AirDef-fork-world-models-client-particles-so.patch
T

578 lines
23 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 12:37:45 +0300
Subject: [PATCH 01/35] 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 <noreply@anthropic.com>
---
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<String, Option<BlockStateId>>,
+}
+
+/// 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<horizon_client::mesher::Vertex>,
+ ) {
+ }
+}
+
+#[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<Vertex>) {
+ 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<ParticleSpawn>) {
+ 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<PathBuf>,
/// 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<far-terrain>);
+ /// 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<f32, f32, f32, f32>,
+ /// 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<model-instance>);
+ /// 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<f32, f32, f32>,
+ /// Every particle's starting velocity, blocks a tick.
+ velocity: tuple<f32, f32, f32>,
+ /// 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<particle-spawn>);
+ /// Plays a sound event (`minecraft:entity.generic.explode`,
+ /// `<mod>:<name>`) 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<tuple<s32, s32>>);
+ /// 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<tuple<s32, s32, s32>>;
}
/// 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<ParticleSpawn> },
+ /// `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<ModMinimap>,
/// Capability `render`'s far terrain, when the mod draws one.
far_terrain: Option<ModFarTerrain>,
+ /// Capability `render`'s world models (AirDef fork), drawn until replaced.
+ models: Vec<ModelInstance>,
+ /// `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<Item = (usize, &ModelInstance)> {
+ 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<ModFarTerrain> {
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<ModelInstance>) {
+ 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<ParticleSpawn>) {
+ let particles: Vec<ParticleSpawn> = 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)