mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge remote-tracking branch 'origin/main' into fix/plates-remaining-kinds
This commit is contained in:
commit
b86f3f517f
33 files changed
+926
-212
No files matched your search
Generated
+1
@@ -1607,6 +1607,7 @@ dependencies = [
|
||||
name = "horizon-protocol"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-core",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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!()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Generated
+1
@@ -2760,6 +2760,7 @@ dependencies = [
|
||||
name = "horizon-protocol"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-core",
|
||||
|
||||
@@ -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@`."
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
Reference in new issue
Block a user