Mods: world queries for simulations (render.query-rays, query-sections, world-changes; API level 12): block-exact rays and section occupancy by collision, sight or sound, answered next tick within budgets, and a log of changed sections

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 21:43:22 +03:00
committed by airdef
parent a8235704e4
commit f87d2c518f
10 changed files with 951 additions and 6 deletions
+2
View File
@@ -116,6 +116,8 @@ mod mod_models;
#[cfg(feature = "mods")]
mod mod_recipes;
mod mod_render;
#[cfg(feature = "mods")]
mod mod_world;
mod mods;
mod mount_menu;
mod mounts;
+277
View File
@@ -0,0 +1,277 @@
//! AirDef fork: capability `render`'s world queries for simulations
//! (`render.query-rays`, `render.query-sections`, `render.world-changes`).
//! Mods ask in a tick and are answered before the next one, from the chunks
//! the game holds: a chunk it doesn't have is `unknown`, never air. The
//! walk and the bit packing are `horizon_mods::world_query`; this is the
//! game's side: which blocks count under each policy, and the chunks.
use super::Game;
use horizon_client::chunk::{Chunk, MIN_Y, SECTIONS};
use horizon_client::world::World;
use horizon_core::geometry::Axis;
use horizon_mods::world_query::{self, BlockProbe, Cell, SectionBits, Trace};
use horizon_mods::{
SectionFill, WorldChanges, WorldPolicy, WorldRay, WorldRayAnswer, WorldSection, WorldVerdict,
};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
/// Block cells all of a tick's segments may walk: the rest are `unknown`.
const CELLS_PER_TICK: usize = 200_000;
/// Policy bits of [`WorldTables::flags`].
const COLLISION: u8 = 1;
const SIGHT: u8 = 2;
const SOUND: u8 = 4;
/// What each block state is to the three policies, and its boxes.
#[derive(Default)]
pub(super) struct WorldTables {
/// Per state: the policy bits that it stops.
flags: Vec<u8>,
/// Per state: its collision boxes in block space; none for a full cube
/// (or a state that stops nothing).
boxes: Vec<Option<Box<[[f64; 6]]>>>,
/// The world the tables were made for.
epoch: u32,
}
fn mask(policy: WorldPolicy) -> u8 {
match policy {
WorldPolicy::Collision => COLLISION,
WorldPolicy::Sight => SIGHT,
WorldPolicy::Sound => SOUND,
}
}
/// The blocks of the resident chunks, for [`world_query::trace`].
struct Probe<'a> {
world: &'a World,
tables: &'a WorldTables,
mask: u8,
last: Option<((i32, i32), &'a Arc<Chunk>)>,
cells: usize,
}
impl BlockProbe for Probe<'_> {
fn cell(&mut self, x: i32, y: i32, z: i32) -> Cell<'_> {
self.cells += 1;
let key = (x >> 4, z >> 4);
let chunk = match self.last {
Some((k, c)) if k == key => c,
_ => {
let Some(c) = self.world.chunks.get(&key) else {
return Cell::Unknown;
};
self.last = Some((key, c));
c
}
};
let state = chunk.get(x & 15, y, z & 15) as usize;
if self
.tables
.flags
.get(state)
.is_none_or(|f| f & self.mask == 0)
{
return Cell::Empty;
}
Cell::Solid(self.tables.boxes[state].as_deref())
}
}
impl Game<'_> {
/// The tables for this world, made when it is new.
fn world_tables(&mut self) -> Arc<WorldTables> {
let epoch = self.world.epoch;
if let Some(t) = &self.mods.world_tables
&& t.epoch == epoch
&& t.flags.len() == self.res.facts().states.len()
{
return t.clone();
}
let facts = self.res.facts();
let cached = self.collision.cached_shapes();
let mut flags = vec![0u8; facts.states.len()];
let mut boxes: Vec<Option<Box<[[f64; 6]]>>> = vec![None; facts.states.len()];
for (i, f) in facts.states.iter().enumerate() {
let state = BlockStateId(i as u32);
if facts.is_air(state) {
continue;
}
let shape = cached.cached_shape(state).ok().flatten();
let (solid, list): (bool, Option<Vec<[f64; 6]>>) = match shape {
Some(s) if s.is_empty() => (false, None),
Some(s) if s.is_block() => (true, None),
Some(s) => (
true,
Some(
s.boxes()
.iter()
.map(|b| {
[Axis::X, Axis::Y, Axis::Z, Axis::X, Axis::Y, Axis::Z]
.into_iter()
.enumerate()
.map(|(k, a)| if k < 3 { b.min(a) } else { b.max(a) })
.collect::<Vec<_>>()
.try_into()
.unwrap()
})
.collect(),
),
),
// Its shape depends on the neighbours: the outline stands in,
// fluids and plants aside.
None => (
f.fluid == horizon_items::blocks::Fluid::None
&& !f.outline.is_empty()
&& f.full_collision,
None,
),
};
if !solid {
continue;
}
let full = shape.is_some_and(|s| s.is_block()) || (shape.is_none() && f.full_collision);
flags[i] = COLLISION
| if f.can_occlude { SIGHT } else { 0 }
| if full || f.full_collision { SOUND } else { 0 };
boxes[i] = list.map(Vec::into_boxed_slice);
}
let tables = Arc::new(WorldTables {
flags,
boxes,
epoch,
});
self.mods.world_tables = Some(tables.clone());
tables
}
/// Answers what the running mods asked of the world last tick, and tells
/// them what changed since.
pub(super) fn answer_mod_world(&mut self) {
let queries = self.mods.mods.take_world_queries();
if queries.is_empty() {
self.world.mod_changes.watching = false;
return;
}
self.world.mod_changes.watching = true;
let tables = self.world_tables();
let (changed, overflow) = self.world.mod_changes.take();
let changes = WorldChanges {
epoch: self.world.epoch,
revision: self.world.revision,
overflow,
sections: changed,
};
let mut budget = CELLS_PER_TICK;
for (i, q) in queries {
let mut rays = Vec::with_capacity(q.rays.len() + q.over.len());
for r in &q.rays {
let (answer, cells) = self.trace_ray(&tables, r, budget);
budget = budget.saturating_sub(cells);
rays.push(answer);
}
rays.extend(q.over.iter().map(|&id| unknown(id, 0.0)));
let mut sections = Vec::new();
for (policy, list) in &q.sections {
for &(x, y, z) in list {
sections.push(self.section_of(&tables, mask(*policy), x, y, z));
}
}
self.mods
.mods
.answer_world(i, rays, sections, changes.clone());
}
}
fn trace_ray(
&self,
tables: &WorldTables,
r: &WorldRay,
budget: usize,
) -> (WorldRayAnswer, usize) {
if budget == 0 {
return (unknown(r.id, 0.0), 0);
}
let mut probe = Probe {
world: &self.world,
tables,
mask: mask(r.policy),
last: None,
cells: 0,
};
let t = world_query::trace(
&mut probe,
[r.start.0, r.start.1, r.start.2],
[r.end.0, r.end.1, r.end.2],
);
let answer = match t {
Trace::Clear => WorldRayAnswer {
id: r.id,
verdict: WorldVerdict::Clear,
fraction: 1.0,
normal: (0, 0, 0),
block: (0, 0, 0),
known_to: 1.0,
},
Trace::Hit {
fraction,
normal,
block,
} => WorldRayAnswer {
id: r.id,
verdict: WorldVerdict::Hit,
fraction,
normal: (normal[0], normal[1], normal[2]),
block: (block[0], block[1], block[2]),
known_to: 1.0,
},
Trace::Unknown { known_to } => unknown(r.id, known_to),
};
(answer, probe.cells)
}
fn section_of(&self, tables: &WorldTables, mask: u8, x: i32, y: i32, z: i32) -> WorldSection {
let mut s = WorldSection {
x,
y,
z,
fill: SectionFill::Unknown,
bits: Vec::new(),
};
let Some(chunk) = self.world.chunks.get(&(x, z)) else {
return s;
};
let index = y - (MIN_Y >> 4);
if !(0..SECTIONS as i32).contains(&index) {
s.fill = SectionFill::Empty;
return s;
}
let section = &chunk.sections[index as usize];
let mut states = [0u32; 4096];
if let Some(v) = section.blocks.uniform() {
states.fill(v);
} else {
section.unpack(&mut states);
}
match world_query::section_bits(&states, &tables.flags, mask) {
SectionBits::Empty => s.fill = SectionFill::Empty,
SectionBits::Full => s.fill = SectionFill::Full,
SectionBits::Mixed(bits) => {
s.fill = SectionFill::Mixed;
s.bits = bits.to_vec();
}
}
s
}
}
fn unknown(id: u32, known_to: f64) -> WorldRayAnswer {
WorldRayAnswer {
id,
verdict: WorldVerdict::Unknown,
fraction: 0.0,
normal: (0, 0, 0),
block: (0, 0, 0),
known_to,
}
}
+4
View File
@@ -48,6 +48,9 @@ pub(super) struct ModsState {
/// Capability `render`'s camera shakes.
#[cfg(feature = "mods")]
pub(super) shake: super::mod_render::Shake,
/// What blocks are to capability `render`'s world queries (AirDef fork).
#[cfg(feature = "mods")]
pub(super) world_tables: Option<std::sync::Arc<super::mod_world::WorldTables>>,
/// Capability `render`'s world models (AirDef fork), with their last poses.
#[cfg(feature = "mods")]
pub(super) models: super::mod_models::ModModels,
@@ -613,6 +616,7 @@ impl Game<'_> {
.collect();
self.mods.mods.answer_ground(i, ground);
}
self.answer_mod_world();
let snapshot = self.mod_snapshot();
self.mods.mods.set_snapshot(snapshot);
let registry = self.pack.assets.registry;
+38
View File
@@ -99,7 +99,40 @@ pub struct World {
/// The moving-piston block entities of the level, by position, copied
/// from the block ticks after each tick: collision and rendering read them.
pub moving_pistons: Vec<([i32; 3], horizon_world::piston_shape::MovingPistonState)>,
/// A number of this world alone (a new one for every level opened): what
/// mods cache about the blocks is void when it changes.
pub epoch: u32,
/// The sections whose blocks changed since a mod last asked, kept only
/// while a mod watches (`render.world-changes`).
pub mod_changes: ModChanges,
}
/// The block changes a mod's cache of the world must hear of.
#[derive(Default)]
pub struct ModChanges {
pub watching: bool,
pub sections: crate::fast_hash::FxHashSet<(i32, i32, i32)>,
/// More sections changed than [`ModChanges::MAX`]: all count as changed.
pub overflow: bool,
}
impl ModChanges {
/// Sections kept between two reads.
pub const MAX: usize = 256;
fn note(&mut self, x: i32, y: i32, z: i32) {
if self.sections.len() >= Self::MAX {
self.overflow = true;
} else {
self.sections.insert((x >> 4, y >> 4, z >> 4));
}
}
/// What changed since the last call, as the mod reads it.
pub fn take(&mut self) -> (Vec<(i32, i32, i32)>, bool) {
(
self.sections.drain().collect(),
std::mem::take(&mut self.overflow),
)
}
}
static NEXT_EPOCH: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(1);
#[derive(Clone, Copy, Debug, Default)]
pub struct Weather {
pub clear_time: i32,
@@ -192,6 +225,8 @@ impl World {
moving_piston_fallback: Vec::new(),
moving_pistons: Vec::new(),
pois: Default::default(),
epoch: NEXT_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
mod_changes: ModChanges::default(),
}
}
/// A controlled blockstate scene for the native scene probes: material
@@ -270,6 +305,9 @@ impl World {
self.watched_writes.push(((x, y, z), previous, state.0));
}
self.revision = self.revision.wrapping_add(1);
if self.mod_changes.watching {
self.mod_changes.note(x, y, z);
}
if let Some(log) = &mut self.block_log {
log.push((x, y, z));
}
+101
View File
@@ -528,6 +528,107 @@ interface render {
/// 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 a block is to a query (AirDef fork): the three policies differ
/// on purpose, so a sensor and a missile may disagree about a pane of
/// glass.
enum policy {
/// Projectile collision: every block with a collision shape stops it
/// (stone, leaves, glass, slabs, fences); air, fluids, plants and
/// other shapeless blocks let it pass.
collision,
/// Sensor line of sight: opaque blocks (those that stop light) only;
/// glass, leaves and fluids are seen through.
sight,
/// Sound occlusion: blocks with a full-cube collision shape.
sound,
}
/// How a query ended. Never `clear` for a chunk that isn't loaded.
enum verdict {
/// Nothing on the way, everything checked.
clear,
/// A block stops it; see the other fields.
hit,
/// A part of the way is in a chunk the game doesn't have (far
/// terrain, not yet loaded) or the budget ran out: no verdict. A
/// `hit` found before the unknown part still comes back as `hit`.
unknown,
}
/// A segment to trace through the loaded blocks.
record ray {
/// Echoed in the answer.
id: u32,
start: tuple<f64, f64, f64>,
end: tuple<f64, f64, f64>,
policy: policy,
}
record ray-answer {
id: u32,
verdict: verdict,
/// For a `hit`: how far along the segment, 0 to 1, where it enters
/// the shape.
fraction: f64,
/// For a `hit`: the face normal (the axis it entered along).
normal: tuple<s32, s32, s32>,
/// For a `hit`: the block hit.
block: tuple<s32, s32, s32>,
/// For an `unknown`: how far along the segment the unknown part
/// begins.
known-to: f64,
}
/// Traces segments through the loaded blocks (AirDef fork): each is
/// walked block by block on the host, against the real collision shapes
/// (`policy`), and answered by `rays` from the next tick on. At most 256
/// segments a tick, each at most 512 blocks long (longer ones are
/// answered `unknown` past that); over the budget they are answered
/// `unknown` too, never `clear`. A game with no chunk at a place answers
/// `unknown` for it.
query-rays: func(rays: list<ray>);
/// The answers to the last tick's `query-rays`, in the order asked.
rays: func() -> list<ray-answer>;
/// What a 16 x 16 x 16 section of the world holds, for a policy.
enum section-fill {
/// The game has no such chunk loaded: nothing is known.
unknown,
/// Nothing in it stops a query.
empty,
/// Every block in it does.
full,
/// Some do: `bits` tells which.
mixed,
}
record section {
/// Section coordinates: block x, y, z divided by 16, rounded down.
x: s32,
y: s32,
z: s32,
fill: section-fill,
/// For `mixed`: 512 bytes, one bit per block, set where it stops
/// the policy's query. The index is (y & 15) * 256 + (z & 15) * 16
/// + (x & 15); bit i is bit (i & 7) of byte i / 8. Empty otherwise.
bits: list<u8>,
}
/// Asks what these sections hold (AirDef fork), for a mod that traces
/// its own paths against a cache of them: at most 64 a tick, answered
/// by `sections` from the next tick on. Over the budget they are not
/// answered: ask again.
query-sections: func(policy: policy, sections: list<tuple<s32, s32, s32>>);
/// The answers to the last tick's `query-sections`.
sections: func() -> list<section>;
/// Changes to the world since the last tick, for a mod's cache.
record changes {
/// A number that changes when another world or dimension opens:
/// everything cached before is void.
epoch: u32,
/// Counts block changes in the loaded world, ever (wraps).
revision: u64,
/// More sections changed than fit: treat everything as changed.
overflow: bool,
/// Sections (as in `section`) where a block changed, at most 256.
sections: list<tuple<s32, s32, s32>>,
}
/// The block changes of the last tick (placed, broken, exploded) and
/// the world's epoch. Only changes in chunks the game has loaded.
world-changes: func() -> changes;
/// A full-screen post pass (AirDef fork): WGSL defining
/// `fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32>`, after a prelude
/// with `frame` (camera matrices, time, `params`), the world's colour and
+8 -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 = 11;
pub const LEVEL: u32 = 12;
/// 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); 61] = [
pub const FUNCTIONS: [(&str, u32); 66] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -76,6 +76,12 @@ pub const FUNCTIONS: [(&str, u32); 61] = [
("render.model-id", 11),
("render.model-generation", 11),
("render.set-models-by-id", 11),
// AirDef fork: the world's blocks for a simulation.
("render.query-rays", 12),
("render.rays", 12),
("render.query-sections", 12),
("render.sections", 12),
("render.world-changes", 12),
];
/// What the game's program carries so the launcher can read its level
+1
View File
@@ -17,6 +17,7 @@ pub mod modpack;
mod runtime;
#[cfg(feature = "runtime")]
pub mod testing;
pub mod world_query;
pub use content::Content;
pub use manifest::{Capability, Manifest, Package, Side, find};
+104 -4
View File
@@ -79,10 +79,12 @@ 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, Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate,
ItemShape, Lighting as ModLighting, Minimap as ModMinimap, ModelInstance, ModelInstanceId,
ParticleSpawn, Point as RenderPoint, PointLight, QuadShape, Rect as RenderRect, Shape,
SpriteShape, TextShape, ViewShape, Zoom as ModZoom,
CameraView, 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, SpriteShape, 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.
@@ -112,6 +114,17 @@ const FUEL_INIT: u64 = 2_000_000_000;
const FUEL_TICK: u64 = 300_000_000;
const FUEL_FRAME: u64 = 10_000_000;
const FUEL_EVENT: u64 = 1_000_000_000;
/// Segments a mod may trace per tick (`render.query-rays`).
pub const RAYS_PER_TICK: usize = 256;
/// Sections a mod may ask the contents of per tick (`render.query-sections`).
pub const SECTIONS_PER_TICK: usize = 64;
/// What a mod asked of the world in a tick (capability `render`, AirDef fork).
pub struct WorldQueries {
pub rays: Vec<WorldRay>,
/// Segments asked beyond the budget: to be answered unknown.
pub over: Vec<u32>,
pub sections: Vec<(WorldPolicy, Vec<(i32, i32, i32)>)>,
}
/// Blocks a mod may set per `world.set-blocks` call.
pub const BLOCKS_PER_CALL: usize = 4096;
/// Chat lines a mod may show per call, HUD lines and their length.
@@ -473,6 +486,19 @@ struct State {
ground_queries: Vec<(i32, i32)>,
/// The game's answers to them.
ground: Vec<(i32, i32, i32)>,
/// `render.query-rays`' segments, answered before the next tick.
ray_queries: Vec<WorldRay>,
/// Ids of segments asked beyond the budget.
ray_over: Vec<u32>,
rays: Vec<WorldRayAnswer>,
/// Whether the mod ever used the world queries.
world_seen: bool,
/// `render.query-sections`: the policy and the sections asked.
section_queries: Vec<(WorldPolicy, Vec<(i32, i32, i32)>)>,
sections_asked: usize,
sections: Vec<WorldSection>,
/// The game's note of what changed in the world last tick.
world_changes: Option<WorldChanges>,
/// `render.set-post`'s source, with a number that changes with it.
post: Option<(u64, String)>,
post_params: Vec<f32>,
@@ -710,6 +736,14 @@ impl Mods {
model_table: model_table.clone(),
ground_queries: Vec::new(),
ground: Vec::new(),
ray_queries: Vec::new(),
ray_over: Vec::new(),
world_seen: false,
rays: Vec::new(),
section_queries: Vec::new(),
sections_asked: 0,
sections: Vec::new(),
world_changes: None,
post: None,
post_params: Vec::new(),
views: Vec::new(),
@@ -1319,6 +1353,40 @@ impl Mods {
Some((*v, src.as_str(), d.post_params.as_slice()))
})
}
/// What each running mod asked of the world this tick: segments and
/// sections (`render.query-rays`, `render.query-sections`), taken.
pub fn take_world_queries(&mut self) -> Vec<(usize, WorldQueries)> {
self.entries
.iter_mut()
.enumerate()
.filter_map(|(i, e)| {
let d = e.run.as_mut()?.store.data_mut();
d.sections_asked = 0;
let q = WorldQueries {
rays: std::mem::take(&mut d.ray_queries),
over: std::mem::take(&mut d.ray_over),
sections: std::mem::take(&mut d.section_queries),
};
(d.world_seen || !q.rays.is_empty() || !q.sections.is_empty()).then_some((i, q))
})
.collect()
}
/// Mod `i`'s answers to its world queries and the note of what changed,
/// for its next calls (what it was not answered, it gets again empty).
pub fn answer_world(
&mut self,
i: usize,
rays: Vec<WorldRayAnswer>,
sections: Vec<WorldSection>,
changes: WorldChanges,
) {
if let Some(run) = self.entries.get_mut(i).and_then(|e| e.run.as_mut()) {
let d = run.store.data_mut();
d.rays = rays;
d.sections = sections;
d.world_changes = Some(changes);
}
}
/// 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()) {
@@ -2176,6 +2244,38 @@ impl render::Host for State {
fn ground(&mut self) -> Vec<(i32, i32, i32)> {
self.ground.clone()
}
fn query_rays(&mut self, rays: Vec<WorldRay>) {
let room = RAYS_PER_TICK.saturating_sub(self.ray_queries.len());
let mut over = rays.into_iter();
self.ray_queries.extend(over.by_ref().take(room));
// Over the budget: unknown, never clear.
self.ray_over.extend(over.take(RAYS_PER_TICK).map(|r| r.id));
self.world_seen = true;
}
fn rays(&mut self) -> Vec<WorldRayAnswer> {
self.rays.clone()
}
fn query_sections(&mut self, policy: WorldPolicy, sections: Vec<(i32, i32, i32)>) {
self.world_seen = true;
let room = SECTIONS_PER_TICK.saturating_sub(self.sections_asked);
let sections: Vec<_> = sections.into_iter().take(room).collect();
self.sections_asked += sections.len();
if !sections.is_empty() {
self.section_queries.push((policy, sections));
}
}
fn sections(&mut self) -> Vec<WorldSection> {
self.sections.clone()
}
fn world_changes(&mut self) -> WorldChanges {
self.world_seen = true;
self.world_changes.clone().unwrap_or(WorldChanges {
epoch: 0,
revision: 0,
overflow: false,
sections: Vec::new(),
})
}
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;
+337
View File
@@ -0,0 +1,337 @@
//! The world queries of capability `render` (AirDef fork, API level 12): a
//! segment walked block by block against collision shapes, and the
//! contents of a 16-block section as a bit set. Pure code over a
//! [`BlockProbe`], so the game answers from its chunks and the tests from
//! a few lines of fixture.
/// What a probe knows about one block cell.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Cell<'a> {
/// No chunk holds this cell.
Unknown,
/// The cell does not stop the policy's queries.
Empty,
/// It does; its collision boxes in cell space (0..1), or none for a full
/// cube.
Solid(Option<&'a [[f64; 6]]>),
}
/// The blocks a query reads.
pub trait BlockProbe {
fn cell(&mut self, x: i32, y: i32, z: i32) -> Cell<'_>;
}
/// How a segment ended.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Trace {
Clear,
Hit {
/// 0 to 1 along the segment.
fraction: f64,
normal: [i32; 3],
block: [i32; 3],
},
Unknown {
/// 0 to 1: where the unknown part begins.
known_to: f64,
},
}
/// Longest segment walked, in blocks; the rest is `Unknown`.
pub const MAX_RAY: f64 = 512.0;
/// The first solid block along `from` to `to`, or `Unknown` where the walk
/// reaches a cell the probe doesn't know. A segment starting inside a solid
/// block hits it at fraction 0.
pub fn trace(probe: &mut impl BlockProbe, from: [f64; 3], to: [f64; 3]) -> Trace {
if !from
.iter()
.chain(&to)
.all(|v| v.is_finite() && v.abs() < 3.0e7)
{
return Trace::Unknown { known_to: 0.0 };
}
let delta = [to[0] - from[0], to[1] - from[1], to[2] - from[2]];
let length = (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt();
// The part walked, as a fraction of the segment.
let walked = if length > MAX_RAY {
MAX_RAY / length
} else {
1.0
};
let mut pos = from.map(|v| v.floor() as i32);
let step: [i32; 3] = delta.map(|d| if d > 0.0 { 1 } else { -1 });
let mut t_max = [f64::INFINITY; 3];
let mut t_delta = [f64::INFINITY; 3];
for a in 0..3 {
if delta[a] != 0.0 {
let boundary = f64::from(pos[a]) + if delta[a] > 0.0 { 1.0 } else { 0.0 };
t_max[a] = (boundary - from[a]) / delta[a];
t_delta[a] = (1.0 / delta[a]).abs();
}
}
let mut face = [0i32; 3];
let mut t = 0.0f64;
loop {
match probe.cell(pos[0], pos[1], pos[2]) {
Cell::Unknown => {
return Trace::Unknown {
known_to: t.min(1.0),
};
}
Cell::Empty => {}
Cell::Solid(boxes) => {
if let Some((hit, normal)) = enter(boxes, pos, from, delta, face) {
return Trace::Hit {
fraction: hit.clamp(0.0, 1.0),
normal,
block: pos,
};
}
}
}
let axis = if t_max[0] < t_max[1] {
if t_max[0] < t_max[2] { 0 } else { 2 }
} else if t_max[1] < t_max[2] {
1
} else {
2
};
t = t_max[axis];
if t > walked {
return if walked < 1.0 {
Trace::Unknown { known_to: walked }
} else {
Trace::Clear
};
}
t_max[axis] += t_delta[axis];
pos[axis] += step[axis];
face = [0; 3];
face[axis] = -step[axis];
}
}
/// Where the segment first meets the cell's boxes (slab method): its
/// fraction and the face's normal. `face` is the face it came in by.
fn enter(
boxes: Option<&[[f64; 6]]>,
cell: [i32; 3],
from: [f64; 3],
delta: [f64; 3],
face: [i32; 3],
) -> Option<(f64, [i32; 3])> {
const FULL: [[f64; 6]; 1] = [[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]];
let boxes = boxes.unwrap_or(&FULL);
let mut best: Option<(f64, [i32; 3])> = None;
for b in boxes {
let (mut t0, mut t1) = (0.0f64, 1.0f64);
let mut normal = face;
let mut miss = false;
for a in 0..3 {
let (lo, hi) = (f64::from(cell[a]) + b[a], f64::from(cell[a]) + b[a + 3]);
if delta[a] == 0.0 {
miss |= from[a] < lo || from[a] > hi;
continue;
}
let (e, x) = ((lo - from[a]) / delta[a], (hi - from[a]) / delta[a]);
let (near, far) = if e < x { (e, x) } else { (x, e) };
if near > t0 {
t0 = near;
normal = [0; 3];
normal[a] = if delta[a] > 0.0 { -1 } else { 1 };
}
t1 = t1.min(far);
}
if miss || t0 > t1 {
continue;
}
if best.is_none_or(|(bt, _)| t0 < bt) {
best = Some((t0, normal));
}
}
best
}
/// A section's contents for a policy: whether every block, none or some stop
/// it, and for some the 512 bytes of bits (index `(y & 15) * 256 + (z & 15) *
/// 16 + (x & 15)`, bit `i & 7` of byte `i / 8`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SectionBits {
Empty,
Full,
Mixed(Box<[u8; 512]>),
}
/// The bits of 4096 block states (in the index order above) under a state
/// table: `table[state] & mask != 0` stops the query.
pub fn section_bits(states: &[u32; 4096], table: &[u8], mask: u8) -> SectionBits {
let mut bits = Box::new([0u8; 512]);
let mut set = 0u32;
for (i, &s) in states.iter().enumerate() {
if table.get(s as usize).is_some_and(|f| f & mask != 0) {
bits[i >> 3] |= 1 << (i & 7);
set += 1;
}
}
match set {
0 => SectionBits::Empty,
4096 => SectionBits::Full,
_ => SectionBits::Mixed(bits),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
/// A world of listed cells; any cell outside `known` columns is unknown.
struct Fixture {
solid: HashMap<(i32, i32, i32), Option<Vec<[f64; 6]>>>,
unknown_from_x: i32,
}
impl BlockProbe for Fixture {
fn cell(&mut self, x: i32, y: i32, z: i32) -> Cell<'_> {
if x >= self.unknown_from_x {
return Cell::Unknown;
}
match self.solid.get(&(x, y, z)) {
None => Cell::Empty,
Some(None) => Cell::Solid(None),
Some(Some(b)) => Cell::Solid(Some(b)),
}
}
}
fn wall(x: i32) -> Fixture {
let mut solid = HashMap::new();
for y in 60..80 {
for z in -5..5 {
solid.insert((x, y, z), None);
}
}
Fixture {
solid,
unknown_from_x: 10_000,
}
}
#[test]
fn a_wall_one_block_thick_is_hit_at_its_face() {
let mut w = wall(3);
let t = trace(&mut w, [0.5, 70.5, 0.5], [20.5, 70.5, 0.5]);
let Trace::Hit {
fraction,
normal,
block,
} = t
else {
panic!("{t:?}")
};
assert!((fraction - 2.5 / 20.0).abs() < 1e-9);
assert_eq!((normal, block), ([-1, 0, 0], [3, 70, 0]));
}
#[test]
fn a_diagonal_segment_does_not_slip_through_a_corner() {
let mut w = wall(3);
let t = trace(&mut w, [0.2, 65.2, 0.2], [6.2, 71.2, 0.2]);
assert!(
matches!(
t,
Trace::Hit {
block: [3, 68, 0],
..
}
),
"{t:?}"
);
}
#[test]
fn a_tunnel_is_clear() {
let mut w = wall(3);
w.solid.remove(&(3, 70, 0));
let t = trace(&mut w, [0.5, 70.5, 0.5], [20.5, 70.5, 0.5]);
assert_eq!(t, Trace::Clear);
}
#[test]
fn a_slab_is_passed_above_and_hit_below() {
let mut w = Fixture {
solid: HashMap::new(),
unknown_from_x: 10_000,
};
w.solid
.insert((2, 70, 0), Some(vec![[0.0, 0.0, 0.0, 1.0, 0.5, 1.0]]));
assert_eq!(
trace(&mut w, [0.5, 70.8, 0.5], [5.5, 70.8, 0.5]),
Trace::Clear
);
let t = trace(&mut w, [0.5, 70.3, 0.5], [5.5, 70.3, 0.5]);
assert!(
matches!(
t,
Trace::Hit {
block: [2, 70, 0],
normal: [-1, 0, 0],
..
}
),
"{t:?}"
);
}
#[test]
fn an_unloaded_chunk_is_unknown_not_air() {
let mut w = Fixture {
solid: HashMap::new(),
unknown_from_x: 8,
};
let t = trace(&mut w, [0.5, 70.5, 0.5], [20.5, 70.5, 0.5]);
let Trace::Unknown { known_to } = t else {
panic!("{t:?}")
};
assert!((known_to - 7.5 / 20.0).abs() < 1e-9);
// A hit before the unknown part still counts.
let mut w = wall(3);
w.unknown_from_x = 8;
assert!(matches!(
trace(&mut w, [0.5, 70.5, 0.5], [20.5, 70.5, 0.5]),
Trace::Hit { .. }
));
}
#[test]
fn a_long_segment_is_walked_only_so_far() {
let mut w = Fixture {
solid: HashMap::new(),
unknown_from_x: 10_000,
};
let t = trace(&mut w, [0.5, 70.5, 0.5], [2000.5, 70.5, 0.5]);
let Trace::Unknown { known_to } = t else {
panic!("{t:?}")
};
assert!((known_to - MAX_RAY / 2000.0).abs() < 1e-9);
}
#[test]
fn starting_inside_a_block_hits_at_zero() {
let mut w = wall(0);
let t = trace(&mut w, [0.5, 70.5, 0.5], [5.5, 70.5, 0.5]);
assert!(
matches!(t, Trace::Hit { fraction, .. } if fraction == 0.0),
"{t:?}"
);
}
#[test]
fn sections_pack_to_bits() {
let table = [0u8, 1, 7];
let mut states = [0u32; 4096];
assert_eq!(section_bits(&states, &table, 1), SectionBits::Empty);
states[256 + 16 * 3 + 5] = 1;
let SectionBits::Mixed(bits) = section_bits(&states, &table, 1) else {
panic!()
};
let i = 256 + 16 * 3 + 5;
assert_eq!(bits[i / 8], 1 << (i % 8));
// A sound-only policy ignores a block marked for collision alone.
assert_eq!(section_bits(&states, &table, 4), SectionBits::Empty);
states.fill(2);
assert_eq!(section_bits(&states, &table, 4), SectionBits::Full);
}
}
+79
View File
@@ -501,6 +501,80 @@ Particles to spawn on this client (AirDef fork).
| `color` | `u32` | 0xRRGGBB for dust and coloured types (0: white). |
| `scale` | `f32` | Dust size (0.01 to 4). |
### enum `policy`
What a block is to a query (AirDef fork): the three policies differ on purpose, so a sensor and a missile may disagree about a pane of glass.
| Case | |
| --- | --- |
| `collision` | Projectile collision: every block with a collision shape stops it (stone, leaves, glass, slabs, fences); air, fluids, plants and other shapeless blocks let it pass. |
| `sight` | Sensor line of sight: opaque blocks (those that stop light) only; glass, leaves and fluids are seen through. |
| `sound` | Sound occlusion: blocks with a full-cube collision shape. |
### enum `verdict`
How a query ended. Never `clear` for a chunk that isn't loaded.
| Case | |
| --- | --- |
| `clear` | Nothing on the way, everything checked. |
| `hit` | A block stops it; see the other fields. |
| `unknown` | A part of the way is in a chunk the game doesn't have (far terrain, not yet loaded) or the budget ran out: no verdict. A `hit` found before the unknown part still comes back as `hit`. |
### record `ray`
A segment to trace through the loaded blocks.
| Field | Type | |
| --- | --- | --- |
| `id` | `u32` | Echoed in the answer. |
| `start` | `tuple<f64, f64, f64>` | |
| `end` | `tuple<f64, f64, f64>` | |
| `policy` | `policy` | |
### record `ray-answer`
| Field | Type | |
| --- | --- | --- |
| `id` | `u32` | |
| `verdict` | `verdict` | |
| `fraction` | `f64` | For a `hit`: how far along the segment, 0 to 1, where it enters the shape. |
| `normal` | `tuple<s32, s32, s32>` | For a `hit`: the face normal (the axis it entered along). |
| `block` | `tuple<s32, s32, s32>` | For a `hit`: the block hit. |
| `known-to` | `f64` | For an `unknown`: how far along the segment the unknown part begins. |
### enum `section-fill`
What a 16 x 16 x 16 section of the world holds, for a policy.
| Case | |
| --- | --- |
| `unknown` | The game has no such chunk loaded: nothing is known. |
| `empty` | Nothing in it stops a query. |
| `full` | Every block in it does. |
| `mixed` | Some do: `bits` tells which. |
### record `section`
| Field | Type | |
| --- | --- | --- |
| `x` | `s32` | Section coordinates: block x, y, z divided by 16, rounded down. |
| `y` | `s32` | |
| `z` | `s32` | |
| `fill` | `section-fill` | |
| `bits` | `list<u8>` | For `mixed`: 512 bytes, one bit per block, set where it stops the policy's query. The index is (y & 15) * 256 + (z & 15) * 16 + (x & 15); bit i is bit (i & 7) of byte i / 8. Empty otherwise. |
### record `changes`
Changes to the world since the last tick, for a mod's cache.
| Field | Type | |
| --- | --- | --- |
| `epoch` | `u32` | A number that changes when another world or dimension opens: everything cached before is void. |
| `revision` | `u64` | Counts block changes in the loaded world, ever (wraps). |
| `overflow` | `bool` | More sections changed than fit: treat everything as changed. |
| `sections` | `list<tuple<s32, s32, s32>>` | Sections (as in `section`) where a block changed, at most 256. |
### record `camera-view`
A camera the game renders the world from besides the player's eye (AirDef fork): the land, the sky and the entities near the player.
@@ -561,6 +635,11 @@ The HD lighting (AirDef fork): the world lit per pixel by the mods' lights, in h
| `play-sound` | `func(event: string, x: f64, y: f64, z: f64, volume: f32, pitch: f32)` | Plays a sound event (`minecraft:entity.generic.explode`, `<mod>:<name>`) at a point on this client. |
| `query-ground` | `func(columns: list<tuple<s32, s32>>)` | Asks for the ground height of these columns (x, z): at most 512 a tick, answered by `ground` from the next tick on. |
| `ground` | `func() -> list<tuple<s32, 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. |
| `query-rays` | `func(rays: list<ray>)` | Traces segments through the loaded blocks (AirDef fork): each is walked block by block on the host, against the real collision shapes (`policy`), and answered by `rays` from the next tick on. At most 256 segments a tick, each at most 512 blocks long (longer ones are answered `unknown` past that); over the budget they are answered `unknown` too, never `clear`. A game with no chunk at a place answers `unknown` for it. |
| `rays` | `func() -> list<ray-answer>` | The answers to the last tick's `query-rays`, in the order asked. |
| `query-sections` | `func(policy: policy, sections: list<tuple<s32, s32, s32>>)` | Asks what these sections hold (AirDef fork), for a mod that traces its own paths against a cache of them: at most 64 a tick, answered by `sections` from the next tick on. Over the budget they are not answered: ask again. |
| `sections` | `func() -> list<section>` | The answers to the last tick's `query-sections`. |
| `world-changes` | `func() -> changes` | The block changes of the last tick (placed, broken, exploded) and the world's epoch. Only changes in chunks the game has loaded. |
| `set-post` | `func(wgsl: string)` | A full-screen post pass (AirDef fork): WGSL defining `fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32>`, after a prelude with `frame` (camera matrices, time, `params`), the world's colour and depth and helpers (`scene`, `depth-at`, `view-dir`, `scene-distance`, `project`). It runs after the world, before the hand and the HUD. Compile errors come back in the chat and the log; the last good pass keeps running. |
| `clear-post` | `func()` | Removes this mod's post pass: the world is shown as the game draws it. |
| `set-params` | `func(params: list<f32>)` | The pass's `frame.params` (up to 1024 floats, as 256 vec4), kept until replaced; call it once a tick (the pass gets the partial tick in `frame.time.y`). |