Files
RustNotMC/crates/horizon-client/src/ticket_levels.rs
T
SashegDev 874b6f20c2 Клиент: начальный импорт кодовой базы
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
2026-10-29 22:04:28 +03:00

449 lines
16 KiB
Rust

//! Which chunks tick, from the chunk tickets of `DistanceManager`.
//!
//! Every player holds two kinds of ticket:
//! - `PLAYER_LOADING` at level 31 (`ENTITY_TICKING`) on every chunk within
//! the server's view distance (Chebyshev), from `PlayerTicketTracker`;
//! - `PLAYER_SIMULATION` at level `31 - simulationDistance` on its own chunk.
//!
//! A `/forceload`ed chunk holds a `FORCED` ticket at level 31 of both kinds.
//! Levels rise by one per chunk away from a ticket (`ChunkTracker`) and each
//! chunk takes the lowest. The loading level gives the chunk's
//! `FullChunkStatus` (`ChunkLevel.fullStatus`: 31 entity ticking, 32 block
//! ticking, 33 full) and the simulation level gives what
//! `inEntityTickingRange` (31 or less) and `inBlockTickingRange` (32 or less)
//! answer. Vanilla needs both:
//! - entities tick where both levels are entity ticking (`ServerLevel.tick`,
//! and `EntityTickList` holds only the entities of entity-ticking chunks);
//! - random ticks run in the simulation tracker's entity-ticking chunks that
//! have a ticking chunk (`ChunkMap.forEachBlockTickingChunk`);
//! - scheduled ticks, block events and block entities run where both are
//! block ticking (`isPositionTickingWithEntitiesLoaded`,
//! `shouldTickBlocksAt`, `LevelChunk.isTicking`).
//!
//! Whoever the players are (the local player, LAN guests, a dedicated
//! server's players), they count the same.
use crate::fast_hash::FxHashMap;
use std::sync::Arc;
/// `ChunkLevel`'s levels.
pub const ENTITY_TICKING: u8 = 31;
pub const BLOCK_TICKING: u8 = 32;
/// What a chunk with no ticket near it has (`FULL` and above count as none).
const NONE: (u8, u8) = (BLOCK_TICKING + 1, BLOCK_TICKING + 1);
/// The tickets of one level: where its players are, its forced chunks, and
/// the server's view and simulation distances.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Sources {
pub players: Vec<(i32, i32)>,
pub forced: Vec<(i32, i32)>,
pub view_distance: i32,
pub simulation_distance: i32,
}
/// Each chunk's (loading, simulation) level, for the chunks where either is
/// block ticking or lower; `None` ticks everywhere (a game with no server and
/// no player: the headless fixtures).
#[derive(Clone, Default)]
pub struct Levels(Option<Arc<Map>>);
/// The levels by chunk: a grid over the tickets' bounding box while it is
/// small (players near each other, the usual case), a map otherwise. Every
/// block entity, mob and ticking chunk asks each tick, so an index beats a
/// hash; and a player crossing into another chunk rebuilds it all.
enum Map {
Grid {
x0: i32,
z0: i32,
width: u32,
depth: u32,
cells: Box<[(u8, u8)]>,
},
Sparse(FxHashMap<(i32, i32), (u8, u8)>),
}
/// The largest grid, in chunks: 1 MiB of levels, a box 724 chunks wide.
const GRID_CELLS: u64 = 1 << 19;
impl Map {
fn get(&self, x: i32, z: i32) -> (u8, u8) {
match self {
Self::Grid {
x0,
z0,
width,
depth,
cells,
} => {
let (dx, dz) = (x.wrapping_sub(*x0) as u32, z.wrapping_sub(*z0) as u32);
if dx < *width && dz < *depth {
cells[(dz * width + dx) as usize]
} else {
NONE
}
}
Self::Sparse(map) => map.get(&(x, z)).copied().unwrap_or(NONE),
}
}
}
impl Levels {
/// Chunk coordinates.
fn get(&self, x: i32, z: i32) -> Option<(u8, u8)> {
Some(self.0.as_ref()?.get(x, z))
}
/// `DistanceManager.inEntityTickingRange`, in an entity-ticking chunk:
/// where entities tick.
pub fn entity_ticking(&self, x: i32, z: i32) -> bool {
self.get(x, z)
.is_none_or(|(l, s)| l <= ENTITY_TICKING && s <= ENTITY_TICKING)
}
/// `inBlockTickingRange` in a block-ticking chunk: where scheduled ticks,
/// block events and block entities run.
pub fn block_ticking(&self, x: i32, z: i32) -> bool {
self.get(x, z)
.is_none_or(|(l, s)| l <= BLOCK_TICKING && s <= BLOCK_TICKING)
}
/// `inEntityTickingRange` in a block-ticking chunk: where random ticks run.
pub fn random_ticking(&self, x: i32, z: i32) -> bool {
self.get(x, z)
.is_none_or(|(l, s)| l <= BLOCK_TICKING && s <= ENTITY_TICKING)
}
/// `ChunkHolder.getFullStatus` is block ticking or more: the chunk's
/// ticking future can complete (its saved ticks unpack then).
pub fn loaded_ticking(&self, x: i32, z: i32) -> bool {
self.get(x, z).is_none_or(|(l, _)| l <= BLOCK_TICKING)
}
/// The same as [`Self::entity_ticking`] at a block position.
pub fn entity_ticking_at(&self, x: i32, z: i32) -> bool {
self.entity_ticking(x >> 4, z >> 4)
}
/// The same as [`Self::block_ticking`] at a block position.
pub fn block_ticking_at(&self, x: i32, z: i32) -> bool {
self.block_ticking(x >> 4, z >> 4)
}
}
/// The levels, rebuilt only when their sources change (a player crossing
/// into another chunk, a distance or a forced chunk changing).
#[derive(Default)]
pub struct ChunkTickets {
sources: Option<Sources>,
levels: Levels,
}
impl ChunkTickets {
/// `None`: tick everywhere.
pub fn update(&mut self, mut sources: Option<Sources>) {
if let Some(s) = sources.as_mut() {
s.players.sort_unstable();
s.players.dedup();
s.forced.sort_unstable();
}
if sources == self.sources {
return;
}
self.levels = Levels(sources.as_ref().map(|s| Arc::new(compute(s))));
self.sources = sources;
}
pub fn levels(&self) -> Levels {
self.levels.clone()
}
}
/// How far a player's tickets reach, and the simulation ticket's level.
fn reach(s: &Sources) -> (i32, i32, i32) {
let view = s.view_distance.clamp(0, 32);
let simulation = s.simulation_distance.clamp(0, 32);
// PLAYER_LOADING within the view distance, PLAYER_SIMULATION at the
// player's chunk (`getPlayerTicketLevel`: never below 0).
let player_sim = (i32::from(ENTITY_TICKING) - simulation).max(0);
let reach = (view + 1).max(i32::from(BLOCK_TICKING) - player_sim);
(view, player_sim, reach)
}
fn compute(s: &Sources) -> Map {
let (view, player_sim, reach) = reach(s);
let boxes = s
.players
.iter()
.map(|&p| (p, reach))
.chain(s.forced.iter().map(|&f| (f, 1)));
let Some((x0, z0, x1, z1)) = boxes.fold(None, |b, ((x, z), r)| {
let (lo_x, lo_z, hi_x, hi_z) = (
i64::from(x) - i64::from(r),
i64::from(z) - i64::from(r),
i64::from(x) + i64::from(r),
i64::from(z) + i64::from(r),
);
Some(match b {
None => (lo_x, lo_z, hi_x, hi_z),
Some((a, b, c, d)) => (lo_x.min(a), lo_z.min(b), hi_x.max(c), hi_z.max(d)),
})
}) else {
return Map::Sparse(FxHashMap::default());
};
let (width, depth) = ((x1 - x0 + 1) as u64, (z1 - z0 + 1) as u64);
if width * depth > GRID_CELLS {
return Map::Sparse(compute_sparse(s));
}
let (x0, z0, width, depth) = (x0 as i32, z0 as i32, width as u32, depth as u32);
let mut cells = vec![NONE; (width * depth) as usize].into_boxed_slice();
let mut lower = |x: i32, z: i32, loading: i32, sim: i32| {
let i = ((z - z0) as u32 * width + (x - x0) as u32) as usize;
let e = &mut cells[i];
e.0 = e.0.min(loading.min(i32::from(NONE.0)) as u8);
e.1 = e.1.min(sim.min(i32::from(NONE.1)) as u8);
};
for &(px, pz) in &s.players {
for dz in -reach..=reach {
for dx in -reach..=reach {
let d = dx.abs().max(dz.abs());
lower(
px + dx,
pz + dz,
i32::from(ENTITY_TICKING) + (d - view).max(0),
player_sim + d,
);
}
}
}
for &(fx, fz) in &s.forced {
for dz in -1i32..=1 {
for dx in -1i32..=1 {
let level = i32::from(ENTITY_TICKING) + dx.abs().max(dz.abs());
lower(fx + dx, fz + dz, level, level);
}
}
}
Map::Grid {
x0,
z0,
width,
depth,
cells,
}
}
/// The levels as a map, for tickets spread too far apart for a grid.
fn compute_sparse(s: &Sources) -> FxHashMap<(i32, i32), (u8, u8)> {
let (view, player_sim, reach) = reach(s);
let mut levels: FxHashMap<(i32, i32), (u8, u8)> = FxHashMap::default();
let mut lower = |key, loading: i32, sim: i32| {
let e = levels.entry(key).or_insert(NONE);
e.0 = e.0.min(loading.min(i32::from(NONE.0)) as u8);
e.1 = e.1.min(sim.min(i32::from(NONE.1)) as u8);
};
for &(px, pz) in &s.players {
for dz in -reach..=reach {
for dx in -reach..=reach {
let d = dx.abs().max(dz.abs());
lower(
(px + dx, pz + dz),
i32::from(ENTITY_TICKING) + (d - view).max(0),
player_sim + d,
);
}
}
}
for &(fx, fz) in &s.forced {
for dz in -1i32..=1 {
for dx in -1i32..=1 {
let level = i32::from(ENTITY_TICKING) + dx.abs().max(dz.abs());
lower((fx + dx, fz + dz), level, level);
}
}
}
levels.retain(|_, l| *l != NONE);
levels
}
#[cfg(test)]
mod tests {
use super::*;
fn levels(players: &[(i32, i32)], forced: &[(i32, i32)], view: i32, sim: i32) -> Levels {
let mut t = ChunkTickets::default();
t.update(Some(Sources {
players: players.to_vec(),
forced: forced.to_vec(),
view_distance: view,
simulation_distance: sim,
}));
t.levels()
}
#[test]
fn simulation_distance_inside_the_view() {
// view 8, simulation 4: entities and random ticks out to 4 chunks,
// scheduled ticks out to 5.
let l = levels(&[(0, 0)], &[], 8, 4);
assert!(l.entity_ticking(4, -4) && !l.entity_ticking(5, 0));
assert!(l.random_ticking(-4, 4) && !l.random_ticking(0, 5));
assert!(l.block_ticking(5, 5) && !l.block_ticking(6, 0));
assert!(l.loaded_ticking(9, 9) && !l.loaded_ticking(10, 0));
}
#[test]
fn view_distance_inside_the_simulation() {
// view 4, simulation 10: what isn't loaded at entity ticking (beyond
// the view distance) runs no entities, and block ticks stop where the
// chunk is only full (beyond the view distance + 1).
let l = levels(&[(0, 0)], &[], 4, 10);
assert!(l.entity_ticking(4, 0) && !l.entity_ticking(5, 0));
assert!(l.random_ticking(5, 0) && !l.random_ticking(6, 0));
assert!(l.block_ticking(5, 5) && !l.block_ticking(6, 0));
}
#[test]
fn every_player_and_forced_chunk() {
let l = levels(&[(0, 0), (40, 0)], &[(-100, 7)], 4, 4);
assert!(l.entity_ticking(44, 0) && l.entity_ticking(0, -4));
assert!(!l.entity_ticking(20, 0));
// A forced chunk entity ticks, its neighbours only block tick.
assert!(l.entity_ticking(-100, 7) && !l.entity_ticking(-101, 7));
assert!(l.block_ticking(-101, 8) && !l.block_ticking(-102, 7));
assert!(!l.random_ticking(-101, 7));
assert!(l.entity_ticking_at(-1600, 112) && !l.block_ticking_at(-1617, 112));
}
#[test]
fn no_tickets_tick_nothing_and_no_sources_everything() {
let none = levels(&[], &[], 10, 10);
assert!(!none.block_ticking(0, 0) && !none.entity_ticking(0, 0));
let everywhere = ChunkTickets::default().levels();
assert!(everywhere.random_ticking(1000, -1000));
}
#[test]
fn rebuilt_only_when_sources_change() {
let mut t = ChunkTickets::default();
let s = |players: Vec<(i32, i32)>| {
Some(Sources {
players,
forced: Vec::new(),
view_distance: 2,
simulation_distance: 2,
})
};
t.update(s(vec![(0, 0), (5, 5)]));
let first = t.levels();
t.update(s(vec![(5, 5), (0, 0)]));
assert!(Arc::ptr_eq(
first.0.as_ref().unwrap(),
t.levels().0.as_ref().unwrap()
));
t.update(s(vec![(1, 0), (5, 5)]));
assert!(t.levels().entity_ticking(3, 0) && !first.entity_ticking(3, 0));
t.update(None);
assert!(t.levels().0.is_none());
}
/// The grid answers as the map does, everywhere around the tickets.
#[test]
fn grid_matches_the_map() {
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = |n: i32| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % (2 * n as u64 + 1)) as i32 - n
};
for case in 0..200 {
let spread = [3, 20, 80][case % 3];
let players: Vec<_> = (0..1 + case % 12)
.map(|_| (next(spread), next(spread)))
.collect();
let forced: Vec<_> = (0..case % 4)
.map(|_| (next(spread), next(spread)))
.collect();
let s = Sources {
players,
forced,
view_distance: 2 + next(12).abs(),
simulation_distance: 2 + next(12).abs(),
};
let Map::Grid { .. } = compute(&s) else {
panic!("case {case} fell back to the map");
};
let (grid, map) = (compute(&s), Map::Sparse(compute_sparse(&s)));
let r = spread + 50;
for z in -r..=r {
for x in -r..=r {
assert_eq!(grid.get(x, z), map.get(x, z), "case {case} at ({x}, {z})");
}
}
}
}
#[test]
fn far_apart_tickets_use_the_map() {
let l = levels(
&[(0, 0), (100_000, -100_000)],
&[(i32::MAX - 2, i32::MIN + 2)],
10,
10,
);
assert!(matches!(**l.0.as_ref().unwrap(), Map::Sparse(_)));
assert!(l.entity_ticking(100_010, -100_010) && !l.block_ticking(50_000, 0));
assert!(l.entity_ticking(i32::MAX - 2, i32::MIN + 2));
}
/// cargo test --release -p horizon-client --lib ticket_levels::tests::levels_cost -- --ignored --nocapture
#[test]
#[ignore]
fn levels_cost() {
for players in [1usize, 10, 25, 50] {
// The benchmark's bots: most near spawn, a fifth flying outwards.
let at: Vec<_> = (0..players)
.map(|i| {
let a = i as f64 * 2.399;
let d = if i % 5 == 0 { 30.0 } else { 3.0 };
((a.cos() * d) as i32, (a.sin() * d) as i32)
})
.collect();
let s = Sources {
players: at.clone(),
forced: Vec::new(),
view_distance: 10,
simulation_distance: 10,
};
let rounds = 200;
let t = std::time::Instant::now();
for _ in 0..rounds {
std::hint::black_box(compute_sparse(std::hint::black_box(&s)));
}
let map_build = t.elapsed().as_secs_f64() * 1e3 / rounds as f64;
let t = std::time::Instant::now();
for _ in 0..rounds {
std::hint::black_box(compute(std::hint::black_box(&s)));
}
let grid_build = t.elapsed().as_secs_f64() * 1e3 / rounds as f64;
// A tick's questions: every chunk within 12 of every player.
let (map, grid) = (Map::Sparse(compute_sparse(&s)), compute(&s));
let ask = |m: &Map| {
let t = std::time::Instant::now();
let mut n = 0u32;
for _ in 0..rounds {
for &(px, pz) in &at {
for z in pz - 12..=pz + 12 {
for x in px - 12..=px + 12 {
n +=
u32::from(std::hint::black_box(m.get(x, z)).0 <= BLOCK_TICKING);
}
}
}
}
std::hint::black_box(n);
t.elapsed().as_secs_f64() * 1e3 / rounds as f64
};
println!(
"{players} players: build {map_build:.3} -> {grid_build:.3} ms, {} lookups {:.3} -> {:.3} ms",
players * 625,
ask(&map),
ask(&grid)
);
}
}
}