Merge remote-tracking branch 'origin/main' into fix/plates-remaining-kinds

This commit is contained in:
HrafnaValkyrja committed 2026-11-01 17:38:15 -05:00
commit b86f3f517f
33 files changed
+926 -212

No files matched your search

Generated
+1
View File
@@ -1607,6 +1607,7 @@ dependencies = [
name = "horizon-protocol"
version = "0.1.0"
dependencies = [
"aes",
"flate2",
"getrandom 0.3.4",
"horizon-core",
+6
View File
@@ -1682,6 +1682,10 @@ async fn open_backend(
label: Some("Horizon device"),
required_features: features,
required_limits: limits,
// Video memory in blocks of 8-64 MiB instead of 128-256 MiB (Vulkan and DirectX 12):
// with the larger blocks a few small buffers kept whole blocks the arena's pages had
// left, and over half the video memory reserved was unused (1,344 MiB for 500).
memory_hints: wgpu::MemoryHints::MemoryUsage,
..Default::default()
};
#[cfg(not(target_os = "macos"))]
@@ -8292,6 +8296,8 @@ impl Renderer {
radius,
timing.as_mut(),
);
} else if let Some(blur) = &mut self.menu_blur {
blur.release();
}
self.hud.draw_range(
encoder,
@@ -173,7 +173,8 @@ impl EntityOutline {
}
/// Uploads this frame's silhouettes (`LevelRenderer.currentFrameRendersEntityOutline`
/// when there are any) and sizes the targets like the window.
/// when there are any) and sizes the targets like the window. A frame with
/// none gives the targets' video memory back until an entity glows again.
pub(super) fn prepare(
&mut self,
device: &wgpu::Device,
@@ -184,6 +185,7 @@ impl EntityOutline {
) {
self.quads = (silhouettes.len() / 4) as u32;
if self.quads == 0 {
self.targets = None;
return;
}
let vertices = silhouettes.len() as u64;
@@ -160,6 +160,11 @@ impl MenuBlur {
})
.collect();
}
/// Give the targets' video memory back: no frame blurs until the next
/// screen that does, which makes them again.
pub fn release(&mut self) {
self.targets.clear();
}
/// Blur `frame` (the swapchain texture) in place through `target`, its
/// non-sRGB view, by `radius` pixels (`menuBackgroundBlurriness`).
#[allow(clippy::too_many_arguments)]
@@ -238,6 +243,90 @@ impl MenuBlur {
#[cfg(test)]
mod tests {
use super::*;
/// A frame of hard stripes, blurred, read back.
fn blurred(
device: &wgpu::Device,
queue: &wgpu::Queue,
blur: &mut MenuBlur,
size: [u32; 2],
) -> Vec<u8> {
let format = wgpu::TextureFormat::Rgba8Unorm;
let extent = wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
};
let stripes: Vec<u8> = (0..size[0] * size[1])
.flat_map(|i| if i % 4 < 2 { [255; 4] } else { [0, 0, 0, 255] })
.collect();
let frame = wgpu::util::DeviceExt::create_texture_with_data(
device,
queue,
&wgpu::TextureDescriptor {
label: None,
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::COPY_DST,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&stripes,
);
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(size[0] * size[1] * 4),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
let view = frame.create_view(&Default::default());
blur.apply(device, queue, &mut encoder, &frame, &view, size, 2.0, None);
encoder.copy_texture_to_buffer(
frame.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &out,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size[0] * 4),
rows_per_image: None,
},
},
extent,
);
queue.submit([encoder.finish()]);
out.slice(..).map_async(wgpu::MapMode::Read, |_| ());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
out.slice(..).get_mapped_range().unwrap().to_vec()
}
/// Released between two screens, the blur makes its targets again and
/// blurs the same.
#[test]
fn blurs_the_same_after_its_targets_were_released() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
let size = [64, 8];
let first = blurred(&device, &queue, &mut blur, size);
// The stripes are blurred into greys.
assert!(first.chunks(4).any(|p| p[0] != 0 && p[0] != 255));
blur.release();
assert_eq!(blurred(&device, &queue, &mut blur, size), first);
}
#[test]
fn box_blur_shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("menu_blur.wgsl")).unwrap();
@@ -92,7 +92,7 @@ pub fn main() -> Result<()> {
if in_ground {
continue;
}
if let Some((stop, _)) =
if let Some((stop, _, _)) =
arrows::clip_block_stop(&scene, &facts, &binding, position, velocity)
{
// onHitBlock: pulled back, stopped, stuck, shaking 7 ticks.
+8 -3
View File
@@ -395,14 +395,15 @@ impl ArrowScene<'_, '_> {
/// `level().clip(new ClipContext(position(), position().add(movement),
/// ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this))`, then
/// `AbstractArrow.onHitBlock`'s `position().subtract(signum * 0.05)`. Returns
/// the position to stop at (pull-back included) and the block that was hit.
/// the position to stop at (pull-back included), the block that was hit and
/// the normal of the face it was hit on.
pub fn clip_block_stop(
world: &World,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
from: Vec3,
movement: Vec3,
) -> Option<(Vec3, BlockPos)> {
) -> Option<(Vec3, BlockPos, [i32; 3])> {
let to = Vec3::new(
from.x + movement.x,
from.y + movement.y,
@@ -425,7 +426,11 @@ pub fn clip_block_stop(
})
.ok()
.flatten()?;
Some((back_off(hit.location, movement), hit.block))
Some((
back_off(hit.location, movement),
hit.block,
hit.direction.normal(),
))
}
/// `AbstractArrow.onHitBlock`'s back-off: vanilla scales `(double) 0.05f` by
/// `Math.signum` per axis, whose value at zero is zero (not Rust's -1.0).
+14
View File
@@ -1342,6 +1342,16 @@ impl LevelAccess for GameLevel<'_> {
self.native_perform_bonemeal(ticks, self.dimension, pos, state);
}
}
fn before_fire_odds(&self, state: BlockStateId, odds: &mut Option<(i32, i32)>) {
if self.native_authoritative {
self.native_fire_odds(self.dimension, state, odds);
}
}
fn before_smoke_source(&self, state: BlockStateId, smoke: &mut Option<bool>) {
if self.native_authoritative {
self.native_smoke_source(self.dimension, state, smoke);
}
}
fn world_surface(&self, x: i32, z: i32) -> i32 {
self.world.world_surface(x, z, |s| self.facts.is_air(s))
}
@@ -2872,6 +2882,9 @@ struct Game<'a> {
lan_acting_uuid: Option<u128>,
/// Their permission level: an op's above 0 (`PlayerList.isOp`).
lan_acting_permission: Option<u8>,
/// Their entity id, which the hub (often taken out of `lan` while they
/// act) would otherwise have to be asked for.
lan_acting_entity: Option<u32>,
/// Command feedback to forward to a guest.
chat_capture: Option<Vec<horizon_client::text::Text>>,
/// Packets for the guest acting now, sent once its action returns.
@@ -18432,6 +18445,7 @@ impl<'a> Game<'a> {
lan_acting_name: None,
lan_acting_uuid: None,
lan_acting_permission: None,
lan_acting_entity: None,
chat_capture: None,
admin_feedback: Vec::new(),
console_broadcast: None,
@@ -199,7 +199,7 @@ impl Game<'_> {
let room = !self.boats[i].under_water();
// (mob, touched by the boat's sweep, its own pushEntities reaches the boat)
let teams = self.mob_teams();
let mobs: Vec<(usize, bool, bool)> = (0..self.mobs.len())
let mobs: Vec<(u32, bool, bool)> = (0..self.mobs.len())
.filter_map(|j| {
let m = &self.mobs[j];
if !pushable(m) || m.extra.mount.is_some() && self.mob_root_vehicle(j) == Some(id) {
@@ -212,7 +212,7 @@ impl Game<'_> {
let pushes = m.species() != Species::Bat
&& mb.intersects(bb)
&& pushable_by(team, None, true);
(touched || pushes).then_some((j, touched, pushes))
(touched || pushes).then_some((m.id, touched, pushes))
})
.collect();
let player_team = teams.of(Who::Player);
@@ -230,7 +230,12 @@ impl Game<'_> {
})
.collect();
drop(teams);
for (j, touched, pushes) in mobs {
for (mob, touched, pushes) in mobs {
// By id: a mod's mount hook may have removed mobs since the list
// was made, which moves the others' indices.
let Some(j) = self.mobs.iter().position(|m| m.id == mob) else {
continue;
};
if touched {
let m = &self.mobs[j];
let boards = pick_up
@@ -244,13 +249,17 @@ impl Game<'_> {
if boards {
// startRiding fails quietly (no push) while the mob's
// boarding cooldown runs (Entity.canRide) or the boat
// is under water.
if room && m.extra.boarding_cooldown <= 0 {
let m = &mut self.mobs[j];
// is under water, or when a mod cancels it
// (EntityMountEvent).
let ready = m.extra.boarding_cooldown <= 0;
if room
&& ready
&& self.native_mob_may_ride(mob, id, true)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob)
{
m.extra.mount = Some(id);
m.extra.passenger = true;
m.ai.nav.stop();
let mob = m.id;
count += 1;
// Mob.startRiding drops its lead.
self.break_leash(mob, true, false);
@@ -342,6 +342,11 @@ impl Game<'_> {
return true;
}
self.stop_riding();
// startRiding stops when stopRiding left the passenger seated (a mod
// canceled the dismount).
if self.riding.is_some() {
return true;
}
self.riding = Some(id);
let p = self.cushions[i].position;
self.audio.play(
+11 -3
View File
@@ -2087,6 +2087,11 @@ impl Game<'_> {
/// (`IntegratedServer.tickPaused`): `ServerGamePacketListenerImpl.tick`
/// skips `tickPlayer`, so no player's air, hunger or damage moves on.
pub(super) fn tick_lan(&mut self, paused: bool) {
// A frozen level does not reach tick_lan_packets through tick_world.
// Apply its packets before taking the snapshot too.
if !paused {
self.tick_lan_packets();
}
let Some(mut host) = self.lan.take() else {
if self.level_pass.running.is_none() {
self.level_pass.packets_drained = false;
@@ -2117,6 +2122,12 @@ impl Game<'_> {
}
// A guest whose file names the vehicle it rode rides it again.
self.restore_guest_vehicles(&mut host);
// Finish the players' mining, item use and pickups before describing
// this tick to their clients. Otherwise a newly broken block and its
// drops remain absent from the snapshot until the following tick.
if !paused {
self.tick_guests(&mut host);
}
// A dedicated server runs the hub without a host player.
if state.is_some() || host.hub.config.dedicated {
let has_guests = !host.hub.guests.is_empty();
@@ -2271,9 +2282,6 @@ impl Game<'_> {
.take()
.expect("the hub is back after the tracking events");
}
if !paused {
self.tick_guests(&mut host);
}
self.lan = Some(host);
// A blast from a guest's attack, and a mace's wind burst.
self.flush_lan_explosions();
@@ -209,7 +209,13 @@ pub(super) struct GuestSwap {
/// The guest's own damage record, in place while they act.
combat: super::combat::CombatTracker,
/// The guest whose turn this one nests in, if any.
acting: (Option<u32>, Option<String>, Option<u128>, Option<u8>),
acting: (
Option<u32>,
Option<String>,
Option<u128>,
Option<u8>,
Option<u32>,
),
/// That turn's `known_movement`.
known_movement: Vec3,
/// The packets queued for that guest.
@@ -540,6 +546,7 @@ impl Game<'_> {
self.lan_acting_name.replace(g.name.clone()),
self.lan_acting_uuid.replace(g.uuid),
self.lan_acting_permission.replace(g.permission),
self.lan_acting_entity.replace(g.entity_id as u32),
);
let packets = std::mem::take(&mut self.guest_packets);
let entity_events = std::mem::take(&mut self.guest_entity_events);
@@ -762,6 +769,7 @@ impl Game<'_> {
self.lan_acting_name,
self.lan_acting_uuid,
self.lan_acting_permission,
self.lan_acting_entity,
) = acting;
self.known_movement = known_movement;
play.death_cause = std::mem::replace(&mut self.ui.death_cause, death_cause);
+17 -11
View File
@@ -1211,7 +1211,7 @@ impl Game<'_> {
|| self.ridden_by_guest(id)
|| self.mobs.iter().any(|m| m.extra.mount == Some(id));
let teams = self.mob_teams();
let candidates: Vec<usize> = (0..self.mobs.len())
let candidates: Vec<u32> = (0..self.mobs.len())
.filter(|&j| {
let m = &self.mobs[j];
if m.removed || !m.pushable() || m.extra.mount == Some(id) {
@@ -1222,23 +1222,29 @@ impl Game<'_> {
}
pushable_by(None, teams.of(Who::Mob(m.id)), true)
})
.map(|j| self.mobs[j].id)
.collect();
drop(teams);
for j in candidates {
for mob in candidates {
// By id: a mod's mount hook may have removed mobs since the list
// was made, which moves the others' indices.
let Some(j) = self.mobs.iter().position(|m| m.id == mob) else {
continue;
};
let species = self.mobs[j].species();
let player = &*self.mobs[j].kind.id == "minecraft:player";
let cooldown = self.mobs[j].extra.boarding_cooldown;
let passenger = self.mobs[j].extra.mount.is_some();
if pickup_instead_of_push(species, passenger, occupied) && !player {
if can_ride_cart(species, cooldown) {
let mob = self.mobs[j].id;
{
let m = &mut self.mobs[j];
m.extra.mount = Some(id);
m.extra.passenger = true;
m.ai.nav.stop();
m.needs_sync = true;
}
// Entity.startRiding: a mod may cancel it (EntityMountEvent).
if can_ride_cart(species, cooldown)
&& self.native_mob_may_ride(mob, id, true)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob)
{
m.extra.mount = Some(id);
m.extra.passenger = true;
m.ai.nav.stop();
m.needs_sync = true;
occupied = true;
self.break_leash(mob, true, false);
}
@@ -849,6 +849,41 @@ impl GameLevel<'_> {
) {
let _ = (ticks, dimension, pos, state);
}
/// On an authoritative server, how fire treats the block `state`
/// (`FireBlock.setFlammable(block, igniteOdds, burnOdds)`): put
/// `(ignite, burn)` in `odds` for your own blocks, and vanilla's table
/// isn't read. Ignite odds above zero let fire stand beside the block and
/// spread into the air around it (planks 5, wool 30, leaves 30); burn odds
/// are how fast fire destroys it (planks 20, wool 60, leaves 60). `(0, 0)`
/// never burns; keep both within vanilla's 0 to 100. A waterlogged state
/// never burns and isn't asked. Answer only for your own blocks and leave
/// `odds` empty otherwise; the level is read-only. Asked hundreds of times
/// per fire tick: keep it short. API @API:fire_odds@.
#[inline(always)]
pub(super) fn native_fire_odds(
&self,
dimension: LevelDimension,
state: BlockStateId,
odds: &mut Option<(i32, i32)>,
) {
let _ = (dimension, state, odds);
}
/// On an authoritative server, `CampfireBlock.isSmokeSource` for the
/// block `state` under a campfire, as the campfire is placed or the block
/// under it changes: `Some(true)` for your own blocks makes the campfire a
/// signal fire whose smoke rises higher, as over a hay bale. The level is
/// read-only. API @API:fire_odds@.
#[inline(always)]
pub(super) fn native_smoke_source(
&self,
dimension: LevelDimension,
state: BlockStateId,
smoke: &mut Option<bool>,
) {
let _ = (dimension, state, smoke);
}
}
/// What dropped the items [`Game::native_loot_drops`] hears.
@@ -10,11 +10,14 @@ impl Game<'_> {
self.serving.is_some() || self.options.dedicated.is_some()
}
/// The entity id of the acting player: a guest's while their action runs, else the host's.
/// The entity id of the acting player: a guest's while their action runs (kept with the
/// action, as the hub is often out of `lan` then), else the host's.
fn native_acting_entity(&self) -> u32 {
let guests = self.lan_guest_entities();
match self.lan_acting {
Some(conn) => guests.iter().find(|g| g.0 == conn).map_or(0, |g| g.1),
Some(conn) => self.lan_acting_entity.unwrap_or_else(|| {
let guests = self.lan_guest_entities();
guests.iter().find(|g| g.0 == conn).map_or(0, |g| g.1)
}),
None => self.lan.as_ref().map_or(0, |host| host.host_entity as u32),
}
}
@@ -63,18 +63,65 @@ impl Game<'_> {
fn native_impact_owner(&self, p: &Projectile) -> u32 {
match p.owner {
Some(Who::Mob(id)) => id,
Some(Who::Player) => match p.guest {
Some(conn) => self
.lan_guest_entities()
.into_iter()
.find(|&(c, _)| c == conn)
.map_or(0, |(_, id)| id),
None => self.lan.as_ref().map_or(0, |host| host.host_entity as u32),
},
Some(Who::Player) => self.native_thrower(p.guest),
None => 0,
}
}
/// The entity id of the player who threw something: the guest `guest`, or
/// the host.
fn native_thrower(&self, guest: Option<u32>) -> u32 {
match guest {
Some(conn) => self
.lan_guest_entities()
.into_iter()
.find(|&(c, _)| c == conn)
.map_or(0, |(_, id)| id),
None => self.lan.as_ref().map_or(0, |host| host.host_entity as u32),
}
}
/// `native_projectile_impact` for a player's thrown trident, which
/// `tick_tridents` flies rather than `tick_projectiles`: it hit the mob
/// `mob`, or with `mob` None the block at `block` on its `face`, at `at`.
/// Whether a mod canceled it.
pub(super) fn native_trident_impact(
&mut self,
trident: u32,
guest: Option<u32>,
mob: Option<u32>,
block: [i32; 3],
face: [i32; 3],
at: Vec3,
) -> bool {
if !self.native_hears_impacts() {
return false;
}
let owner = self.native_thrower(guest);
let (entity, entity_kind) = match mob {
Some(id) => {
let (entity, kind) = self.native_impact_entity(id);
(Some(entity), kind)
}
None => (None, String::new()),
};
let dimension = self.dim.id();
let mut canceled = false;
self.native_projectile_impact(
dimension,
trident,
"minecraft:trident",
owner,
entity,
&entity_kind,
block,
face,
[at.x, at.y, at.z],
&mut canceled,
);
canceled
}
/// The entity id and type a projectile's hit on the reserved or ordinary
/// `id` stands for: a dragon part is the dragon, an End crystal its own id.
fn native_impact_entity(&self, id: u32) -> (u32, String) {
+31 -4
View File
@@ -604,7 +604,14 @@ impl Game<'_> {
}
}
}
if let Some((id, _)) = hit_mob {
// ProjectileImpactEvent for the mob hit: a canceled one does
// nothing, and the trident stays where it would have hit.
let canceled_mob = hit_mob.is_some_and(|(id, _)| {
self.native_trident_impact(t.id, t.guest, Some(id), [0; 3], [0; 3], t.position)
});
if canceled_mob {
// No damage, sound or deflection: it flies on.
} else if let Some((id, _)) = hit_mob {
// ThrownTrident.getDamage: `EnchantmentHelper.modifyDamage` on 8
// (impaling hits `#sensitive_to_impaling`, the aquatic tag).
let victim = self
@@ -689,9 +696,29 @@ impl Game<'_> {
// `AbstractArrow.onHitBlock` on a hit.
let from = t.position;
let flight = t.velocity;
if let Some((stop, block)) =
clip_block_stop(&self.world, self.res.facts(), self.collision, from, flight)
{
let clip = clip_block_stop(
&self.world,
self.res.facts(),
self.collision,
from,
flight,
);
// ProjectileImpactEvent for the block hit: a canceled one
// doesn't stick the trident, which stays where it would
// have hit, still flying.
let canceled_block = clip.is_some_and(|(stop, block, face)| {
self.native_trident_impact(
t.id,
t.guest,
None,
[block.x, block.y, block.z],
face,
stop,
)
});
if canceled_block && let Some((stop, _, _)) = clip {
t.position = stop;
} else if let Some((stop, block, _)) = clip {
// Projectile.onHit on a block: PROJECTILE_LAND at the
// block's centre.
self.projectile_event(
+59 -21
View File
@@ -187,7 +187,10 @@ struct Encoded {
non_air: bool,
/// LevelChunkSection.write, and its block entity blocks.
wire: Option<(Arc<[u8]>, Entities)>,
/// The section's light as the wire's two DataLayers, when not uniform.
/// The section's light as the wire's two DataLayers, when not uniform:
/// made by the warm-up threads and taken by the body that sends it.
/// Not kept once sent: two layers per lit section of every column the
/// level holds cost more memory than repacking them costs time.
light: Option<Arc<Layers>>,
}
@@ -899,13 +902,10 @@ impl ChunkEncoder {
/// a client not tracking the block would otherwise keep its old light.
pub fn light_update(&self, chunk: &Chunk, changed: SectionMask) -> Vec<u8> {
let first = self.sections.start;
let lights: Vec<(usize, SectionLight)> = self
.light_sections(chunk, SectionMask::ALL)
.into_iter()
.filter(|&(b, _)| {
// Light section b holds the column's section first + b - 1.
(1..=self.sections.len()).contains(&b) && changed.contains(first + b - 1)
})
// Light section b holds the column's section first + b - 1.
let lights: Vec<(usize, SectionLight)> = (1..=self.sections.len())
.filter(|&b| changed.contains(first + b - 1))
.map(|b| (b, self.section_light(&chunk.sections[first + b - 1])))
.collect();
horizon_protocol::packets::play::Clientbound::LightUpdate(
horizon_protocol::packets::play::LightUpdate {
@@ -1038,25 +1038,28 @@ impl ChunkEncoder {
/// ClientboundLightUpdatePacketData: the light layers of the sections
/// that exist around the column (`around`: the non-air sections of it
/// and its neighbours). A resident section's layers come from
/// the cache while it is unchanged (uniform light is quicker to fill than
/// to keep).
/// and its neighbours), made as they are sent (`section_light`).
fn light_data(&self, chunk: &Chunk, around: SectionMask) -> LightData {
light_data_of(&self.light_sections(chunk, around), self.sky)
}
fn light_sections(&self, chunk: &Chunk, around: SectionMask) -> Vec<(usize, SectionLight)> {
light_sections(&chunk.sections[self.sections.clone()], around, |s| {
if let Light::Uniform(v) = s.light {
return SectionLight::Uniform(v);
}
SectionLight::Layers(self.cached(s, |e| e.light.clone()).unwrap_or_else(|| {
let layers = Arc::new(Layers::of(&s.light));
self.cached(s, |e| e.light = Some(layers.clone()));
layers
}))
self.section_light(s)
})
}
/// A section's light: the layers the warm-up threads made for it, taken,
/// or made now.
fn section_light(&self, s: &Arc<Section>) -> SectionLight {
if let Light::Uniform(v) = s.light {
return SectionLight::Uniform(v);
}
SectionLight::Layers(
self.cached(s, |e| e.light.take())
.unwrap_or_else(|| Arc::new(Layers::of(&s.light))),
)
}
}
/// The order LevelChunk.getBlockEntities() iterates in: a fastutil
@@ -1250,8 +1253,8 @@ fn light_data(sections: &[Arc<Section>], around: SectionMask) -> LightData {
light_data_of(&lights, true)
}
/// A light section's two DataLayers: a resident section's from the cache,
/// or one value everywhere, which is quicker to write than to keep.
/// A light section's two DataLayers, or one value everywhere, which is
/// quicker to write than to keep.
enum SectionLight {
Layers(Arc<Layers>),
/// Sky light in the high nibble, block light in the low one.
@@ -1367,6 +1370,7 @@ fn write_light(lights: &[(usize, SectionLight)], sky: bool, w: &mut Writer) {
mod tests {
use super::*;
use crate::chunk::{MIN_Y, SECTIONS};
use horizon_protocol::packets::play;
use horizon_world::nbt::JavaString;
#[test]
@@ -1581,6 +1585,40 @@ mod tests {
let second = encoder.encode(&chunk, &HashMap::new()).light;
assert_ne!(first, second);
assert_eq!(second, light_data(&chunk.sections, SectionMask::ALL));
// Sent again unchanged (a player coming back in range), the same light.
assert_eq!(encoder.encode(&chunk, &HashMap::new()).light, second);
}
/// A light update carries the layers of the sections that changed, and
/// only those.
#[test]
fn light_updates_carry_the_changed_sections() {
let encoder = encoder();
let mut chunk = Chunk::empty(0, 0);
// Sky light 5 and block light 10 in section 2, others in section 5.
Arc::make_mut(&mut chunk.sections[2]).light = Light::Full(Box::new([0x5A; 4096]));
Arc::make_mut(&mut chunk.sections[5]).light = Light::Full(Box::new([0x21; 4096]));
let mut changed = SectionMask::EMPTY;
changed.insert(2);
let bytes = encoder.light_update(&chunk, changed);
let mut r = horizon_protocol::buf::Reader::new(&bytes);
let id = r.varint().unwrap();
let Ok(play::Clientbound::LightUpdate(update)) = play::Clientbound::decode(id, r.rest())
else {
panic!("not a light update");
};
let light = update.light;
// Light section 3 holds section 2, the only one sent.
let bit = horizon_protocol::buf::bit;
let sent = |mask: &[u8]| {
(0..SECTIONS + 2)
.filter(|&b| bit(mask, b))
.collect::<Vec<_>>()
};
assert_eq!(sent(&light.sky_mask), [3]);
assert_eq!(sent(&light.block_mask), [3]);
assert_eq!(light.sky, [vec![0x55; 2048]]);
assert_eq!(light.block, [vec![0xAA; 2048]]);
}
/// A neighbour still a proto chunk counts for the light layers as it
+2
View File
@@ -16,6 +16,8 @@ getrandom = "0.3"
sha1 = "0.10"
# Secure chat: SHA256withRSA message signatures.
sha2 = "0.10"
# AES-NI at run time on x86 for the connection cipher (crypto/blocks.rs).
aes = "0.8.4"
# The name servers of Windows' network adapters, for Minecraft SRV redirects.
[target.'cfg(windows)'.dependencies]
+96 -121
View File
@@ -5,107 +5,15 @@
//! needed: CFB8 encrypts the shift register in both directions.
use sha1::{Digest, Sha1};
const SBOX: [u8; 256] = {
// Generated from the AES field inverse and affine transform at compile time.
let mut sbox = [0u8; 256];
let mut p: u8 = 1;
let mut q: u8 = 1;
loop {
// p *= 3
p = p ^ (p << 1) ^ if p & 0x80 != 0 { 0x1B } else { 0 };
// q /= 3
q ^= q << 1;
q ^= q << 2;
q ^= q << 4;
if q & 0x80 != 0 {
q ^= 0x09;
}
let x = q ^ q.rotate_left(1) ^ q.rotate_left(2) ^ q.rotate_left(3) ^ q.rotate_left(4);
sbox[p as usize] = x ^ 0x63;
if p == 1 {
break;
}
}
sbox[0] = 0x63;
sbox
};
fn xtime(b: u8) -> u8 {
(b << 1) ^ if b & 0x80 != 0 { 0x1B } else { 0 }
}
/// AES-128 block encryption (FIPS-197).
#[derive(Clone)]
pub struct Aes128 {
round_keys: [[u8; 16]; 11],
}
impl Aes128 {
pub fn new(key: &[u8; 16]) -> Self {
let mut w = [[0u8; 4]; 44];
for i in 0..4 {
w[i].copy_from_slice(&key[i * 4..i * 4 + 4]);
}
let mut rcon = 1u8;
for i in 4..44 {
let mut t = w[i - 1];
if i % 4 == 0 {
t = [
SBOX[t[1] as usize] ^ rcon,
SBOX[t[2] as usize],
SBOX[t[3] as usize],
SBOX[t[0] as usize],
];
rcon = xtime(rcon);
}
for j in 0..4 {
w[i][j] = w[i - 4][j] ^ t[j];
}
}
let mut round_keys = [[0u8; 16]; 11];
for (r, key) in round_keys.iter_mut().enumerate() {
for c in 0..4 {
key[c * 4..c * 4 + 4].copy_from_slice(&w[r * 4 + c]);
}
}
Self { round_keys }
}
pub fn encrypt_block(&self, block: &mut [u8; 16]) {
let add = |s: &mut [u8; 16], k: &[u8; 16]| s.iter_mut().zip(k).for_each(|(a, b)| *a ^= b);
add(block, &self.round_keys[0]);
for round in 1..=10 {
for b in block.iter_mut() {
*b = SBOX[*b as usize];
}
// ShiftRows (column-major state: byte r + 4c).
let s = *block;
for c in 0..4 {
for r in 0..4 {
block[r + 4 * c] = s[r + 4 * ((c + r) % 4)];
}
}
if round != 10 {
for c in 0..4 {
let col = [
block[4 * c],
block[4 * c + 1],
block[4 * c + 2],
block[4 * c + 3],
];
let all = col[0] ^ col[1] ^ col[2] ^ col[3];
for r in 0..4 {
block[4 * c + r] = col[r] ^ all ^ xtime(col[r] ^ col[(r + 1) % 4]);
}
}
}
add(block, &self.round_keys[round]);
}
}
}
mod blocks;
pub use blocks::Blocks as Aes128;
use blocks::LANES;
/// AES/CFB8 stream state for one direction of a connection.
#[derive(Clone)]
pub struct Cfb8 {
aes: Aes128,
/// The last 16 ciphertext bytes (the IV before any).
register: [u8; 16],
}
impl Cfb8 {
@@ -116,26 +24,28 @@ impl Cfb8 {
register: *iv,
}
}
fn keystream(&self) -> u8 {
let mut block = self.register;
self.aes.encrypt_block(&mut block);
block[0]
}
fn shift(&mut self, cipher_byte: u8) {
self.register.copy_within(1.., 0);
self.register[15] = cipher_byte;
}
/// Each byte's keystream is the encrypted register, which holds the
/// ciphertext before it: encryption goes one byte at a time.
pub fn encrypt(&mut self, data: &mut [u8]) {
for b in data {
*b ^= self.keystream();
self.shift(*b);
}
self.aes.cfb8_encrypt(&mut self.register, data);
}
/// Decryption knows every ciphertext byte up front, so the registers of
/// [`LANES`] bytes in a row are known together and encrypted at once.
pub fn decrypt(&mut self, data: &mut [u8]) {
for b in data {
let c = *b;
*b ^= self.keystream();
self.shift(c);
for run in data.chunks_mut(LANES) {
// The register, then this run's ciphertext: byte i's register
// is window[i..i + 16].
let mut window = [0u8; 16 + LANES];
window[..16].copy_from_slice(&self.register);
window[16..16 + run.len()].copy_from_slice(run);
let mut blocks: [[u8; 16]; LANES] =
std::array::from_fn(|i| window[i..i + 16].try_into().unwrap());
self.aes.encrypt_lanes(&mut blocks);
for (b, k) in run.iter_mut().zip(&blocks) {
*b ^= k[0];
}
self.register
.copy_from_slice(&window[run.len()..run.len() + 16]);
}
}
}
@@ -279,14 +189,6 @@ mod tests {
.collect()
}
#[test]
fn sbox_spot_checks() {
assert_eq!(SBOX[0x00], 0x63);
assert_eq!(SBOX[0x01], 0x7c);
assert_eq!(SBOX[0x53], 0xed);
assert_eq!(SBOX[0xff], 0x16);
}
#[test]
fn aes128_fips197_vector() {
let key: [u8; 16] = hex("000102030405060708090a0b0c0d0e0f").try_into().unwrap();
@@ -313,6 +215,79 @@ mod tests {
assert_eq!(data, plain);
}
/// Bytes from a fixed xorshift: the same every run.
fn noise(n: usize, mut seed: u64) -> Vec<u8> {
(0..n)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed as u8
})
.collect()
}
#[test]
fn lanes_match_single_blocks_and_the_aes_crate() {
use aes::cipher::{BlockEncrypt, KeyInit};
let key: [u8; 16] = noise(16, 1).try_into().unwrap();
let aes = Aes128::new(&key);
let reference = aes::Aes128::new(&key.into());
let mut lanes: [[u8; 16]; LANES] =
std::array::from_fn(|i| noise(16, 7 + i as u64).try_into().unwrap());
let inputs = lanes;
aes.encrypt_lanes(&mut lanes);
for (input, out) in inputs.iter().zip(&lanes) {
let mut one = *input;
aes.encrypt_block(&mut one);
assert_eq!(&one, out);
let mut expected = aes::Block::from(*input);
reference.encrypt_block(&mut expected);
assert_eq!(out[..], expected[..]);
}
}
/// Decryption in runs of [`LANES`] gives what the one-byte-at-a-time
/// definition gives, however the stream is cut into reads.
#[test]
fn cfb8_decrypts_like_the_byte_by_byte_definition() {
let key: [u8; 16] = noise(16, 3).try_into().unwrap();
let iv: [u8; 16] = noise(16, 4).try_into().unwrap();
let plain = noise(5000, 5);
let mut cipher = plain.clone();
Cfb8::new(&key, &iv).encrypt(&mut cipher);
// The definition: P[i] = C[i] ^ E(register)[0], register += C[i].
let aes = Aes128::new(&key);
let mut register = iv;
let expected: Vec<u8> = cipher
.iter()
.map(|&c| {
let mut block = register;
aes.encrypt_block(&mut block);
register.copy_within(1.., 0);
register[15] = c;
c ^ block[0]
})
.collect();
assert_eq!(expected, plain);
for cuts in [
&[5000][..],
&[1, 7, 8, 9, 15, 16, 17, 1000, 3927],
&[3, 4997],
] {
let mut data = cipher.clone();
let mut dec = Cfb8::new(&key, &iv);
let mut rest = &mut data[..];
for &n in cuts {
let (head, tail) = rest.split_at_mut(n);
dec.decrypt(head);
rest = tail;
}
assert!(rest.is_empty());
assert_eq!(data, plain, "cuts {cuts:?}");
}
}
/// The digests of single names published with the protocol docs.
#[test]
fn server_hash_vectors() {
@@ -0,0 +1,207 @@
//! AES-128 encryption of several blocks at once, for CFB8.
//!
//! aarch64 builds that have the AES extension at compile time (every Apple Silicon Mac:
//! `aarch64-apple-darwin` targets the M1) use the ARMv8 AES instructions here directly.
//! Everywhere else the RustCrypto `aes` crate (MIT OR Apache-2.0) does it: AES-NI chosen at
//! run time on x86 and x86_64, a constant-time software AES on CPUs without it.
//!
//! [`LANES`] blocks go through at once: the AES instructions take a few cycles each but
//! start one per cycle, so independent blocks overlap. CFB8 decryption has [`LANES`]
//! independent blocks for every [`LANES`] bytes (see `Cfb8::decrypt`).
/// Blocks encrypted per call: 8 fill the ARM pipelines; the `aes` crate pays a fixed
/// cost per call, which 32 blocks spread (measured on an AMD EPYC 7662).
pub use imp::LANES;
/// One expanded AES-128 key.
#[derive(Clone)]
pub struct Blocks(imp::Key);
impl Blocks {
pub fn new(key: &[u8; 16]) -> Self {
Self(imp::Key::new(key))
}
/// Encrypt one block in place.
pub fn encrypt_block(&self, block: &mut [u8; 16]) {
self.0.encrypt_block(block);
}
/// CFB8 encryption: each byte's keystream is the encrypted register,
/// which then shifts in that byte's ciphertext.
pub fn cfb8_encrypt(&self, register: &mut [u8; 16], data: &mut [u8]) {
self.0.cfb8_encrypt(register, data);
}
/// Encrypt [`LANES`] blocks in place.
pub fn encrypt_lanes(&self, blocks: &mut [[u8; 16]; LANES]) {
self.0.encrypt_lanes(blocks);
}
}
// The only unsafe code in this crate: the ARMv8 AES intrinsics are `#[target_feature]`
// functions, which Rust lets safe code call only from a function with the same feature.
#[cfg(all(target_arch = "aarch64", target_feature = "aes"))]
#[allow(unsafe_code)]
mod imp {
use core::arch::aarch64::{
uint8x16_t, vaeseq_u8, vaesmcq_u8, vcombine_u8, vcreate_u8, vdupq_n_u8, veorq_u8, vextq_u8,
vgetq_lane_u8, vgetq_lane_u64, vreinterpretq_u64_u8,
};
pub const LANES: usize = 8;
/// FIPS-197's S-box, for the key schedule only (the rounds use AESE).
const SBOX: [u8; 256] = {
let mut sbox = [0u8; 256];
let (mut p, mut q) = (1u8, 1u8);
loop {
// p *= 3, q /= 3 in GF(2^8).
p = p ^ (p << 1) ^ if p & 0x80 != 0 { 0x1B } else { 0 };
q ^= q << 1;
q ^= q << 2;
q ^= q << 4;
if q & 0x80 != 0 {
q ^= 0x09;
}
let x = q ^ q.rotate_left(1) ^ q.rotate_left(2) ^ q.rotate_left(3) ^ q.rotate_left(4);
sbox[p as usize] = x ^ 0x63;
if p == 1 {
break;
}
}
sbox[0] = 0x63;
sbox
};
/// FIPS-197 key expansion: 11 round keys, in the byte order AESE takes.
fn round_keys(key: &[u8; 16]) -> [[u8; 16]; 11] {
let mut w = [[0u8; 4]; 44];
for i in 0..4 {
w[i].copy_from_slice(&key[i * 4..i * 4 + 4]);
}
let mut rcon = 1u8;
for i in 4..44 {
let mut t = w[i - 1];
if i % 4 == 0 {
t = [
SBOX[t[1] as usize] ^ rcon,
SBOX[t[2] as usize],
SBOX[t[3] as usize],
SBOX[t[0] as usize],
];
rcon = (rcon << 1) ^ if rcon & 0x80 != 0 { 0x1B } else { 0 };
}
for j in 0..4 {
w[i][j] = w[i - 4][j] ^ t[j];
}
}
let mut keys = [[0u8; 16]; 11];
for (r, k) in keys.iter_mut().enumerate() {
for c in 0..4 {
k[c * 4..c * 4 + 4].copy_from_slice(&w[r * 4 + c]);
}
}
keys
}
#[derive(Clone, Copy)]
pub struct Key([uint8x16_t; 11]);
impl Key {
pub fn new(key: &[u8; 16]) -> Self {
// SAFETY: as in `encrypt_block`.
Self(round_keys(key).map(|k| unsafe { load(&k) }))
}
pub fn encrypt_block(&self, block: &mut [u8; 16]) {
// SAFETY: this module is compiled only for targets whose CPUs all have
// the AES extension (`cfg(target_feature = "aes")`), and NEON comes with it.
unsafe { encrypt::<1>(&self.0, std::slice::from_mut(block).try_into().unwrap()) }
}
pub fn encrypt_lanes(&self, blocks: &mut [[u8; 16]; LANES]) {
// SAFETY: as in `encrypt_block`.
unsafe { encrypt(&self.0, blocks) }
}
pub fn cfb8_encrypt(&self, register: &mut [u8; 16], data: &mut [u8]) {
// SAFETY: as in `encrypt_block`.
unsafe { cfb8_encrypt(&self.0, register, data) }
}
}
/// The register stays in a vector: one AES per byte and a byte shift.
#[target_feature(enable = "neon,aes")]
fn cfb8_encrypt(k: &[uint8x16_t; 11], register: &mut [u8; 16], data: &mut [u8]) {
let mut r = load(register);
for b in data {
let mut s = r;
for k in &k[..9] {
s = vaesmcq_u8(vaeseq_u8(s, *k));
}
let s = veorq_u8(vaeseq_u8(s, k[9]), k[10]);
*b ^= vgetq_lane_u8::<0>(s);
r = vextq_u8::<1>(r, vdupq_n_u8(*b));
}
store(r, register);
}
#[target_feature(enable = "neon,aes")]
fn store(v: uint8x16_t, b: &mut [u8; 16]) {
let v = vreinterpretq_u64_u8(v);
b[..8].copy_from_slice(&vgetq_lane_u64::<0>(v).to_le_bytes());
b[8..].copy_from_slice(&vgetq_lane_u64::<1>(v).to_le_bytes());
}
#[target_feature(enable = "neon,aes")]
fn load(b: &[u8; 16]) -> uint8x16_t {
let half = |i: usize| u64::from_le_bytes(b[i..i + 8].try_into().unwrap());
vcombine_u8(vcreate_u8(half(0)), vcreate_u8(half(8)))
}
/// AESE is AddRoundKey, SubBytes and ShiftRows; AESMC is MixColumns; the last
/// round has no MixColumns and ends with the eleventh key. Rounds go across all
/// `N` blocks before the next, so the blocks' instructions overlap.
#[target_feature(enable = "neon,aes")]
fn encrypt<const N: usize>(k: &[uint8x16_t; 11], blocks: &mut [[u8; 16]; N]) {
let mut s = blocks.each_ref().map(|b| load(b));
for k in &k[..9] {
for v in &mut s {
*v = vaesmcq_u8(vaeseq_u8(*v, *k));
}
}
for (v, b) in s.into_iter().zip(blocks) {
store(veorq_u8(vaeseq_u8(v, k[9]), k[10]), b);
}
}
}
#[cfg(not(all(target_arch = "aarch64", target_feature = "aes")))]
mod imp {
use aes::cipher::{BlockEncrypt, KeyInit};
pub const LANES: usize = 32;
#[derive(Clone)]
pub struct Key(aes::Aes128);
impl Key {
pub fn new(key: &[u8; 16]) -> Self {
Self(aes::Aes128::new(key.into()))
}
pub fn encrypt_block(&self, block: &mut [u8; 16]) {
self.0.encrypt_block(block.into());
}
pub fn cfb8_encrypt(&self, register: &mut [u8; 16], data: &mut [u8]) {
for b in data {
let mut block = aes::Block::from(*register);
self.0.encrypt_block(&mut block);
*b ^= block[0];
register.copy_within(1.., 0);
register[15] = *b;
}
}
pub fn encrypt_lanes(&self, blocks: &mut [[u8; 16]; LANES]) {
let mut b = blocks.map(aes::Block::from);
self.0.encrypt_blocks(&mut b);
for (out, b) in blocks.iter_mut().zip(b) {
out.copy_from_slice(&b);
}
}
}
}
+2 -1
View File
@@ -1,7 +1,8 @@
//! The Minecraft Java Edition network protocol (of the version in `horizon_core::version`):
//! primitive codecs, framing, compression, encryption, and typed packets for
//! every connection state, shared by the Horizon client and (later) server.
#![forbid(unsafe_code)]
// `deny`, not `forbid`: crypto/blocks.rs allows the ARMv8 AES intrinsics, nothing else.
#![deny(unsafe_code)]
pub mod buf;
pub mod chunk;
pub mod connection;
+9 -6
View File
@@ -11,6 +11,7 @@ use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
use std::{
collections::{BTreeMap, HashMap},
io,
sync::Arc,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -39,10 +40,12 @@ struct State {
}
/// Prepared face geometry and a bounded cache shared across state pairs. Geometry
/// is deduplicated by the imported immutable shape, not copied for every state.
/// A clone (each meshing worker keeps one, with its own cache) shares the states
/// and faces, which never change once made.
#[derive(Clone)]
pub struct FaceVisibility {
states: Vec<State>,
faces: Vec<VoxelShape>,
states: Arc<[State]>,
faces: Arc<[VoxelShape]>,
cache: HashMap<(u32, u32), bool>,
}
impl FaceVisibility {
@@ -50,8 +53,8 @@ impl FaceVisibility {
/// answers "unknown render state".
pub fn empty() -> Self {
Self {
states: Vec::new(),
faces: vec![VoxelShape::empty(), VoxelShape::block()],
states: Arc::new([]),
faces: Arc::new([VoxelShape::empty(), VoxelShape::block()]),
cache: HashMap::new(),
}
}
@@ -140,8 +143,8 @@ impl FaceVisibility {
});
}
Ok(Self {
states,
faces,
states: states.into(),
faces: faces.into(),
cache: HashMap::new(),
})
}
+11
View File
@@ -468,6 +468,17 @@ pub trait LevelAccess {
fn is_client_side(&self) -> bool {
false
}
/// `FireBlock`'s flammability table for `state`'s block, before the
/// table answers it: `(igniteOdds, burnOdds)`, as `FireBlock.setFlammable`
/// sets them. Fire spread, burning out and where fire can stand read it;
/// a waterlogged state never burns and isn't asked. An answer put in
/// `odds` is the result. The default does nothing.
fn before_fire_odds(&self, _state: BlockStateId, _odds: &mut Option<(i32, i32)>) {}
/// `CampfireBlock.isSmokeSource(state)` for the block under a campfire,
/// before vanilla's rule (a hay bale) answers it: true makes the campfire
/// a signal fire. An answer put in `smoke` is the result. The default
/// does nothing.
fn before_smoke_source(&self, _state: BlockStateId, _smoke: &mut Option<bool>) {}
/// `getBlockState`. Unloaded or out-of-bounds positions read as air.
fn block_state(&self, pos: BlockPos) -> BlockStateId;
/// Storage write (`LevelChunkSection.setBlockState`). Returns the previous
+2 -4
View File
@@ -670,11 +670,9 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
}
Class::Campfire => {
let water = self.fluid_at(pos).kind() == crate::fluid::FluidKind::Water;
let hay = table
.block_by_name("minecraft:hay_block")
.is_some_and(|hay| b.is_block(self.below_state(pos), hay));
let smoke = self.is_smoke_source(self.below_state(pos));
let state = table.set_bool(default, p.waterlogged, water);
let state = table.set_bool(state, p.signal_fire, hay);
let state = table.set_bool(state, p.signal_fire, smoke);
let state = table.set_bool(state, p.lit, !water);
Some(table.set_dir(state, p.facing, horizontal))
}
+1 -6
View File
@@ -283,12 +283,7 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
Class::Campfire => {
self.waterlogged_tick(state, pos);
if dir == Direction::Down {
let hay = table.block_by_name("minecraft:hay_block");
table.set_bool(
state,
p.signal_fire,
hay.is_some_and(|hay| b.is_block(nstate, hay)),
)
table.set_bool(state, p.signal_fire, self.is_smoke_source(nstate))
} else {
sup!()
}
+24 -8
View File
@@ -491,18 +491,34 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
self.ignite_odds(state) > 0
}
pub(crate) fn ignite_odds(&self, state: BlockStateId) -> i32 {
if self.b.is_waterlogged(state) {
0
} else {
self.b.data(state).ignite_odds
}
self.fire_odds(state).0
}
pub(crate) fn burn_odds(&self, state: BlockStateId) -> i32 {
self.fire_odds(state).1
}
/// `FireBlock.getIgniteOdds` and `getBurnOdds`: the level's answer, else
/// `FireBlock.bootStrap`'s table.
fn fire_odds(&self, state: BlockStateId) -> (i32, i32) {
if self.b.is_waterlogged(state) {
0
} else {
self.b.data(state).burn_odds
return (0, 0);
}
let mut odds = None;
self.level.before_fire_odds(state, &mut odds);
odds.unwrap_or_else(|| {
let data = self.b.data(state);
(data.ignite_odds, data.burn_odds)
})
}
/// `CampfireBlock.isSmokeSource`: a hay bale, unless the level answers.
pub(crate) fn is_smoke_source(&self, state: BlockStateId) -> bool {
let mut smoke = None;
self.level.before_smoke_source(state, &mut smoke);
smoke.unwrap_or_else(|| {
self.b
.table
.block_by_name("minecraft:hay_block")
.is_some_and(|hay| self.b.is_block(state, hay))
})
}
/// `ScaffoldingBlock.getDistance`.
pub(crate) fn scaffolding_distance(&self, pos: BlockPos) -> i32 {
+121
View File
@@ -59,6 +59,10 @@ pub(crate) struct TestLevel {
/// `analog_states` (`adjust_has_analog_output`).
pub analog: HashMap<BlockPos, i32>,
pub analog_states: HashSet<BlockStateId>,
/// What `before_fire_odds` answers for a state.
pub fire_odds: HashMap<BlockStateId, (i32, i32)>,
/// What `before_smoke_source` answers for a state.
pub smoke_sources: HashMap<BlockStateId, bool>,
}
impl TestLevel {
fn new(b: &Behaviours) -> Self {
@@ -75,6 +79,8 @@ impl TestLevel {
block_light: HashMap::new(),
client_side: false,
guarded: HashSet::new(),
fire_odds: HashMap::new(),
smoke_sources: HashMap::new(),
}
}
}
@@ -154,6 +160,16 @@ impl LevelAccess for TestLevel {
.chain(destroy.iter().copied())
.any(|p| self.guarded.contains(&p))
}
fn before_fire_odds(&self, state: BlockStateId, odds: &mut Option<(i32, i32)>) {
if let Some(&answer) = self.fire_odds.get(&state) {
*odds = Some(answer);
}
}
fn before_smoke_source(&self, state: BlockStateId, smoke: &mut Option<bool>) {
if let Some(&answer) = self.smoke_sources.get(&state) {
*smoke = Some(answer);
}
}
}
struct World<'f> {
@@ -3290,3 +3306,108 @@ fn survive_hooks_answer_for_the_block() {
));
assert!(level.asked.get() > asked);
}
#[test]
fn a_level_can_answer_how_a_block_burns() {
let mut w = world!();
w.floor(0, 10, "stone");
let glass = w.id("glass", &[]);
let planks = w.id("oak_planks", &[]);
let burn = |w: &mut World, fire: BlockPos| {
w.set(fire.offset(0, -1, 0), w.id("netherrack", &[]));
w.set(fire.offset(1, 0, 0), glass);
w.set(fire.offset(-1, 0, 0), planks);
w.set(fire, w.id("fire", &[]));
for _ in 0..4000 {
w.tick();
}
};
// Vanilla's table: glass never burns, planks do.
let fire = BlockPos::new(0, 1, 0);
burn(&mut w, fire);
assert_eq!(w.get(fire.offset(1, 0, 0)), glass, "glass doesn't burn");
assert_ne!(w.get(fire.offset(-1, 0, 0)), planks, "planks burn");
// The level's answer wins over the table, either way.
w.level.fire_odds.insert(glass, (30, 60));
w.level.fire_odds.insert(planks, (0, 0));
let fire = BlockPos::new(0, 1, 6);
burn(&mut w, fire);
assert_ne!(w.get(fire.offset(1, 0, 0)), glass, "flammable glass burns");
assert_eq!(
w.get(fire.offset(-1, 0, 0)),
planks,
"fireproof planks don't"
);
// Fire in the air beside a block stays only while the block's ignite
// odds are above zero, and burn odds of zero never destroy the block.
let lit_beside = |w: &mut World, at: BlockPos, ticks: usize| {
w.set(at.offset(1, 0, 0), glass);
w.set(at, w.id("fire", &[]));
for _ in 0..ticks {
w.tick();
}
w.get(at)
};
let fire = w.id("fire", &[]);
w.level.fire_odds.clear();
let at = BlockPos::new(-6, 5, 0);
assert_ne!(
w.b().block(lit_beside(&mut w, at, 45)),
w.b().block(fire),
"fire beside plain glass goes out"
);
w.level.fire_odds.insert(glass, (5, 0));
let at = BlockPos::new(-6, 5, 6);
assert_eq!(
w.b().block(lit_beside(&mut w, at, 45)),
w.b().block(fire),
"fire stands beside glass that ignites"
);
for _ in 0..4000 {
w.tick();
}
assert_eq!(w.get(at.offset(1, 0, 0)), glass, "and never burns it away");
}
#[test]
fn a_level_can_answer_what_makes_a_campfire_signal() {
let mut w = world!();
let (stone, hay) = (w.id("stone", &[]), w.id("hay_block", &[]));
let ground = BlockPos::new(0, 0, 0);
let campfire = ground.offset(0, 1, 0);
let place = |w: &mut World| {
w.level.blocks.remove(&campfire);
let ctx = PlaceContext {
clicked_pos: ground,
face: Direction::Up,
hit: horizon_core::geometry::Vec3::new(0.5, 1.0, 0.5),
inside: false,
yaw: 0.0,
pitch: 0.0,
sneaking: false,
item: "minecraft:campfire".into(),
can_use_game_master_blocks: false,
};
let b = w.b();
assert!(matches!(
b.place_block(&mut w.level, &mut w.ticks, &ctx),
PlaceOutcome::Placed { .. }
));
w.prop(campfire, "signal_fire")
};
w.put(ground, stone);
assert_eq!(place(&mut w), "false");
w.put(ground, hay);
assert_eq!(place(&mut w), "true", "vanilla's hay bale");
// The level makes stone a smoke source and hay not: placed, and as the ground changes.
w.level.smoke_sources.insert(stone, true);
w.level.smoke_sources.insert(hay, false);
assert_eq!(place(&mut w), "false");
w.set(ground, stone);
assert_eq!(w.prop(campfire, "signal_fire"), "true");
w.set(ground, hay);
assert_eq!(w.prop(campfire, "signal_fire"), "false");
}
+1
View File
@@ -2760,6 +2760,7 @@ dependencies = [
name = "horizon-protocol"
version = "0.1.0"
dependencies = [
"aes",
"flate2",
"getrandom 0.3.4",
"horizon-core",
+67
View File
@@ -11640,5 +11640,72 @@ CHECKS += ['blast-bystanders']
CHECK_FNS['blast-bystanders'] = check_blast_bystanders
def check_mining_drop_latency(host, guest, out):
"""An instant survival break sends its drop in the tick that handles START (#4572).
Queue the click behind the server barrier, then release it at one TPS. A
snapshot taken before tick_guests delays the drop until the next second.
Both the integrated owner and a LAN guest must see the first snapshot.
"""
hx, _, hz = pos_of(host)
x, y, z = int(hx), 100, int(hz)
seen = {}
try:
host('command', command=f'fill {x-5} {y-1} {z-5} {x+5} {y-1} {z+5} minecraft:stone')
host('command', command=f'fill {x-5} {y} {z-5} {x+5} {y+4} {z+5} minecraft:air')
for player in (host, guest):
player('command', command='clear @s')
player('command', command='gamemode survival')
for player, other in ((host, guest), (guest, host)):
host('command', command='tick rate 20')
host('command', command='kill @e[type=minecraft:item]')
player('command', command=f'tp @s {x+0.5} {y} {z-3.5}')
other('command', command=f'tp @s {x+3.5} {y} {z+0.5}')
host('command', command=f'setblock {x} {y} {z} minecraft:torch')
player.wait(20)
assert all(g.raw('block', x, y, z).get('block') == 'minecraft:torch'
for g in (host, guest)), 'torch did not reach both clients'
player.raw('look-at', x+0.5, y+0.3, z+0.5)
host('command', command='tick rate 1')
host.raw('server-hold', 'on')
try:
player.raw('mouse', 'left', 'click')
# The client's input tick also slows to one TPS. Wait for its
# prediction while transport runs and the server is still held.
deadline = time.monotonic() + 3
while player.raw('block', x, y, z).get('block') != 'minecraft:air':
assert time.monotonic() < deadline, \
f'{player.name}: client did not predict the instant break'
time.sleep(0.02)
finally:
host.raw('server-hold', 'off')
started = time.monotonic()
arrivals = {}
while time.monotonic() - started < 0.75 and len(arrivals) < 2:
for viewer in (host, guest):
if viewer.name in arrivals:
continue
entities = viewer.raw('entities', 24, 'minecraft:item')['entities']
if any(abs(e['pos'][0] - x - 0.5) < 2
and abs(e['pos'][2] - z - 0.5) < 2 for e in entities):
arrivals[viewer.name] = round(time.monotonic() - started, 3)
time.sleep(0.01)
seen[player.name] = arrivals
lab.write_json(out / 'mining-drop-latency.json', seen)
assert len(arrivals) == 2, \
f'{player.name}: drop waited for a second server tick: {seen}'
finally:
host.raw('server-hold', 'off')
host('command', command='tick rate 20')
host('command', command='kill @e[type=minecraft:item]')
for player in (host, guest):
player.raw('release')
return seen
CHECKS += ['mining-drop-latency']
CHECK_FNS['mining-drop-latency'] = check_mining_drop_latency
if __name__ == '__main__':
main()
@@ -0,0 +1,14 @@
{
"hooks": [
{
"name": "fire_odds",
"target": "horizon_client::game::native_hooks::GameLevel<'_>::native_fire_odds"
},
{
"name": "smoke_source",
"target": "horizon_client::game::native_hooks::GameLevel<'_>::native_smoke_source"
}
],
"rust_summary": "how fire burns a mod block (`native_fire_odds`, `FireBlock.setFlammable`), and a mod block under\na campfire that makes it a signal fire (`native_smoke_source`, `CampfireBlock.isSmokeSource`).",
"doc_summary": "how a mod block burns, and whether it makes a campfire over it a signal fire, on an authoritative server. `native_fire_odds(dimension, state, odds)` answers\n`FireBlock`'s flammability table (`FireBlock.setFlammable(block, igniteOdds, burnOdds)`) before vanilla's table: put\n`(ignite, burn)` in `odds` for your own blocks. Ignite odds above zero let fire stand beside the block and spread\nto the air around it (planks 5, wool 30); burn odds are how fast fire destroys it (planks 20, wool 60); `(0, 0)` never\nburns; keep both within vanilla's 0 to 100. A waterlogged state never burns and isn't asked. It runs hundreds of times per fire tick: keep it short.\n`native_smoke_source(dimension, state, smoke)` answers `CampfireBlock.isSmokeSource` for the block under a campfire,\nas the campfire is placed or that block changes: `Some(true)` makes the campfire a signal fire, as a hay bale does.\nAnswer only for your own blocks and leave the answer empty otherwise; the level is read-only. The deprecated in-frame\npath fires neither. Bundles that use them need `\"hooks\": @API:fire_odds@`."
}
+1 -1
View File
@@ -58,7 +58,7 @@ CreateWorldScreen.onCreate port crates/horizon-client/src/title.rs::Title::creat
CreateWorldScreen.createWorldAndCleanup inline crates/horizon-client/src/game/startup_menu.rs::StartupMenu::queue_entry gap: minor: temp datapack folder cleanup not modelled (no datapack copy)
CreateWorldScreen.createNewWorld port crates/horizon-client/src/game.rs::Game::open_world gap: minor: the folder is made in WorldRequest::prepare and no datapacks are copied in
CreateWorldScreen.createLevelSettings port crates/horizon-client/src/game/world_loading.rs::WorldRequest::prepare name, mode, difficulty, hardcore and commands written with the new level; gap: minor: debug world (spectator, peaceful) not modelled
CreateWorldScreen.keyPressed missing player: Enter does not create the world; startup_menu title_key only confirms a focused rename box, so Enter on Create New World does nothing
CreateWorldScreen.keyPressed port crates/horizon-client/src/title.rs::confirm_key_size player: Enter with no button focused creates the world (Screenless Create mode falls through to create_action), as the screen does after its widgets decline the key
CreateWorldScreen.extractRenderState port crates/horizon-client/src/title/create_world.rs::Title::draw_create draws the footer separator
CreateWorldScreen.extractMenuBackground port crates/horizon-client/src/title/create_world.rs::Title::draw_create draws the tab header strip and menu background
CreateWorldScreen.getOrCreateTempDataPackDir missing minor: temp folder for datapacks chosen at creation; Horizon has no create-time datapack picker
1 SocialInteractionsPlayerList.enableScissor skip:service social list (hidden and blocked players); Horizon has no Social Interactions screen
58 CreateWorldScreen.createWorldAndCleanup inline crates/horizon-client/src/game/startup_menu.rs::StartupMenu::queue_entry gap: minor: temp datapack folder cleanup not modelled (no datapack copy)
59 CreateWorldScreen.createNewWorld port crates/horizon-client/src/game.rs::Game::open_world gap: minor: the folder is made in WorldRequest::prepare and no datapacks are copied in
60 CreateWorldScreen.createLevelSettings port crates/horizon-client/src/game/world_loading.rs::WorldRequest::prepare name, mode, difficulty, hardcore and commands written with the new level; gap: minor: debug world (spectator, peaceful) not modelled
61 CreateWorldScreen.keyPressed missing port crates/horizon-client/src/title.rs::confirm_key_size player: Enter does not create the world; startup_menu title_key only confirms a focused rename box, so Enter on Create New World does nothing player: Enter with no button focused creates the world (Screenless Create mode falls through to create_action), as the screen does after its widgets decline the key
62 CreateWorldScreen.extractRenderState port crates/horizon-client/src/title/create_world.rs::Title::draw_create draws the footer separator
63 CreateWorldScreen.extractMenuBackground port crates/horizon-client/src/title/create_world.rs::Title::draw_create draws the tab header strip and menu background
64 CreateWorldScreen.getOrCreateTempDataPackDir missing minor: temp folder for datapacks chosen at creation; Horizon has no create-time datapack picker
+1 -1
View File
@@ -29,7 +29,7 @@ EnchantmentHelper.updateEnchantments port crates/horizon-items/src/item_data.rs:
EnchantmentHelper.processAmmoUse port crates/horizon-client/src/game/enchant_effects.rs::Game::bow_enchantments Infinity as a flag; the ammo count change is decided by the caller
EnchantmentHelper.processBlockExperience inline crates/horizon-client/src/game.rs::Game::break_block_as_player Silk Touch zeroes the XP at the call site; spawners keep it
EnchantmentHelper.processMobExperience skip:other no enchantment in the data uses mob_experience, so the amount never changes
EnchantmentHelper.createBook missing player: the creative menu has no enchanted book for each enchantment (CreativeModeTabs); creative.rs lists none
EnchantmentHelper.createBook inline crates/horizon-client/src/creative.rs::from_json the search tab comes from creative-inventory.json, whose export carries the 171 enchanted book stacks (vanilla CreativeModeTabs generates them)
EnchantmentHelper.runIterationOnItem port crates/horizon-items/src/item_data.rs::ItemStack::enchantments the (enchantment, level) iteration; callers loop over it
EnchantmentHelper.runIterationOnEquipment inline crates/horizon-client/src/game/enchant_effects.rs::Game::apply_enchantment_attributes gap: minor: worn armor only; hand slots are not read
EnchantmentHelper.isImmuneToDamage port crates/horizon-client/src/game/enchant_effects.rs::Game::wears_burn_from_stepping_immunity gap: minor: worn armor only; no requirements or damage source check
1 Enchantment.getSlotItems inline crates/horizon-world/src/loot.rs::LootData::enchantment_level slot filter and item lookup folded into the best-level loop
29 EnchantmentHelper.processAmmoUse port crates/horizon-client/src/game/enchant_effects.rs::Game::bow_enchantments Infinity as a flag; the ammo count change is decided by the caller
30 EnchantmentHelper.processBlockExperience inline crates/horizon-client/src/game.rs::Game::break_block_as_player Silk Touch zeroes the XP at the call site; spawners keep it
31 EnchantmentHelper.processMobExperience skip:other no enchantment in the data uses mob_experience, so the amount never changes
32 EnchantmentHelper.createBook missing inline crates/horizon-client/src/creative.rs::from_json player: the creative menu has no enchanted book for each enchantment (CreativeModeTabs); creative.rs lists none the search tab comes from creative-inventory.json, whose export carries the 171 enchanted book stacks (vanilla CreativeModeTabs generates them)
33 EnchantmentHelper.runIterationOnItem port crates/horizon-items/src/item_data.rs::ItemStack::enchantments the (enchantment, level) iteration; callers loop over it
34 EnchantmentHelper.runIterationOnEquipment inline crates/horizon-client/src/game/enchant_effects.rs::Game::apply_enchantment_attributes gap: minor: worn armor only; hand slots are not read
35 EnchantmentHelper.isImmuneToDamage port crates/horizon-client/src/game/enchant_effects.rs::Game::wears_burn_from_stepping_immunity gap: minor: worn armor only; no requirements or damage source check
+1 -1
View File
@@ -51,7 +51,7 @@ DeltaTracker.Timer.unPause port crates/horizon-client/src/clock.rs::Timer::updat
GameNarrator missing minor: no text-to-speech; the narrator option is stored but nothing is spoken
HotbarManager.get port crates/horizon-client/src/hotbar.rs::load file loaded when the creative hotbar opens (creative_hotbar.rs)
KeyMapping.forAllKeyMappings port crates/horizon-client-input/src/keybinds.rs::KeyBinds::mappings_for one key drives every mapping bound to it
KeyMapping.setAll missing player: held keys are not re-read when the window regains focus; after alt-tab with a move key held, the walk stops until the key is pressed again
KeyMapping.setAll port crates/horizon-client/src/game.rs::handle_window_event_inner WindowEvent::Focused(false) drops every held key and mouse button, so nothing sticks across alt-tab; ModifiersChanged reconciles the rest
KeyMapping.resetMapping skip:other no lookup table to rebuild; mappings_for reads the current keys
KeyMapping.compareTo skip:ui controls list order is the MAPPINGS table order in keybinds.rs
KeyMapping.createNameSupplier skip:ui key label supplier
1 PlayerHurtEntityTrigger.TriggerInstance.playerHurtEntityWithDamage skip:data criterion factory; Horizon reads the criterion from the advancement JSON
51 GameNarrator missing minor: no text-to-speech; the narrator option is stored but nothing is spoken
52 HotbarManager.get port crates/horizon-client/src/hotbar.rs::load file loaded when the creative hotbar opens (creative_hotbar.rs)
53 KeyMapping.forAllKeyMappings port crates/horizon-client-input/src/keybinds.rs::KeyBinds::mappings_for one key drives every mapping bound to it
54 KeyMapping.setAll missing port crates/horizon-client/src/game.rs::handle_window_event_inner player: held keys are not re-read when the window regains focus; after alt-tab with a move key held, the walk stops until the key is pressed again WindowEvent::Focused(false) drops every held key and mouse button, so nothing sticks across alt-tab; ModifiersChanged reconciles the rest
55 KeyMapping.resetMapping skip:other no lookup table to rebuild; mappings_for reads the current keys
56 KeyMapping.compareTo skip:ui controls list order is the MAPPINGS table order in keybinds.rs
57 KeyMapping.createNameSupplier skip:ui key label supplier