Merge remote-tracking branch 'origin/main' into hdr-polish

# Conflicts:
#	docs/graphics.md
This commit is contained in:
Senjin committed 2026-11-01 23:36:32 +01:00
commit 0df0b4d191
47 files changed
+1614 -272

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",
@@ -76,6 +76,12 @@ impl Luminance {
self.0.get(item).copied().unwrap_or(0)
}
/// The HDR tier of an item that glows, as its block's (`mesher::hdr_tier`), 0 for the rest.
pub fn hdr_tier(&self, item: &str) -> u8 {
let name = item.strip_prefix("minecraft:").unwrap_or(item);
crate::mesher::hdr_tier(name, self.of(item)) & !crate::mesher::WHOLE
}
#[cfg(test)]
fn from_pairs(pairs: &[(&str, u8)]) -> Self {
Self(pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect())
@@ -119,6 +125,23 @@ pub fn level_at(pos: [f32; 3], lights: &[[f32; 4]]) -> f32 {
mod tests {
use super::*;
#[test]
fn items_shine_as_their_blocks() {
use crate::mesher::{BRIGHT, INCANDESCENT};
let lum = Luminance::from_pairs(&[
("minecraft:torch", 14),
("minecraft:glowstone", 15),
("minecraft:lava_bucket", 15),
("minecraft:campfire", 15),
]);
assert_eq!(lum.hdr_tier("minecraft:torch"), INCANDESCENT);
assert_eq!(lum.hdr_tier("minecraft:glowstone"), BRIGHT);
assert_eq!(lum.hdr_tier("minecraft:lava_bucket"), INCANDESCENT);
// Items carry no sprite thresholds: only the tier, not "all over".
assert_eq!(lum.hdr_tier("minecraft:campfire"), BRIGHT);
assert_eq!(lum.hdr_tier("minecraft:apple"), 0);
}
#[test]
fn colored_sources_are_camera_relative_and_bounded() {
let lights = select_colored(
@@ -344,6 +344,13 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
return 0.175 * f32(3u - t) + 0.165 * (1.0 - lift);
}
// The hand in HDR output (gfx.rs "HDR hand marks"): where it drew, what the world kept there
// no longer shines; fs_hdr_mark then marks the light sources it holds.
@fragment fn fs_hdr_hand_unmark(in: Out) -> @location(0) vec4<f32> {
if textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, 1.0);
}
fn celestial_color(in: Out) -> vec4<f32> {
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
// vertex colour, with fog fading both RGB and alpha before additive blending.
+73 -1
View File
@@ -802,6 +802,7 @@ pub struct Renderer {
mirror: Option<MirrorTarget>,
entity_pipeline: wgpu::RenderPipeline,
hdr_mark_pipeline: wgpu::RenderPipeline,
hdr_hand_unmark_pipeline: wgpu::RenderPipeline,
hdr_mark_lightning_pipeline: wgpu::RenderPipeline,
weather: weather::Weather,
crumbling_pipeline: wgpu::RenderPipeline,
@@ -2991,6 +2992,17 @@ impl Renderer {
"fs_hdr_mark",
wgpu::ColorWrites::ALPHA,
);
let hdr_hand_unmark_pipeline = entity_pipeline_with(
"HDR hand unmark",
None,
false,
wgpu::CompareFunction::GreaterEqual,
Default::default(),
wgpu::BlendState::REPLACE,
false,
"fs_hdr_hand_unmark",
wgpu::ColorWrites::ALPHA,
);
// Attribute 7: 6 is the entity vertex buffer's glint UV.
let lightning_attributes = wgpu::vertex_attr_array![7=>Float32x3];
let lightning_vertex = [
@@ -3576,6 +3588,7 @@ impl Renderer {
mirror: None,
entity_pipeline,
hdr_mark_pipeline,
hdr_hand_unmark_pipeline,
hdr_mark_lightning_pipeline,
weather,
crumbling_pipeline,
@@ -3731,6 +3744,11 @@ impl Renderer {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
if self.hdr.is_some() {
// Drawn into the world: the light sources it holds are marked as the world's are.
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
}
}
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
/// secondary stroke first when high contrast is on.
@@ -4648,6 +4666,10 @@ impl Renderer {
}
/// The F3 screen's HDR line while the setting is on: the swapchain HDR presents to with
/// its paper white and peak, or that the surface offers none.
/// Whether HDR output is on: the setting, where the display offers it.
pub fn hdr_on(&self) -> bool {
self.hdr.is_some()
}
pub fn hdr_line(&self) -> String {
match &self.hdr {
Some(hdr) => format!(
@@ -7207,6 +7229,15 @@ impl Renderer {
((base_quads - translucent_item_quads) * 4) as i32,
0..1,
);
if self.hdr.is_some() {
// A glowing item in the world (Item sheet quads), as entities are.
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.draw_indexed(
0..translucent_item_quads * 6,
((base_quads - translucent_item_quads) * 4) as i32,
0..1,
);
}
pass.set_bind_group(0, &self.group, &[]);
}
if crumbling_quads > 0 && !oit_on {
@@ -7521,6 +7552,7 @@ impl Renderer {
for (quads, first) in [
(oit_entity_quads, base_quads),
(additive_quads, world_quads),
(translucent_item_quads, base_quads - translucent_item_quads),
(
translucent_particle_quads,
base_quads - translucent_item_quads - translucent_particle_quads,
@@ -8078,7 +8110,12 @@ impl Renderer {
view: &self.depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Discard,
// HDR output's hand marks test against it (below).
store: if self.hdr.is_some() {
wgpu::StoreOp::Store
} else {
wgpu::StoreOp::Discard
},
}),
stencil_ops: None,
}),
@@ -8092,6 +8129,41 @@ impl Renderer {
drop(pass);
if let Some(hdr) = &self.hdr {
hdr.mark_hand(encoder, timing.as_mut());
if let Some(view) = hdr.hand_light_view() {
// The light sources in the hand may shine, the rest of it stays at the
// world's brightness: nothing the world kept here shows through it.
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("HDR hand marks"),
timestamp_writes: timing.as_mut().and_then(|t| t.writes("HDR hand marks")),
color_attachments: &attachments,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Discard,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_bind_group(0, &self.hand_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
let quads = 0..overlay.hand_quads * 6;
pass.set_pipeline(&self.hdr_hand_unmark_pipeline);
pass.draw_indexed(quads.clone(), overlay.first_hand_vertex, 0..1);
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.draw_indexed(quads, overlay.first_hand_vertex, 0..1);
}
}
}
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
+10 -1
View File
@@ -320,7 +320,10 @@ impl Hdr {
"Horizon HDR frame",
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let world = texture("Horizon HDR world", wgpu::TextureUsages::COPY_DST);
let world = texture(
"Horizon HDR world",
wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR bindings"),
@@ -438,6 +441,12 @@ impl Hdr {
pass.draw(0..3, 0..1);
}
/// Where the hand's marks go: the kept world's alpha, which HDR output reads for it too.
pub fn hand_light_view(&self) -> Option<wgpu::TextureView> {
let t = self.targets.as_ref()?;
Some(t.world.create_view(&Default::default()))
}
/// Writes the finished frame to the swapchain image `target`.
/// The tiers' windows for the shaders that mark the frame (`Globals::hdr_windows`):
/// where each starts, then where each is full.
@@ -6,6 +6,8 @@ use wgpu::util::DeviceExt;
/// Native cube order: +X, -X, +Y, -Y, +Z, -Z (CubeMapTexture.SUFFIXES).
const FACE_ORDER: [u8; 6] = [1, 3, 5, 4, 0, 2];
/// Where the cube's faces live in a resource root.
const BACKGROUND: &str = "assets/minecraft/textures/gui/title/background";
#[derive(Debug)]
pub struct Faces {
@@ -14,9 +16,18 @@ pub struct Faces {
}
impl Faces {
pub fn load(root: &Path) -> Self {
Self::try_load(root).unwrap_or_else(|error| {
log::warn!("Horizon: title panorama unavailable ({error})");
/// The title artwork of `root` (the packs' overlay) over `defaults` (the
/// game's own resources). `CubeMapTexture` registers each face with the
/// resource manager, which takes the topmost pack that has it: a pack that
/// leaves a face alone keeps vanilla's, and only a cube no layer completes
/// falls back to the grey placeholder that an installation without the
/// imported panorama shows.
pub fn load(root: &Path, defaults: &Path) -> Self {
Self::try_load(root, defaults).unwrap_or_else(|error| {
log::warn!(
"Horizon: no title panorama ({error}); prepare the game again in the launcher \
(or run horizon-prepare panorama)"
);
Self {
size: 1,
pixels: [96, 110, 111, 255].repeat(6),
@@ -24,13 +35,11 @@ impl Faces {
})
}
fn try_load(root: &Path) -> Result<Self> {
let background = root.join("assets/minecraft/textures/gui/title/background");
fn try_load(root: &Path, defaults: &Path) -> Result<Self> {
let mut size = 0;
let mut pixels = Vec::new();
for face in FACE_ORDER {
let path = background.join(format!("panorama_{face}.png"));
let (w, h, data) = decode(&path)?;
let (path, w, h, data) = load_face(root, defaults, face)?;
if w != h || (size != 0 && w != size) {
return Err(format!(
"{}: panorama faces must be equally sized squares",
@@ -48,6 +57,34 @@ impl Faces {
}
}
/// `panorama_{face}.png` from the first layer that has one, with the path it
/// came from. Reading a face that no layer provides fails naming every path
/// tried, so a report of a missing panorama says where the game looked.
fn load_face(
root: &Path,
defaults: &Path,
face: u8,
) -> Result<(std::path::PathBuf, u32, u32, Vec<u8>)> {
let name = format!("panorama_{face}.png");
let mut tried: Vec<(std::path::PathBuf, String)> = Vec::new();
for layer in [root, defaults] {
let path = layer.join(BACKGROUND).join(&name);
// A vanilla-only installation loads one root twice.
if tried.first().is_some_and(|(seen, _)| *seen == path) {
continue;
}
match decode(&path) {
Ok((w, h, data)) => return Ok((path, w, h, data)),
Err(error) => tried.push((path, error.to_string())),
}
}
let tried: Vec<String> = tried
.iter()
.map(|(path, error)| format!("{}: {error}", path.display()))
.collect();
Err(tried.join("; ").into())
}
fn projection(width: u32, height: u32, yaw: f32, pitch: f32) -> Mat4 {
// CubeMap.render: 85-degree FOV, near/far 0.05/10, rotationX(PI), rotateX, rotateY.
Mat4::perspective_rh(
@@ -253,9 +290,18 @@ mod tests {
sync::atomic::{AtomicU32, Ordering},
};
/// One resource root: a pack overlay, or the game's own resources.
struct Fixture(std::path::PathBuf);
impl Fixture {
fn new(size: u32) -> Self {
let fixture = Self::bare();
for face in 0..6 {
fixture.write(face, size, size);
}
fixture
}
/// A root with the faces' folder and none of the faces.
fn bare() -> Self {
static NEXT: AtomicU32 = AtomicU32::new(0);
let root = std::env::temp_dir().join(format!(
"horizon-panorama-{}-{}",
@@ -264,9 +310,6 @@ mod tests {
));
let fixture = Self(root);
fs::create_dir_all(fixture.background()).unwrap();
for face in 0..6 {
fixture.write(face, size, size);
}
fixture
}
fn background(&self) -> std::path::PathBuf {
@@ -309,7 +352,7 @@ mod tests {
#[test]
fn original_resolution_texels_and_vanilla_cube_orientation_are_preserved() {
let f = Fixture::new(1024);
let faces = Faces::try_load(&f.0).unwrap();
let faces = Faces::try_load(&f.0, &f.0).unwrap();
assert_eq!(faces.size, 1024);
assert_eq!(faces.pixels.len(), 6 * 1024 * 1024 * 4);
// CubeMapTexture's actual layer suffixes, with each source row flipped vertically.
@@ -329,21 +372,56 @@ mod tests {
#[test]
fn placeholders_load_and_incomplete_or_mismatched_cubes_fall_back() {
let f = Fixture::new(1);
assert_eq!(Faces::try_load(&f.0).unwrap().size, 1);
assert_eq!(Faces::try_load(&f.0, &f.0).unwrap().size, 1);
f.write(5, 2, 1);
assert!(
Faces::try_load(&f.0)
Faces::try_load(&f.0, &f.0)
.unwrap_err()
.to_string()
.contains("equally sized squares")
);
let fallback = Faces::load(&f.0);
let fallback = Faces::load(&f.0, &f.0);
assert_eq!(fallback.pixels.len(), 24);
f.write(5, 2, 2);
assert!(Faces::try_load(&f.0).is_err());
assert!(Faces::try_load(&f.0, &f.0).is_err());
fs::remove_file(f.background().join("panorama_5.png")).unwrap();
assert!(Faces::try_load(&f.0).is_err());
assert_eq!(Faces::load(&f.0).size, 1);
assert!(Faces::try_load(&f.0, &f.0).is_err());
assert_eq!(Faces::load(&f.0, &f.0).size, 1);
}
#[test]
fn a_pack_without_a_face_keeps_vanillas_and_its_own_face_wins() {
let vanilla = Fixture::new(4);
// A pack that replaces one face of the cube: the other five are
// still the game's, as the bottom of the pack stack provides them.
let pack = Fixture::bare();
pack.write(2, 4, 4);
let faces = Faces::try_load(&pack.0, &vanilla.0).unwrap();
assert_eq!(faces.size, 4);
assert_eq!(faces.pixels.len(), 6 * 4 * 4 * 4);
// Each face's red channel is its index (`face` above), and the cube
// holds them in FACE_ORDER: layer 5 is face 2, the pack's, and layer 4
// is face 0, which only the game's resources have.
let red = |layer: usize, texel: usize| faces.pixels[(layer * 16 + texel) * 4];
assert_eq!(red(5, 0), 2);
assert_eq!(red(4, 0), 0);
}
#[test]
fn a_missing_cube_names_every_path_it_tried() {
let vanilla = Fixture::bare();
let pack = Fixture::bare();
let error = Faces::try_load(&pack.0, &vanilla.0)
.unwrap_err()
.to_string();
for root in [&pack, &vanilla] {
let path = root.background().join("panorama_1.png");
assert!(error.contains(&path.display().to_string()), "{error}");
}
// No layer completes the cube: the one-pixel grey placeholder, and the
// window still has a title.
let fallback = Faces::load(&pack.0, &vanilla.0);
assert_eq!((fallback.size, fallback.pixels.len()), (1, 24));
}
#[test]
+6 -4
View File
@@ -156,7 +156,8 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
// menu, by how much.
let covered = f.a > 0.0;
let ui = select(f.a, 0.0, f.a < 1.5 / 255.0);
let hand = covered && f.a < 1.5 / 255.0;
let ui = select(f.a, 0.0, hand);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(max(c, vec3(0.0)), vec3(2.2));
let w = textureLoad(world, at, 0);
@@ -164,10 +165,11 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
// drawn over them since: grey text over the white sun still looks like the sun
// darkened, but it raised the frame's alpha. Its band of 0.175 is the tier, the
// lower the brighter, and where in the band how much (hdr_mark).
// lower the brighter, and where in the band how much (hdr_mark). The hand
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
var lifted = false;
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && !covered
&& shows_world(c, w.rgb) {
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75
&& (hand || (!covered && shows_world(c, w.rgb))) {
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
let tier = 3u - band;
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
+47 -5
View File
@@ -1357,7 +1357,7 @@ impl Mesher<'_> {
},
)
};
let vertices = |tint: Option<u32>| {
let vertices = |tint: Option<u32>, uv: [[f32; 2]; 4]| {
std::array::from_fn::<_, 4, _>(|i| {
let g = gray[i] as u32;
let rgb = match tint {
@@ -1368,16 +1368,16 @@ impl Mesher<'_> {
(
TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
q.uv[i],
uv[i],
0,
emitting(pack_rgb(rgb), tier),
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
),
q.uv[i],
uv[i],
)
})
};
let corners = vertices(tint);
let corners = vertices(tint, q.uv);
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
// solid layer whatever their sprite.
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
@@ -1426,6 +1426,15 @@ impl Mesher<'_> {
Pick::Tile(t) => self.translucent(tile(t)),
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
};
// Overlay tiles face the standard way (Continuity: "overlay textures always have the
// standard orientation"), whatever turns the model's own UVs (dirt's and sand's random
// rotations): the cube's UVs at the quad's corners.
let standard = q.positions.map(|p| standard_uv(q.facing, p));
// Over a `ctm_compact` face, the same pieces: the same depths and sort centres.
let cuts = match pick {
Pick::Quarters(_) => cuts(&corners),
_ => Cuts([0.0, 1.0, 1.0, 1.0], 2),
};
for overlay in overlays[..count].iter().flatten().copied() {
let tint = overlay.tint.and_then(|(index, block)| {
let info = block
@@ -1438,7 +1447,14 @@ impl Mesher<'_> {
translucent: overlay.translucent || face_translucent,
opaque: false,
};
self.quad_as(out, false, tile, vertices(tint).map(|c| c.0), false);
let whole = vertices(tint, standard);
for s in cuts.windows(2) {
for t in cuts.windows(2) {
let piece = [(s[0], t[0]), (s[1], t[0]), (s[1], t[1]), (s[0], t[1])]
.map(|(s, t)| bilinear(&whole, s, t).0);
self.quad_as(out, false, tile, piece, false);
}
}
}
}
/// One terrain quad with sprite `tile`, in the pass its alpha needs (`solid_leaves`: no
@@ -1548,6 +1564,19 @@ fn cuts(c: &[(TerrainVertex, [f32; 2]); 4]) -> Cuts {
}
out
}
/// The UV a vanilla cube's face gives the block-local point `p` (`BlockElement`'s default UVs),
/// in sprite units: up is north on the top face, south on the bottom one, and up on the sides.
fn standard_uv(facing: horizon_assets::model::Direction, [x, y, z]: [f32; 3]) -> [f32; 2] {
use horizon_assets::model::Direction as D;
match facing {
D::Down => [x, 1.0 - z],
D::Up => [x, z],
D::North => [1.0 - x, 1.0 - y],
D::South => [x, 1.0 - y],
D::West => [z, 1.0 - y],
D::East => [1.0 - z, 1.0 - y],
}
}
/// The quarter of the sprite (top-left, top-right, bottom-left, bottom-right) a piece of a
/// quad lies in, by its UV centre (v grows downward).
fn quarter_of(piece: &[(TerrainVertex, [f32; 2]); 4]) -> usize {
@@ -1658,4 +1687,17 @@ mod quarter_tests {
[0, 1, 2, 3]
);
}
#[test]
fn overlays_take_the_cubes_own_uvs() {
use horizon_assets::model::Direction as D;
// A top face: the north-west corner is the sprite's top-left, so a tile's top edge is
// north whatever the model's turn.
assert_eq!(standard_uv(D::Up, [0.0, 1.0, 0.0]), [0.0, 0.0]);
assert_eq!(standard_uv(D::Up, [1.0, 1.0, 1.0]), [1.0, 1.0]);
// A north face seen from the north: its top-left is the east top corner.
assert_eq!(standard_uv(D::North, [1.0, 1.0, 0.0]), [0.0, 0.0]);
assert_eq!(standard_uv(D::East, [1.0, 0.0, 1.0]), [0.0, 1.0]);
assert_eq!(standard_uv(D::Down, [0.0, 0.0, 1.0]), [0.0, 0.0]);
}
}
+165
View File
@@ -2073,6 +2073,10 @@ pub fn tnt_to_nbt(
}
.tag(raw);
put(&mut tag, "fuse", Tag::Short(fuse as i16));
// PrimedTnt writes its block state (a summoned `block_state` is kept).
if tag.get("block_state").is_none() {
put(&mut tag, "block_state", string("minecraft:tnt"));
}
tag
}
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
@@ -2485,6 +2489,122 @@ pub fn firework_rocket_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<R
})
}
/// What `FireworkRocketEntity.addAdditionalSaveData` writes, over `Projectile`'s
/// Owner and LeftOwner.
pub struct RocketSave<'a> {
pub position: Vec3,
pub motion: Vec3,
pub life: i32,
pub lifetime: i32,
pub item: &'a crate::items::ItemStack,
pub angled: bool,
pub owner: Option<[i32; 4]>,
pub left_owner: bool,
}
pub fn firework_rocket_to_nbt(r: &RocketSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let mut tag = Base {
id: "minecraft:firework_rocket",
uuid,
position: r.position,
motion: r.motion,
rotation: [0.0, 0.0],
on_ground: false,
fall_distance: 0.0,
}
.tag(raw);
for (key, value) in [
("Life", Tag::Int(r.life)),
("LifeTime", Tag::Int(r.lifetime)),
("FireworksItem", item_to_nbt(r.item)),
("ShotAtAngle", Tag::Byte(i8::from(r.angled))),
("HasBeenShot", Tag::Byte(1)),
] {
put(&mut tag, key, value);
}
if r.left_owner {
put(&mut tag, "LeftOwner", Tag::Byte(1));
}
if let Some(owner) = r.owner {
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
}
tag
}
/// A thrown trident as `AbstractArrow` + `ThrownTrident` save it.
pub struct TridentSave<'a> {
pub position: Vec3,
pub motion: Vec3,
pub yaw: f32,
pub pitch: f32,
pub in_ground: bool,
pub life: i32,
pub shake: u32,
pub dealt_damage: bool,
pub creative_only: bool,
pub item: &'a crate::items::ItemStack,
}
pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let mut tag = Base {
id: "minecraft:trident",
uuid,
position: t.position,
motion: t.motion,
rotation: [t.yaw, t.pitch],
on_ground: false,
fall_distance: 0.0,
}
.tag(raw);
for (key, value) in [
("life", Tag::Short(t.life as i16)),
("shake", Tag::Byte(t.shake as i8)),
("inGround", Tag::Byte(i8::from(t.in_ground))),
// AbstractArrow.Pickup: 0 disallowed, 1 allowed, 2 creative only.
("pickup", Tag::Byte(if t.creative_only { 2 } else { 1 })),
("damage", Tag::Double(2.0_f64.to_bits())),
("HasBeenShot", Tag::Byte(1)),
("crit", Tag::Byte(0)),
("PierceLevel", Tag::Byte(0)),
("SoundEvent", string("minecraft:item.trident.hit_ground")),
("item", item_to_nbt(t.item)),
("DealtDamage", Tag::Byte(i8::from(t.dealt_damage))),
] {
put(&mut tag, key, value);
}
tag
}
/// (position, motion, yaw, pitch, in ground, life, shake, dealt damage,
/// creative only, the trident item)
pub struct TridentNbt {
pub position: Vec3,
pub motion: Vec3,
pub yaw: f32,
pub pitch: f32,
pub in_ground: bool,
pub life: i32,
pub shake: u32,
pub dealt_damage: bool,
pub creative_only: bool,
pub item: crate::items::ItemStack,
}
pub fn trident_from_nbt(tag: &Tag) -> Option<TridentNbt> {
let [yaw, pitch] = rotation(tag);
Some(TridentNbt {
position: position(tag)?,
motion: motion(tag),
yaw,
pitch,
in_ground: flag(tag, "inGround"),
life: number(tag.get("life")).unwrap_or(0) as i32,
shake: number(tag.get("shake")).unwrap_or(0).max(0) as u32,
dealt_damage: flag(tag, "DealtDamage"),
creative_only: number(tag.get("pickup")) == Some(2),
item: tag
.get("item")
.and_then(item_from_nbt)
.unwrap_or_else(|| crate::items::ItemStack::new("minecraft:trident", 1)),
})
}
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
#[allow(clippy::too_many_arguments)]
pub fn falling_block_to_nbt(
@@ -3058,6 +3178,51 @@ mod tests {
use super::*;
use crate::items::ItemStack;
/// A saved rocket and trident come back with their own Life, LifeTime,
/// flags and position (the keys vanilla's addAdditionalSaveData writes).
#[test]
fn rocket_and_trident_round_trip() {
let item = ItemStack::new("minecraft:firework_rocket", 1);
let tag = firework_rocket_to_nbt(
&RocketSave {
position: Vec3::new(1.5, 70.0, -3.25),
motion: Vec3::new(0.0, 0.5, 0.0),
life: 7,
lifetime: 31,
item: &item,
angled: true,
owner: None,
left_owner: false,
},
[1, 2, 3, 4],
None,
);
let r = firework_rocket_from_nbt(&tag, None).unwrap();
assert_eq!((r.life, r.lifetime, r.angled), (7, 31, true));
assert_eq!(r.position, Vec3::new(1.5, 70.0, -3.25));
let trident = ItemStack::new("minecraft:trident", 1);
let tag = trident_to_nbt(
&TridentSave {
position: Vec3::new(2.0, 64.0, 2.0),
motion: Vec3::new(0.0, -0.25, 0.0),
yaw: 10.0,
pitch: 20.0,
in_ground: true,
life: 9,
shake: 3,
dealt_damage: true,
creative_only: true,
item: &trident,
},
[1, 2, 3, 4],
None,
);
let t = trident_from_nbt(&tag).unwrap();
assert_eq!((t.in_ground, t.life, t.shake), (true, 9, 3));
assert_eq!((t.dealt_damage, t.creative_only), (true, true));
assert_eq!((t.yaw, t.pitch), (10.0, 20.0));
}
/// The item, the spawn delay and the fields Horizon doesn't model come
/// back; a compound without an item is no spawner.
#[test]
+115 -44
View File
@@ -1911,7 +1911,7 @@ impl UiResources {
pub fn load(root: &std::path::Path, defaults: &std::path::Path) -> Result<Self> {
Ok(Self {
atlas: HudAtlas::build_over(root, defaults)?,
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(root)),
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(root, defaults)),
})
}
/// What a server's game holds, since it draws nothing: the font, which books
@@ -2243,6 +2243,11 @@ struct Game<'a> {
/// The cursor icon the window shows (`widgets::take_cursor` each frame).
cursor_icon: horizon_client::widgets::Cursor,
captured: bool,
/// The window held the pointer when it lost focus: the system lets a
/// grabbed pointer go with the focus, and a window that comes back still
/// wanting it takes it again (`MouseHandler.grabMouse`, whose grab
/// outlives the window's focus).
regrab_on_refocus: bool,
/// Only the local player owning keyboard and mouse changes the native pointer.
keyboard_mouse: bool,
/// This view's corner in its shared window; cursor warps include the offset.
@@ -2289,7 +2294,7 @@ struct Game<'a> {
/// Horizon Extras' dynamic lights this frame (`dynamic_lights.rs`), and how brightly each
/// item glows (built the first time the extra is on).
dynamic_lights: Vec<[f32; 4]>,
luminance: Option<horizon_client::dynamic_light::Luminance>,
luminance: std::cell::OnceCell<horizon_client::dynamic_light::Luminance>,
/// Name tags drawn over everything (`RenderPipelines.TEXT_SEE_THROUGH`).
see_through_vertices: Vec<Vertex>,
/// The first-person player's model, which only a native pack's shadow pass draws.
@@ -3667,32 +3672,72 @@ impl Game<'_> {
self.dropped.pop();
}
}
/// The block entity at `pos` saved with its id and position
/// (`saveWithFullMetadata`), or the saved one Horizon keeps as loaded:
/// what a broken block's loot sees as `LootContextParams.BLOCK_ENTITY`.
fn loot_block_entity(&self, pos: [i32; 3], block: &str) -> Option<horizon_world::nbt::Tag> {
let key = (pos[0], pos[1], pos[2]);
match self.world.block_entities.get(&key) {
Some(entity) => Some(horizon_client::save::encode_block_entity(
key, entity, block,
)),
None => spill::saved_entity(&self.world, pos),
}
}
/// Where a loot roll at `origin` happens, for a mod's own loot kinds:
/// the block entity it may copy from, and this level as
/// `LootContext.getLevel` gives it (`Level.isRaining`: rain above 0.2;
/// `isThundering`: thunder above 0.9).
fn loot_place_at(
&self,
origin: [f64; 3],
block_entity: Option<horizon_world::nbt::Tag>,
) -> horizon_world::loot::NativeLootPlace {
let (rain, thunder) = self.world.weather.levels(1.0);
horizon_world::loot::NativeLootPlace {
origin: Some(origin),
block_entity,
level: Some(horizon_world::loot::NativeLootLevel {
dimension: self.dim.id().to_owned(),
game_time: self.world.game_time,
day_time: self.world.day_time,
raining: rain > 0.2,
thundering: thunder > 0.9,
}),
}
}
/// `Block.getDrops` for a block a player breaks: the block's loot table
/// with the player as `this`, drawing from its server random sequence
/// (`LootContext.Builder.create`), else the level random.
/// (`LootContext.Builder.create`), else the level random. The roll's
/// origin is the block's centre and `block_entity` the entity it had.
fn block_loot_at(
&mut self,
pos: [i32; 3],
state: BlockStateId,
held: Option<&ItemStack>,
block_entity: Option<horizon_world::nbt::Tag>,
) -> Vec<ItemStack> {
let registry = self.pack.assets.registry;
let dynamic = self.pending.dynamic(pos);
let mut drops = match world_items!(self).block_loot_sequence(registry, state) {
Some(sequence) => {
let random = self.rand_sequences.get(&sequence, self.level.seed);
self.res
.items()
.block_drops_by_player(registry, state, held, &dynamic, random)
}
None => world_items!(self).block_drops_by_player(
registry,
state,
held,
&dynamic,
&mut self.world.random,
),
};
let place = self.loot_place_at(pos.map(|v| f64::from(v) + 0.5), block_entity);
let mut drops =
horizon_world::loot::with_native_loot_place(place, || {
match world_items!(self).block_loot_sequence(registry, state) {
Some(sequence) => {
let random = self.rand_sequences.get(&sequence, self.level.seed);
self.res
.items()
.block_drops_by_player(registry, state, held, &dynamic, random)
}
None => world_items!(self).block_drops_by_player(
registry,
state,
held,
&dynamic,
&mut self.world.random,
),
}
});
let player = self.native_acting_id();
self.native_block_drops(pos, state, Some(player), held, None, &mut drops);
drops
@@ -5530,6 +5575,11 @@ impl Game<'_> {
}
// The block entity as it was, for what the drops copy from it.
let key = (pos[0], pos[1], pos[2]);
let loot_entity = if self.creative {
None
} else {
self.loot_block_entity(pos, &facts.name)
};
if let Some(entity) = self.world.block_entities.get(&key).cloned() {
let name = self.world.block_names.get(&key).cloned();
self.pending.record(pos, &entity, &facts.name, name);
@@ -5594,7 +5644,7 @@ impl Game<'_> {
if let Some(p) = self.player.as_mut() {
p.cause_food_exhaustion(0.005);
}
let drops = self.block_loot_at(pos, state, held.as_ref());
let drops = self.block_loot_at(pos, state, held.as_ref(), loot_entity);
let silk = held
.as_ref()
.is_some_and(|h| h.enchantment_level("minecraft:silk_touch") > 0);
@@ -7901,6 +7951,17 @@ impl Game<'_> {
self.capture(true);
}
}
/// The window came back into focus after holding the pointer: the system
/// dropped the grab with the focus, and vanilla's grab outlives it
/// (`MouseHandler.mouseGrabbed` stays true while GLFW keeps the cursor
/// mode), so the game takes the pointer again without waiting for a
/// click. A screen that is open keeps it free, as releasing the pointer
/// for that screen did: it is the screen, not the focus, that shows it.
fn regrab_on_refocus(&mut self) {
if std::mem::take(&mut self.regrab_on_refocus) && self.screen == Screen::Playing {
self.capture(true);
}
}
fn draw_item(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
// `GuiGraphics.renderItem`: the resolved item model (tints, foil and
// special renderers included), then the decorations.
@@ -16787,31 +16848,40 @@ impl Game<'_> {
WindowEvent::Focused(focused) => {
self.focused = focused;
self.focus_lost = (!focused).then(Instant::now);
if !focused && self.control.is_none() {
self.keys.clear();
// Latched sneak/sprint live in `toggles`, not in the
// physical keys: put them back so a short alt-tab
// (no pause screen) does not drop them. Only a mapping
// whose toggle option is on is latched; a held one has
// no state to keep, and an unfocused window never gets
// its key release, so leaving it down would keep the
// player sneaking or sprinting until the key is tapped.
for (slot, needs_toggle) in [
self.settings.toggle_crouch,
self.settings.toggle_sprint,
self.settings.toggle_attack,
self.settings.toggle_use,
]
.into_iter()
.enumerate()
{
self.toggles.0[slot].set_down(false, needs_toggle);
if focused {
self.regrab_on_refocus();
} else {
// A held pointer goes with the focus, wherever the
// system keeps the grab: the window asks for it back
// when the focus returns (`regrab_on_refocus`).
let held = self.captured;
if self.control.is_none() {
self.keys.clear();
// Latched sneak/sprint live in `toggles`, not in the
// physical keys: put them back so a short alt-tab
// (no pause screen) does not drop them. Only a mapping
// whose toggle option is on is latched; a held one has
// no state to keep, and an unfocused window never gets
// its key release, so leaving it down would keep the
// player sneaking or sprinting until the key is tapped.
for (slot, needs_toggle) in [
self.settings.toggle_crouch,
self.settings.toggle_sprint,
self.settings.toggle_attack,
self.settings.toggle_use,
]
.into_iter()
.enumerate()
{
self.toggles.0[slot].set_down(false, needs_toggle);
}
self.sync_toggle(0);
self.sync_toggle(1);
self.left = false;
self.right = false;
self.capture(false);
}
self.sync_toggle(0);
self.sync_toggle(1);
self.left = false;
self.right = false;
self.capture(false);
self.regrab_on_refocus = held;
}
}
WindowEvent::CursorMoved { position, .. } => {
@@ -17988,6 +18058,7 @@ impl<'a> Game<'a> {
cursor: (0.0, 0.0),
cursor_icon: horizon_client::widgets::Cursor::Arrow,
captured: false,
regrab_on_refocus: false,
keyboard_mouse: true,
view_at: [0, 0],
split_hud: Default::default(),
@@ -18012,7 +18083,7 @@ impl<'a> Game<'a> {
last_device_recovery: None,
entity_vertices: Vec::new(),
dynamic_lights: Vec::new(),
luminance: None,
luminance: Default::default(),
see_through_vertices: Vec::new(),
shadow_only_vertices: Default::default(),
entity_spans: Default::default(),
@@ -1286,6 +1286,7 @@ impl Game<'_> {
self.dim == super::Dim::Nether,
)),
glint: self.glint(),
tier: 0,
};
let profile = stack
.extra
+10 -1
View File
@@ -723,6 +723,10 @@ impl Game<'_> {
let mut round = self.native_tick_round();
// A server player's boat is driven by its client (lan_vehicles.rs).
let driven = self.guest_vehicles();
// EntityTickEvent.Post comes once AbstractBoat.tick is over, after the
// passes below (lava, ejection, bubbles, contacts): the ticked boats,
// in order, kept only while a mod listens (no list without mods).
let mut ticked = Vec::new();
for i in 0..self.boats.len() {
// A mod's hook may have removed boats.
if i >= self.boats.len() {
@@ -759,7 +763,9 @@ impl Game<'_> {
};
let b = self.boats[i].clone();
self.boats[i] = self.tick_boat(b, false);
self.native_tick_post_one(&round, id, &kind);
if round.listening() {
ticked.push((id, kind));
}
}
// AbstractBoat.tick's applyEffectsFromBlocks, on the server for every
// boat, the client-driven ones too.
@@ -793,6 +799,9 @@ impl Game<'_> {
self.boat_bubble_tick(i);
self.boat_contacts(i);
}
for (id, kind) in ticked {
self.native_tick_post_one(&round, id, &kind);
}
}
/// `Entity.canSimulateMovement`: for a boat (`AUTHORITATIVE_SIDE`) a
/// server leaves the boat its player drives to that player's client, and a
+2 -1
View File
@@ -2244,7 +2244,8 @@ impl Game<'_> {
self.tnts.len()
+ self.falling.len()
+ self.creatures.projectiles.len()
+ self.rockets.len(),
+ self.rockets.len()
+ self.tridents.len(),
)
}
/// BlockStateArgument -> registry state.
+5 -1
View File
@@ -1466,7 +1466,11 @@ impl Game<'_> {
mob_drops: self.ui.rules.bool("mob_drops"),
world_seed: self.level.seed,
};
let (items, skulls) = drops::die(m, &death, &mut self.rand_sequences);
// LootContextParams.ORIGIN of a death: the mob's position.
let place = self.loot_place_at([m.position.x, m.position.y, m.position.z], None);
let (items, skulls) = horizon_world::loot::with_native_loot_place(place, || {
drops::die(m, &death, &mut self.rand_sequences)
});
if skulls && let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker) {
// Creeper.droppedSkulls: one head per charged explosion.
self.combat.charged_exploders.retain(|c| *c != id);
@@ -7,6 +7,11 @@ use horizon_client::dynamic_light::{self, BURNING, Luminance, Source};
impl Game<'_> {
/// Fills `dynamic_lights` for the frame: the player's and mobs' held items and fire, and
/// glowing dropped items, the nearest [`dynamic_light::MAX`] of them.
/// How brightly each item glows, built on first use.
pub(super) fn luminance(&self) -> &Luminance {
self.luminance
.get_or_init(|| Luminance::new(self.res.facts(), self.pack.assets.registry))
}
pub(super) fn gather_dynamic_lights(&mut self, camera: DVec3, alpha: f64) {
let mut out = std::mem::take(&mut self.dynamic_lights);
out.clear();
@@ -14,10 +19,7 @@ impl Game<'_> {
self.dynamic_lights = out;
return;
}
if self.luminance.is_none() {
self.luminance = Some(Luminance::new(self.res.facts(), self.pack.assets.registry));
}
let lum = self.luminance.as_ref().expect("built above");
let lum = self.luminance();
let held = |item: Option<&str>| item.map_or(0, |i| lum.of(i));
let mut sources = Vec::new();
if let Some(p) = &self.player
+15 -9
View File
@@ -1112,16 +1112,22 @@ impl Game<'_> {
// tables' survives_explosion and explosion_decay draw from their
// random sequence.
let decay = (interaction == BlockInteraction::DestroyWithDecay).then_some(radius);
let mut drops = world_items!(self).block_drops_exploded(
self.pack.assets.registry,
state,
None,
&dynamic,
&mut self.world.random,
Some((&mut self.rand_sequences, self.level.seed)),
decay,
false,
let place = self.loot_place_at(
pos.map(|v| f64::from(v) + 0.5),
self.loot_block_entity(pos, &name),
);
let mut drops = horizon_world::loot::with_native_loot_place(place, || {
world_items!(self).block_drops_exploded(
self.pack.assets.registry,
state,
None,
&dynamic,
&mut self.world.random,
Some((&mut self.rand_sequences, self.level.seed)),
decay,
false,
)
});
self.native_block_drops(pos, state, None, None, Some(radius), &mut drops);
for mut stack in drops {
if &*stack.item == "minecraft:air" || stack.count == 0 {
@@ -77,6 +77,32 @@ impl Rocket {
item,
}
}
/// `FireworkRocketEntity.addAdditionalSaveData`; the owner only when it is `player`.
pub(super) fn to_nbt(
&self,
uuid: [i32; 4],
player: Option<[i32; 4]>,
raw: Option<&horizon_world::nbt::Tag>,
) -> horizon_world::nbt::Tag {
horizon_client::entity_nbt::firework_rocket_to_nbt(
&horizon_client::entity_nbt::RocketSave {
position: self.position,
motion: self.velocity,
life: self.life,
lifetime: self.lifetime,
item: &self.item,
angled: self.angled,
owner: if matches!(self.owner, Some(Who::Player)) {
player
} else {
None
},
left_owner: self.left_owner,
},
uuid,
raw,
)
}
/// A rocket loaded from its NBT (`/summon`, a saved chunk).
pub(super) fn from_nbt(r: RocketNbt) -> Self {
let item = r
+19 -5
View File
@@ -294,14 +294,19 @@ impl Game<'_> {
seed: i32,
) -> ItemRenderState {
let level = self.item_level();
self.pack.item_renderer.resolve(
let mut state = self.pack.item_renderer.resolve(
&item_render::stack_view(stack),
display,
owner,
Some(&level),
seed,
&self.item_env(),
)
);
state.hdr_tier = self.luminance().hdr_tier(&stack.item);
if state.hdr_tier > 0 && self.renderer.as_ref().is_some_and(|r| r.hdr_on()) {
state.hdr_light = self.luminance().of(&stack.item);
}
state
}
/// `GuiGraphics.renderItem(player, level, stack, x, y, 0)`.
pub(super) fn draw_item_model(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
@@ -362,9 +367,10 @@ impl Game<'_> {
) {
let mut sink = WorldSink {
out,
light,
lights: Some(lights),
light: item_hdr_light(state, light),
lights: (state.hdr_light == 0).then_some(lights),
glint: self.glint(),
tier: state.hdr_tier,
};
state.submit(
&self.pack.item_renderer,
@@ -384,9 +390,10 @@ impl Game<'_> {
) {
let mut sink = WorldSink {
out,
light,
light: item_hdr_light(state, light),
lights: None,
glint: self.glint(),
tier: state.hdr_tier,
};
state.submit(
&self.pack.item_renderer,
@@ -482,6 +489,13 @@ impl Game<'_> {
}
}
/// The packed light of an item (sky low, block above it: `vertex_light`, entity.wgsl), its block
/// light raised to what it glows with in HDR output.
fn item_hdr_light(state: &ItemRenderState, light: u32) -> u32 {
let block = ((light >> 8) & 0xff).max(u32::from(state.hdr_light) * 16);
(light & !0xff00) | block << 8
}
#[cfg(test)]
mod tests {
use super::*;
@@ -95,6 +95,9 @@ pub(super) struct GuestPlay {
pub(super) lectern: Option<super::lan_stations::GuestLectern>,
/// The menu the mods last heard this guest open (`tick_player_menus`).
pub(super) menu_heard: Option<super::native_menus::HeardMenu>,
/// The equipment the mods last heard this guest wear (`native_players_equipment`), by
/// `EquipmentSlot`: main hand, off hand, feet, legs, chest, head.
pub(super) equipment_heard: Option<Box<[Option<crate::items::ItemStack>; 6]>>,
/// What the guest's body was given for its effects and enchantments.
potions: super::potions::BodyPotions,
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
@@ -3161,6 +3164,11 @@ impl Game<'_> {
// count) falls instead of staying up for a player who is gone.
self.guest_container_closed(host, conn);
host.hub.close_container(conn);
// PlayerList.respawn builds a new `ServerPlayer`, whose `lastEquipmentItems` starts
// empty: the mods hear each worn item again from empty (`native_players_equipment`).
if let Some(play) = host.play.get_mut(&conn) {
play.equipment_heard = None;
}
// PlayerList.respawn: a server's player comes back in its respawn
// point's level: the Overworld, where the world spawn and beds are,
// or another one's anchor or forced point, which its block resolves
@@ -3306,6 +3314,7 @@ impl Game<'_> {
self.lan_containers_tick(host);
self.tick_lan_mount_menus(host);
self.tick_player_menus(host);
self.native_players_equipment(host);
self.announce_guest_advancements(host);
let conns: Vec<u32> = host
.hub
+34 -6
View File
@@ -102,6 +102,11 @@ pub(super) struct Minecart {
pub flipped: bool,
pub(super) on_ground: bool,
horizontal_collision: bool,
/// The horizontal speed the cart wanted when the collision stopped it:
/// `MinecartTNT.tick` reads the delta after the move, which a cart on a
/// rail keeps (`AbstractMinecart.moveAlongTrack` re-applies the track's
/// velocity), so a cart that ran into a block still counts as moving.
collision_speed: f64,
fall_distance: f64,
pub hurt_time: i32,
pub hurt_dir: i32,
@@ -256,6 +261,7 @@ impl Minecart {
flipped: false,
on_ground: false,
horizontal_collision: false,
collision_speed: 0.0,
fall_distance: 0.0,
hurt_time: 0,
hurt_dir: 1,
@@ -485,6 +491,9 @@ impl Game<'_> {
c.on_ground = delta.y < 0.0 && moved.y > delta.y;
c.horizontal_collision =
(moved.x - delta.x).abs() > 1.0e-7 || (moved.z - delta.z).abs() > 1.0e-7;
if c.horizontal_collision {
c.collision_speed = delta.x * delta.x + delta.z * delta.z;
}
if (moved.x - delta.x).abs() > 1.0e-7 {
c.velocity.x = 0.0;
}
@@ -902,6 +911,11 @@ impl Game<'_> {
let v = c.velocity;
let fall = c.fall_distance;
self.move_cart(c, v);
// `comeOffTrack` moves the cart and keeps the delta the move resolved
// (then scales it by the air drag), so a cart an obstacle stopped here
// is not moving any more and `MinecartTNT.tick` does not detonate it -
// unlike the rail paths, which re-apply the track's velocity.
c.collision_speed = 0.0;
if !c.on_ground {
c.velocity = Vec3::new(
c.velocity.x * 0.95,
@@ -983,12 +997,9 @@ impl Game<'_> {
self.explode_cart(c, speed);
return false;
}
if c.horizontal_collision {
let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z;
if speed >= f64::from(0.01_f32) {
self.explode_cart(c, speed);
return false;
}
if tnt_ignites_by_collision(c.horizontal_collision, c.collision_speed) {
self.explode_cart(c, c.collision_speed);
return false;
}
}
// MinecartSpawner.tick: `BaseSpawner.serverTick` at the cart's block.
@@ -2252,8 +2263,25 @@ impl Minecart {
}
}
/// `MinecartTNT.tick`'s collision detonation: a cart still moving at a tenth
/// of a block a tick or more when a block stops it blows up.
fn tnt_ignites_by_collision(horizontal_collision: bool, speed_sqr: f64) -> bool {
horizontal_collision && speed_sqr >= 0.009_999_999_776_482_582
}
#[cfg(test)]
mod tests {
/// The collision detonation of a TNT minecart needs both the collision and
/// a cart that was still moving: a nudge below a tenth of a block a tick
/// does not set it off, and a fast cart on an open track does not either.
#[test]
fn a_tnt_cart_detonates_only_when_a_collision_stops_a_moving_one() {
assert!(tnt_ignites_by_collision(true, 0.01));
assert!(tnt_ignites_by_collision(true, 0.36), "three tenths a tick");
assert!(!tnt_ignites_by_collision(true, 0.0), "stopped at the wall");
assert!(!tnt_ignites_by_collision(true, 0.009), "a nudge");
assert!(!tnt_ignites_by_collision(false, 0.36), "nothing in the way");
}
use super::*;
#[test]
@@ -654,6 +654,7 @@ impl Game<'_> {
None
},
glint: self.glint(),
tier: 0,
};
let profile = stack
.extra
@@ -287,8 +287,16 @@ impl Game<'_> {
.map(|g| g.conn)
.collect();
for conn in conns {
// `AbstractContainerScreen.tick` closes the container once the player is no longer
// alive, so a death closes the menu now (the lid falls, the mods hear Close), not at
// the respawn.
let dead = host.hub.guest(conn).is_some_and(|g| g.dead);
if dead && host.hub.guest(conn).is_some_and(|g| g.menu.is_some()) {
self.guest_container_closed(host, conn);
host.hub.close_container(conn);
}
// Compared as borrowed: nothing is built each tick while it stays open.
let now = open_menu_of(host, conn);
let now = if dead { None } else { open_menu_of(host, conn) };
let heard = host.play.get(&conn).and_then(|p| p.menu_heard.as_ref());
if now == heard.map(|h| (h.window, h.menu.as_str(), h.pos)) {
continue;
@@ -55,6 +55,57 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// `LivingEntity.collectEquipmentChanges` for the server's players (they are living entities
/// too): each slot whose stack differs from what the mods last heard is told, the previous
/// stack with it, so a hotbar selection that moves the main hand counts. A player new to the
/// level counts from empty, as for mobs, and only while a mod listens.
pub(super) fn native_players_equipment(&mut self, host: &mut super::lan::LanHost) {
let mut listened = false;
self.native_living_equipment_listened(&mut listened);
if !listened {
return;
}
let mut changes = Vec::new();
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
let now: [Option<crate::items::ItemStack>; 6] = [
g.inventory.get(g.selected).cloned().flatten(),
g.offhand.clone(),
g.armor[3].clone(),
g.armor[2].clone(),
g.armor[1].clone(),
g.armor[0].clone(),
];
let Some(play) = host.play.get_mut(&g.conn) else {
continue;
};
let heard = play.equipment_heard.get_or_insert_with(Default::default);
if **heard == now {
continue;
}
for (slot, name) in PLAYER_SLOTS.into_iter().enumerate() {
if now[slot] != heard[slot] {
let from = heard[slot].take();
changes.push((g.entity_id as u32, name, from, now[slot].clone()));
}
}
**heard = now;
}
for (id, slot, from, to) in changes {
self.native_living_equipment_change(
id,
"minecraft:player",
slot,
from.as_ref(),
to.as_ref(),
);
}
}
}
/// The `EquipmentSlot` names of a player's equipment, in the order of `native_players_equipment`.
const PLAYER_SLOTS: [&str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
/// A mob as a native mod reads it.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
+1 -1
View File
@@ -454,7 +454,7 @@ pub(super) fn stage_pack(
let (render, atlas, panorama) = std::thread::scope(|s| {
let atlas =
s.spawn(|| HudAtlas::build_over(&root, vanilla_root).map_err(|e| e.to_string()));
let panorama = s.spawn(|| horizon_client::gfx::panorama::Faces::load(&root));
let panorama = s.spawn(|| horizon_client::gfx::panorama::Faces::load(&root, vanilla_root));
(assets.reload(&root), atlas.join(), panorama.join())
});
let render = Arc::new(render?);
+46
View File
@@ -317,6 +317,8 @@ impl Game<'_> {
| "minecraft:egg"
| "minecraft:ender_pearl"
| "minecraft:falling_block"
| "minecraft:firework_rocket"
| "minecraft:trident"
| "minecraft:end_crystal"
| "minecraft:ender_dragon"
) || horizon_client::projectiles::thrown_type(&kind).is_some())
@@ -434,6 +436,12 @@ impl Game<'_> {
});
}
}
"minecraft:trident" => {
if let Some(n) = entity_nbt::trident_from_nbt(&tag) {
self.tridents
.push(super::trident::ThrownTrident::from_nbt(n));
}
}
"minecraft:firework_rocket" => {
if let Some(r) = entity_nbt::firework_rocket_from_nbt(&tag, player_uuid) {
self.rockets.push(super::fireworks::Rocket::from_nbt(r));
@@ -1129,6 +1137,41 @@ impl Game<'_> {
out.entry(chunk_of(f.position)).or_default().push(tag);
}
}
for r in &self.rockets {
if keys.contains(&chunk_of(r.position)) {
let (uuid, raw) = anon(&self.persist, "minecraft:firework_rocket", r.position);
out.entry(chunk_of(r.position)).or_default().push(r.to_nbt(
uuid,
self.level.player_uuid,
raw.as_ref(),
));
}
}
for t in &self.tridents {
if keys.contains(&chunk_of(t.position)) {
let (uuid, raw) = anon(&self.persist, "minecraft:trident", t.position);
out.entry(chunk_of(t.position))
.or_default()
.push(t.to_nbt(uuid, raw.as_ref()));
}
}
for i in 0..self.tnts.len() {
let t = &self.tnts[i];
if keys.contains(&chunk_of(t.position)) {
let (id, at) = (t.id, t.position);
let uuid = self.uuid_for(id);
let t = &self.tnts[i];
let tag = entity_nbt::tnt_to_nbt(
t.position,
t.velocity,
t.on_ground,
t.fuse,
uuid,
self.persist.raw.get(&id),
);
out.entry(chunk_of(at)).or_default().push(tag);
}
}
let boats: Vec<usize> = (0..self.boats.len())
.filter(|&i| keys.contains(&chunk_of(self.boats[i].position)))
.collect();
@@ -1378,6 +1421,9 @@ impl Game<'_> {
!leaves || !gone(p.position)
});
self.falling.retain(|f| !gone(f.position));
self.rockets.retain(|r| !gone(r.position));
self.tridents.retain(|t| !gone(t.position));
self.tnts.retain(|t| !gone(t.position));
self.persist
.anon
.retain(|_, (at, _, _)| !keys.contains(&chunk_of(*at)));
@@ -40,6 +40,13 @@ enum Target {
ArmorStand(usize),
/// Primed TNT (`PrimedTnt`): slime piston bounce (#4550).
Tnt(usize),
/// Every other entity `getEntities` returns is pushed too: only
/// `PushReaction.IGNORE_ENTITY` (a marker armor stand) is spared.
Falling(usize),
Orb(usize),
Projectile(usize),
Rocket(usize),
Trident(usize),
}
/// `Entity.getStepHeight` of each body, read before the push.
@@ -360,6 +367,11 @@ impl Game<'_> {
targets.extend((0..self.carts.len()).map(Target::Cart));
targets.extend((0..self.armor_stands.len()).map(Target::ArmorStand));
targets.extend((0..self.tnts.len()).map(Target::Tnt));
targets.extend((0..self.falling.len()).map(Target::Falling));
targets.extend((0..self.orbs.len()).map(Target::Orb));
targets.extend((0..self.creatures.projectiles.len()).map(Target::Projectile));
targets.extend((0..self.rockets.len()).map(Target::Rocket));
targets.extend((0..self.tridents.len()).map(Target::Trident));
targets
}
@@ -468,6 +480,67 @@ impl Game<'_> {
supported: false,
})
}
Target::Falling(i) => {
let entity = &self.falling[i];
Some(Body {
bounds: horizon_client::falling_block::bounds(entity.position),
position: entity.position,
on_ground: false,
step_height: 0.0,
supported: false,
})
}
Target::Orb(i) => {
let entity = &self.orbs[i];
Some(Body {
bounds: entity.bounds(),
position: entity.position,
on_ground: entity.on_ground,
step_height: 0.0,
supported: false,
})
}
Target::Projectile(i) => {
let entity = &self.creatures.projectiles[i];
Some(Body {
bounds: entity.bounds(),
position: entity.position,
on_ground: false,
step_height: 0.0,
supported: false,
})
}
Target::Rocket(i) => {
let entity = &self.rockets[i];
Some(Body {
bounds: entity.bounds(),
position: entity.position,
on_ground: false,
step_height: 0.0,
supported: false,
})
}
Target::Trident(i) => {
let entity = &self.tridents[i];
Some(Body {
bounds: Aabb::new(
Vec3::new(
entity.position.x - 0.25,
entity.position.y,
entity.position.z - 0.25,
),
Vec3::new(
entity.position.x + 0.25,
entity.position.y + 0.5,
entity.position.z + 0.25,
),
),
position: entity.position,
on_ground: false,
step_height: 0.0,
supported: false,
})
}
}
}
@@ -585,6 +658,27 @@ impl Game<'_> {
self.armor_stands[i].position = self.armor_stands[i].position.add_vector(movement)
}
Target::Tnt(i) => self.tnts[i].position = self.tnts[i].position.add_vector(movement),
Target::Falling(i) => {
self.falling[i].position = self.falling[i].position.add_vector(movement)
}
Target::Orb(i) => {
let orb = &mut self.orbs[i];
orb.position = orb.position.add_vector(movement);
if movement.y != 0.0 {
orb.on_ground = movement.y < 0.0;
}
}
Target::Projectile(i) => {
let p = &mut self.creatures.projectiles[i];
p.position = p.position.add_vector(movement);
p.needs_sync = true;
}
Target::Rocket(i) => {
self.rockets[i].position = self.rockets[i].position.add_vector(movement)
}
Target::Trident(i) => {
self.tridents[i].position = self.tridents[i].position.add_vector(movement)
}
}
}
@@ -628,6 +722,28 @@ impl Game<'_> {
// on-ground Y * -0.5 friction that cancels the launch.
self.tnts[i].on_ground = false;
}
Target::Falling(i) => {
self.falling[i].velocity =
piston_push::bounce_velocity(movement, self.falling[i].velocity)
}
Target::Orb(i) => {
self.orbs[i].velocity =
piston_push::bounce_velocity(movement, self.orbs[i].velocity);
self.orbs[i].on_ground = false;
}
Target::Projectile(i) => {
let p = &mut self.creatures.projectiles[i];
p.velocity = piston_push::bounce_velocity(movement, p.velocity);
p.needs_sync = true;
}
Target::Rocket(i) => {
self.rockets[i].velocity =
piston_push::bounce_velocity(movement, self.rockets[i].velocity)
}
Target::Trident(i) => {
self.tridents[i].velocity =
piston_push::bounce_velocity(movement, self.tridents[i].velocity)
}
}
}
}
@@ -422,13 +422,8 @@ impl Game<'_> {
if self.pack_ids.is_none() {
return ([0; 2], [0; 2]);
}
if self.luminance.is_none() {
self.luminance = Some(horizon_client::dynamic_light::Luminance::new(
self.res.facts(),
self.pack.assets.registry,
));
}
let (Some(ids), Some(lum)) = (&self.pack_ids, &self.luminance) else {
let lum = self.luminance();
let Some(ids) = &self.pack_ids else {
return ([0; 2], [0; 2]);
};
let hands = [self.selected, super::shield::OFFHAND_SLOT].map(|slot| {
+36
View File
@@ -55,6 +55,42 @@ struct Catcher {
touches: bool,
}
impl ThrownTrident {
/// A trident loaded from its NBT (`AbstractArrow.readAdditionalSaveData`).
pub(super) fn from_nbt(n: horizon_client::entity_nbt::TridentNbt) -> Self {
let mut t = Self::remote(0, n.position, n.yaw, n.pitch);
t.loyalty = n.item.enchantment_level("minecraft:loyalty");
t.stack = n.item;
t.velocity = n.motion;
t.in_ground = n.in_ground;
t.in_ground_time = n.life.max(0) as u32;
t.shake = n.shake;
t.dealt_damage = n.dealt_damage;
t.creative_only = n.creative_only;
t
}
/// `AbstractArrow.addAdditionalSaveData` + `DealtDamage`.
pub(super) fn to_nbt(
&self,
uuid: [i32; 4],
raw: Option<&horizon_world::nbt::Tag>,
) -> horizon_world::nbt::Tag {
horizon_client::entity_nbt::trident_to_nbt(
&horizon_client::entity_nbt::TridentSave {
position: self.position,
motion: self.velocity,
yaw: self.yaw,
pitch: self.pitch,
in_ground: self.in_ground,
life: self.in_ground_time as i32,
shake: self.shake,
dealt_damage: self.dealt_damage,
creative_only: self.creative_only,
item: &self.stack,
},
uuid,
raw,
)
}
/// A trident a server sent (`handleAddEntity`): placed and moved by it.
pub(super) fn remote(id: u32, position: Vec3, yaw: f32, pitch: f32) -> Self {
Self {
+10 -1
View File
@@ -201,6 +201,11 @@ pub struct ItemRenderState {
pub layers: Vec<LayerRender>,
pub oversized_in_gui: bool,
pub animated: bool,
/// HDR output's tier of an item that glows (`dynamic_light::Luminance::hdr_tier`).
pub hdr_tier: u8,
/// In HDR output, the block light of an item that glows: its block's, as a placed one has
/// it, so a torch doesn't go dark in the shade while it shines. 0 for the rest.
pub hdr_light: u8,
}
impl ItemRenderState {
pub fn is_empty(&self) -> bool {
@@ -746,6 +751,8 @@ impl ItemRenderer {
layers,
oversized_in_gui: resolved.oversized_in_gui,
animated,
hdr_tier: 0,
hdr_light: 0,
}
}
/// `GrassColor.get` over the loaded colormap.
@@ -845,6 +852,8 @@ pub struct WorldSink<'a> {
pub lights: Option<(Vec3, Vec3)>,
/// The `GLINT_TEXTURING` sample for foil quads (`None` leaves them unglinted).
pub glint: Option<Glint>,
/// HDR output's tier (`ItemRenderState::hdr_tier`): rides in the layer's high bits.
pub tier: u8,
}
impl ItemSink for WorldSink<'_> {
fn quad(&mut self, q: ItemQuad) {
@@ -882,7 +891,7 @@ impl ItemSink for WorldSink<'_> {
normal: if q.cull_back { CULL_BACK } else { [0; 4] },
pos: p.to_array(),
uv,
layer: overlay | oit,
layer: overlay | oit | u32::from(self.tier) << 16,
color,
light,
glint_uv,
+35 -2
View File
@@ -285,12 +285,24 @@ pub fn name_line(stack: &ItemStack, translate: &dyn Fn(&str) -> Option<String>)
.unwrap_or_else(|| path.replace('_', " "))
}
/// ItemStack.getRarity: the ominous banner override, then the enchantment upgrade.
/// ItemStack.getRarity: the stack's `minecraft:rarity` (the item's default
/// unless a command or server set it; the ominous banner's uncommon), then
/// the enchantment upgrade.
pub fn rarity<'a>(stack: &ItemStack, default: &'a str) -> &'a str {
let set = match stack.data().and_then(|d| d.other("minecraft:rarity")) {
Some(Tag::String(s)) => match &*s.to_string_lossy() {
"common" => Some("common"),
"uncommon" => Some("uncommon"),
"rare" => Some("rare"),
"epic" => Some("epic"),
_ => None,
},
_ => None,
};
let rarity = if stack.data().is_some_and(|d| d.ominous_banner) {
"uncommon"
} else {
default
set.unwrap_or(default)
};
if stack.enchantments().is_empty() {
rarity
@@ -1327,6 +1339,27 @@ mod tests {
assert_eq!(rarity(&plain, "common"), "common");
}
/// `/give @a stick[rarity=epic,enchantments={aqua_affinity:1}]`: the
/// stack's rarity wins over the item's, and stays epic when enchanted.
#[test]
fn a_stacks_rarity_component_sets_its_rarity() {
let mut stick = ItemStack::new("minecraft:stick", 1);
stick
.data_mut()
.other
.push(("minecraft:rarity".into(), Tag::String("epic".into())));
assert_eq!(rarity(&stick, "common"), "epic");
stick.enchant("minecraft:aqua_affinity", 1);
assert_eq!(rarity(&stick, "common"), "epic");
let mut sword = ItemStack::new("minecraft:diamond_sword", 1);
sword
.data_mut()
.other
.push(("minecraft:rarity".into(), Tag::String("uncommon".into())));
sword.enchant("minecraft:sharpness", 1);
assert_eq!(rarity(&sword, "common"), "rare");
}
#[test]
fn name_line_translates_items_of_any_namespace() {
let translate = |key: &str| match key {
+1
View File
@@ -1571,6 +1571,7 @@ impl LootContext<'_> {
luck: 0.0,
biome: None,
seed: random.next_long(),
place: horizon_world::loot::current_native_loot_place(),
};
let mut holds = false;
horizon_world::loot::native_loot_condition(k, c, &ctx, &mut holds);
+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;
+135 -6
View File
@@ -8,13 +8,15 @@
//! maps are kept as found (structure search is not run); conditions that
//! need an entity, tool or damage source fail when the context has none, as
//! they do for containers.
use crate::lazy_json::LazyJson;
use crate::{lazy_json::LazyJson, nbt::Tag};
use horizon_core::random::{LegacyRandom, RandomSource};
use serde_json::Value;
use std::{
cell::RefCell,
collections::HashMap,
fs, io,
path::Path,
rc::Rc,
sync::{Arc, Mutex, Weak},
};
@@ -96,11 +98,11 @@ pub struct NativeLootTool {
/// What a mod's own loot kinds see of the roll: the entity, the damage,
/// the player who killed it, the tool, the block, the luck, the biome,
/// and a seed from the roll's random. The tool and block are filled by
/// the items engine (block drops, entity drops, loot_table, interact_loot);
/// the entity, damage and player by the main loot engine; each is None in
/// the other. A table without a mod's kinds draws nothing and stays
/// bit-for-bit the same.
/// where the roll happens, and a seed from the roll's random. The tool and
/// block are filled by the items engine (block drops, entity drops,
/// loot_table, interact_loot); the entity, damage and player by the main
/// loot engine; each is None in the other. A table without a mod's kinds
/// draws nothing and stays bit-for-bit the same.
pub struct NativeLootContext<'a> {
/// The entity the table names (`this`), if any.
pub entity: Option<&'a LootEntity>,
@@ -122,6 +124,69 @@ pub struct NativeLootContext<'a> {
/// A seed from the roll's random, for a mod's own draws. Drawn only
/// when a mod's kind is in the table.
pub seed: i64,
/// Where the roll happens: the origin, the block entity and the level,
/// when the game rolled the table at a place (a broken block's drops,
/// a mob's death loot); None for the other rolls. Either engine fills it.
/// API @API:loot_context_place@.
pub place: Option<Rc<NativeLootPlace>>,
}
/// Where a loot roll happens, as a mod's own loot kinds see it:
/// `LootContextParams.ORIGIN`, `LootContextParams.BLOCK_ENTITY`, and what
/// vanilla's own conditions read of `LootContext.getLevel` (`TimeCheck`:
/// the time; `WeatherCheck`: the rain and the thunder). The game sets it
/// around a roll with [`with_native_loot_place`]. API @API:loot_context_place@.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NativeLootPlace {
/// `ORIGIN`: a broken block's centre, a dead mob's position.
pub origin: Option<[f64; 3]>,
/// `BLOCK_ENTITY`: the broken block's entity as it was before the
/// break, saved with its id and position (`saveWithFullMetadata`); a
/// mod's block entity carries its own data and its `Items`.
pub block_entity: Option<Tag>,
/// The level the roll is in.
pub level: Option<NativeLootLevel>,
}
/// The level a loot roll is in, as a mod's own loot kinds see it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NativeLootLevel {
/// The dimension's id (`minecraft:overworld`).
pub dimension: String,
/// `Level.getGameTime`.
pub game_time: i64,
/// `Level.getDayTime`: it counts on from day to day, so the time of
/// day is `day_time % 24000` (night from 13000 to 23000).
pub day_time: i64,
/// `Level.isRaining`.
pub raining: bool,
/// `Level.isThundering`.
pub thundering: bool,
}
thread_local! {
static NATIVE_LOOT_PLACE: RefCell<Option<Rc<NativeLootPlace>>> = const { RefCell::new(None) };
}
/// Runs `roll` with `place` as where its loot rolls happen: every
/// [`NativeLootContext`] made inside it, by either engine, carries it. The
/// place before is back when `roll` returns, so these nest.
/// API @API:loot_context_place@.
pub fn with_native_loot_place<T>(place: NativeLootPlace, roll: impl FnOnce() -> T) -> T {
struct Restore(Option<Rc<NativeLootPlace>>);
impl Drop for Restore {
fn drop(&mut self) {
let before = self.0.take();
NATIVE_LOOT_PLACE.with(|p| *p.borrow_mut() = before);
}
}
let _restore = Restore(NATIVE_LOOT_PLACE.with(|p| p.replace(Some(Rc::new(place)))));
roll()
}
/// The place [`with_native_loot_place`] set for the rolls now running, if any.
pub fn current_native_loot_place() -> Option<Rc<NativeLootPlace>> {
NATIVE_LOOT_PLACE.with(|p| p.borrow().clone())
}
/// One generated item stack.
@@ -1364,6 +1429,7 @@ impl<R: RandomSource> Context<'_, R> {
luck: self.luck,
biome: self.biome,
seed,
place: current_native_loot_place(),
}
}
fn run(&mut self, f: &Value, mut stack: LootStack) -> LootStack {
@@ -2133,6 +2199,69 @@ mod native_kinds_tests {
assert_eq!(a, b, "same seed, different result");
}
fn place(x: f64) -> NativeLootPlace {
NativeLootPlace {
origin: Some([x, 64.0, 0.5]),
..NativeLootPlace::default()
}
}
/// A roll sees the innermost place set around it; the outer one is back
/// after the inner returns, and nothing after the outer, even when the
/// roll panicked.
#[test]
fn a_place_holds_for_its_roll_only() {
assert_eq!(current_native_loot_place(), None);
with_native_loot_place(place(1.5), || {
with_native_loot_place(place(2.5), || {
assert_eq!(
current_native_loot_place().unwrap().origin,
Some([2.5, 64.0, 0.5])
);
});
assert_eq!(
current_native_loot_place().unwrap().origin,
Some([1.5, 64.0, 0.5])
);
});
assert_eq!(current_native_loot_place(), None);
let panicked = std::panic::catch_unwind(|| {
with_native_loot_place(place(3.5), || panic!("a roll failed"));
});
assert!(panicked.is_err());
assert_eq!(current_native_loot_place(), None);
}
/// A place changes nothing a table rolls: the same items, and the random
/// left where it was, with a mod's kind in the table or not.
#[test]
fn a_place_leaves_the_roll_as_it_was() {
for (name, json) in [
(
"place_vanilla",
r#"{"pools":[{"rolls":{"type":"minecraft:uniform","min":1,"max":4},
"entries":[{"type":"minecraft:item","name":"minecraft:stone"},
{"type":"minecraft:item","name":"minecraft:dirt"}]}]}"#,
),
(
"place_mod",
r#"{"pools":[{"rolls":2,"entries":[{"type":"minecraft:item","name":"minecraft:stone"}],
"modifier":[{"type":"lootmod:x"}]}]}"#,
),
] {
let data = table(name, json);
let mut plain = horizon_core::random::LegacyRandom::new(7);
let mut placed = horizon_core::random::LegacyRandom::new(7);
let without = data.roll("minecraft:test", &mut plain, 0.0, None, None);
let with = with_native_loot_place(place(0.5), || {
data.roll("minecraft:test", &mut placed, 0.0, None, None)
});
assert_eq!(with, without, "{name}");
assert_eq!(placed.next_long(), plain.next_long(), "{name}");
}
}
#[test]
fn an_unknown_minecraft_function_is_skipped() {
let data = table(
+1 -1
View File
@@ -35,7 +35,7 @@ Every row has a one-line description and its cost in frame time (none, low, medi
**Translucent batching** (only in the file for now: `translucentBatching` in `horizon-graphics.txt`, off by default; edit it with the game closed, it applies at the next start) gives water, glass, ice and other translucent quads a vertex buffer of their own, with pages up to 2 GiB, and their sorted indices the same: the back-to-front list then stays in one page and goes out in a few draw calls instead of switching pages every few sections. The picture is the same; it costs a little video memory, as solid and translucent quads no longer share free space. The large pages shrink once the view has settled (a second after terrain stops arriving): one much larger than what it holds, as after a teleport or a lower render distance, moves into a page of the right size, about 32 MiB a frame. It needs indirect multi-draw that isn't OpenGL's (which loops over the entries on the CPU), and devices whose vanilla frames draw flat (Metal, Intel Gen11 and Gen12 on Vulkan) don't offer it. A `shaders/` pack's terrain and shadow passes, and the `[shadow]` pass of one of Horizon's own packs, draw flat where the device can, reading translucent quads beside their section's solid ones: while such a pack runs they go back there. Such a pack picked at the start has the world laid out for it from the first; switching to or from one later meshes the world again.
**HDR** (off by default) lives on its own screen, Graphics: HDR, behind **HDR Settings...** under Display, which is greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); there the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; these often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Brightness and Paper White full screen, one test picture at a time, drawn by the last pass straight in nits above a band of controls: Horizon's logo at the slider on black, lowered until it disappears; the logo at the slider inside a tenth of the screen at 10 000 nits (small, so the display doesn't dim), raised until it disappears; a white card, set to a white that's comfortable in the light the player plays in. Save writes the three, Cancel or Escape keeps what was set, and a step left untouched keeps Auto. The Peak step needs a path that doesn't tone-map: on KWin the logo never disappears, so where the system reports its values the button reads **Calibrate Anyway...** and says the display is already set up (**Use Automatic Setup** once they were changed by hand). Where nothing is reported, Auto is 203 and 1000 nits, and the Graphics: HDR screen asks the player to calibrate. **Black Level** (0 is Auto: what the system reports, else an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the last pass, the frame's alpha is zeroed, everything drawn after raises it where it covers the world, the first-person hand's is then scaled down to 1/255 (it stays at the world's brightness), and the last pass scales the rest by UI Brightness and lifts none of it (so text over the sun stays text). With HDR on, F2 writes vanilla's screenshot as always and an HDR10 copy beside it (`…-hdr.png`): the HDR pass drawn once more into a 10-bit PQ target, whatever the swapchain is, saved as a 16-bit PNG tagged BT.2020 with PQ (`cICP`, PNG's third edition) and the peak and paper white as content light levels (`cLLI`); viewers that read `cICP` show it in HDR, and others that honour its ICC profile (reference white at the profile's white) a correct SDR picture with clipped highlights. Not lifted yet: translucent blocks (the nether portal), distant LOD terrain. The screen shows these as plain numbers: they are nits only where the compositor passes PQ through unchanged (Windows, Hyprland with tone mapping off), not on KWin or macOS, and the game can't tell which.
**HDR** (off by default) lives on its own screen, Graphics: HDR, behind **HDR Settings...** under Display, which is greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); there the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; these often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Brightness and Paper White full screen, one test picture at a time, drawn by the last pass straight in nits above a band of controls: Horizon's logo at the slider on black, lowered until it disappears; the logo at the slider inside a tenth of the screen at 10 000 nits (small, so the display doesn't dim), raised until it disappears; a white card, set to a white that's comfortable in the light the player plays in. Save writes the three, Cancel or Escape keeps what was set, and a step left untouched keeps Auto. The Peak step needs a path that doesn't tone-map: on KWin the logo never disappears, so where the system reports its values the button reads **Calibrate Anyway...** and says the display is already set up (**Use Automatic Setup** once they were changed by hand). Where nothing is reported, Auto is 203 and 1000 nits, and the Graphics: HDR screen asks the player to calibrate. **Black Level** (0 is Auto: what the system reports, else an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the last pass, the frame's alpha is zeroed, everything drawn after raises it where it covers the world, the first-person hand's is then scaled down to 1/255 (it stays at the world's brightness), and the last pass scales the rest by UI Brightness and lifts none of it (so text over the sun stays text). With HDR on, F2 writes vanilla's screenshot as always and an HDR10 copy beside it (`…-hdr.png`): the HDR pass drawn once more into a 10-bit PQ target, whatever the swapchain is, saved as a 16-bit PNG tagged BT.2020 with PQ (`cICP`, PNG's third edition) and the peak and paper white as content light levels (`cLLI`); viewers that read `cICP` show it in HDR, and others that honour its ICC profile (reference white at the profile's white) a correct SDR picture with clipped highlights. Glowing items shine by their block's tier when dropped, framed, or held by anyone, your own hand included; the hand itself stays at the world's brightness. Not lifted yet: translucent blocks (the nether portal), distant LOD terrain. The screen shows these as plain numbers: they are nits onlLine truncated
HDR's light sources shine by the kind of light rather than its level (`mesher::hdr_tier`): the sun most, then glowstone, campfires and the soul fires, then flames and lava, torch and candle flames among them (particles too), then faint glowers such as a redstone torch or magma; glow lichen stays as in SDR. Each tier has its share of the headroom and the brightness its lift starts and ends at, the texel's own before lighting, so a light source shines alike at any light level and on every face (`gfx/hdr.rs` `TIERS`); `HORIZON_HDR_DEBUG=1` greys what isn't lifted and tints what is magenta.
+6
View File
@@ -430,6 +430,12 @@ there follows when the stack types converge. In the items engine's context the `
and `biome` fields are `None` and `luck` is 0; in the main loot engine's context the `tool` and `block`
fields are `None`.
Both engines' contexts carry `place`, where the roll happens: `origin` (a broken block's centre, a dead mob's
position), `block_entity` (the broken block's entity as it was before the break, saved with its id, position and
`Items`) and `level` (the dimension, the game time, the time of day, the rain and the thunder, for conditions like
vanilla's `time_check` and `weather_check`). The game sets it for a block a player or an explosion breaks and for a
mob's death loot; on other rolls `place` is `None`.
### Data files
A bundle's `data/` folder (any files) is staged as `native-data/<mod>/` at the root of the workspace. A helper or
+1
View File
@@ -2760,6 +2760,7 @@ dependencies = [
name = "horizon-protocol"
version = "0.1.0"
dependencies = [
"aes",
"flate2",
"getrandom 0.3.4",
"horizon-core",
+25
View File
@@ -0,0 +1,25 @@
from pathlib import Path
import tempfile
import unittest
from tools import file_lock
class FileLockTests(unittest.TestCase):
def test_a_held_lock_refuses_another_until_closed(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "slot.lock"
first = open(path, "w")
second = open(path, "a")
try:
self.assertTrue(file_lock.lock(first, wait=False))
self.assertFalse(file_lock.lock(second, wait=False))
first.close()
self.assertTrue(file_lock.lock(second, wait=False))
finally:
first.close()
second.close()
if __name__ == "__main__":
unittest.main()
+48 -5
View File
@@ -107,7 +107,8 @@ struct ServerPacks { prompt: Option<()> }
struct Game {
focused: bool, agent_pointer: bool, window: Option<Window>, mouse: Mouse,
keyboard_mouse: bool, view_at: [u32; 2],
captured: bool, title: Option<()>, screen: Screen, remote: Option<Remote>,
captured: bool, regrab_on_refocus: bool, title: Option<()>, screen: Screen,
remote: Option<Remote>,
mining: bool, lan_acting: Option<u32>, renderer: Option<Renderer>, cursor: (f32, f32),
server_packs: ServerPacks, options: Options,
}
@@ -115,7 +116,7 @@ impl Default for Game {
fn default() -> Self {
Self { focused: true, agent_pointer: false, window: Some(Window::default()),
keyboard_mouse: true, view_at: [0, 0],
mouse: Mouse::default(), captured: false, title: None,
mouse: Mouse::default(), captured: false, regrab_on_refocus: false, title: None,
screen: Screen::Playing, remote: None, mining: false, lan_acting: None,
renderer: None, cursor: (0.0, 0.0), server_packs: ServerPacks::default(),
options: Options::default() }
@@ -182,11 +183,52 @@ fn main() {
assert!(game.agent_pointer);
assert_eq!(game.window.as_ref().unwrap().grabs.get(), grabs);
game.focused = true;
assert!(!game.captured); // Focus alone does not resume gameplay.
game.capture(true); // Click or resume action.
assert!(!game.captured); // Setting the flag is not a grab: `capture` is.
game.capture(true); // `regrab_on_refocus`, or a click on the world.
assert!(game.captured);
assert_eq!(game.cursor, (213.5, 120.0)); // The grab puts the mouse at the centre.
}
// A window that held the pointer asks for it back when the focus returns:
// the system drops a grabbed pointer with the focus and vanilla's grab
// outlives it (`MouseHandler.mouseGrabbed` stays true while GLFW keeps its
// cursor mode), so no click is needed to lock it again.
{
let mut game = Game::default();
game.capture(true);
assert!(game.captured);
let grabs = game.window.as_ref().unwrap().grabs.get();
game.focused = false; // Alt+Tab: the release `window_event` makes.
game.regrab_on_refocus = game.captured;
game.capture(false);
assert!(!game.captured);
game.focused = true;
game.regrab_on_refocus();
assert!(game.captured);
assert!(game.window.as_ref().unwrap().grabs.get() > grabs);
assert!(game.mouse.ignore_first);
// Taken once: a focus gain that follows a screen's release takes nothing.
game.capture(false);
let grabs = game.window.as_ref().unwrap().grabs.get();
game.regrab_on_refocus();
assert!(!game.captured);
assert_eq!(game.window.as_ref().unwrap().grabs.get(), grabs);
// A screen open while the window was away keeps the pointer free.
game.regrab_on_refocus = true;
game.screen = Screen::Inventory;
game.regrab_on_refocus();
assert!(!game.captured);
assert_eq!(game.window.as_ref().unwrap().grabs.get(), grabs);
// A window that never held the pointer does not take it on focus.
game.screen = Screen::Playing;
game.regrab_on_refocus();
assert!(!game.captured);
assert_eq!(game.window.as_ref().unwrap().grabs.get(), grabs);
// A prompt over the world keeps the pointer, as the grab itself checks.
game.regrab_on_refocus = true;
game.server_packs.prompt = Some(());
game.regrab_on_refocus();
assert!(!game.captured);
}
// A controller-only player's screens leave the shared native pointer alone.
{
let mut game = Game::default();
@@ -275,7 +317,8 @@ fn main() {
"mouse_position_after_grab_change")
fixture = fixture.replace("HELPER", function(mouse, "mouse_position_after_grab_change"))
fixture = fixture.replace("METHODS", capture + "\n" +
method(source, "capture_loaded_world"))
method(source, "capture_loaded_world") + "\n" +
method(source, "regrab_on_refocus"))
with tempfile.TemporaryDirectory(prefix="horizon-input-") as folder:
folder = Path(folder)
rust = folder / "capture.rs"
+28
View File
@@ -0,0 +1,28 @@
"""An exclusive lock on an open file, held until it is closed: flock where it exists, msvcrt's byte lock on Windows."""
import os
import time
def lock(handle, wait=True):
"""Lock `handle`; with wait=False, False at once when another process holds it."""
if os.name != "nt":
import fcntl
try:
fcntl.flock(handle, fcntl.LOCK_EX | (0 if wait else fcntl.LOCK_NB))
return True
except OSError:
if wait:
raise
return False
import msvcrt
handle.flush()
os.lseek(handle.fileno(), 0, os.SEEK_SET) # the lock covers the first byte, wherever the file was opened
while True:
try:
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
return True
except OSError:
if not wait:
return False
time.sleep(0.1)
+6 -3
View File
@@ -36,9 +36,12 @@ def run_game(command, **kwargs):
@contextmanager
def suite_lock():
"""Real-client CI suites share a GPU, even when parity.py runs harnesses in parallel."""
import fcntl
with (Path(tempfile.gettempdir()) / 'horizon-integration-gpu.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
try:
from . import file_lock
except ImportError:
import file_lock
with (Path(tempfile.gettempdir()) / 'horizon-integration-gpu.lock').open('a') as handle:
file_lock.lock(handle)
yield
+36 -2
View File
@@ -376,7 +376,7 @@ CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'menu-transfer'
'flow-push', 'bubble-column', 'arrows', 'kills', 'looting', 'leave', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'names', 'feedback', 'titles', 'chat', 'styles',
'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing',
'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'shift-double-click', 'recipe-shift', 'shift-drag', 'modifier-release', 'wheel', 'sleep', 'sleep-villager', 'bed-occupied', 'sleep-reload', 'predict', 'push',
'piston-push', 'piston-slime', 'eat', 'eat-on-block', 'cauldron', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles',
'piston-push', 'piston-entities', 'piston-slime', 'eat', 'eat-on-block', 'cauldron', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles',
'worn', 'blast', 'blast-projectiles', 'barter', 'piglin_offspring', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'plate-projectiles', 'effects',
'portals', 'distance', 'growth', 'doors', 'flight', 'ladder', 'boats', 'boat_ice_reboard', 'boat_shoved', 'minecarts', 'frames', 'hands',
'lightning', 'riding', 'occupied_horse', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption',
@@ -3328,6 +3328,40 @@ def wait_state(game, pos, key, want, ticks):
return state_of(game, pos, key)
def check_piston_entities(host, guest, out):
"""A moving piston pushes a falling block and an arrow standing in front of it."""
hx, _, hz = pos_of(host)
x, y, z = (int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8)
host('command', command=f'fill {x-4} {y-1} {z-4} {x+7} {y-1} {z+4} minecraft:stone')
host('command', command=f'fill {x-4} {y} {z-4} {x+7} {y+5} {z+4} minecraft:air')
host('command', command=f'setblock {x} {y} {z} minecraft:piston[facing=east]')
host('command', command=f'tp @s {x+5.5} {y} {z+3.5}')
kinds = {
'falling_block': '{BlockState:{Name:"minecraft:sand"},Time:1,NoGravity:1b}',
'arrow': '{}',
}
for i, (kind, nbt) in enumerate(kinds.items()):
host('command', command=f'summon minecraft:{kind} {x+1.5} {y} {z+0.5} {nbt}')
host.wait(20)
def xs():
got = {}
for kind in kinds:
objs = [o for o in host('status')['projectiles'] if kind in str(o.get('kind', '')).lower()
and abs(o["position"][2] - z - 0.5) < 0.6]
assert objs, f'{kind} not found before the push'
got[kind] = objs[0]['position'][0]
return got
before = xs()
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
host.wait(30)
after = xs()
results = {}
for kind in kinds:
results[kind] = after[kind] - before[kind]
assert results[kind] > 0.3, f'piston did not push {kind}: {before[kind]} -> {after[kind]}'
return results
def check_piston_push(host, guest, out):
"""A moving piston pushes both players and a stationary mob on each host path."""
hx, _, hz = pos_of(host)
@@ -6666,7 +6700,7 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour,
'shift-drag': check_shift_drag, 'wheel': check_wheel, 'sleep': check_sleep, 'sleep-villager': check_sleep_villager,
'modifier-release': check_modifier_release,
'bed-occupied': check_bed_occupied, 'sleep-reload': check_sleep_reload,
'push': check_push, 'piston-push': check_piston_push,
'push': check_push, 'piston-push': check_piston_push, 'piston-entities': check_piston_entities,
'piston-slime': check_piston_slime,
'eat': check_eat, 'eat-on-block': check_eat_on_block, 'cauldron': check_cauldron, 'eating': check_eating, 'xp': check_xp,
'objects': check_objects, 'projectiles': check_projectiles, 'blast': check_blast, 'blast-projectiles': check_blast_projectiles,
@@ -0,0 +1,8 @@
{
"hooks": [],
"rust_summary": "where a loot roll happens, for a mod's own loot kinds\n(`NativeLootContext.place`: the origin, the block entity and the level).",
"doc_summary": "where a loot roll happens, for a mod's own loot kinds: `ctx.place` in `native_loot_function` and\n`native_loot_condition` brings `LootContextParams.ORIGIN` (a broken block's centre, a dead mob's position), `BLOCK_ENTITY` (the broken\nblock's entity as it was, saved with its id, position and `Items`) and the level (its dimension, game time, time of day, rain and\nthunder), on a block broken by a player or an explosion and on a mob's death loot; None on the other rolls. The game sets it\naround a roll with `with_native_loot_place`. Bundles that use it need `\"hooks\": @API:loot_context_place@`.",
"comments": [
"crates/horizon-world/src/loot.rs"
]
}
+33 -19
View File
@@ -56,8 +56,10 @@ import time
try:
from . import parity_reach
from . import file_lock
except ImportError:
import parity_reach
import file_lock
ROOT = Path(__file__).resolve().parents[1]
LOCAL = ROOT / ".local"
@@ -594,8 +596,9 @@ def java_sources(arguments):
def run_environment(reuse_oracles=False):
environment = dict(os.environ, CARGO_TARGET_DIR=str(TARGET), PYTHONDONTWRITEBYTECODE="1", HORIZON_NO_AUDIO="1", **PROFILE)
environment.pop("CARGO_BUILD_JOBS", None)
if reuse_oracles:
# tools/parity_shim.py stands in for java and javac and replays unchanged oracle runs.
# tools/parity_shim.py stands in for java and javac and replays unchanged oracle runs. Not on Windows: a
# program started without a shell is found there only as an .exe, and the shim's execv returns before the program.
if reuse_oracles and os.name != "nt":
SHIM.mkdir(parents=True, exist_ok=True)
for name in ("java", "javac"):
link = SHIM / name
@@ -736,16 +739,13 @@ def publish(name, record):
def machine_slot():
"""One of MACHINE_JOBS lock files shared by every runner on this machine, or None if all are taken."""
import fcntl
slots = SHARED / "slots"
slots.mkdir(parents=True, exist_ok=True)
for index in range(MACHINE_JOBS):
handle = open(slots / f"{index}.lock", "w")
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
if file_lock.lock(handle, wait=False):
return handle
except OSError:
handle.close()
handle.close()
return None
@@ -792,7 +792,8 @@ def outcome(name, script, passed, elapsed, trace_path, hasher=None):
trace = None # killed before writing it, or the disk was full: the pass can't be keyed
if trace:
record["dependencies"], record["artifacts"] = dependencies(trace, harness_settings(script).get("PARITY_RUST_SCOPE"))
record["peak_bytes"] = trace["peak_bytes"]
if "peak_bytes" in trace:
record["peak_bytes"] = trace["peak_bytes"]
if passed:
clear_large_outputs(name, trace)
if hasher is not None:
@@ -873,7 +874,7 @@ class LocalExecutor:
if process.poll() is None and elapsed < self.timeout:
continue
if process.poll() is None:
os.killpg(process.pid, 9)
kill_tree(process)
process.wait()
print(f"--- {name}: timed out after {self.timeout}s", file=item["log"])
item["log"].close()
@@ -884,10 +885,18 @@ class LocalExecutor:
def stop(self):
for item in self.running.values():
try:
os.killpg(item["process"].pid, 9)
except ProcessLookupError:
pass
kill_tree(item["process"])
def kill_tree(process):
"""Kill a harness and what it started: its process group (start_new_session), or its tree on Windows."""
if os.name == "nt":
subprocess.run(["taskkill", "/T", "/F", "/PID", str(process.pid)], capture_output=True)
return
try:
os.killpg(process.pid, 9)
except ProcessLookupError:
pass
def host_config():
@@ -1258,8 +1267,11 @@ def stale(name, state, hasher, environment):
def trace_main(output, script, arguments):
"""Run one harness and record what it read and executed."""
import atexit
import resource
import runpy
try:
import resource
except ImportError: # Windows: no peak to report, the scheduler assumes DEFAULT_MEMORY
resource = None
read, commands = set(), []
def audit(event, details):
@@ -1274,14 +1286,16 @@ def trace_main(output, script, arguments):
def write():
modules = [getattr(module, "__file__", None) for module in list(sys.modules.values())]
peak = max(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss, resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss)
peak //= 1024 if sys.platform == "darwin" else 1 # bytes on macOS, KiB elsewhere
Path(output).write_text(json.dumps({
trace = {
"modules": sorted({m for m in modules if m and inside(m, ROOT)}),
"read": sorted(path for path in read if os.path.isabs(path) or not path.startswith("<")),
"commands": commands,
"peak_bytes": peak * 1024,
}))
}
if resource:
peak = max(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss, resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss)
peak //= 1024 if sys.platform == "darwin" else 1 # bytes on macOS, KiB elsewhere
trace["peak_bytes"] = peak * 1024
Path(output).write_text(json.dumps(trace))
atexit.register(write)
sys.addaudithook(audit)
+3 -3
View File
@@ -212,10 +212,10 @@ def main():
'file fixes), on the sample worlds saved before it, for developing the fixes of a range of versions')
args = p.parse_args()
import datafix_worlds
import fcntl
import file_lock
WORLDS.parent.mkdir(parents=True, exist_ok=True)
with open(WORLDS.parent / 'generate.lock', 'w') as lock:
fcntl.flock(lock, fcntl.LOCK_EX) # another worktree may be generating them
with open(WORLDS.parent / 'generate.lock', 'w') as handle:
file_lock.lock(handle) # another worktree may be generating them
missing = [v for v in [*datafix_worlds.VERSIONS, *datafix_worlds.VARIANTS] if not (datafix_worlds.worlds_dir(v) / v / 'level.dat').exists()]
if missing:
print('generating sample worlds:', ' '.join(missing), flush=True)