Merge remote-tracking branch 'hr/main' into pr-5783

This commit is contained in:
levzzz committed 2026-11-01 15:53:01 +05:00
commit e918209069
38 files changed
+1420 -106

No files matched your search

+11
View File
@@ -544,6 +544,17 @@ impl HudAtlas {
}
}
let assets = root.join("assets");
// A native mod's particle type names its textures `<ns>:particle/<path>`
// and needn't ship content, so every namespace's folder is packed.
if folders.contains(&"particle")
&& let Ok(namespaces) = fs::read_dir(&assets)
{
for ns in namespaces.flatten() {
if ns.file_name() != "minecraft" {
walk(&ns.path().join("textures/particle"), &mut files);
}
}
}
// Content mods' GUI textures, for their screens: `<namespace>:gui/...`.
if full && let Ok(namespaces) = fs::read_dir(&assets) {
for ns in namespaces.flatten() {
+137 -1
View File
@@ -47,6 +47,9 @@ pub struct ParticleOptions {
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
pub geyser: Option<(i32, f32)>,
/// A native mod's own particle type, by its namespaced name (`mymod:spark`):
/// `SimpleParticleType` plus the provider `particles/<name>.json` names.
pub mod_particle: Option<String>,
}
impl ParticleOptions {
pub fn simple(ty: ParticleType) -> Self {
@@ -59,6 +62,15 @@ impl ParticleOptions {
trail: None,
item: None,
geyser: None,
mod_particle: None,
}
}
/// A native mod's `SimpleParticleType`: the sprite set of
/// `assets/<namespace>/particles/<path>.json`, drawn as a plain billboard.
pub fn named(name: impl Into<String>) -> Self {
Self {
mod_particle: Some(name.into()),
..Self::simple(ParticleType::Smoke)
}
}
/// `GeyserParticleOptions(type, waterBlocks)`.
@@ -1055,6 +1067,8 @@ pub struct ParticleEngine {
/// it on the translucent layer (a texel with 0 < alpha < 255).
atlas: HashMap<String, (Rect, bool)>,
sets: HashMap<ParticleType, u32>,
/// A native mod's sprite sets, by namespaced particle name.
mod_sets: HashMap<String, u32>,
set_sprites: Vec<Vec<u32>>,
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
@@ -1092,6 +1106,7 @@ impl ParticleEngine {
rects: Vec::new(),
atlas: HashMap::new(),
sets: HashMap::new(),
mod_sets: HashMap::new(),
set_sprites: Vec::new(),
emitters: Vec::new(),
starters: Vec::new(),
@@ -1123,12 +1138,24 @@ impl ParticleEngine {
.map(|name| self.atlas.get(name).map(|&(rect, _)| rect))
.collect();
self.sets.clear();
self.mod_sets.clear();
self.set_sprites.clear();
self.item_sprites.clear();
self.load_sprites(root);
}
/// The sprite sets of every `particles/*.json` under `root`.
/// The sprite sets of every `particles/*.json` under `root`: vanilla's own
/// and a native mod's (`assets/<namespace>/particles/<name>.json`).
fn load_sprites(&mut self, root: &Path) {
let assets = root.join("assets");
if let Ok(namespaces) = std::fs::read_dir(&assets) {
for namespace in namespaces.flatten() {
let ns = namespace.file_name().to_string_lossy().into_owned();
if ns == "minecraft" || !namespace.path().is_dir() {
continue;
}
self.load_mod_sprites(&ns, &namespace.path().join("particles"));
}
}
let dir = root.join("assets/minecraft/particles");
for &ty in ParticleType::ALL {
let Ok(bytes) = std::fs::read(dir.join(format!("{}.json", ty.name()))) else {
@@ -1154,6 +1181,56 @@ impl ParticleEngine {
}
}
}
/// A namespace's particle JSONs that vanilla has no type for: each becomes
/// one sprite set under `<ns>:<file name>`.
fn load_mod_sprites(&mut self, ns: &str, dir: &Path) {
let Ok(files) = std::fs::read_dir(dir) else {
return;
};
for file in files.flatten() {
let path = file.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
let Some(stem) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
continue;
};
let key = format!("{ns}:{stem}");
if ParticleType::from_name(&key).is_some() {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
continue;
};
let sprites: Vec<u32> = json["textures"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|t| t.as_str())
.map(|t| {
// A texture is `<ns>:<path>` (bare means
// `minecraft:`), and the atlas names a sprite
// `<ns>:particle/<path>` — `minecraft`'s own
// without its namespace.
let name = match t.split_once(':') {
Some(("minecraft", path)) => format!("particle/{path}"),
Some((ns, path)) => format!("{ns}:particle/{path}"),
None => format!("particle/{t}"),
};
self.intern(&name)
})
.collect()
})
.unwrap_or_default();
if !sprites.is_empty() {
self.mod_sets.insert(key, self.set_sprites.len() as u32);
self.set_sprites.push(sprites);
}
}
}
fn intern(&mut self, name: &str) -> u32 {
if let Some(i) = self.names.iter().position(|n| n == name) {
return i as u32;
@@ -1626,6 +1703,23 @@ impl ParticleEngine {
) -> Option<Particle> {
use ParticleType as T;
let ty = o.ty;
// A native mod's SimpleParticleType: vanilla's TextureSheetParticle with
// the sprite set its particles/<name>.json names, no tint, 20..40 ticks.
if let Some(name) = &o.mod_particle {
let set = *self.mod_sets.get(name)?;
let mut r = LegacyRandom::new(seed);
let mut q = Particle::moving(&mut r, ParticleType::Smoke, p, v);
q.ty = ParticleType::Smoke;
q.gravity = 0.0;
q.rgba = [1.0; 4];
q.lifetime = 20 + r.next_int_bound(20).unwrap_or(0);
q.set = Some(set);
let sprites = &self.set_sprites[set as usize];
q.sprite = Sprite::Index(
sprites[r.next_int_bound(sprites.len() as i32).unwrap_or(0) as usize],
);
return Some(q);
}
let set = self.set_of(ty);
let mut local = LegacyRandom::new(seed);
let r = &mut local;
@@ -4222,6 +4316,48 @@ const CREEPER: [[f64; 2]; 12] = [
mod tests {
use super::*;
#[test]
fn a_mod_particles_json_becomes_its_sprite_set() {
let dir =
std::env::temp_dir().join(format!("horizon-mod-particles-{}", std::process::id()));
let particles = dir.join("assets/mymod/particles");
std::fs::create_dir_all(&particles).unwrap();
std::fs::write(
particles.join("spark.json"),
br#"{"textures":["mymod:spark/1","mymod:spark/2"]}"#,
)
.unwrap();
assert!(particles.join("spark.json").exists());
let mut engine = ParticleEngine::new(&dir, &HashMap::new(), &HashSet::new());
let set = *engine
.mod_sets
.get("mymod:spark")
.expect("the set is loaded");
assert_eq!(engine.set_sprites[set as usize].len(), 2);
// The named option spawns a billboard from that set, not a vanilla type.
let made = engine
.make(
&ParticleOptions::named("mymod:spark"),
[0.0; 3],
[0.0; 3],
7,
)
.expect("a mod particle is made");
assert_eq!(made.set, Some(set));
// A name no pack knows draws nothing, like a missing vanilla sprite set.
assert!(
engine
.make(
&ParticleOptions::named("mymod:missing"),
[0.0; 3],
[0.0; 3],
7
)
.is_none()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
@@ -47,7 +47,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
))?;
let parse_ms = parse_started.elapsed().as_secs_f64() * 1000.0;
let facts_started = Instant::now();
let (facts, gameplay) = BlockFacts::load_with_projection(root, assets.registry, &json)?;
let (facts, gameplay) =
BlockFacts::load_with_projection(root, assets.registry, &json, true)?;
let facts_ms = facts_started.elapsed().as_secs_f64() * 1000.0;
let behaviours_started = Instant::now();
let behaviours = Behaviours::load_with_gameplay(assets.registry, root, &json, &gameplay)?;
+71 -45
View File
@@ -2246,6 +2246,9 @@ struct Game<'a> {
/// The glint clock and options, read once at the start of each frame
/// (game/item_draw.rs).
glint_frame: horizon_client::item_render::GlintFrame,
/// Mod block entities' geometry their mods kept
/// (game/native_block_entity_draw.rs).
kept_block_entities: std::cell::RefCell<native_block_entity_draw::KeptGeometry>,
/// `item_level` for one frame, game time, day time and dimension.
item_level_cache: std::cell::RefCell<
Option<(
@@ -2333,6 +2336,10 @@ struct Game<'a> {
/// Mobs a player hurt (`LivingEntity.lastHurtByPlayer`), with the guest
/// it was by connection (None: the host).
last_hurt_by_player: HashMap<u32, Option<u32>>,
/// Leashed mobs a player killed, with the killer's UUID: the lead drops
/// on the corpse's next tick (`Leashable.tickLeash`), for the mods. With
/// the corpse's dimension: each level's tick prunes only its own.
lead_killers: HashMap<u32, (u128, Dim)>,
/// Animals a player fed into love (`Animal.loveCause`), with the guest it was by
/// connection (None: the host). Breeding credits them, wherever they are.
love_cause: HashMap<u32, Option<u32>>,
@@ -2849,6 +2856,56 @@ impl Dim {
}
}
}
/// `Inventory.add` on a player's 36 slots (hotbar first) and off hand,
/// `max` the stack's own maximum: onto equal stacks first
/// (`getSlotWithRemainingSpace`: the selected slot, the off hand, then the
/// hotbar and the main inventory), then into empty slots of the inventory
/// (`getFreeSlot`, never the off hand). What does not fit stays in `stack`;
/// true when some of it is left.
fn inventory_add(
inventory: &mut [Option<ItemStack>],
offhand: &mut Option<ItemStack>,
selected: usize,
max: u32,
stack: &mut ItemStack,
) -> bool {
let order = std::iter::once(selected)
.chain(std::iter::once(shield::OFFHAND_SLOT))
.chain((0..36).filter(|&i| i != selected));
for i in order {
let slot = if i == shield::OFFHAND_SLOT {
&mut *offhand
} else {
&mut inventory[i]
};
if let Some(slot) = slot
&& slot.same_item_same_components(stack)
&& slot.count < max
{
let moved = (max - slot.count).min(stack.count);
slot.count += moved;
stack.count -= moved;
if stack.count == 0 {
return false;
}
}
}
for slot in inventory.iter_mut().take(36) {
if slot.is_none() {
let moved = stack.count.min(max);
*slot = Some(ItemStack {
count: moved,
..stack.clone()
});
stack.count -= moved;
if stack.count == 0 {
return false;
}
}
}
true
}
fn facing_offset(facing: &str) -> [i32; 3] {
match facing {
"north" => [0, 0, -1],
@@ -3240,57 +3297,23 @@ impl Game<'_> {
tutorial::Step::FindTree | tutorial::Step::PunchTree | tutorial::Step::CraftPlanks
)
.then(|| stack.clone());
let mut added = false;
let max = self
.res
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack);
// getSlotWithRemainingSpace: the selected slot, the off hand, then
// the hotbar and the main inventory.
let order: Vec<usize> = std::iter::once(self.selected)
.chain(std::iter::once(shield::OFFHAND_SLOT))
.chain((0..36).filter(|&i| i != self.selected))
.collect();
for &i in &order {
let slot = if i == shield::OFFHAND_SLOT {
&mut self.offhand
} else {
&mut self.inventory[i]
};
if let Some(slot) = slot
&& slot.same_item_same_components(&stack)
&& slot.count < max
{
let moved = (max - slot.count).min(stack.count);
slot.count += moved;
stack.count -= moved;
added |= moved > 0;
if stack.count == 0 {
self.tutorial_get_item(tutorial_item.as_ref());
return None;
}
}
}
for i in 0..36 {
if self.inventory[i].is_none() {
let moved = stack.count.min(max);
self.inventory[i] = Some(ItemStack {
count: moved,
..stack.clone()
});
stack.count -= moved;
added |= moved > 0;
if stack.count == 0 {
self.tutorial_get_item(tutorial_item.as_ref());
return None;
}
}
}
if added {
let before = stack.count;
let left = inventory_add(
&mut self.inventory,
&mut self.offhand,
self.selected,
max,
&mut stack,
);
if stack.count < before {
self.tutorial_get_item(tutorial_item.as_ref());
}
Some(stack)
left.then_some(stack)
}
/// The block's first up face (or any face) and its fixed tint, for block
/// models drawn as entities (falling blocks, carried blocks).
@@ -11768,6 +11791,7 @@ impl Game<'_> {
self.boss_overlay.reset();
// GameRenderer.resetData (Minecraft.clearClientLevel).
self.item_activation.reset();
self.kept_block_entities.get_mut().clear();
self.bosses = Default::default();
self.streaming.paused = true;
self.world = World::new(self.res.facts().air, 0);
@@ -17174,6 +17198,7 @@ impl<'a> Game<'a> {
pad: Default::default(),
frames: 0,
glint_frame: Default::default(),
kept_block_entities: Default::default(),
item_level_cache: Default::default(),
world_frames: 0,
fps: 0.0,
@@ -17229,6 +17254,7 @@ impl<'a> Game<'a> {
attack_ticks: 100,
attack_item: None,
last_hurt_by_player: HashMap::new(),
lead_killers: HashMap::new(),
love_cause: HashMap::new(),
died: Default::default(),
be_anims: {
@@ -18138,7 +18164,7 @@ fn gameplay_tables(
))
.map_err(|e| e.to_string())?;
let (facts, gameplay_projection) =
BlockFacts::load_with_projection(root, assets.registry, &behaviour_json)
BlockFacts::load_with_projection(root, assets.registry, &behaviour_json, parallel)
.map_err(|e| e.to_string())?;
horizon_client::block_contact::set_mod_blocks(
facts
+18 -5
View File
@@ -71,7 +71,7 @@ impl Game<'_> {
}
let at = hit.pos.map(|v| f64::from(v) + 0.5);
self.audio.play(sound, Some(at), 1.0, 1.0);
if brushable && self.brush_block(hit.pos, hit.face) {
if brushable && self.brush_block(hit.pos, hit.face, slot) {
self.damage_held(1);
}
}
@@ -113,7 +113,7 @@ impl Game<'_> {
}
/// `BrushableBlockEntity.brush`: whether the block changed (the brush wears).
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3]) -> bool {
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3], slot: usize) -> bool {
let time = self.world.game_time;
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
brush.hit_face.get_or_insert(face);
@@ -141,7 +141,7 @@ impl Game<'_> {
let before = completion_state(brush.count);
brush.count += 1;
if brush.count >= 10 {
self.brushing_completed(pos, brush.hit_face);
self.brushing_completed(pos, brush.hit_face, slot);
return true;
}
self.schedule_brushable_tick(pos);
@@ -191,7 +191,7 @@ impl Game<'_> {
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
/// brushed face and the block turns into its sand or gravel.
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>) {
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>, slot: usize) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let turns_into = match &*self.res.facts().get(state).name {
"minecraft:suspicious_sand" => "minecraft:sand",
@@ -212,7 +212,20 @@ impl Game<'_> {
f64::from(pos[1] + face[1]) + 0.625,
f64::from(pos[2] + face[2]) + 0.5,
];
self.spawn_item(item, at, [0.0; 3], 0);
let mut drops = vec![item];
let tool = self.stack_in(slot).cloned();
let origin = pos.map(|c| f64::from(c) + 0.5);
self.native_use_drops(
"",
origin,
Some((pos, state)),
None,
tool.as_ref(),
&mut drops,
);
for item in drops {
self.spawn_item(item, at, [0.0; 3], 0);
}
}
// Level event 3008 for every player, then the block change.
self.level_event_at(3008, pos, state.0 as i32);
+49 -8
View File
@@ -1220,6 +1220,9 @@ impl Game<'_> {
let player = killer
.filter(|_| m.ai.last_hurt_by_player > 0)
.and_then(|conn| self.killer_uuid(conn));
if let Some(player) = player.filter(|_| m.extra.leash.is_some()) {
self.lead_killers.insert(m.id, (player, self.dim));
}
self.drop_death_loot(&mut dying, player, by_player, killer_entity, consumed, pos);
self.create_wither_rose(m);
if m.species() == Species::CopperGolem {
@@ -1467,6 +1470,7 @@ impl Game<'_> {
player: player_uuid,
tool: None,
explosion: None,
used: false,
};
self.native_loot_drops(&context, &mut stacks);
stacks.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
@@ -4891,6 +4895,9 @@ impl Game<'_> {
let feet = [m.position.x, m.position.y, m.position.z];
let mut handled = true;
let mut drops: Vec<(String, u32)> = Vec::new();
// The shearing table rolled, for the mods (none for a mooshroom).
let mut drops_table = String::new();
let mob = (m.id, super::native_hooks::mob_kind(m));
// `Sheep.shear`'s drops add a spread on top of the ItemEntity's own
// motion; `MushroomCow.shear`'s do not.
let mut wool = false;
@@ -4980,8 +4987,9 @@ impl Game<'_> {
Species::Sheep if item == "minecraft:shears" && !m.extra.sheared && !m.is_baby() => {
m.extra.sheared = true;
let color = horizon_client::mobs::render::DYE_NAMES[m.extra.color as usize & 15];
drops_table = format!("minecraft:shearing/sheep/{color}");
let stacks = world_items!(self).loot_table(
&format!("minecraft:shearing/sheep/{color}"),
&drops_table,
false,
None,
&mut self.world.random,
@@ -5482,8 +5490,22 @@ impl Game<'_> {
self.trigger_item_entity("minecraft:player_sheared_equipment", &stack, &target);
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
}
for (item, count) in drops {
for _ in 0..count {
let mut drops: Vec<ItemStack> = drops
.into_iter()
.map(|(item, count)| ItemStack::new(&item, count))
.collect();
if !drops.is_empty() {
self.native_use_drops(
&drops_table,
feet,
None,
Some((mob.0, &mob.1)),
held.as_ref(),
&mut drops,
);
}
for stack in drops {
for _ in 0..stack.count {
// `spawnAtLocation(stack, 1.0)`: an ItemEntity a block above
// the feet that starts with the constructor's own motion, and
// `setDefaultPickUpDelay`. Sheep.shear adds its spread on top;
@@ -5501,10 +5523,8 @@ impl Game<'_> {
}
self.spawn_item(
ItemStack {
item: item.as_str().into(),
count: 1,
damage: 0,
extra: None,
..stack.clone()
},
[feet[0], feet[1] + 1.0, feet[2]],
v,
@@ -5536,10 +5556,21 @@ impl Game<'_> {
_ => return false,
};
let at = m.position;
let mob = (m.id, super::native_hooks::mob_kind(m));
let tool = self.held().cloned();
self.audio.play(sound, Some([at.x, at.y, at.z]), 1.0, 1.0);
self.mobs[index].extra.sheared = true;
for stack in self.sequence_loot(table, table, tool.as_ref()) {
let mut stacks = self.sequence_loot(table, table, tool.as_ref());
let origin = [at.x, at.y, at.z];
self.native_use_drops(
table,
origin,
None,
Some((mob.0, &mob.1)),
tool.as_ref(),
&mut stacks,
);
for stack in stacks {
self.spawn_at_location(at, height, stack);
}
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
@@ -5562,7 +5593,17 @@ impl Game<'_> {
let feet = [at.x, at.y, at.z];
let tool = self.held().cloned();
let table = "minecraft:brush/armadillo";
for d in self.sequence_loot(table, table, tool.as_ref()) {
let mut stacks = self.sequence_loot(table, table, tool.as_ref());
let kind = super::native_hooks::mob_kind(&self.mobs[index]);
self.native_use_drops(
table,
feet,
None,
Some((id, &kind)),
tool.as_ref(),
&mut stacks,
);
for d in stacks {
self.spawn_at_location(at, 0.0, d);
}
self.audio
+5 -1
View File
@@ -1583,7 +1583,11 @@ impl Game<'_> {
_ => {
// The game's dispatcher, captured for the console.
self.chat_capture = Some(Vec::new());
self.as_console(|g| g.run_command(line));
// A command a native mod registered goes to the mods first,
// looked up by the same first word the match above read.
if !self.perform_native_console_command(line.trim()) {
self.as_console(|g| g.run_command(line));
}
for text in self.chat_capture.take().unwrap_or_default() {
self.console_reply(text);
}
+18 -4
View File
@@ -300,19 +300,33 @@ impl Game<'_> {
let s = f.step().map(f64::from);
let c = center(f);
let at = [c[0] + s[0] * 0.15, c[1], c[2] + s[2] * 0.15];
let mut drops = Vec::new();
if with_frame {
let frame = ItemStack {
drops.push(ItemStack {
item: Arc::from(f.kind()),
count: 1,
damage: 0,
extra: None,
};
self.spawn_item(frame, at, [0.0; 3], 10);
});
}
if let Some(item) = f.item.clone()
&& self.world.random.next_float() < f.drop_chance
{
self.spawn_item(item, at, [0.0; 3], 10);
drops.push(item);
}
if by_player && !drops.is_empty() {
let tool = self.held().cloned();
self.native_use_drops(
"",
c,
None,
Some((f.id, f.kind())),
tool.as_ref(),
&mut drops,
);
}
for stack in drops {
self.spawn_item(stack, at, [0.0; 3], 10);
}
}
/// `ServerExplosion.hurtEntities` for item frames: a frame within the blast
@@ -2762,6 +2762,30 @@ impl Game<'_> {
command: &str,
signed: Option<Vec<(String, horizon_client::lan::hub::chat::ChatMessage)>>,
) {
// The mods hear every command first, and may stop it.
let mut canceled = false;
let mut replies = Vec::new();
if let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) {
self.native_player_command(
uuid,
&name,
command.trim_start_matches('/'),
&mut canceled,
&mut replies,
);
}
for line in replies {
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content: horizon_client::lan::data::component_nbt(&line.component()),
overlay: false,
}),
);
}
if canceled {
return;
}
if self.guest_message_command(host, conn, command, signed.as_ref()) {
return;
}
@@ -3098,6 +3122,7 @@ impl Game<'_> {
// restartClientLoadTimerAfterRespawn.
g.dead = false;
g.load_timeout = horizon_client::lan::guest::CLIENT_LOADED_TIMEOUT;
g.loading = true;
g.death_time = 0;
g.health = 20.0;
g.food = 20;
@@ -6,7 +6,7 @@ use horizon_client::lan::hub::Hub;
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_protocol::{
entity_data::{Particle, ParticleData},
entity_data::{MOD_PARTICLE_ID, Particle, ParticleData},
packets::play::{self, Clientbound as C},
};
use horizon_world::registry::BlockStateId;
@@ -28,6 +28,7 @@ fn send_native_nearby(
host: &LanHost,
dimension: &str,
packet: Vec<u8>,
horizon_only: bool,
within_range: impl Fn([f64; 3]) -> bool,
) {
for guest in host
@@ -36,10 +37,22 @@ fn send_native_nearby(
.iter()
.filter(|g| g.placed && g.dimension == dimension && within_range(g.position))
{
// A vanilla client cannot decode a native mod's particle id; see
// `mod_particle_only_for_horizon`.
if horizon_only && guest.brand != "horizon" {
continue;
}
guest.sender.send(packet.clone());
}
}
/// A native mod's particle travels as `MOD_PARTICLE_ID` (-1) plus its name.
/// Vanilla reads the id with `IdMap.byIdOrThrow`, which throws on -1 and
/// drops the connection, so such a particle may only go to Horizon clients.
fn mod_particle_only_for_horizon(particle: &Particle) -> bool {
matches!(particle.data, Some(ParticleData::Mod(_)))
}
/// One of a packet's `count` particles (`handleParticleEvent`): its position
/// and speed. DEFAULT (0) draws both from a Gaussian; ALTERNATIVE (1) and
/// ALTERNATIVE_WITH_SPEED (2) spread over `[0, dist)`, the first keeping
@@ -152,6 +165,7 @@ impl Game<'_> {
else {
return;
};
let horizon_only = mod_particle_only_for_horizon(&particle);
let packet = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
@@ -163,6 +177,9 @@ impl Game<'_> {
randomization: 0,
});
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
if horizon_only && g.brand != "horizon" {
continue;
}
// ServerLevel compares the player's block centre, strictly within 32.
let distance: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
@@ -252,6 +269,13 @@ impl Game<'_> {
options: &ParticleOptions,
state: Option<BlockStateId>,
) -> Option<Particle> {
// A native mod's own type: no registry id, so the sentinel and the name.
if let Some(name) = &options.mod_particle {
return Some(Particle {
id: MOD_PARTICLE_ID,
data: Some(ParticleData::Mod(name.clone())),
});
}
let name = format!("minecraft:{}", options.ty.name());
let id = hub.registry_id("minecraft:particle_type", &name)?;
use ParticleType as T;
@@ -292,6 +316,11 @@ impl Game<'_> {
p: &Particle,
at: [f64; 3],
) -> Option<ParticleOptions> {
if p.id == MOD_PARTICLE_ID
&& let Some(ParticleData::Mod(name)) = &p.data
{
return Some(ParticleOptions::named(name.clone()));
}
let name = self
.remote
.as_ref()?
@@ -434,6 +463,7 @@ impl Game<'_> {
let Some(host) = self.lan.as_ref() else {
return;
};
let horizon_only = mod_particle_only_for_horizon(&particle);
let body = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
@@ -447,7 +477,7 @@ impl Game<'_> {
.encode();
// BlockPos.closerToCenterThan(at, 32): from the centre of the
// player's block.
send_native_nearby(host, dimension, body, |position| {
send_native_nearby(host, dimension, body, horizon_only, |position| {
let d: f64 = (0..3)
.map(|k| (position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
@@ -480,7 +510,7 @@ impl Game<'_> {
)
.encode();
let range = f64::from(sound.volume.max(1.0)) * 16.0;
send_native_nearby(host, sound.dimension, packet, |position| {
send_native_nearby(host, sound.dimension, packet, false, |position| {
let distance: f64 = (0..3).map(|k| (position[k] - sound.at[k]).powi(2)).sum();
distance <= range * range
});
+12 -1
View File
@@ -112,13 +112,24 @@ impl Game<'_> {
if !integrated && (!host.settings.guest_commands || !allowed) {
return false;
}
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
let Some((permission, uuid, name)) = host
.hub
.guest(conn)
.map(|g| (g.permission, g.uuid, g.name.clone()))
else {
return false;
};
let command = line.trim_start_matches('/');
if !self.signable_arguments(permission, command).is_empty() {
return false;
}
// The mods hear it as they hear a typed command; the batch is quiet,
// so their replies go nowhere.
let mut canceled = false;
self.native_player_command(uuid, &name, command, &mut canceled, &mut Vec::new());
if canceled {
return false;
}
self.run_guest_command(GuestCommand {
conn,
command: command.to_owned(),
+52 -6
View File
@@ -127,6 +127,14 @@ impl Game<'_> {
}
/// `Leashable.dropLeash` / `removeLeash`: the lead falls as an item.
pub(super) fn break_leash(&mut self, id: u32, drop: bool, sound: bool) {
self.drop_leash(id, drop, sound, false);
}
/// `break_leash` by the acting player's use (untying it, shears).
fn break_leash_used(&mut self, id: u32, drop: bool) {
self.drop_leash(id, drop, false, true);
}
fn drop_leash(&mut self, id: u32, drop: bool, sound: bool, used: bool) {
let killer = self.lead_killers.remove(&id).map(|(killer, _)| killer);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
@@ -136,16 +144,54 @@ impl Game<'_> {
// Leashable.tickLeash gates a dying entity's lead drop on entity_drops.
let drop = drop && (m.alive() || self.ui.rules.bool("entity_drops"));
let at = [m.position.x, m.position.y, m.position.z];
let kind = super::native_hooks::mob_kind(m);
if sound {
self.lead_sound("break", at);
}
if drop {
if !drop {
return;
}
// The mods' say when a player is behind it: the user, else the
// killer of a mob that died leashed.
let player = if used {
Some(self.native_acting_id())
} else {
killer
};
let Some(player) = player else {
self.drop_lead_item(at);
return;
};
let mut drops = vec![ItemStack::new("minecraft:lead", 1)];
let context = super::native_hooks::NativeLootDrops {
table: "",
dimension: self.dim.id(),
origin: at,
block: None,
entity: Some((id, &kind)),
player: Some(player),
tool: None,
explosion: None,
used,
};
self.native_loot_drops(&context, &mut drops);
for stack in drops {
if stack.count > 0 && &*stack.item != "minecraft:air" {
let v = super::dispenser::new_item_motion();
self.spawn_item(stack, at, v, 10);
}
}
}
/// Holders read from a save become real once the entity is found; a
/// holder that never shows up drops the lead after 100 ticks.
pub(super) fn tick_leads(&mut self) {
// A corpse gone without dropping its lead takes its killer along.
// Only this level's: the others' mobs aren't in place.
if !self.lead_killers.is_empty() {
let (mobs, dim) = (&self.mobs, self.dim);
self.lead_killers
.retain(|id, (_, at)| *at != dim || mobs.iter().any(|m| m.id == *id && !m.removed));
}
let player_uuid = self.level.player_uuid.unwrap_or([0, 0, 0, 1]);
for i in 0..self.mobs.len() {
let Some(pending) = self.mobs[i].extra.leash_pending else {
@@ -222,7 +268,7 @@ impl Game<'_> {
if item == "minecraft:shears" {
let mut dropped = false;
if self.mobs[index].extra.leash.is_some() {
self.break_leash(id, true, false);
self.break_leash_used(id, true);
dropped = true;
}
let others: Vec<u32> = self
@@ -232,7 +278,7 @@ impl Game<'_> {
.map(|m| m.id)
.collect();
for o in others {
self.break_leash(o, true, false);
self.break_leash_used(o, true);
dropped = true;
}
let boats = self.boats_on(Holder::Mob(id));
@@ -255,7 +301,7 @@ impl Game<'_> {
.is_none_or(|p| p == self.acting_player_uuid())
{
let creative = self.creative;
self.break_leash(id, !creative, false);
self.break_leash_used(id, !creative);
self.lead_game_event("minecraft:entity_interact", at);
self.lead_sound("untied", at);
return true;
@@ -264,7 +310,7 @@ impl Game<'_> {
let center = self.player_center();
if self.can_attach_lead(index, center) {
if self.mobs[index].extra.leash.is_some() {
self.break_leash(id, true, false);
self.break_leash_used(id, true);
}
let holder = self.acting_player_uuid();
self.mobs[index].extra.leash = Some(Holder::Player);
@@ -335,7 +381,7 @@ impl Game<'_> {
if knot_hit && held_item.as_deref() == Some("minecraft:shears") {
for i in existing {
let id = self.mobs[i].id;
self.break_leash(id, true, false);
self.break_leash_used(id, true);
}
self.drop_boat_leashes(&existing_boats);
self.audio
@@ -12,7 +12,8 @@ use horizon_assets::{
};
use horizon_client::{
block_entity_render::{diffuse, diffuse_lights, vertex_light},
containers::BlockEntity,
containers::{BlockEntity, ModBlockEntity},
fast_hash::FxHashMap,
gfx::Cull,
hud::Rect,
items::ItemStack,
@@ -57,6 +58,7 @@ pub struct BlockEntityDraw {
poses: Vec<Mat4>,
lights: (Vec3, Vec3),
vertices: Vec<Vertex>,
kept: bool,
}
#[allow(dead_code)]
@@ -92,6 +94,19 @@ impl BlockEntityDraw {
pub fn scale(&mut self, by: [f32; 3]) {
self.apply(Mat4::from_scale(Vec3::from_array(by)));
}
/// Keeps what this call drew: the block is drawn the same again, with
/// neither `native_block_entity_submit` nor
/// `native_block_entity_render_bounding_box` called, until its state,
/// its block entity (what the server sent), its light or the resource
/// packs change. Call it only when what you drew reads none of
/// `partial`, `game_time`, the blocks around it or anything else of
/// yours, and moves nothing by itself: an idle machine, not a turning
/// shaft. Kept geometry stands still: an enchanted item's glint, and an
/// item drawn with `native_draw_item` whose model reads the time (a clock,
/// a compass), stop where they were. API @API:block_entity_keep@.
pub fn keep(&mut self) {
self.kept = true;
}
fn apply(&mut self, m: Mat4) {
if let Some(pose) = self.poses.last_mut() {
*pose *= m;
@@ -145,6 +160,98 @@ fn shaded(tint: u32, brightness: f32) -> u32 {
0xFF00_0000 | c(0) << 16 | c(8) << 8 | c(16)
}
/// Game ticks kept geometry stays after it was last drawn, as signs' text.
const KEPT_FOR: i64 = 100;
/// What kept geometry was drawn with besides its block entity: drawn again
/// when any of it changes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct KeptKey {
dim: Dim,
state: u32,
light: u32,
}
struct KeptEntry {
key: KeptKey,
entity: ModBlockEntity,
bounds: [f32; 6],
/// Game time it was last drawn.
used: i64,
/// Relative to the block's corner.
vertices: Vec<Vertex>,
}
/// Geometry mods kept (`BlockEntityDraw::keep`), by position. As
/// `SignTexts` keeps a sign's text, it is kept relative to the block and
/// moved to the camera each frame.
#[derive(Default)]
pub(super) struct KeptGeometry {
entries: FxHashMap<[i32; 3], KeptEntry>,
/// Game time of the last sweep of what is out of view.
swept: i64,
}
impl KeptGeometry {
/// Drops what was not drawn for `KEPT_FOR` ticks, once every `KEPT_FOR`.
fn sweep(&mut self, now: i64) {
if (now - self.swept).abs() >= KEPT_FOR {
self.swept = now;
self.entries
.retain(|_, e| (now - KEPT_FOR..=now).contains(&e.used));
}
}
/// What is kept at `pos` when it was drawn with `key` and `entity`,
/// marked drawn at `now`.
fn get(
&mut self,
pos: [i32; 3],
key: KeptKey,
entity: &ModBlockEntity,
now: i64,
) -> Option<&KeptEntry> {
let entry = self
.entries
.get_mut(&pos)
.filter(|e| e.key == key && e.entity == *entity)?;
entry.used = now;
Some(entry)
}
fn keep(
&mut self,
pos: [i32; 3],
key: KeptKey,
entity: &ModBlockEntity,
bounds: [f32; 6],
vertices: &[Vertex],
now: i64,
) {
let mut kept = self
.entries
.remove(&pos)
.map_or_else(Vec::new, |e| e.vertices);
kept.clear();
kept.extend_from_slice(vertices);
self.entries.insert(
pos,
KeptEntry {
key,
entity: entity.clone(),
bounds,
used: now,
vertices: kept,
},
);
}
fn forget(&mut self, pos: [i32; 3]) {
self.entries.remove(&pos);
}
/// Forgets everything: the atlas's sprites moved.
pub(super) fn clear(&mut self) {
self.entries.clear();
}
}
impl Game<'_> {
/// `native_block_entity_submit` for each of `blocks` (in drawn sections,
/// drawn by no vanilla renderer) whose block entity is a mod's, closer than
@@ -166,7 +273,11 @@ impl Game<'_> {
poses: Vec::new(),
lights: diffuse_lights(self.dim == Dim::Nether),
vertices: std::mem::take(out),
kept: false,
};
let now = self.world.game_time;
let mut kept = self.kept_block_entities.borrow_mut();
kept.sweep(now);
for &(pos, state) in blocks {
let corner = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
// Vec3.closerThan: strictly closer.
@@ -178,9 +289,27 @@ impl Game<'_> {
else {
continue;
};
let origin = (corner - camera).as_vec3();
let light = vertex_light(self.world.light(pos[0], pos[1], pos[2]));
let key = KeptKey {
dim: self.dim,
state,
light,
};
if let Some(entry) = kept.get(pos, key, entity, now) {
if in_frustum(self.view_frustum.as_ref(), origin, entry.bounds) {
let start = draw.vertices.len();
self.mark_block_entity(start, state);
draw.vertices.extend(entry.vertices.iter().map(|v| Vertex {
pos: (Vec3::from_array(v.pos) + origin).to_array(),
..*v
}));
self.cull_entity_vertices(&mut draw.vertices, start);
}
continue;
}
let id = BlockStateId(state);
let block = &*self.res.facts().get(id).name;
let origin = (corner - camera).as_vec3();
let mut bounds = DEFAULT_BOUNDS;
self.native_block_entity_render_bounding_box(pos, block, id, entity, &mut bounds);
if !in_frustum(self.view_frustum.as_ref(), origin, bounds) {
@@ -188,11 +317,22 @@ impl Game<'_> {
}
let start = draw.vertices.len();
self.mark_block_entity(start, state);
// Drawn relative to the block, then moved to the camera, so what
// is kept can be moved again.
draw.block = pos;
draw.poses.clear();
draw.poses.push(Mat4::from_translation(origin));
draw.light = vertex_light(self.world.light(pos[0], pos[1], pos[2]));
draw.poses.push(Mat4::IDENTITY);
draw.light = light;
draw.kept = false;
self.native_block_entity_submit(pos, block, id, entity, &mut draw);
if draw.kept {
kept.keep(pos, key, entity, bounds, &draw.vertices[start..], now);
} else {
kept.forget(pos);
}
for v in &mut draw.vertices[start..] {
v.pos = (Vec3::from_array(v.pos) + origin).to_array();
}
self.cull_entity_vertices(&mut draw.vertices, start);
}
*out = draw.vertices;
@@ -362,6 +502,7 @@ mod tests {
poses: vec![Mat4::from_translation(Vec3::new(10.0, 0.0, 0.0))],
lights: diffuse_lights(false),
vertices: Vec::new(),
kept: false,
}
}
@@ -446,4 +587,81 @@ mod tests {
// The vertex colour is RGBA8 read little-endian: red in the low byte.
assert_eq!(shaded(0x10_20_30, 1.0), 0xFF_30_20_10);
}
fn machine(progress: i32) -> ModBlockEntity {
ModBlockEntity {
id: "mymod:machine".into(),
data: vec![progress],
..Default::default()
}
}
fn vertex(x: f32) -> Vertex {
Vertex {
pos: [x, 0.0, 0.0],
..bytemuck::Zeroable::zeroed()
}
}
const KEY: KeptKey = KeptKey {
dim: Dim::Overworld,
state: 7,
light: 0xF0F0,
};
#[test]
fn kept_geometry_is_drawn_again_until_what_it_was_drawn_with_changes() {
let mut kept = KeptGeometry::default();
let pos = [1, 2, 3];
kept.keep(pos, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.5)], 0);
let entry = kept.get(pos, KEY, &machine(0), 1).expect("kept");
assert_eq!(entry.vertices.len(), 1);
assert_eq!(entry.vertices[0].pos, [0.5, 0.0, 0.0]);
assert_eq!(entry.bounds, DEFAULT_BOUNDS);
// New data from the server, another state, light or dimension, or
// another position: none of them is the kept geometry.
assert!(kept.get(pos, KEY, &machine(1), 1).is_none());
for changed in [
KeptKey { state: 8, ..KEY },
KeptKey {
light: 0x00F0,
..KEY
},
KeptKey {
dim: Dim::Nether,
..KEY
},
] {
assert!(kept.get(pos, changed, &machine(0), 1).is_none());
}
assert!(kept.get([1, 2, 4], KEY, &machine(0), 1).is_none());
// Drawn again and kept with the new data, it replaces the old.
kept.keep(pos, KEY, &machine(1), DEFAULT_BOUNDS, &[vertex(0.25)], 2);
assert!(kept.get(pos, KEY, &machine(0), 2).is_none());
let entry = kept.get(pos, KEY, &machine(1), 2).expect("kept again");
assert_eq!(entry.vertices[0].pos, [0.25, 0.0, 0.0]);
// Drawn without keeping: forgotten.
kept.forget(pos);
assert!(kept.get(pos, KEY, &machine(1), 3).is_none());
}
#[test]
fn kept_geometry_out_of_view_for_its_ticks_is_swept() {
let mut kept = KeptGeometry::default();
let (seen, gone) = ([0, 0, 0], [5, 0, 0]);
kept.keep(seen, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.0)], 0);
kept.keep(gone, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.0)], 0);
kept.get(gone, KEY, &machine(0), 50);
// A sweep at 100 keeps both: each was drawn in the last 100 ticks.
kept.sweep(100);
assert!(kept.entries.contains_key(&gone));
assert!(kept.entries.contains_key(&seen));
kept.get(seen, KEY, &machine(0), 150);
// At 199 no sweep is due yet; at 200 the one last drawn at 50 goes.
kept.sweep(199);
assert!(kept.entries.contains_key(&gone));
kept.sweep(200);
assert!(!kept.entries.contains_key(&gone));
assert!(kept.get(seen, KEY, &machine(0), 200).is_some());
}
}
+147 -3
View File
@@ -678,7 +678,9 @@ pub(crate) struct NativeLootDrops<'a> {
pub(crate) origin: [f64; 3],
/// The block broken: its position and state.
pub(crate) block: Option<([i32; 3], BlockStateId)>,
/// The mob that died: its entity ID and type (`minecraft:zombie`).
/// The mob that died, or the entity a player's use dropped them from (the
/// sheep sheared, the item frame broken): its entity ID and type
/// (`minecraft:zombie`).
pub(crate) entity: Option<(u32, &'a str)>,
/// The player who broke the block or killed the mob, when known. A mob
/// names one only while it remembers the player's hit (100 ticks), so one
@@ -688,6 +690,16 @@ pub(crate) struct NativeLootDrops<'a> {
pub(crate) tool: Option<&'a ItemStack>,
/// The radius of the explosion that broke the block.
pub(crate) explosion: Option<f32>,
/// A player's use dropped them rather than a break or a death: shears on a
/// sheep, a mooshroom, a snow golem or a bogged, a brush on an armadillo or
/// a suspicious block, shears carving a pumpkin, picking sweet berries or
/// cave vines, a hit taking an item frame or its item, or untying a lead
/// or cutting it with shears. `player` names them; `block` or `entity` says
/// what dropped them. API @API:use_drops@. A mob that a player killed while
/// leashed drops its lead on its next tick as a call of its own (`entity`
/// the mob, `player` its killer, `table` empty, `used` false). API
/// @API:lead_drops@.
pub(crate) used: bool,
}
/// A depth-tested outline of the client's block selection shape.
@@ -896,7 +908,7 @@ pub(crate) struct NativePlayer {
pub(crate) struct NativeClock {
/// Ticks since the world was created (`Level.getGameTime`).
pub(crate) game_time: i64,
/// The time of day in ticks (`Level.getDayTime`): 0 at sunrise, 24000 a
/// The time of day in ticks (`Level.getOverworldClockTime`): 0 at sunrise, 24000 a
/// day. It keeps counting past 24000 (`/time add`, days going by), so take
/// it modulo 24000 for the hour; `/time set` can make it negative.
pub(crate) day_time: i64,
@@ -1734,6 +1746,39 @@ impl Game<'_> {
) {
let _ = (player, command, ran);
}
/// A command a player typed (NeoForge's `CommandEvent`, before
/// `Commands.performCommand` runs it), without its slash: any command,
/// vanilla's and the mods' own, also one the player may not run. Set
/// `canceled` to stop it: nothing runs and nothing is answered but the
/// lines pushed to `replies`, which go to the player as system chat.
/// No player's state is swapped in and the server's player list is busy
/// with this one, so `native_tell` and the other helpers that take a UUID
/// reach nobody here: answer through `replies`. Once for each command a
/// player sends, on a server, and for each line of a quiet command batch
/// (`horizon:quiet_commands`, whose replies are dropped). A command the
/// server refuses before reading it (unsigned chat arguments, the chat
/// rate limit) never reaches it. @API:native_player_command@.
pub(super) fn native_player_command(
&mut self,
player: u128,
name: &str,
command: &str,
canceled: &mut bool,
replies: &mut Vec<Text>,
) {
let _ = (player, name, command, canceled, replies);
}
/// A command a mod registered, typed on the dedicated server's console
/// or sent through `horizon-server console`
/// (`DedicatedServer.handleConsoleInputs`), without its slash: set `ran`
/// when it is yours. There is no player: the source is the console, at
/// permission level 4, so `native_acting_player` is None and the
/// player-only helpers give nothing; `native_feedback` answers on the
/// console. A name no mod registered goes to vanilla's dispatcher as
/// before. @API:native_perform_console_command@.
pub(super) fn native_perform_console_command(&mut self, command: &str, ran: &mut bool) {
let _ = (command, ran);
}
/// A click in a menu a mod opened (`native_open_menu`), which moved
/// nothing: the menu's `slot` (its own first, then the player's
/// inventory, `native_menu_inventory_slot`; -999 outside), the `button`
@@ -2128,7 +2173,9 @@ impl Game<'_> {
/// entity's data (a shulker box's items) after this. Add, change or take out
/// stacks; empty ones don't drop (NeoForge's global loot modifiers, Fabric's
/// `LootTableEvents.MODIFY_DROPS`); `context` says what dropped them and
/// how, and it is called even when `drops` is empty. It hears blocks that a
/// how, and it is called even when `drops` is empty. A player's use drops
/// through it too (`NativeLootDrops::used`: shears, a brush, an item
/// frame), with that player named. It hears blocks that a
/// player, an explosion or a mob breaks, not other breaks (leaves decaying,
/// a torch losing its wall, a piston, `/setblock ... destroy`), nor a
/// creative break or a tool that can't harvest the block, nor experience.
@@ -2454,6 +2501,10 @@ impl GameLevel<'_> {
/// directional particle. The return value means accepted, not delivered.
/// Block particle types require their block state; item, trail and
/// vibration particles are not supported by the current network dispatch.
/// `ParticleOptions::named("mymod:spark")` sends a mod's own type
/// (API @API:mod_particle_types@): Horizon clients draw it from the sprite
/// set in the mod's `assets/<namespace>/particles/<path>.json`, a client
/// without that file draws nothing, and vanilla clients aren't sent it.
pub(super) fn native_send_particles(
&mut self,
options: &ParticleOptions,
@@ -2464,6 +2515,16 @@ impl GameLevel<'_> {
count: i32,
) -> bool {
use ParticleType as T;
// A native mod's own type: the name has to be namespaced, and a client
// draws it only when its pack has the sprite set.
if let Some(name) = &options.mod_particle {
let namespaced = name
.split_once(':')
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
if !self.native_authoritative || !namespaced {
return false;
}
}
let supported = match options.ty {
T::Block | T::BlockMarker | T::FallingDust | T::DustPillar | T::BlockCrumble => {
state.is_some_and(|id| self.registry.state(id).is_some())
@@ -2690,6 +2751,62 @@ impl Game<'_> {
let _ = self.process_level_events();
changed
}
/// The strongest redstone signal reaching the block at `pos` from its six
/// neighbours, 0 to 15 (`SignalGetter.getBestNeighborSignal`, what a
/// dispenser or a piston reads), in the level in place, `dimension`. 0 on
/// a client's copy of the world and for another dimension. A mod's
/// explosive, launcher or machine reads it in its block entity's tick to
/// go off or switch on. API @API:native_neighbor_signal@.
pub(super) fn native_neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32 {
if (self.serving.is_none() && self.options.dedicated.is_none())
|| dimension != self.dim.id()
{
return 0;
}
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.best_neighbor_signal(level, ticks, at))
}
/// The light at `pos` in the world in place, as `(block, sky)`, each 0 to
/// 15 (`Level.getBrightness` for `LightLayer.BLOCK` and `LightLayer.SKY`).
/// A solar panel checks for full sky light above it, a lamp or a mob farm
/// for darkness. `(0, 0)` in a chunk that isn't loaded. Like the other
/// reads here it reads the world this game holds: the level in place on
/// a server, the server's copy on a client. API @API:native_world_state@.
pub(super) fn native_light(&self, pos: [i32; 3]) -> (u8, u8) {
if !self.world.loaded(pos[0], pos[2]) {
return (0, 0);
}
let light = self.world.light(pos[0], pos[1], pos[2]);
(light & 15, light >> 4)
}
/// The rain and thunder levels of the world in place, each 0.0 to 1.0
/// (`Level.getRainLevel` and `Level.getThunderLevel`): what a solar panel
/// or a wind turbine reads. Both 0 in the Nether and the End, which have
/// no weather (`Level.canHaveWeather`). API @API:native_world_state@.
pub(super) fn native_weather(&self) -> (f32, f32) {
if self.dim != super::Dim::Overworld {
return (0.0, 0.0);
}
self.world.weather.levels(1.0)
}
/// The world's time of day in ticks (`Level.getOverworldClockTime`), the
/// same as `native_clock`'s `day_time`; it keeps counting past 24000, so
/// take it modulo 24000 for the hour. API @API:native_world_state@.
pub(super) fn native_day_time(&self) -> i64 {
self.world.day_time
}
/// The lowest block y and the height of the world in place
/// (`LevelHeightAccessor.getMinY` and `getHeight`): how deep a quarry or a
/// miner may dig. API @API:native_world_state@.
pub(super) fn native_world_height(&self) -> (i32, i32) {
(self.world.shape.min_y, self.world.shape.height)
}
/// The most of the item `item` one slot holds (`Item.getDefaultMaxStackSize`),
/// or `None` for an unknown item: for a mod's container that fills or
/// merges stacks itself. API @API:native_world_state@.
pub(super) fn native_max_stack_size(&self, item: &str) -> Option<u32> {
self.res.items().get(item).map(|i| i.max_stack)
}
/// The random of the server level `dimension`: vanilla's `Level.random`
/// (`level.getRandom()`), the one its explosions, block drops and other
/// level draws use, so a mod's draws move vanilla's later ones as a Java
@@ -3320,6 +3437,33 @@ impl Game<'_> {
player,
tool,
explosion,
used: false,
};
self.native_loot_drops(&context, drops);
drops.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
}
/// The mods' say on what the acting player's use drops
/// (`NativeLootDrops::used`): `table` is the loot table rolled, if any.
pub(super) fn native_use_drops(
&mut self,
table: &str,
origin: [f64; 3],
block: Option<([i32; 3], BlockStateId)>,
entity: Option<(u32, &str)>,
tool: Option<&ItemStack>,
drops: &mut Vec<ItemStack>,
) {
let player = Some(self.native_acting_id());
let context = NativeLootDrops {
table,
dimension: self.dim.id(),
origin,
block,
entity,
player,
tool,
explosion: None,
used: true,
};
self.native_loot_drops(&context, drops);
drops.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
@@ -695,6 +695,188 @@ impl Game<'_> {
count,
)
}
/// Gives `stack` whole (its damage, name, enchantments... kept) to the
/// player `uuid`, as `Inventory.add` places it: onto equal stacks first,
/// then into empty inventory slots, never the armour or an empty off
/// hand. What did not fit comes back (nothing is dropped): all of it for
/// a player who isn't here, is dead, or whose state is swapped out by
/// another player's (inside that player's hook, only they are reached).
/// The player's screen follows as for any change to their inventory.
/// What an auto-pickup or a machine's output to a player needs, from any
/// server hook. API @API:give_stack@.
pub(super) fn native_give_stack(
&mut self,
uuid: u128,
mut stack: ItemStack,
) -> Option<ItemStack> {
if stack.count == 0 {
return None;
}
if uuid == self.native_acting_id() {
// Their state is in place: the host's, or a guest's swapped in.
if self
.player
.as_ref()
.is_none_or(|p| p.health.health() <= 0.0)
{
return Some(stack);
}
return self.give(stack);
}
// Inside a player's hook their state is in place and the records of
// the others may hold swapped state (turns nest): only they are
// reached, as the doc says.
if self.lan_acting.is_some() {
return Some(stack);
}
let max = self
.res
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack);
let Some(host) = self.lan.as_mut() else {
return Some(stack);
};
let Some(g) = host
.hub
.guests
.iter_mut()
.find(|g| g.placed && !g.dead && g.uuid == uuid)
else {
return Some(stack);
};
// inventory_add indexes the 36 main slots.
if g.inventory.len() < 36 {
g.inventory.resize(36, None);
}
let left = super::inventory_add(
&mut g.inventory,
&mut g.offhand,
g.selected,
max,
&mut stack,
);
left.then_some(stack)
}
}
/// An experience orb as a server mod reads it (`native_experience_orbs`).
/// Read by mods only.
#[allow(dead_code)]
#[derive(Clone, Debug)]
pub(super) struct NativeExperienceOrb {
/// The entity's ID, as `native_entity_join_level` names it.
pub id: u32,
pub position: [f64; 3],
/// `ExperienceOrb.value`: the points one pickup gives.
pub value: u32,
/// `ExperienceOrb.count`: orbs merged into this one, each worth `value`.
pub count: u32,
}
/// Whether an orb at `position` (its box: 0.5 wide and high, standing on
/// it) meets the box `min`..`max`, touching faces excluded (`AABB.intersects`).
fn orb_meets(position: [f64; 3], min: [f64; 3], max: [f64; 3]) -> bool {
position[0] - 0.25 < max[0]
&& position[0] + 0.25 > min[0]
&& position[1] < max[1]
&& position[1] + 0.5 > min[1]
&& position[2] - 0.25 < max[2]
&& position[2] + 0.25 > min[2]
}
/// Experience for server mods. API @API:give_experience@.
#[allow(dead_code)]
impl Game<'_> {
/// The experience orbs of the server level `dimension` whose box meets
/// the box `min`..`max`, in the level's order: what a mod reads before it
/// cancels an orb in `native_entity_join_level`, to give its points with
/// `native_give_experience`. Empty for a level not in place and on a
/// client of a server.
pub(super) fn native_experience_orbs(
&self,
dimension: &str,
min: [f64; 3],
max: [f64; 3],
) -> Vec<NativeExperienceOrb> {
if (self.serving.is_none() && self.options.dedicated.is_none())
|| dimension != self.dim.id()
{
return Vec::new();
}
self.orbs
.iter()
.filter(|o| o.count > 0)
.map(|o| NativeExperienceOrb {
id: o.id,
position: [o.position.x, o.position.y, o.position.z],
value: o.value,
count: o.count,
})
.filter(|o| orb_meets(o.position, min, max))
.collect()
}
/// Gives `points` of experience to the player `uuid` as an orb they touch
/// does (`ExperienceOrb.playerTouch`): Mending repairs their damaged items
/// with it first (`repairPlayerItems`), then the rest goes to their
/// experience (`Player.giveExperiencePoints`), with the level-up sound
/// every fifth level. No orb pickup sound. False, and nothing given, for
/// a player who isn't here or is dead, and for another player than the one
/// whose state is swapped in (inside that player's hook, only they are
/// reached), and while the server's player list is busy elsewhere in the
/// tick: give first, and cancel an orb only when this returned true.
/// Their bar follows as for any change to their experience.
pub(super) fn native_give_experience(&mut self, uuid: u128, points: u32) -> bool {
self.native_as_player(uuid, |game| {
let rest = if points > 0 { game.mending(points) } else { 0 };
if rest > 0 {
game.give_orb_points(rest);
}
})
}
/// Adds `levels` to the player `uuid`'s level, or takes them off when
/// negative (`Player.giveExperienceLevels`): what the anvil and the
/// enchanting table charge with, and a mod can charge or reward with.
/// Below level 0 the level and the points are 0. The progress toward the
/// next level is kept. False, and nothing changed, as for
/// `native_give_experience`.
pub(super) fn native_give_levels(&mut self, uuid: u128, levels: i32) -> bool {
self.native_as_player(uuid, |game| game.give_levels(levels))
}
/// Runs `f` with the player `uuid`'s state in place: theirs already when
/// they are the acting player (the host, or a guest swapped in), else a
/// guest's through `as_guest`, only where no other player is swapped in.
/// False when it didn't run: no such player here, or dead.
fn native_as_player(&mut self, uuid: u128, f: impl FnOnce(&mut Self)) -> bool {
if uuid == self.native_acting_id() {
if self
.player
.as_ref()
.is_none_or(|p| p.health.health() <= 0.0)
{
return false;
}
f(self);
return true;
}
if self.lan_acting.is_some() {
return false;
}
let Some(mut host) = self.lan.take() else {
return false;
};
let conn = host
.hub
.guests
.iter()
.find(|g| g.placed && !g.dead && g.uuid == uuid)
.map(|g| g.conn);
let ran = conn
.and_then(|conn| self.as_guest(&mut host, conn, f))
.is_some();
self.lan = Some(host);
ran
}
}
fn system_chat(line: &Text) -> C {
@@ -766,6 +948,30 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// A console line for the mods: true when a mod registered its command
/// (`native_register_commands`). The console is level 4, so a registered
/// usage is always allowed; a command no mod takes answers as vanilla's
/// unknown command.
pub(super) fn perform_native_console_command(&mut self, line: &str) -> bool {
let Some(host) = &self.lan else {
return false;
};
if Self::native_command_level(host, line).is_none() {
return false;
}
let mut ran = false;
self.native_perform_console_command(line, &mut ran);
if !ran {
self.native_feedback(
Text::translate("command.unknown.command", &[])
.with_color(horizon_client::text::RED),
);
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -836,6 +1042,59 @@ mod tests {
);
}
#[test]
fn orbs_are_found_by_their_half_block_box() {
// A block's cell, as a mod asks for a broken block's drops.
let (min, max) = ([10.0, 64.0, -3.0], [11.0, 65.0, -2.0]);
assert!(orb_meets([10.5, 64.2, -2.5], min, max), "inside");
assert!(
orb_meets([11.2, 64.0, -2.5], min, max),
"its box reaches in"
);
assert!(
orb_meets([10.5, 63.6, -2.5], min, max),
"standing just below"
);
assert!(!orb_meets([11.25, 64.0, -2.5], min, max), "a face touching");
assert!(!orb_meets([10.5, 63.5, -2.5], min, max), "its top touching");
assert!(!orb_meets([10.5, 65.0, -2.5], min, max), "standing on top");
}
#[test]
fn whole_stacks_are_given_onto_equal_stacks_only() {
use super::super::inventory_add;
let mut inv = vec![stack("minecraft:dirt", 64); 36];
inv[2] = stack("minecraft:iron_sword", 1);
inv[2].as_mut().unwrap().damage = 5;
inv[5] = stack("minecraft:arrow", 60);
inv[7] = None;
inv[20] = None;
let mut off = stack("minecraft:arrow", 62);
// A damaged sword never joins another stack: the first free slot.
let mut sword = stack("minecraft:iron_sword", 1).unwrap();
sword.damage = 40;
assert!(!inventory_add(&mut inv, &mut off, 5, 1, &mut sword));
assert_eq!(inv[7].as_ref().unwrap().damage, 40, "its damage is kept");
assert_eq!(inv[2].as_ref().unwrap().damage, 5);
// Arrows: the selected slot, then the off hand, then a free slot,
// and what is left over comes back.
let mut arrows = stack("minecraft:arrow", 80).unwrap();
assert!(inventory_add(&mut inv, &mut off, 5, 64, &mut arrows));
assert_eq!(inv[5].as_ref().unwrap().count, 64);
assert_eq!(off.as_ref().unwrap().count, 64);
assert_eq!(inv[20].as_ref().unwrap().count, 64);
assert_eq!(arrows.count, 80 - 4 - 2 - 64);
// A full inventory with an empty off hand takes nothing.
let mut off = None;
let mut apple = stack("minecraft:apple", 3).unwrap();
assert!(inventory_add(&mut inv, &mut off, 0, 64, &mut apple));
assert!(off.is_none(), "never into an empty off hand (getFreeSlot)");
assert_eq!(apple.count, 3);
}
#[test]
fn usages_merge_into_one_tree_with_runnable_nodes() {
let (t, levels, problems) = tree(&[
+2
View File
@@ -331,6 +331,8 @@ impl Game<'_> {
.set_resource_root(self.options.resources.clone());
self.maps.slots.forget();
self.map_patches.clear();
// Kept mod block entities name the old atlas's sprites.
self.kept_block_entities.get_mut().clear();
// The atlas's sprites moved: the particle engine re-reads every rect
// and the packs' sprite sets, or every particle draws a stale region.
self.particles
+5 -1
View File
@@ -115,7 +115,11 @@ impl Game<'_> {
return;
}
let g = host.hub.guest(conn).expect("found above");
if excused(g, &self.world, host.states.as_slice(), &host.hub.guests) {
// A client that hasn't said it loaded the level is still on its loading screen, where it
// doesn't move (the server stopped waiting for it after 60 ticks): the replay falls or drifts
// where it stands. Its first move counts as any other.
let loading = g.loading && !over.moved;
if loading || excused(g, &self.world, host.states.as_slice(), &host.hub.guests) {
if let Some(replay) = host
.hub
.guest_mut(conn)
+11 -4
View File
@@ -42,11 +42,18 @@ impl Game<'_> {
// ItemEntity in front of the carved side, moved by the level random
// (no pickup delay).
let (dx, dz) = (f64::from(dx), f64::from(dz));
for seeds in self.sequence_loot(
"minecraft:carve/pumpkin",
"minecraft:carve/pumpkin",
let table = "minecraft:carve/pumpkin";
let mut drops = self.sequence_loot(table, table, Some(shears));
let origin = pos.map(|c| f64::from(c) + 0.5);
self.native_use_drops(
table,
origin,
Some((pos, state)),
None,
Some(shears),
) {
&mut drops,
);
for seeds in drops {
let at = [
f64::from(pos[0]) + 0.5 + dx * 0.65,
f64::from(pos[1]) + 0.1,
+2
View File
@@ -1064,6 +1064,8 @@ impl Game<'_> {
self.titles.overlay_time = 0;
// GameRenderer.resetData and narrator.clear (Minecraft.disconnect).
self.item_activation.reset();
// Kept mod block entity geometry belongs to the world leaving.
self.kept_block_entities.get_mut().clear();
self.clear_narrator();
// A world is leaving either way: its icon attempt goes with it.
self.world_icon = None;
+105 -3
View File
@@ -222,11 +222,54 @@ pub(super) struct Spill {
/// Block entities the behaviour removed, for the game's own follow-ups
/// (open screens, playing songs).
pub(super) removed: Vec<([i32; 3], BlockEntity)>,
/// Runs of `spawns` a player's use popped (a picked berry bush), for the
/// mods' `native_loot_drops`.
pub(super) used: Vec<UsedDrops>,
}
/// `spawns[start..end]` came from `table` at the block `pos` in `state`.
pub(super) struct UsedDrops {
start: usize,
end: usize,
pos: [i32; 3],
state: BlockStateId,
table: &'static str,
}
impl Spill {
pub(super) fn is_empty(&self) -> bool {
self.spawns.is_empty() && self.orbs.is_empty() && self.removed.is_empty()
self.spawns.is_empty()
&& self.orbs.is_empty()
&& self.removed.is_empty()
&& self.used.is_empty()
}
}
/// Lets `say` change each use's run of `spawns`, newest first so the earlier
/// ranges hold. A run's stacks keep their spots, and a stack `say` adds takes
/// the run's last spot, else the block's top.
fn retell_runs(
spawns: &mut Vec<Spawn>,
used: &[UsedDrops],
mut say: impl FnMut(&UsedDrops, [f64; 3], &mut Vec<ItemStack>),
) {
for used in used.iter().rev() {
let run: Vec<Spawn> = spawns.drain(used.start..used.end).collect();
let mut drops: Vec<ItemStack> = run.iter().map(|s| s.stack.clone()).collect();
let origin = used.pos.map(|c| f64::from(c) + 0.5);
say(used, origin, &mut drops);
let top = [origin[0], origin[1] + 0.5, origin[2]];
let kept: Vec<Spawn> = drops
.into_iter()
.enumerate()
.map(|(i, stack)| Spawn {
stack,
pos: run.get(i).or(run.last()).map_or(top, |s| s.pos),
motion: run.get(i).and_then(|s| s.motion),
delay: run.get(i).map_or(10, |s| s.delay),
})
.collect();
spawns.splice(used.start..used.start, kept);
}
}
@@ -324,7 +367,12 @@ impl GameLevel<'_> {
}
/// `Block.dropFromBlockInteractLootTable` with `Block.popResource` of
/// every stack.
pub(super) fn drop_interact_loot(&mut self, pos: BlockPos, state: BlockStateId, table: &str) {
pub(super) fn drop_interact_loot(
&mut self,
pos: BlockPos,
state: BlockStateId,
table: &'static str,
) {
let drops = self.items.interact_loot(
table,
self.registry,
@@ -333,11 +381,19 @@ impl GameLevel<'_> {
self.rand_sequences,
self.seed,
);
let start = self.spill.spawns.len();
for stack in drops {
if &*stack.item != "minecraft:air" && stack.count > 0 {
self.pop_resource([pos.x, pos.y, pos.z], stack);
}
}
self.spill.used.push(UsedDrops {
start,
end: self.spill.spawns.len(),
pos: [pos.x, pos.y, pos.z],
state,
table,
});
}
/// `Block.dropResources(state, level, pos, blockEntity)`: the block's loot
/// (from its random sequence, else the level random), with what its block
@@ -648,7 +704,13 @@ impl Game<'_> {
if self.spill.is_empty() {
return;
}
let spill = std::mem::take(&mut self.spill);
let mut spill = std::mem::take(&mut self.spill);
let used = std::mem::take(&mut spill.used);
let tool = self.held().cloned();
retell_runs(&mut spill.spawns, &used, |used, origin, drops| {
let block = Some((used.pos, used.state));
self.native_use_drops(used.table, origin, block, None, tool.as_ref(), drops);
});
for s in spill.spawns {
let motion = s.motion.unwrap_or_else(new_item_motion);
self.spawn_item(s.stack, s.pos, motion, s.delay);
@@ -676,6 +738,46 @@ mod tests {
use horizon_client::{block_entity_item, item_data, mobs::raider, save};
use horizon_world::nbt::Tag;
#[test]
fn a_used_run_is_retold_in_place() {
let spawn = |item: &str, x: f64| Spawn {
stack: ItemStack::new(item, 1),
pos: [x, 0.0, 0.0],
motion: None,
delay: 10,
};
let mut spawns = vec![
spawn("minecraft:stone", 0.0),
spawn("minecraft:sweet_berries", 1.0),
spawn("minecraft:sweet_berries", 2.0),
spawn("minecraft:dirt", 3.0),
];
let used = [UsedDrops {
start: 1,
end: 3,
pos: [4, 64, 4],
state: BlockStateId(0),
table: "minecraft:harvest/sweet_berry_bush",
}];
retell_runs(&mut spawns, &used, |used, origin, drops| {
assert_eq!(used.table, "minecraft:harvest/sweet_berry_bush");
assert_eq!(origin, [4.5, 64.5, 4.5]);
assert_eq!(drops.len(), 2);
drops.remove(0);
drops.push(ItemStack::new("minecraft:diamond", 1));
});
let items: Vec<(&str, f64)> = spawns.iter().map(|s| (&*s.stack.item, s.pos[0])).collect();
assert_eq!(
items,
[
("minecraft:stone", 0.0),
("minecraft:sweet_berries", 1.0),
("minecraft:diamond", 2.0),
("minecraft:dirt", 3.0),
]
);
}
#[test]
fn ominous_banner_survives_placement_save_and_drop() {
let original = raider::ominous_banner();
+5
View File
@@ -173,6 +173,10 @@ pub struct Guest {
pub moved: bool,
/// The client finished loading terrain (ServerboundPlayerLoadedPacket).
pub loaded: bool,
/// Since it joined or respawned, the client hasn't said it loaded the level
/// (ServerboundPlayerLoadedPacket): it may still be on its loading screen, where it doesn't
/// move, even once the server stops waiting for it (`load_timeout`).
pub loading: bool,
/// Ticks left before the server stops waiting for that packet
/// (ServerGamePacketListenerImpl.clientLoadedTimeoutTimer): 60 at join and
/// after a respawn, 0 once it comes.
@@ -370,6 +374,7 @@ impl Guest {
changes: Default::default(),
moved: false,
loaded: false,
loading: true,
load_timeout: CLIENT_LOADED_TIMEOUT,
center: None,
sent_chunks: Default::default(),
+11 -2
View File
@@ -2410,9 +2410,16 @@ impl Hub {
// track_entities). On the first tick vanilla's ran before
// ServerPlayer.tick, whose updatePlayerAttributes (and doTick's
// removeFrost) created the instances the guest's tick just made: they
// go out on the next tick, after the first chunk batch (#5560).
// go out on the next tick, after the first chunk batch (#5560). This
// server's first batch waits for the player's chunks to be ready, so
// they wait for it too: a slow load must not put them before it.
let g = &mut self.guests[i];
if let Some(m) = g.motion.as_mut().filter(|_| !first_tick) {
let after_first_batch = g.unacknowledged > 0 || g.max_unacknowledged > 1;
if let Some(m) = g
.motion
.as_mut()
.filter(|_| !first_tick && after_first_batch)
{
let values = super::attributes::take_dirty(&mut m.attributes);
if !values.is_empty() {
g.send(&C::UpdateAttributes(play::UpdateAttributes {
@@ -2756,6 +2763,7 @@ impl Hub {
S::PlayerLoaded(_) => {
let g = &mut self.guests[i];
g.loaded = true;
g.loading = false;
g.load_timeout = 0;
}
// PlayerChunkSender.onChunkBatchReceivedByClient.
@@ -3366,6 +3374,7 @@ impl Hub {
// restartClientLoadTimerAfterRespawn: the client loads a whole new level before it
// reports it loaded.
g.load_timeout = super::guest::CLIENT_LOADED_TIMEOUT;
g.loading = true;
// ServerLevel.removePlayerImmediately: the dead player's
// Entity.setRemoved (KILLED) stops its riding; the new player rides
// nothing.
+37 -6
View File
@@ -49,6 +49,8 @@ pub struct Over {
pub weight: f32,
/// The farthest tick, as played: what the log tells of it.
pub at_worst: Option<Played>,
/// One of them came with a move.
pub moved: bool,
}
/// Ticks within the tolerance in a row after which a replay that lands off takes the client's
@@ -314,6 +316,7 @@ impl Replay {
} else {
counted = true;
over.ticks += 1;
over.moved |= tick.position.is_some();
if offset > over.worst {
over.at_worst = self.ticks.back().copied();
}
@@ -641,6 +644,9 @@ mod tests {
/// On tick `.0`, the server sends the player attributes (the server's body has no sprint
/// modifier): `.1` as in [`Change::Attributes`], which the client takes.
attributes: Option<(usize, Option<bool>)>,
/// The first ticks the client is on its loading screen: it ends them without moving or
/// sending a move.
frozen: usize,
}
impl Run {
@@ -655,6 +661,7 @@ mod tests {
burst: None,
blocks: Vec::new(),
attributes: None,
frozen: 0,
}
}
}
@@ -733,6 +740,7 @@ mod tests {
let run_teleport = run.teleport;
let run_burst = run.burst;
let run_attributes = run.attributes;
let run_frozen = run.frozen;
let mut back = start;
for (i, (input, yaw)) in run.script.into_iter().enumerate() {
let tick = i as u64;
@@ -757,13 +765,16 @@ mod tests {
back = before;
}
client.yaw = yaw;
world(&mut client, &mut |w| {
w.controlled_simulation_tick(keys(input)).unwrap()
});
client.position = cheat(i, before, client.position);
if i >= run_frozen {
world(&mut client, &mut |w| {
w.controlled_simulation_tick(keys(input)).unwrap()
});
client.position = cheat(i, before, client.position);
let p = client.position;
let ground = u8::from(client.ground.on_ground);
record.moved(Some([p.x, p.y, p.z]), Some((yaw, run.pitch)), ground);
}
let p = client.position;
let ground = u8::from(client.ground.on_ground);
record.moved(Some([p.x, p.y, p.z]), Some((yaw, run.pitch)), ground);
record.tick_end(input, client.sprinting);
// A burst: the server hears these ticks now and plays them together at the last one.
if run_burst.is_some_and(|(from, to)| (from..to).contains(&i)) {
@@ -798,6 +809,26 @@ mod tests {
Some(played)
}
/// A client still loading the level (#5849) neither moves nor sends a move, while the replay
/// falls from where it joined: those ticks are over, but none with a move, which is what lets
/// the server excuse them. Its first move after them counts as any other.
#[test]
fn a_client_on_its_loading_screen_is_over_without_a_move() {
let run = Run {
start: Vec3::new(20.5, 12.0, 20.5),
script: vec![(0, 0.0); 20],
frozen: 7,
..Run::walk()
};
let Some(steps) = steps_in(run, |_, _, honest| honest, None) else {
return;
};
let (frozen, after) = steps.split_at(7);
assert!(frozen.iter().any(|s| s.over.ticks > 0), "the replay falls");
assert!(frozen.iter().all(|s| !s.over.moved));
assert!(after.iter().all(|s| s.over.ticks == 0 || s.over.moved));
}
#[test]
fn a_played_tick_tells_its_offset_and_state() {
let played = Played {
@@ -528,7 +528,7 @@ fn content_root_loads_with_every_table() {
BlockFacts::load_with_behaviours(&root, new, &shared_json).unwrap();
let independent_behaviours = horizon_world::level::Behaviours::load(new, &root).unwrap();
let (deferred_facts, gameplay) =
BlockFacts::load_with_projection(&root, new, &shared_json).unwrap();
BlockFacts::load_with_projection(&root, new, &shared_json, true).unwrap();
let deferred_behaviours =
horizon_world::level::Behaviours::load_with_gameplay(new, &root, &shared_json, &gameplay)
.unwrap();
@@ -744,7 +744,7 @@ fn shared_behaviour_document_matches_independent_loaders() {
let original = BlockFacts::load(&root, assets.registry).unwrap();
let (shared, b) = BlockFacts::load_with_behaviours(&root, assets.registry, &doc).unwrap();
let (deferred_facts, projection) =
BlockFacts::load_with_projection(&root, assets.registry, &doc).unwrap();
BlockFacts::load_with_projection(&root, assets.registry, &doc, true).unwrap();
let deferred = horizon_world::level::Behaviours::load_with_gameplay(
assets.registry,
&root,
+30 -5
View File
@@ -241,12 +241,14 @@ impl BlockFacts {
}
/// Decode state facts once, retaining only the small owned fields needed later.
/// `parallel`: the JSON validation pass may run on a thread of its own.
pub fn load_with_projection(
root: &Path,
registry: &BlockRegistry,
behaviours: &Value,
parallel: bool,
) -> Result<(Self, Vec<GameplayProjection>)> {
Self::load_with_callback(root, registry, behaviours, |entries| {
Self::load_with_callback(root, registry, behaviours, parallel, |entries| {
let mut strings = HashMap::new();
Ok(entries
.iter()
@@ -260,7 +262,7 @@ impl BlockFacts {
registry: &BlockRegistry,
behaviours: &Value,
) -> Result<Self> {
Ok(Self::load_with_callback(root, registry, behaviours, |_| Ok(()))?.0)
Ok(Self::load_with_callback(root, registry, behaviours, true, |_| Ok(()))?.0)
}
/// Build both tables from the same behaviour document and gameplay decoder.
@@ -269,7 +271,7 @@ impl BlockFacts {
registry: &BlockRegistry,
behaviours: &Value,
) -> Result<(Self, Behaviours)> {
Self::load_with_callback(root, registry, behaviours, |entries| {
Self::load_with_callback(root, registry, behaviours, true, |entries| {
Ok(Behaviours::load_with_gameplay(
registry, root, behaviours, entries,
)?)
@@ -280,11 +282,34 @@ impl BlockFacts {
root: &Path,
registry: &BlockRegistry,
behaviours: &Value,
parallel: bool,
callback: impl FnOnce(&[decode::Entry<'_>]) -> Result<T>,
) -> Result<(Self, T)> {
let bytes = fs::read(root.join("gameplay-facts.json"))?;
serde_json::from_slice::<decode::Validate>(&bytes)?;
let raw: decode::Gameplay<'_> = serde_json::from_slice(&bytes)?;
let validate = || serde_json::from_slice::<decode::Validate>(&bytes).map(drop);
if !parallel {
validate()?;
return Self::decode_with_callback(&bytes, registry, behaviours, callback);
}
// The validation pass (a third of the load) only reports errors: it
// reads the file beside the decode, and its error still wins.
std::thread::scope(|scope| {
let validated = scope.spawn(validate);
let decoded = Self::decode_with_callback(&bytes, registry, behaviours, callback);
validated
.join()
.unwrap_or_else(|panic| std::panic::resume_unwind(panic))?;
decoded
})
}
fn decode_with_callback<T>(
bytes: &[u8],
registry: &BlockRegistry,
behaviours: &Value,
callback: impl FnOnce(&[decode::Entry<'_>]) -> Result<T>,
) -> Result<(Self, T)> {
let raw: decode::Gameplay<'_> = serde_json::from_slice(bytes)?;
let entries = raw
.states
.0
@@ -135,3 +135,32 @@ fn raw_shape_capture_keeps_original_json_errors() {
);
}
}
#[test]
fn the_validation_error_still_fails_the_load_first() {
// The typed decode reads this shape (raw), the validation pass rejects
// its number, and the empty behaviour document would fail the load next.
let facts = br#"{"states":[{"id":0,"outline":[[1e400]]}]}"#;
let expected = serde_json::from_slice::<Value>(facts)
.unwrap_err()
.to_string();
let root = std::env::temp_dir().join(format!("horizon-facts-validate-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("gameplay-facts.json"), facts).unwrap();
let registry = horizon_world::registry::BlockRegistry::from_facts_json(
&br#"{"states":[["minecraft:air",true,false,"","minecraft:air"]],"defaults":{"minecraft:air":0}}"#[..],
)
.unwrap();
for parallel in [false, true] {
let error = super::super::BlockFacts::load_with_projection(
&root,
&registry,
&Value::Null,
parallel,
)
.err()
.unwrap();
assert_eq!(error.to_string(), expected, "parallel: {parallel}");
}
std::fs::remove_dir_all(&root).unwrap();
}
@@ -161,7 +161,14 @@ pub enum ParticleData {
GeyserBase(i32, f32),
/// Vibration: where it travels to, then its arrival time in ticks.
Vibration { source: PositionSource, ticks: i32 },
/// Horizon extension: a native mod's own particle type, by its namespaced
/// name (`mymod:spark`). Vanilla has no id for it; see `MOD_PARTICLE_ID`.
Mod(String),
}
/// Horizon extension for a native mod's particle type. Vanilla's
/// `minecraft:particle_type` ids are registry ids and never negative, so this
/// one cannot collide with a vanilla type.
pub const MOD_PARTICLE_ID: i32 = -1;
/// `PositionSource.STREAM_CODEC`: a `minecraft:position_source_type` id
/// (block 0, entity 1), then that type's data.
#[derive(Clone, Debug, PartialEq)]
@@ -172,6 +179,13 @@ pub enum PositionSource {
impl Particle {
pub fn read(r: &mut Reader) -> Result<Particle> {
let id = r.varint()?;
// A native mod's type: the namespaced name follows the sentinel id.
if id == MOD_PARTICLE_ID {
return Ok(Particle {
id,
data: Some(ParticleData::Mod(r.string(32767)?)),
});
}
// `minecraft:particle_type` registry order as of 26.3.
let data = match id {
1 | 2 | 36 | 121 | 125 => Some(ParticleData::State(r.varint()?)),
@@ -248,6 +262,7 @@ impl Particle {
w.varint(*d);
}
ParticleData::Item(s) => Template::write(s, w),
ParticleData::Mod(name) => w.string(name),
ParticleData::Vibration { source, ticks } => {
match source {
PositionSource::Block(pos) => {
@@ -536,6 +551,11 @@ mod tests {
wire: Default::default(),
})),
),
// A native mod's type: the sentinel id, then the namespaced name.
(
MOD_PARTICLE_ID,
Some(ParticleData::Mod("mymod:spark".to_owned())),
),
(
58,
Some(ParticleData::Vibration {
+29 -1
View File
@@ -913,6 +913,16 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
`number`, `boolean`, `player`, `item`, `block_pos`. The usages of one command merge into one tree, which every
player receives after vanilla's (filtered by their level, as vanilla's is), so their chat box completes and colours
it. A name vanilla has, or a usage that doesn't parse, is left out with a warning in the log.
- **`native_player_command(player, name, command, canceled, replies)`**: any command a player typed (without the
slash), vanilla's too and commands the player may not run, heard before anything looks at it, and each line of a
quiet command batch. Set `canceled` and nothing runs; push lines to `replies` and the player gets them as system chat
(a quiet batch drops them). The player's state is not swapped in, and `native_tell` and the other helpers that take a
UUID reach nobody inside this hook: answer through `replies`. Commands may carry secrets (`/msg`, a login plugin's
password), so don't log them carelessly.
- **`native_perform_console_command(command, ran)`**: a registered command typed on the dedicated server's console
(without its slash), run as the server at level 4: no player, so `native_acting_player()` is None and the player-only
helpers give nothing. The mod parses the line and sets `ran`; `native_feedback` answers on the console. A name no mod
registered goes to vanilla's dispatcher as before; the single player owner's console is not covered.
- **`native_perform_command(player, command, ran)`**: a player typed a registered command (without its slash) and
their level allows it, whether the world allows commands or not. The mod parses the line and sets `ran`. The
player's state is swapped in for the call, as for their other actions, so `native_player` reads them; a
@@ -929,6 +939,18 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
the 36 inventory slots and the off hand, never the armour, and only at **plain** stacks: no damage and no
component (name, enchantment...), so a shop never takes a worn or enchanted item for a new one. Each returns how
many it counted, took or gave; the player's screen follows as for any change to their inventory.
- `native_give_stack(uuid, stack)`, a whole stack (damage, name, enchantments... kept) to any player from any server
hook, as `Inventory.add` places it (onto equal stacks, then empty inventory slots); what did not fit comes back, all
of it for a player not here, dead, or swapped out by another player's hook. Nothing is dropped: an auto-pickup
leaves the rest on the ground, a machine keeps it.
- Experience: `native_experience_orbs(dimension, min, max)`, the orbs in a box (id, position, `value`, and `count`
for merged orbs), read before a mod cancels an orb in `native_entity_join_level`; and `native_give_experience(uuid,
points)`, points given to any player as touching an orb gives them: Mending repairs their damaged items first, the
rest goes to their experience, with the level-up sound; and `native_give_levels(uuid, levels)`, levels added or,
negative, taken off (`Player.giveExperienceLevels`, as the anvil and the enchanting table charge; below 0 the level
and points are 0; no level-up sound). Both return false for a player not here, dead, or swapped out by another
player's hook, and where the server's player list is busy (some world ticks hold it): give first, and cancel the orb
only when the give returned true. A merged orb is worth `value` × `count`.
- API 21: `native_acting_player()`, the player whose state is swapped in (inside `native_perform_command`,
`native_right_click_block`, `native_use_item_on`, `native_destroy_block`...; None elsewhere);
`native_run_command(line)`, a command run as the server's console (level 4, no output), only where no player is
@@ -992,7 +1014,13 @@ Changing loot a mod didn't make, as NeoForge's `LootTableLoadEvent` and global l
it (a mob names one only while it remembers the player's hit, 100 ticks), the tool and an explosion's radius. It hears blocks
that a player, an explosion or a mob breaks, not other breaks (leaves decaying, a torch losing its wall, a piston,
`/setblock ... destroy`), nor a creative break or a tool that can't harvest the block. A block's stacks get its block entity's data (a
shulker box's items) after the hook, and a container's contents spill on their own, not through it.
shulker box's items) after the hook, and a container's contents spill on their own, not through it. A player's use drops
through it too, with `used` set and that player named: shears on a sheep, a mooshroom, a snow golem or a bogged (its shearing
table), a brush on an armadillo (`minecraft:brush/armadillo`) or a suspicious block (its item, no table), shears carving a
pumpkin, picking sweet berries or cave vines (their harvest table), and a player's hit taking an item frame or its item (the
frame's entity, no table). A dispenser's shears and an explosion's frames aren't told. Leads: untying one or cutting it with
shears is a use (the leashed mob, no table), and a mob a player killed while leashed drops its lead on its next tick as a call
of its own, naming the killer (`used` false, no table). A lead that snaps or loses its holder names no player and isn't told.
### A block's items
@@ -0,0 +1,8 @@
{
"hooks": [],
"rust_summary": "keeping a mod block entity's geometry while it doesn't change\n(`BlockEntityDraw::keep`).",
"doc_summary": "keeping a mod block entity's geometry: `draw.keep()` in `native_block_entity_submit` keeps what that call drew, and the\nblock is drawn the same again without calling `native_block_entity_submit` or `native_block_entity_render_bounding_box`\nuntil its state, its block entity (what the server sent), its light or the resource packs change, so idle machines cost\nno call into the mod each frame. Keep only geometry that reads none of `partial`, `game_time`, the blocks around it or other state of yours:\nkept geometry stands still (an enchanted item's glint, a clock or compass drawn with `native_draw_item`).\nBundles that use it need `\"hooks\": @API:block_entity_keep@`.",
"comments": [
"crates/horizon-client/src/game/native_block_entity_draw.rs"
]
}
@@ -0,0 +1,8 @@
{
"hooks": [],
"rust_summary": "experience for server mods: reading orbs (`native_experience_orbs`), giving points as an orb does (`native_give_experience`) and giving or taking levels (`native_give_levels`).",
"doc_summary": "`native_experience_orbs(dimension, min, max)`, the experience orbs in a box; `native_give_experience(uuid, points)`, points given to any player as an orb they touch gives them (Mending first); and `native_give_levels(uuid, levels)`, levels given or, negative, taken. Bundles need \"hooks\": @API:give_experience@.",
"comments": [
"crates/horizon-client/src/game/native_server.rs"
]
}
@@ -0,0 +1,6 @@
{
"hooks": [],
"rust_summary": "giving a whole stack to any player from a server hook (`native_give_stack`).",
"doc_summary": "`native_give_stack(uuid, stack)`: gives a whole stack (damage, name, enchantments kept) to any player from a server hook, as `Inventory.add` places it, and returns what did not fit. Bundles need \"hooks\": @API:give_stack@.",
"comments": ["crates/horizon-client/src/game/native_server.rs"]
}
@@ -0,0 +1,6 @@
{
"hooks": [],
"rust_summary": "lead drops to `native_loot_drops`: untying a lead or cutting it with shears, and the lead a mob a player\nkilled while leashed drops on its next tick, with the player named.",
"doc_summary": "lead drops to `native_loot_drops`: untying a lead or cutting it with shears (`used`), and the lead a mob a\nplayer killed while leashed drops on its next tick, naming the killer. Bundles that rely on them need\n`\"hooks\": @API:lead_drops@`.",
"comments": []
}
@@ -0,0 +1,5 @@
{
"hooks": [],
"rust_summary": "a mod's own particle types for `native_send_particles`\n(`ParticleOptions::named`).",
"doc_summary": "native mod particle types to `native_send_particles`: `ParticleOptions::named(\"mymod:spark\")` sends a particle of the mod's own type, drawn by Horizon clients from the sprite set in the mod's `assets/<namespace>/particles/<path>.json` (`\"textures\": [\"mymod:spark/1\", ...]`, resolved as `particle/<texture>` in the atlas). The name must be namespaced; a client without that file draws nothing for it, vanilla clients (whose brand isn't Horizon's) aren't sent it, and the call still returns true. The type behaves as vanilla's `SimpleParticleType` with a sprite set: a plain billboard, no tint, 20 to 40 ticks, its motion the game's default spread (the speed sets its direction, not how fast it goes). Bundles that use it need `\"hooks\": @API:mod_particle_types@`."
}
@@ -0,0 +1,6 @@
{
"hooks": [],
"rust_summary": "reading redstone from a server mod's block (`native_neighbor_signal`).",
"doc_summary": "`native_neighbor_signal(dimension, pos)`, the strongest redstone signal reaching a block from its neighbours (`SignalGetter.getBestNeighborSignal`, 0 to 15), as a dispenser or a piston reads it: what a mod's explosive, launcher or machine needs to go off or switch on from its block entity's tick. 0 on a client and for another dimension. Bundles that use it need \"hooks\": @API:native_neighbor_signal@.",
"comments": []
}
@@ -0,0 +1,7 @@
{
"hooks": [
{"name": "perform_console_command", "target": "horizon_client::game::native_hooks::Game<'_>::native_perform_console_command"}
],
"rust_summary": "a mod's command typed on the dedicated server's console (`native_perform_console_command`).",
"doc_summary": "a mod's command typed on the dedicated server's console: `native_perform_console_command(command, ran)` hears a command a mod registered with `native_register_commands`, without its slash, from the console (permission level 4, no player: `native_acting_player` is None), and a mod sets `ran`. `native_feedback` answers on the console. A name no mod registered goes to vanilla's dispatcher as before; the single player owner's console is not covered. Bundles that use it need `\"hooks\": @API:native_perform_console_command@`."
}
@@ -0,0 +1,13 @@
{
"hooks": [
{
"name": "player_command",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_command",
"neoforge": "CommandEvent",
"side": "server",
"cancellable": true
}
],
"rust_summary": "any command a player types, cancellable (`native_player_command`).",
"doc_summary": "any command a player types: `native_player_command(player, name, command, cancel, replies)` is heard before the command is looked at (NeoForge's `CommandEvent`), without its slash, for vanilla's commands, the mods' own and commands the player may not run, once for each command a player sends, on a server. Also each line of a quiet command batch. A mod sets `canceled` to stop it: nothing runs and nothing is answered but the lines it pushes to `replies`, which reach the player as system chat. Bundles that use it need `\"hooks\": @API:native_player_command@`."
}
@@ -0,0 +1,6 @@
{
"hooks": [],
"rust_summary": "reading the world's light, weather, time of day, height and item stack sizes from a native mod (`native_light`, `native_weather`, `native_day_time`, `native_world_height`, `native_max_stack_size`).",
"doc_summary": "`native_light(pos)` (block and sky light, 0 to 15, both 0 where the chunk isn't loaded), `native_weather()` (rain and thunder levels, 0 in the Nether and the End), `native_day_time()`, `native_world_height()` (min y and height) and `native_max_stack_size(item)`: what a mod's solar panel, wind turbine, quarry or own container needs without reaching into Horizon's internals. Bundles that use them need \"hooks\": @API:native_world_state@.",
"comments": []
}
@@ -0,0 +1,6 @@
{
"hooks": [],
"rust_summary": "player-use drops to `native_loot_drops` (`NativeLootDrops::used`): shearing, brushing, carving a\npumpkin, picking berries and breaking an item frame, with the player named.",
"doc_summary": "player-use drops to `native_loot_drops`: shears on a sheep, a mooshroom, a snow golem or a bogged, a brush on an\narmadillo or a suspicious block, carving a pumpkin, picking sweet berries or cave vines, and a player taking an item frame or\nits item now pass through it too, with `NativeLootDrops::used` set and the player named. Bundles that read `used` need\n`\"hooks\": @API:use_drops@`.",
"comments": []
}