Merge remote-tracking branch 'origin/main' into refactor/furnace-cooking

This commit is contained in:
Ludovic Marie
2026-10-28 13:11:00 +01:00
56 changed files with 904 additions and 173 deletions
+1
View File
@@ -6,3 +6,4 @@
kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
+3
View File
@@ -86,6 +86,9 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
generation, the launcher's updates) wait for a local proof first ([merging](docs/release-flow.md#merging)).
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
How agents, checks, releases and signed updates keep this safe: [security](docs/security.md).
Generated
+5 -5
View File
@@ -3206,9 +3206,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
@@ -4386,14 +4386,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
"webpki-roots 1.0.10",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [
"rustls-pki-types",
]
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Ludovic
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -145,6 +145,22 @@ const KEYS: &[(&str, KeyCode)] = &[
("key.keyboard.period", KeyCode::Period),
("key.keyboard.slash", KeyCode::Slash),
("key.keyboard.caps.lock", KeyCode::CapsLock),
("key.keyboard.world.1", KeyCode::IntlBackslash),
("key.keyboard.international1", KeyCode::IntlRo),
("key.keyboard.international3", KeyCode::IntlYen),
("key.keyboard.lang1", KeyCode::Lang1),
("key.keyboard.lang2", KeyCode::Lang2),
("key.keyboard.lang3", KeyCode::Lang3),
("key.keyboard.lang4", KeyCode::Lang4),
("key.keyboard.lang5", KeyCode::Lang5),
("key.keyboard.help", KeyCode::Help),
("key.keyboard.select", KeyCode::Select),
("key.keyboard.again", KeyCode::Again),
("key.keyboard.undo", KeyCode::Undo),
("key.keyboard.cut", KeyCode::Cut),
("key.keyboard.copy", KeyCode::Copy),
("key.keyboard.paste", KeyCode::Paste),
("key.keyboard.find", KeyCode::Find),
("key.keyboard.f1", KeyCode::F1),
("key.keyboard.f2", KeyCode::F2),
("key.keyboard.f3", KeyCode::F3),
@@ -157,6 +173,18 @@ const KEYS: &[(&str, KeyCode)] = &[
("key.keyboard.f10", KeyCode::F10),
("key.keyboard.f11", KeyCode::F11),
("key.keyboard.f12", KeyCode::F12),
("key.keyboard.f13", KeyCode::F13),
("key.keyboard.f14", KeyCode::F14),
("key.keyboard.f15", KeyCode::F15),
("key.keyboard.f16", KeyCode::F16),
("key.keyboard.f17", KeyCode::F17),
("key.keyboard.f18", KeyCode::F18),
("key.keyboard.f19", KeyCode::F19),
("key.keyboard.f20", KeyCode::F20),
("key.keyboard.f21", KeyCode::F21),
("key.keyboard.f22", KeyCode::F22),
("key.keyboard.f23", KeyCode::F23),
("key.keyboard.f24", KeyCode::F24),
("key.keyboard.print.screen", KeyCode::PrintScreen),
("key.keyboard.scroll.lock", KeyCode::ScrollLock),
("key.keyboard.pause", KeyCode::Pause),
@@ -188,6 +216,36 @@ const KEYS: &[(&str, KeyCode)] = &[
("key.keyboard.keypad.0", KeyCode::Numpad0),
("key.keyboard.keypad.period", KeyCode::NumpadDecimal),
("key.keyboard.keypad.equal", KeyCode::NumpadEqual),
("key.keyboard.keypad.comma", KeyCode::NumpadComma),
("key.keyboard.keypad.backspace", KeyCode::NumpadBackspace),
(
"key.keyboard.keypad.left.parenthesis",
KeyCode::NumpadParenLeft,
),
(
"key.keyboard.keypad.right.parenthesis",
KeyCode::NumpadParenRight,
),
("key.keyboard.keypad.hash", KeyCode::NumpadHash),
(
"key.keyboard.keypad.memory.store",
KeyCode::NumpadMemoryStore,
),
(
"key.keyboard.keypad.memory.recall",
KeyCode::NumpadMemoryRecall,
),
(
"key.keyboard.keypad.memory.clear",
KeyCode::NumpadMemoryClear,
),
("key.keyboard.keypad.memory.add", KeyCode::NumpadMemoryAdd),
(
"key.keyboard.keypad.memory.subtract",
KeyCode::NumpadMemorySubtract,
),
("key.keyboard.keypad.clear", KeyCode::NumpadClear),
("key.keyboard.keypad.clear.entry", KeyCode::NumpadClearEntry),
("key.keyboard.menu", KeyCode::ContextMenu),
("key.keyboard.left.control", KeyCode::ControlLeft),
("key.keyboard.left.shift", KeyCode::ShiftLeft),
+2
View File
@@ -218,6 +218,8 @@ impl RenderAssets {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.finish();
// `finish` grows the buffer by doubling: keep only the bytes in use.
sprites.pixels.shrink_to_fit();
Ok(Self {
sprite_size: sprites.size,
sprite_minimum_size: sprites.minimum_size,
@@ -35,6 +35,25 @@ pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
data(stack).is_some_and(|(id, _)| OP_ONLY.contains(&id.as_str()))
}
/// `SpawnEggItem.shouldPrintOpWarning` for a player with game master
/// permission: a spawn egg carries data of an op-only entity type
/// (`EntityTypes.OP_ONLY_CUSTOM_DATA`).
pub fn spawn_egg_op_warning(stack: &crate::items::ItemStack) -> bool {
stack.item.ends_with("_spawn_egg")
&& matches!(
stack
.data()
.and_then(|d| d.other("minecraft:entity_data"))
.and_then(|data| data.get("id")),
Some(Tag::String(id)) if matches!(
id.to_string_lossy().as_str(),
"minecraft:falling_block"
| "minecraft:command_block_minecart"
| "minecraft:spawner_minecart"
)
)
}
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
/// spawners alike): the entity type a spawner item spawns.
@@ -97,6 +97,16 @@ pub fn diffuse(normal: Vec3, lights: (Vec3, Vec3)) -> f32 {
pub fn vertex_light(packed: u8) -> u32 {
(u32::from(packed >> 4) * 16) | ((u32::from(packed & 15) * 16) << 8)
}
/// `ChestRenderer.xmasTextures`: `SpecialDates.isExtendedChristmas` (24 to 26
/// December in the local time zone), read once when the renderer is created.
fn extended_christmas() -> bool {
use chrono::Datelike;
static XMAS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*XMAS.get_or_init(|| {
let now = chrono::Local::now();
now.month() == 12 && (24..=26).contains(&now.day())
})
}
fn pack_color(rgb: u32, alpha: u32, brightness: f32) -> u32 {
let c = |shift: u32| (((rgb >> shift) & 255) as f32 * brightness) as u32;
alpha << 24 | c(0) << 16 | c(8) << 8 | c(16)
@@ -1173,11 +1183,25 @@ impl BlockEntityRenderer {
}
}
Kind::Chest {
block,
texture,
part,
y_rot,
..
} => {
// ChestRenderer.getChestMaterial: normal and trapped chests use the christmas
// sprites on 24-26 December. Copper and ender chests keep theirs.
let texture = if matches!(block.as_str(), "chest" | "trapped_chest")
&& extended_christmas()
{
match part {
ChestPart::Single => "entity/chest/christmas",
ChestPart::Left => "entity/chest/christmas_left",
ChestPart::Right => "entity/chest/christmas_right",
}
} else {
*texture
};
let model = match part {
ChestPart::Single => "chest",
ChestPart::Left => "double_chest_left",
+21
View File
@@ -772,6 +772,27 @@ pub fn bob_matrix(state: &BobState, damage_tilt_strength: f64, bob_view_option:
pose
}
/// The spinning effect `GameRenderer.renderLevel` multiplies into the level's
/// projection after the bob: a skew about a tilted axis, from a portal's or
/// Nausea's intensity (already scaled by Distortion Effects) and the angle in
/// degrees. The first-person hand keeps its own projection without it.
pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4 {
if intensity <= 0.0 {
return glam::Mat4::IDENTITY;
}
let mut skew = 5.0 / (intensity * intensity + 5.0) - intensity * 0.04;
skew *= skew;
let axis = glam::Vec3::new(
0.0,
std::f32::consts::SQRT_2 / 2.0,
std::f32::consts::SQRT_2 / 2.0,
);
let angle = angle_degrees * DEG_TO_RAD;
glam::Mat4::from_axis_angle(axis, angle)
* glam::Mat4::from_scale(glam::Vec3::new(1.0 / skew, 1.0, 1.0))
* glam::Mat4::from_axis_angle(axis, -angle)
}
#[cfg(test)]
mod tests {
#[test]
+25 -3
View File
@@ -355,6 +355,19 @@ impl Creative {
pub fn search_items(&self) -> &[ItemStack] {
&self.search_items
}
/// Opening the screen: vanilla rebuilds CreativeModeInventoryScreen, whose
/// init() makes a fresh search box and a zeroed scrollbar; only the tab
/// persists (its static selectedTab). selectTab on the reopened screen
/// takes the oldTab != tab branch, so it empties and refocuses the box.
pub fn reopen(&mut self) {
self.search.set_value("");
self.release();
self.scroll = 0.0;
self.search.focused = self.tab().search;
if self.search.focused {
self.search.reset_blink();
}
}
/// Indices into `search_items` that CreativeModeInventoryScreen.refreshSearchResults
/// shows for `term`: the whole tab when empty, the tag tree after `#`, else
/// the name tree, both over vanilla's suffix arrays (substring matches,
@@ -575,6 +588,8 @@ pub enum DisplayClick {
Nothing,
/// SWAP: the hotbar slot gets a full stack of the item.
Hotbar(usize, ItemStack),
/// SWAP with button 40 (Key F): the off-hand gets a full stack of the item.
Offhand(ItemStack),
/// THROW: a copy of the item is dropped.
Drop(ItemStack),
}
@@ -596,8 +611,11 @@ pub fn click_display(
match input {
Input::Swap => {
return match shown {
Some(s) => DisplayClick::Hotbar(button as usize, copy(s, max(s))),
None => DisplayClick::Nothing,
Some(s) if button == 40 => DisplayClick::Offhand(copy(s, max(s))),
Some(s) if (0..9).contains(&button) => {
DisplayClick::Hotbar(button as usize, copy(s, max(s)))
}
Some(_) | None => DisplayClick::Nothing,
};
}
Input::Clone => {
@@ -720,11 +738,15 @@ mod tests {
display(Some(stack("ender_pearl", 9)), 1, Pickup).0,
Some(stack("ender_pearl", 8))
);
// Number keys fill a hotbar slot, Q drops copies, pick block clones.
// Number keys fill a hotbar slot, F fills off-hand, Q drops copies, pick block clones.
assert_eq!(
display(None, 3, Swap).1,
DisplayClick::Hotbar(3, stack("stone", 64))
);
assert_eq!(
display(None, 40, Swap).1,
DisplayClick::Offhand(stack("stone", 64))
);
assert_eq!(
display(None, 0, Throw).1,
DisplayClick::Drop(stack("stone", 1))
+4
View File
@@ -29,6 +29,10 @@ impl EffectInstance {
fn infinite(&self) -> bool {
self.duration == -1
}
/// `MobEffectInstance.endsWithin`.
pub fn ends_within(&self, ticks: i32) -> bool {
!self.infinite() && self.duration <= ticks
}
fn shorter_than(&self, other: &Self) -> bool {
!self.infinite() && (self.duration < other.duration || other.infinite())
}
+121 -38
View File
@@ -586,8 +586,8 @@ struct GameLevel<'w> {
cart_boxes: Vec<([f64; 3], [f64; 3])>,
/// The item frames a comparator reads through a conductor.
item_frames: &'w [horizon_client::entities::ItemFrame],
/// Each minecart's kind and comparator signal, in `cart_boxes` order.
cart_signals: Vec<(minecarts::CartKind, i32)>,
/// The minecarts, in `cart_boxes` order, for a detector rail's comparator.
carts: &'w [minecarts::Minecart],
/// Arrow and thrown trident boxes (wooden buttons).
arrow_boxes: Vec<([f64; 3], [f64; 3])>,
/// The other entities that keep blocks from being placed into them.
@@ -671,15 +671,22 @@ impl EntityObstructionWorld for BuildingObstruction {
}
}
impl GameLevel<'_> {
/// The mobs that trigger plates and tripwires: living entities that don't
/// ignore block triggers (`Entity.isIgnoringBlockTriggers`: bats; armor
/// stands are not in `mobs`).
/// The living entities that trigger plates and tripwires and don't ignore
/// block triggers (`Entity.isIgnoringBlockTriggers`: bats and marker armor
/// stands). Armor stands live apart from `mobs`.
fn mob_trigger_boxes(&self) -> impl Iterator<Item = Aabb> + '_ {
self.blockers
.mobs
.iter()
.filter(|m| !m.removed && m.species() != horizon_client::mobs::Species::Bat)
.map(Mob::bounds)
.chain(
self.blockers
.armor_stands
.iter()
.filter(|s| !s.marker)
.map(|s| s.bounds()),
)
}
/// The non-living entities that trigger plates and tripwires (boats,
/// crystals, primed TNT, falling blocks, item entities, experience orbs and
@@ -731,24 +738,36 @@ impl GameLevel<'_> {
Vec3::new(x + 0.8, y + 0.8, z + 0.8),
);
let mut container = None;
for ((lo, hi), &(kind, signal)) in self.cart_boxes.iter().zip(&self.cart_signals) {
let cart = Aabb::new(
for ((lo, hi), cart) in self.cart_boxes.iter().zip(self.carts) {
let bounds = Aabb::new(
Vec3::new(lo[0], lo[1], lo[2]),
Vec3::new(hi[0], hi[1], hi[2]),
);
if !area.intersects(cart) {
if !area.intersects(bounds) {
continue;
}
match kind {
match cart.kind {
minecarts::CartKind::CommandBlock => return 0,
minecarts::CartKind::Chest | minecarts::CartKind::Hopper => {
container = container.or(Some(signal));
// Counted here, for the cart a comparator reads, not for
// every cart on every block tick.
container = container.or_else(|| Some(self.cart_signal(cart)));
}
_ => {}
}
}
container.unwrap_or(0)
}
/// A container minecart's comparator signal: its open menu's slots while
/// someone has it open, else its own.
fn cart_signal(&self, cart: &minecarts::Minecart) -> i32 {
let key = (i32::MAX - 15, minecarts::CART_KEY_Y, cart.id as i32);
let slots = match self.world.block_entities.get(&key) {
Some(BlockEntity::Chest(open)) => open,
_ => &cart.items,
};
minecarts::Minecart::container_signal(slots, self.items)
}
/// Every entity box that blocks building: the players who aren't
/// spectators, the minecarts and the `Blockers` (a removed mob or a marker
/// armor stand, whose box is empty, doesn't).
@@ -1178,6 +1197,8 @@ struct WorldRest {
end_environment: Environment,
behaviours: Behaviours,
player_arm: Option<horizon_client::mobs::Part>,
/// The slim model's right arm (3 pixels wide).
player_arm_slim: Option<horizon_client::mobs::Part>,
player_type: Option<Arc<horizon_client::mobs::MobType>>,
/// `SpinAttackEffectModel` (Riptide's spinning boxes).
spin_attack: Option<horizon_client::mobs::Part>,
@@ -1250,8 +1271,12 @@ impl Resources {
fn threads(&self) -> usize {
self.core.threads
}
fn player_arm(&self) -> &Option<horizon_client::mobs::Part> {
&self.rest().player_arm
fn player_arm(&self, slim: bool) -> &Option<horizon_client::mobs::Part> {
if slim {
&self.rest().player_arm_slim
} else {
&self.rest().player_arm
}
}
fn player_type(&self) -> &Option<Arc<horizon_client::mobs::MobType>> {
&self.rest().player_type
@@ -2842,7 +2867,6 @@ impl Game<'_> {
)
})
.collect();
let cart_signals = self.cart_signals();
let arrow_boxes = self
.creatures
.projectiles
@@ -2886,7 +2910,7 @@ impl Game<'_> {
let mut level = GameLevel {
cart_boxes,
item_frames: &self.item_frames,
cart_signals,
carts: &self.carts,
arrow_boxes,
blockers,
dropped: &self.dropped,
@@ -2913,7 +2937,13 @@ impl Game<'_> {
behaviours: self.res.behaviours(),
items: self.res.items(),
recipes: self.res.recipes(),
loot: horizon_client::structure_blocks::loot_data(&self.options.resources),
// A client rolls no loot (LootTable.getRandomItems needs a
// ServerLevel): it doesn't load the tables on its game thread.
loot: if self.remote.is_some() {
None
} else {
horizon_client::structure_blocks::loot_data(&self.options.resources)
},
registry: self.pack.assets.registry,
rules,
difficulty: i32::from(self.ui.difficulty),
@@ -5260,8 +5290,13 @@ impl Game<'_> {
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
MotionEvent::HoneySlide {
sound, particles, ..
pos,
sound,
particles,
} => {
if self.world.game_time % 20 == 0 {
self.trigger_honey_slide([pos.x, pos.y, pos.z]);
}
if sound {
self.audio
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
@@ -5625,8 +5660,10 @@ impl Game<'_> {
if self.lan_acting.is_some() {
return;
}
// MouseHandler.grabMouse never captures an inactive window.
if capture && !self.focused {
// MouseHandler.grabMouse never captures an inactive window, and vanilla
// grabs only on `setScreen(null)`: the pack prompt and server dialogs
// are screens over the world that keep the pointer.
if capture && (!self.focused || self.server_packs.prompt.is_some() || self.dialog_open()) {
return;
}
// The container screen's press state belongs to the screen: a screen
@@ -5695,17 +5732,22 @@ impl Game<'_> {
fn apply_cursor_icon(&mut self) {
use horizon_client::widgets::Cursor;
let wanted = horizon_client::widgets::take_cursor();
if wanted == self.cursor_icon {
let effective = if self.settings.allow_cursor_changes {
wanted
} else {
Cursor::Arrow
};
if effective == self.cursor_icon {
return;
}
if let Some(window) = &self.window {
window.set_cursor(match wanted {
window.set_cursor(match effective {
Cursor::Arrow => winit::window::CursorIcon::Default,
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
});
}
self.cursor_icon = wanted;
self.cursor_icon = effective;
}
/// LevelLoadingScreen.onClose -> Gui.setScreen(null) -> grabMouse.
fn capture_loaded_world(&mut self) {
@@ -6102,7 +6144,14 @@ impl Game<'_> {
// Screen.extractBlurredBackground blurs the world and HUD, while the
// screen's background, widgets, toasts and debug overlay stay sharp.
batch.blur_at = batch.vertices.len() as u32;
match self.screen {
// PackConfirmScreen takes the open screen's place (it comes back once
// the prompt is answered): the screen under the prompt isn't drawn.
let screen = if self.server_packs.prompt.is_some() {
Screen::Playing
} else {
self.screen
};
match screen {
Screen::Paused if self.pause_menu => self.draw_pause_menu(&mut batch),
// PauseScreen(false): only its title, at the top.
Screen::Paused => {
@@ -6142,9 +6191,11 @@ impl Game<'_> {
// ToastManager.extractRenderState: F1 hides the toasts too.
self.draw_toasts(&mut batch, !show_hud);
self.draw_pack_toasts(&mut batch, !show_hud);
self.remote_hud(&mut batch);
self.draw_sign_editor(&mut batch);
self.draw_book(&mut batch);
if self.server_packs.prompt.is_none() {
self.remote_hud(&mut batch);
self.draw_sign_editor(&mut batch);
self.draw_book(&mut batch);
}
batch
}
fn creative_inventory_open(&self) -> bool {
@@ -6612,9 +6663,12 @@ impl Game<'_> {
let catalog = self.res.items();
let max = |s: &ItemStack| catalog.get(&s.item).map_or(64, |i| i.max_stack);
match click_display(&mut self.carried, shown, max, button, input) {
DisplayClick::Hotbar(i, stack) => self.inventory[i] = Some(stack),
DisplayClick::Hotbar(i, stack) if i < self.inventory.len() => {
self.inventory[i] = Some(stack);
}
DisplayClick::Offhand(stack) => self.offhand = Some(stack),
DisplayClick::Drop(stack) => self.player_drop(stack),
DisplayClick::Nothing => {}
_ => {}
}
}
/// Several clicks on one menu instance (a quick craft drag is three).
@@ -10457,14 +10511,18 @@ impl Game<'_> {
if hand == Hand::Off {
continue;
}
let Some(arm) = self.res.player_arm().clone() else {
let slim = self.player_model.as_ref().is_some_and(|m| m.slim);
let Some(arm) = self.res.player_arm(slim).clone() else {
continue;
};
// The player's own skin, as the inventory's model shows it.
let sprites = &self.gui.atlas.sprites;
let Some(r) = self
.gui
.atlas
.sprites
.get("entity/player/wide/steve")
.player_model
.as_ref()
.and_then(|m| m.skin.as_deref())
.and_then(|skin| sprites.get(skin))
.or_else(|| sprites.get("entity/player/wide/steve"))
.copied()
else {
continue;
@@ -11660,6 +11718,11 @@ impl Game<'_> {
} else {
self.bob_matrix(camera_alpha as f32)
};
let spin = if title {
glam::Mat4::IDENTITY
} else {
self.spinning_matrix(camera_alpha as f32)
};
let terrain_distance = self.terrain_distance();
let distance = (terrain_distance * 16) as f32;
let depth_far =
@@ -11667,7 +11730,7 @@ impl Game<'_> {
self.view_frustum = self
.renderer
.as_ref()
.map(|r| r.camera_frustum(fov_degrees, yaw, pitch, bob, depth_far));
.map(|r| r.camera_frustum(fov_degrees, yaw, pitch, bob * spin, depth_far));
// Keep the buffer local while builders need mutable access to the game.
let mut entities = std::mem::take(&mut self.entity_vertices);
entities.clear();
@@ -11750,12 +11813,24 @@ impl Game<'_> {
pitch,
fov_degrees,
bob,
spin,
sky,
distance,
detail_distance: (self.streaming.radius * 16) as f32,
depth_far,
underwater,
title,
sky_blocked: self.potions.effects.get("blindness").is_some()
|| self.potions.effects.get("darkness").is_some()
|| matches!(
horizon_client::fog::fog_type(
&self.world,
self.res.facts(),
[camera.x, camera.y, camera.z],
self.forward(),
),
horizon_client::fog::FogType::Lava | horizon_client::fog::FogType::PowderSnow
),
skybox: self.dim == Dim::Overworld && self.settings.horizon.sky,
end_sky: self.dim == Dim::End && self.settings.horizon.sky,
end_sky_sprite: self
@@ -12068,11 +12143,12 @@ impl Game<'_> {
Screen::Playing => {
// key.inventory while riding a tamed mount opens its inventory.
if !self.open_ridden_mount_menu() {
self.screen = if self.creative {
Screen::Creative
if self.creative {
self.screen = Screen::Creative;
self.creative_screen.reopen();
} else {
Screen::Inventory
};
self.screen = Screen::Inventory;
}
self.capture(false);
}
}
@@ -12652,13 +12728,16 @@ impl ApplicationHandler for Game<'_> {
self.left = false;
self.right = false;
self.capture(false);
// Screens (books, signs) stay open instead of pausing.
// Gui.setPauseScreen: an open screen (books, signs, the
// pack prompt, a server dialog) stays instead of pausing.
if self.screen == Screen::Playing
&& self.player.is_some()
&& !self.options.no_pause
&& self.settings.file.bool("pauseOnLostFocus")
&& self.book.is_none()
&& self.sign_edit.is_none()
&& self.server_packs.prompt.is_none()
&& !self.dialog_open()
{
self.screen = Screen::Paused;
}
@@ -14512,6 +14591,8 @@ fn load_world_rest(
player_type: perspective::player_type(root),
player_arm: horizon_client::mobs::load_model(root, "minecraft:player#main")
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
player_arm_slim: horizon_client::mobs::load_model(root, "minecraft:player_slim#main")
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
spin_attack: horizon_client::mobs::load_model(root, "minecraft:spin_attack#main"),
})
}
@@ -14649,6 +14730,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
pitch: 10.0,
fov_degrees: 85.0,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: horizon_client::environment::Sky::default(),
distance: 12.0,
detail_distance: 12.0,
@@ -14656,6 +14738,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
underwater: false,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
@@ -993,6 +993,42 @@ impl Game<'_> {
block_ok && state_ok && adv::conditions(&c["player"], &pctx, w)
});
}
/// `SlideDownBlockTrigger`: match the actual block and its state.
pub(super) fn trigger_honey_slide(&mut self, pos: [i32; 3]) {
let me = self.player_entity();
self.trigger("minecraft:slide_down_block", move |c, w| {
let ctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &ctx, w) && adv::block(c, pos, w)
});
}
/// `TargetBlockTrigger`: signal and projectile conditions are evaluated in
/// the shooter's context, including distance from the shooter.
pub(super) fn trigger_target_hit(&mut self, projectile: &EntityView, strength: i32) {
let me = self.player_entity();
let projectile = projectile.clone();
self.trigger("minecraft:target_hit", move |c, w| {
let player = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let target = adv::Entities {
this: Some(&projectile),
..player
};
adv::conditions(&c["player"], &player, w)
&& adv::range_i(&c["signal_strength"], i64::from(strength))
&& adv::conditions(&c["projectile"], &target, w)
});
}
/// `ItemUsedOnLocationTrigger` (placed_block, item_used_on_block).
pub(super) fn trigger_block(&mut self, name: &str, pos: [i32; 3], stack: &ItemStack) {
let me = self.player_entity();
+9 -1
View File
@@ -563,13 +563,21 @@ impl Game<'_> {
};
let time = (self.world.game_time.rem_euclid(40)) as f32 + alpha as f32;
let scoping = self.scoping();
let view = f64::from(self.settings.render_distance * 16);
for (k, e) in &self.world.block_entities {
let BlockEntity::Beacon(b) = e else { continue };
// `getBeamSections` is empty while Levels is 0.
if b.levels == 0 || b.sections.is_empty() {
continue;
}
let base = glam::DVec3::new(f64::from(k.0) + 0.5, f64::from(k.1), f64::from(k.2) + 0.5);
let distance = (base - camera).truncate().length() as f32;
// `BeaconRenderer.shouldRender`: horizontal distance under render distance × 16.
let offset = base - camera;
let horizontal = glam::DVec2::new(offset.x, offset.z).length();
if horizontal >= view {
continue;
}
let distance = horizontal as f32;
let scale = if scoping {
1.0
} else {
+17 -3
View File
@@ -76,9 +76,11 @@ fn view_pages(data: Option<&StackData>) -> Vec<Text> {
.iter()
.enumerate()
.map(|(i, page)| match tagged.then(|| &w.page_tags[i]) {
Some(tag @ horizon_world::nbt::Tag::Compound(_)) => {
horizon_client::entity_nbt::name_text(tag)
}
// A component object, or a component list (`ComponentSerialization`
// takes both; servers behind ViaVersion send the list form).
Some(
tag @ (horizon_world::nbt::Tag::Compound(_) | horizon_world::nbt::Tag::List(_)),
) => horizon_client::entity_nbt::name_text(tag),
_ => Text::literal(page),
})
.collect()
@@ -137,6 +139,18 @@ impl Game<'_> {
}
}
ClickEvent::OpenUrl(url) => self.click_url(url),
ClickEvent::RunCommand(command) => self.click_command(&command),
ClickEvent::SuggestCommand(command) => {
// defaultHandleClickEvent: the chat screen takes it over.
if self.player.is_some() {
self.close_book();
self.open_chat(&command);
}
}
ClickEvent::CopyToClipboard(value) => {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
}
_ => {}
}
}
+1 -1
View File
@@ -859,7 +859,7 @@ impl Game<'_> {
/// first, as `ClientPacketListener.sendUnattendedCommand` lets client
/// commands take it. (Vanilla asks first when the command fails to
/// parse; here the server answers it.)
fn click_command(&mut self, command: &str) {
pub(super) fn click_command(&mut self, command: &str) {
let command = command.strip_prefix('/').unwrap_or(command);
if self.mod_command(command) {
return;
+17 -2
View File
@@ -2666,10 +2666,25 @@ impl Game<'_> {
let hit = self.with_level(|b, level, ticks| {
b.target_projectile_hit(level, ticks, block, location, face, arrow)
});
if hit.is_some() {
if let Some(strength) = hit {
let _ = self.process_level_events();
if p.owner == Some(Who::Player) {
if p.owner == Some(Who::Player) && (p.guest.is_none() || p.guest == self.lan_acting) {
self.stats.custom("target_hit", 1);
let kind = if p
.arrow
.as_ref()
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow")
{
"minecraft:spectral_arrow"
} else {
p.kind.id()
};
let projectile = horizon_client::advancements::EntityView {
kind: kind.to_owned(),
position: [p.position.x, p.position.y, p.position.z],
..Default::default()
};
self.trigger_target_hit(&projectile, strength);
}
}
}
+25 -1
View File
@@ -1,11 +1,13 @@
//! Field of view (`Camera.tickFov`, `calculateFov`,
//! `AbstractClientPlayer.getFieldOfViewModifier`), view and hurt bobbing
//! (`GameRenderer.bobHurt`, `bobView`) and the spyglass (`SpyglassItem`,
//! (`GameRenderer.bobHurt`, `bobView`), the portal and Nausea spin
//! (`GameRenderer.renderLevel`) and the spyglass (`SpyglassItem`,
//! `Hud.extractSpyglassOverlay`). The arithmetic is `horizon_client::camera`.
use super::Game;
use horizon_client::{
camera::{
BobState, CameraFluid, FovPlayer, HurtBob, UseItem, bob_matrix, field_of_view_modifier,
spinning_effect_matrix,
},
fog::{FogType, fog_type},
hud::HudBatch,
@@ -26,6 +28,28 @@ impl Game<'_> {
pub(super) fn fov_effect_scale(&self) -> f32 {
self.settings.file.double("fovEffectScale") as f32
}
/// `options.screenEffectScale` (Distortion Effects).
pub(super) fn screen_effect_scale(&self) -> f32 {
self.settings.file.double("screenEffectScale") as f32
}
/// `LocalPlayer.getEffectBlendFactor(NAUSEA, partial)`: 0 without the effect.
pub(super) fn nausea_blend(&self, partial: f32) -> f32 {
if self.potions.effects.get("nausea").is_none() {
return 0.0;
}
let fx = &self.screen_fx;
fx.o_nausea + partial * (fx.nausea - fx.o_nausea)
}
/// The spin `GameRenderer.renderLevel` multiplies into the level's
/// projection: the larger of the portal and Nausea intensities, scaled by
/// Distortion Effects squared, turning by `LocalPlayer.getSpinningEffectAngle`.
pub(super) fn spinning_matrix(&self, partial: f32) -> glam::Mat4 {
let fx = &self.screen_fx;
let portal = fx.o_portal + partial * (fx.portal - fx.o_portal);
let scale = self.screen_effect_scale();
let intensity = portal.max(self.nausea_blend(partial)) * (scale * scale);
spinning_effect_matrix(intensity, fx.spinning_time + partial * fx.spinning_speed)
}
/// What `AbstractClientPlayer.getFieldOfViewModifier` reads.
fn fov_player(&self) -> Option<FovPlayer> {
let p = self.player.as_ref()?;
+3 -1
View File
@@ -429,7 +429,9 @@ impl Game<'_> {
if let Some(id) = map {
// ItemFrameRenderer: the map fills the face, in 90-degree steps.
let depth = if f.invisible { 0.5 } else { 0.4375 };
let item_light = if f.glow { 240 | 240 << 8 } else { packed };
// A glow frame's map is 15728850 = 0xF000D2 (sky coordinate 240,
// block coordinate 210), not full bright.
let item_light = if f.glow { 240 | 210 << 8 } else { packed };
let m = pose
* glam::DMat4::from_translation(glam::DVec3::new(0.0, 0.0, depth))
* glam::DMat4::from_rotation_z(
+4 -4
View File
@@ -82,11 +82,11 @@ fn civil(days: i64) -> (i64, u32, u32) {
(yoe + era * 400 + i64::from(m <= 2), m, d)
}
/// `DateTimeFormatter.ofPattern(pattern).format(now)` for the month, day,
/// year and time letters item models use (UTC: no time zone database).
/// year and time letters item models use, in the system time zone
/// (`LocalTime` without a `time_zone`).
pub(super) fn local_time(pattern: &str) -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
let now = chrono::Local::now();
let secs = now.timestamp() + i64::from(now.offset().local_minus_utc());
let (y, mo, d) = civil(secs.div_euclid(86_400));
let s = secs.rem_euclid(86_400);
let (h, mi, se) = (s / 3600, s / 60 % 60, s % 60);
+11 -1
View File
@@ -2607,6 +2607,9 @@ impl Game<'_> {
let _ = w.tick_environment_damage();
let _ = w.apply_fire_contact();
let _ = w.server_hazards_tick(in_wall);
// LivingEntity.aiStep: a Riptide spin counts down on the server's
// copy too, or it would stay in the spin pose (eye 0.4) for good.
w.player.count_down_server_spin();
// Player.tick ends with updatePlayerPose: a swimmer, or a player
// crawling under a block, takes the short box its moves are checked with.
let _ = w.update_player_pose();
@@ -2706,8 +2709,15 @@ impl Game<'_> {
// others (Player.playSound excludes the player, whose client
// plays it), the entity event 53 everyone around.
MotionEvent::HoneySlide {
sound, particles, ..
pos,
sound,
particles,
} => {
if self.world.game_time % 20 == 0 {
self.as_guest(host, conn, |game| {
game.trigger_honey_slide([pos.x, pos.y, pos.z])
});
}
let Some((id, at)) = host.hub.guest(conn).map(|g| (g.entity_id, g.position))
else {
continue;
+1 -17
View File
@@ -202,7 +202,7 @@ impl Minecart {
self.velocity.x.hypot(self.velocity.z)
}
/// `AbstractContainerMenu.getRedstoneSignalFromContainer` of `slots`.
fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
pub(super) fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
let mut total = 0.0f32;
for s in slots.iter().flatten() {
let max = items.get(&s.item).map_or(64, |i| i.max_stack).min(64);
@@ -1029,22 +1029,6 @@ impl Game<'_> {
}
best
}
/// Each cart's kind and comparator signal, for the detector rail
/// (`DetectorRailBlock.getAnalogOutputSignal`). A cart whose menu is open
/// counts the items in that menu.
pub(super) fn cart_signals(&self) -> Vec<(CartKind, i32)> {
self.carts
.iter()
.map(|c| {
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
let slots = match self.world.block_entities.get(&key) {
Some(BlockEntity::Chest(open)) => open,
_ => &c.items,
};
(c.kind, Minecart::container_signal(slots, self.res.items()))
})
.collect()
}
/// `interact`: ride, open, fuel or ignite the cart under the crosshair.
pub(super) fn interact_cart(&mut self) -> bool {
let Some((i, d)) = self.cart_hit() else {
@@ -95,7 +95,9 @@ impl Game<'_> {
/// Capability `recipes`'s tables and known displays for this tick's
/// snapshot; nothing while no running mod reads them.
pub(super) fn mod_recipes(&mut self) -> (Option<Arc<Recipes>>, Arc<[i32]>) {
if !self.mods.mods.wants_recipes() {
// The tables come from the world resources, which may still be
// loading behind the title on a slow start.
if !self.mods.mods.wants_recipes() || self.res.rest.get().is_none() {
return (None, Arc::from([]));
}
if self.mods.recipes.is_none() {
@@ -158,7 +158,10 @@ pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
ambient_interval: 80,
flags: Default::default(),
model: Arc::new(model),
layers: Default::default(),
// PlayerModel's slim arms, for slim skins.
layers: horizon_client::mobs::load_model(root, "minecraft:player_slim#main")
.map(|slim| [("player_slim#main".to_owned(), Arc::new(slim))].into())
.unwrap_or_default(),
texture: "entity/player/wide/steve".into(),
brain: Brain::Passive,
anim: Anim::Humanoid { zombie_arms: false },
+79 -27
View File
@@ -213,7 +213,8 @@ pub(super) struct Remote {
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
show_death_screen: bool,
player_type: Option<Arc<MobType>>,
/// Inventory as last synchronised, for creative-mode slot updates.
/// Inventory (then the off hand) as last synchronised, for creative-mode
/// slot updates.
synced: Vec<Option<ItemStack>>,
decode_errors: u32,
clock: Option<i32>,
@@ -510,7 +511,7 @@ impl Game<'_> {
death_message: None,
show_death_screen: true,
player_type,
synced: vec![None; 36],
synced: vec![None; 37],
decode_errors: 0,
clock: None,
clock_rate: 1.0,
@@ -3086,6 +3087,20 @@ impl Game<'_> {
.collect();
Some((players, r.online_mode, upside_down))
}
/// The signed-in account's `textures`, once the services answered.
fn own_textures(&self) -> Option<horizon_client::skins::Textures> {
use horizon_client::skins::{self, NameOrId, Resolve};
let auth = horizon_client::auth::current()?;
let state = self
.skin_profiles
.borrow_mut()
.state(&NameOrId::Id(auth.uuid));
let Resolve::Done(Some(profile)) = state else {
return None;
};
let (value, signature) = profile.textures()?;
Some(skins::unpack(&value, signature, &skins::TEXTURE_URIS))
}
/// `PlayerInfo.getSkin` for every player entity, including NPCs that are
/// never in the tab list: resolve each profile's textures and give the
/// level's player models their skin.
@@ -3094,12 +3109,23 @@ impl Game<'_> {
return;
};
let local = r.profile;
let entries: Vec<(u128, horizon_client::skins::Textures)> = r
let mut entries: Vec<(u128, horizon_client::skins::Textures)> = r
.skin_textures
.iter()
.map(|(uuid, textures)| (*uuid, textures.clone()))
.collect();
// Minecraft.profileFuture: the signed-in account's own profile, fetched from the
// services, gives the local player its skin where the server sends none (single
// player's offline-mode server).
if !entries.iter().any(|(u, t)| *u == local && t.skin.is_some())
&& let Some(own) = self.own_textures()
{
entries.retain(|(u, _)| *u != local);
entries.push((local, own));
}
let mut resolved: Vec<(u128, String)> = Vec::new();
// PlayerSkin.model of each resolved skin: the textures' `slim`.
let mut slim = std::collections::HashSet::new();
for (uuid, textures) in entries {
// PlayerInfo.createSkinLookup: only the local player's own skin
// may be unsigned.
@@ -3110,10 +3136,22 @@ impl Game<'_> {
})
};
if let Some(skin) = skin {
if textures.slim {
slim.insert(uuid);
}
resolved.push((uuid, skin));
}
}
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.again_skin(skin));
// PlayerInfo.getSkin: the downloaded skin and its model, else the
// profile's default skin (DefaultPlayerSkin.get), slim or wide.
let skin_of = |skins: &HashMap<u128, String>, uuid: u128| match skins.get(&uuid) {
Some(skin) => (skin.clone(), slim.contains(&uuid)),
None => {
let skin = horizon_client::tab_list::default_skin(uuid);
(skin.to_owned(), skin.contains("/slim/"))
}
};
let Some((level, skins, own)) = self.remote.as_mut().map(|r| {
r.skins = resolved.into_iter().collect();
(
@@ -3122,27 +3160,26 @@ impl Game<'_> {
.map(|(id, uuid)| (*id as u32, *uuid))
.collect::<Vec<_>>(),
r.skins.clone(),
r.skins.get(&r.profile).cloned(),
r.profile,
)
}) else {
return;
};
for (id, uuid) in level {
let Some(skin) = skins.get(&uuid) else {
continue;
};
let (skin, is_slim) = skin_of(&skins, uuid);
if let Some(mob) = self
.mobs
.iter_mut()
.find(|m| m.id == id && &*m.kind.id == "minecraft:player")
{
mob.skin = Some(skin.clone());
mob.skin = Some(skin);
mob.slim = is_slim;
}
}
if let Some(skin) = own
&& let Some(mob) = self.player_model.as_mut()
{
if let Some(mob) = self.player_model.as_mut() {
let (skin, is_slim) = skin_of(&skins, own);
mob.skin = Some(skin);
mob.slim = is_slim;
}
// ClientMannequin.updateSkin (PlayerSkinRenderCache.lookup): a
// mannequin's DATA_PROFILE `textures` property resolves like a
@@ -3192,7 +3229,7 @@ impl Game<'_> {
(m.id, textures)
})
.collect();
let mut resolved: Vec<(u32, Option<String>)> = Vec::new();
let mut resolved: Vec<(u32, Option<String>, bool)> = Vec::new();
for (id, textures) in mannequins {
let skin = {
let mut downloads = self.skin_downloads.borrow_mut();
@@ -3200,15 +3237,17 @@ impl Game<'_> {
downloads.state(kind, url)
})
};
resolved.push((id, skin));
let slim = skin.is_some() && textures.slim;
resolved.push((id, skin, slim));
}
for (id, skin) in resolved {
for (id, skin, slim) in resolved {
let skin = skin.filter(|s| !s.starts_with("skins/") || self.again_skin(s));
if let Some(mob) = self
.mobs
.iter_mut()
.find(|m| m.id == id && &*m.kind.id == "minecraft:mannequin")
{
mob.slim = slim && skin.is_some();
mob.skin = skin;
}
}
@@ -3592,6 +3631,7 @@ impl Game<'_> {
self.sleep_timer = super::tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
// Gui.tick: the titles, the action bar and the held item's name.
self.tick_hud();
self.tick_subtitles();
let entity_collisions = self.player_entity_collisions();
let pushed = self.remote_pushes();
let Some(player) = self.player.as_mut() else {
@@ -4307,12 +4347,14 @@ impl Game<'_> {
{
out.push(packet);
}
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot).
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot);
// `synced[36]` is the off hand.
let mut creative_slots = Vec::new();
if self.creative {
for i in 0..36 {
if self.inventory[i] != r.synced[i] {
r.synced[i] = self.inventory[i].clone();
for i in 0..37 {
let stack = self.inventory.get(i).unwrap_or(&self.offhand);
if *stack != r.synced[i] {
r.synced[i] = stack.clone();
creative_slots.push(i);
}
}
@@ -4321,8 +4363,13 @@ impl Game<'_> {
self.send(packet);
}
for i in creative_slots {
let slot = if i < 9 { 36 + i } else { i } as i16;
let item = self.to_network(&self.inventory[i]);
// InventoryMenu's slots: the hotbar is 36..=44, the off hand 45.
let slot = match i {
0..9 => 36 + i,
36 => 45,
_ => i,
} as i16;
let item = self.to_network(self.inventory.get(i).unwrap_or(&self.offhand));
self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot,
item,
@@ -4331,7 +4378,8 @@ impl Game<'_> {
}
/// Inventory as the server last described it.
fn remote_synced(&mut self) {
let inventory = self.inventory.clone();
let mut inventory = self.inventory.clone();
inventory.push(self.offhand.clone());
if let Some(r) = self.remote.as_mut() {
r.synced = inventory;
}
@@ -5055,8 +5103,11 @@ impl Game<'_> {
// countdown goes on past zero (LivingEntity.updateUsingItem completes
// only on the server) until the server's USE_ITEM_COMPLETE.
let remaining = consumable.ticks() - ticks as i32;
if consumable.particles && consumable.should_emit(remaining) {
self.eat_particles(&stack, 5);
if consumable.should_emit(remaining) {
// `emit_consume` plays the eat sound and spawns the bites (when the
// item has consume particles), as `tick_used_item` does on the
// player's own server.
self.emit_consume(&consumable, &stack, 5);
}
self.using = Some((slot, ticks + 1));
}
@@ -5337,11 +5388,12 @@ impl Game<'_> {
KeyCode::KeyE => {
// The inventory opens without telling the server.
self.recipe_ui.reset_screen();
self.screen = if self.creative {
Screen::Creative
if self.creative {
self.screen = Screen::Creative;
self.creative_screen.reopen();
} else {
Screen::Inventory
};
self.screen = Screen::Inventory;
}
self.craft_size = 2;
self.capture(false);
Ok(true)
+15 -4
View File
@@ -108,7 +108,11 @@ impl Game<'_> {
rain,
thunder,
skybox: self.dim == Dim::Overworld,
blindness: None,
blindness: self
.potions
.effects
.get("blindness")
.map(|effect| (effect.duration, effect.duration == -1)),
darkness: self.darkness_fog(),
darken_world: self
.boss_overlay
@@ -140,7 +144,8 @@ impl Game<'_> {
} else {
63.0
};
camera.y < horizon
self.dim == Dim::Overworld
&& camera.y < horizon
&& fog_type(
&self.world,
self.res.facts(),
@@ -209,8 +214,8 @@ impl Game<'_> {
percent_frozen: p.percent_frozen(),
partial,
portal: [fx.o_portal, fx.portal],
nausea: 0.0,
screen_effect_scale: 1.0,
nausea: self.nausea_blend(partial),
screen_effect_scale: self.screen_effect_scale(),
};
let mut scope = self.scope_scale;
let gui_overlays =
@@ -247,6 +252,12 @@ impl Game<'_> {
let brightness = self.eye_brightness(e);
let inside_portal = self.client_portal_effect();
self.screen_fx.tick(brightness, inside_portal);
let nausea = self
.potions
.effects
.get("nausea")
.map(|e| !e.ends_within(60));
self.screen_fx.tick_spinning(nausea);
// LocalPlayer water vision and AtmosphericFogEnvironment rain fog.
let in_water =
fog_type(&self.world, self.res.facts(), e, self.forward()) == FogType::Water;
@@ -269,6 +269,8 @@ impl Game<'_> {
open.requests.push((p.id, p.url, p.hash));
open.required = p.required;
open.prompt = text;
// Gui.setScreen: KeyMapping.releaseAll, so no walking behind the prompt.
self.keys.clear();
self.capture(false);
}
/// `handleResourcePackPop`: one pack, or all of them.
+18 -8
View File
@@ -2059,9 +2059,16 @@ impl Game<'_> {
);
}
}
// ItemStack.OP_NBT_WARNING, for a player with game master permission.
if !nested && self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) {
for (key, fallback) in [
// ItemStack.OP_NBT_WARNING: `BlockItem` and `SpawnEggItem`
// `.shouldPrintOpWarning` for a player with game master permission;
// a nested projectile has no player. The first line is bold.
if !nested
&& self.ui.registries.permission >= 2
&& (item_info.is_some_and(|i| i.block.is_some())
&& block_entity_item::op_warning(stack)
|| block_entity_item::spawn_egg_op_warning(stack))
{
for (i, (key, fallback)) in [
("item.op_warning.line1", "Warning:"),
(
"item.op_warning.line2",
@@ -2071,12 +2078,15 @@ impl Game<'_> {
"item.op_warning.line3",
"Do not use unless you know the exact contents!",
),
] {
push(
&mut lines,
self.translate(key, fallback),
]
.into_iter()
.enumerate()
{
let text = horizon_client::text::Text::legacy(&self.translate(key, fallback));
lines.push((
if i == 0 { text.bold() } else { text },
rgba(255, 85, 85, 255),
);
));
}
}
lines
+9 -7
View File
@@ -105,12 +105,9 @@ const COMPLETE_COLUMN: u32 = (1 << (crate::chunk::HEIGHT / 16)) - 1;
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
/// The world pass again after occlusion culling's test: it loads both
/// targets (phase 2 draws over phase 1, reading the test's results).
/// LevelRenderer: no sky inside lava or powder snow (sky fog ends within 2 blocks).
/// `LevelRenderer.addSkyPass`: fluids and camera effects block the sky pass.
fn sky_open(frame: &Frame) -> bool {
match frame.fog {
Some(f) => f.sky_end > 2.0,
None => !frame.underwater,
}
!frame.sky_blocked
}
/// The Overworld-style sky (disc, sunset, stars, void) draws this frame.
@@ -222,6 +219,9 @@ pub struct Frame<'a> {
/// The `GameRenderer.bobHurt`/`bobView` pose multiplied into the
/// projection (`crate::camera::bob_matrix`).
pub bob: Mat4,
/// The spinning effect multiplied into the level's projection only
/// (`crate::camera::spinning_effect_matrix`), not the hand's.
pub spin: Mat4,
pub sky: Sky,
/// Render distance in blocks (fog end).
pub distance: f32,
@@ -235,6 +235,8 @@ pub struct Frame<'a> {
pub cave_culling: bool,
/// Title screen: only the dedicated panorama cube map and the HUD.
pub title: bool,
/// Lava, powder snow, blindness or darkness blocks the sky pass.
pub sky_blocked: bool,
/// Dimensions without a skybox (the Nether) clear to the fog colour.
pub skybox: bool,
/// The End: clear to black and draw the resident sky box behind the world.
@@ -3418,7 +3420,7 @@ impl Renderer {
frame.fov_degrees,
frame.yaw,
frame.pitch,
frame.bob,
frame.bob * frame.spin,
frame.depth_far,
)
}
@@ -4508,7 +4510,7 @@ impl Renderer {
cull.bind_depth(&self.device, world_depth);
}
let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0);
let clear = if frame.title || (frame.end_sky && !frame.underwater) {
let clear = if frame.title || (frame.end_sky && !frame.underwater && sky_open(frame)) {
wgpu::Color::BLACK
} else {
wgpu::Color {
+3 -2
View File
@@ -361,8 +361,9 @@ impl NativePost {
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
let projection =
super::world_projection(frame.fov_degrees, aspect, frame.depth_far) * frame.bob;
let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far)
* frame.bob
* frame.spin;
// A celestial body's direction at `angle` degrees (SkyRenderer: Y -90°, then X by the
// angle, of straight up): overhead at 0, rising in the east (+x).
let body = |angle: f32| {
@@ -287,7 +287,8 @@ impl FrameUniforms {
aspect,
0.05,
frame.depth_far,
) * frame.bob;
) * frame.bob
* frame.spin;
let camera = frame.camera.as_vec3();
let (previous_camera, previous_view, previous_projection) =
self.previous
+3
View File
@@ -937,6 +937,8 @@ pub struct Mob {
/// The skin texture of a player mob (`PlayerInfo.getSkin`), which
/// replaces the type's default when its download is ready.
pub skin: Option<String>,
/// `PlayerSkin.model` is `SLIM`: the player model with 3-pixel arms.
pub slim: bool,
/// A mannequin's `DATA_PROFILE` (ResolvableProfile): the profile's
/// `textures` property resolves to `skin` like a player's.
pub profile: Option<horizon_protocol::entity_data::Profile>,
@@ -1908,6 +1910,7 @@ impl Mob {
rider: None,
outbox: Vec::new(),
skin: None,
slim: false,
profile: None,
attrs: Attrs {
speed: kind.speed,
@@ -405,3 +405,43 @@ impl Goal for Rolled {
scared(m)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mobs::sensor_scan_tests::kind;
use std::sync::Arc;
/// An armadillo on the ground.
fn armadillo() -> Mob {
let mut kind = kind();
let k = Arc::make_mut(&mut kind);
k.id = "minecraft:armadillo".into();
k.species = Species::Armadillo;
let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0);
m.on_ground = true;
m
}
#[test]
fn a_hit_rolls_it_up_into_a_ball() {
// Armadillo.actuallyHurt: a hit by a living thing curls it up and
// keeps the danger in mind for 80 ticks, so the model shows the
// shell instead of the armadillo (#2759).
let mut m = armadillo();
after_hurt(&mut m, &Damage::player([0.0, 0.0, 0.0]));
assert_eq!(state(&m), ROLLING);
// Armadillo.rollUp: the roll sound.
assert!(
m.outbox
.iter()
.any(|e| matches!(e, MobEvent::Sound(s, ..) if *s == "entity.armadillo.roll")),
"the roll plays its sound"
);
// ArmadilloState.shouldHideInShell: the shell shows after five ticks.
for _ in 0..6 {
tick(&mut m);
}
assert!(hiding_in_shell(&m), "the model shows the shell");
}
}
+4 -2
View File
@@ -115,8 +115,9 @@ pub fn body(m: &Mob) -> (String, String) {
}
};
if avatar(m) {
// AvatarRenderer: PlayerModel's slim layer for a slim skin.
return (
"main".into(),
if m.slim { "player_slim#main" } else { "main" }.into(),
m.skin.clone().unwrap_or_else(|| m.kind.texture.clone()),
);
}
@@ -784,7 +785,8 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
/// `HumanoidArmorLayer`: the armour models of the mob's main model with the
/// equipment textures (leather tinted, with its overlay).
fn humanoid_armor(m: &Mob, v: &mut Vec<Layer>) {
if super::equine::is_equine(m.species()) {
// A player's or mannequin's armour is drawn by the game's armour layers.
if super::equine::is_equine(m.species()) || avatar(m) {
return;
}
let (model, _) = body(m);
@@ -1331,6 +1331,7 @@ impl Def {
let mut v = vec![base];
let extra: &[&str] = match self.species {
Species::Pig => &["pig_baby", "cold_pig"],
Species::Mannequin => &["player_slim"],
Species::Cow => &[
"cow_baby",
"cold_cow",
+44 -1
View File
@@ -1551,7 +1551,10 @@ impl MotionWorld<'_, '_, '_> {
// Only the server broadcasts event 53 (`!level.isClientSide()`).
let particles = !self.player.client_side
&& random.next_int_bound(5).map_err(|e| format!("{e:?}"))? == 0;
if sound || particles {
// The server also checks the slide criterion every 20 game ticks,
// including ticks without a sound or a particle event.
let criterion = !self.player.client_side && self.scene.game_time % 20 == 0;
if sound || particles || criterion {
self.player.events.push(MotionEvent::HoneySlide {
pos,
sound,
@@ -3511,6 +3514,46 @@ mod tests {
}
}
#[test]
fn a_server_copy_of_a_riptide_spin_counts_down_and_stands_again() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 10.0, 20.5)).unwrap();
player.auto_spin_attack_ticks = 2;
player.auto_spin_attack_dmg = 8.0;
let mut w = MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
};
w.update_player_pose().unwrap();
assert_eq!(w.player.pose, crate::pose::Pose::SpinAttack);
w.player.count_down_server_spin();
w.update_player_pose().unwrap();
assert_eq!(w.player.pose, crate::pose::Pose::SpinAttack);
assert_eq!(w.player.auto_spin_attack_dmg, 8.0);
w.player.count_down_server_spin();
assert_eq!(w.player.auto_spin_attack_ticks, 0);
assert_eq!(w.player.auto_spin_attack_dmg, 0.0);
w.update_player_pose().unwrap();
assert_eq!(w.player.pose, crate::pose::Pose::Standing);
assert_eq!(w.player.dimensions.eye_height, 1.62);
// Nothing to count once the spin is over.
w.player.count_down_server_spin();
assert_eq!(w.player.auto_spin_attack_ticks, 0);
}
#[test]
fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
+58 -7
View File
@@ -73,6 +73,17 @@ pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
Opt::RotateWithMinecarts,
Opt::HighContrastBlockOutlines,
];
/// `MouseSettingsScreen.options` in Java Edition 26.3, in vanilla order.
pub(crate) const MOUSE_OPTIONS: &[Opt] = &[
Opt::Sensitivity,
Opt::WheelSensitivity,
Opt::DiscreteScroll,
Opt::InvertX,
Opt::InvertY,
Opt::AllowCursorChanges,
];
impl Opt {
/// Keep controls visible while their underlying systems are still missing.
pub(crate) fn available(self) -> bool {
@@ -89,7 +100,6 @@ impl Opt {
| Opt::DistortionEffects
| Opt::MonochromeLogo
| Opt::RotateWithMinecarts
| Opt::HighContrastBlockOutlines
// Saved, but nothing in rendering reads them yet.
| Opt::ChunkFade
| Opt::ImprovedTransparency
@@ -129,8 +139,10 @@ impl Opt {
Opt::SimulationDistance => "options.simulationDistance",
Opt::Sensitivity => "options.sensitivity",
Opt::WheelSensitivity => "options.mouseWheelSensitivity",
Opt::InvertX => "options.invertMouseX",
Opt::InvertY => "options.invertMouseY",
Opt::DiscreteScroll => "options.discrete_mouse_scroll",
Opt::AllowCursorChanges => "options.allowCursorChanges",
Opt::Volume(_) => "soundCategory.master",
Opt::GuiScale => "options.guiScale",
Opt::ViewBobbing => "options.viewBobbing",
@@ -454,8 +466,10 @@ impl Opt {
Opt::NarratorHotkey => on_off(s.file.bool("narratorHotkey")),
Opt::RotateWithMinecarts => on_off(s.file.bool("rotateWithMinecart")),
Opt::HighContrastBlockOutlines => on_off(s.file.bool("highContrastBlockOutline")),
Opt::InvertX => on_off(s.invert_x),
Opt::InvertY => on_off(s.invert_y),
Opt::DiscreteScroll => on_off(s.discrete_scroll),
Opt::AllowCursorChanges => on_off(s.allow_cursor_changes),
Opt::ViewBobbing => on_off(s.view_bobbing),
Opt::Vsync => on_off(s.vsync),
Opt::Fullscreen => on_off(s.fullscreen),
@@ -652,6 +666,7 @@ impl Opt {
Opt::TextBackground
| Opt::HideLightningFlashes
| Opt::HideSplashTexts
| Opt::HighContrastBlockOutlines
| Opt::HideMatchedNames
| Opt::OnlySecureChat
| Opt::SaveChatDrafts => {
@@ -659,8 +674,10 @@ impl Opt {
s.file
.set(key, crate::options_file::V::Bool(!s.file.bool(key)));
}
Opt::InvertX => s.invert_x = !s.invert_x,
Opt::InvertY => s.invert_y = !s.invert_y,
Opt::DiscreteScroll => s.discrete_scroll = !s.discrete_scroll,
Opt::AllowCursorChanges => s.allow_cursor_changes = !s.allow_cursor_changes,
Opt::ViewBobbing => s.view_bobbing = !s.view_bobbing,
Opt::Vsync => s.vsync = !s.vsync,
Opt::Fullscreen => s.fullscreen = !s.fullscreen,
@@ -1434,12 +1451,7 @@ impl OptionsUi {
]));
rows
}
Page::Mouse => pairs(&[
Opt::Sensitivity,
Opt::WheelSensitivity,
Opt::DiscreteScroll,
Opt::InvertY,
]),
Page::Mouse => pairs(MOUSE_OPTIONS),
Page::Chat => pairs(&[
Opt::ChatVisibility,
Opt::ChatColors,
@@ -2876,6 +2888,45 @@ mod tests {
assert_ne!(s.text_background(0.0), chat_only);
}
#[test]
fn mouse_settings_match_java_26_3_option_order() {
let mut ui = OptionsUi::default();
ui.show(Page::Mouse);
let mut options = Vec::new();
for row in ui.list_rows(&Settings::default()) {
if let Row::Pair(a, b) = row {
options.push(a);
if let Some(b) = b {
options.push(b);
}
}
}
assert_eq!(
options,
vec![
Opt::Sensitivity,
Opt::WheelSensitivity,
Opt::DiscreteScroll,
Opt::InvertX,
Opt::InvertY,
Opt::AllowCursorChanges,
]
);
}
#[test]
fn mouse_settings_round_trip_with_java_defaults() {
let mut settings = Settings::default();
assert!(!settings.invert_x);
assert!(settings.allow_cursor_changes);
Opt::InvertX.cycle(&mut settings, false, [854, 480]);
Opt::AllowCursorChanges.cycle(&mut settings, false, [854, 480]);
let loaded = Settings::parse(&settings.serialize());
assert!(loaded.invert_x);
assert!(!loaded.allow_cursor_changes);
assert!(!loaded.file.bool("allowCursorChanges"));
}
#[test]
fn accessibility_has_all_options_and_disabled_placeholders() {
let mut s = Settings::default();
+12
View File
@@ -78,6 +78,18 @@ impl Dimensions {
}
}
impl PlayerMotion {
/// `LivingEntity.aiStep` counting down `autoSpinAttackTicks` for a
/// server's copy of a player, which runs no client tick: the spin ends
/// after its ticks and its damage goes with it, so the pose (and the eye
/// a bow or a trident is shot from) comes back to standing.
pub fn count_down_server_spin(&mut self) {
if self.auto_spin_attack_ticks > 0 {
self.auto_spin_attack_ticks -= 1;
if self.auto_spin_attack_ticks <= 0 {
self.auto_spin_attack_dmg = 0.0;
}
}
}
pub fn dimensions_for_pose(&mut self, pose: Pose) -> Result<Dimensions> {
Ok(pose.dimensions(self.attribute("minecraft:scale")? as f32))
}
@@ -69,6 +69,12 @@ pub struct ScreenEffects {
pub vignette_brightness: f32,
pub portal: f32,
pub o_portal: f32,
/// Nausea's `MobEffectInstance.BlendState` factor and the previous tick's.
pub nausea: f32,
pub o_nausea: f32,
/// `LocalPlayer.spinningEffectTime` and `spinningEffectSpeed` (degrees).
pub spinning_time: f32,
pub spinning_speed: f32,
}
impl Default for ScreenEffects {
fn default() -> Self {
@@ -76,6 +82,10 @@ impl Default for ScreenEffects {
vignette_brightness: 1.0,
portal: 0.0,
o_portal: 0.0,
nausea: 0.0,
o_nausea: 0.0,
spinning_time: 0.0,
spinning_speed: 0.0,
}
}
}
@@ -105,6 +115,28 @@ impl ScreenEffects {
};
self.portal = (self.portal + step).clamp(0.0, 1.0);
}
/// `LocalPlayer.tickSpinningEffect` and Nausea's blend. `nausea` is `None`
/// without the effect, else whether it is past its blend-out point
/// (`!endsWithin(60)`, `MobEffects.NAUSEA`).
pub fn tick_spinning(&mut self, nausea: Option<bool>) {
self.o_nausea = self.nausea;
match nausea {
// BlendState.tick: fades in over 150 ticks and out over 20.
Some(active) => {
let (target, duration) = if active { (1.0, 150.0) } else { (0.0, 20.0) };
self.nausea += (target - self.nausea).clamp(-1.0 / duration, 1.0 / duration);
}
// The blend state belongs to the instance, so a new one starts at 0.
None => self.nausea = 0.0,
}
if self.portal > 0.0 || self.nausea > 0.0 {
self.spinning_speed =
(self.portal * 20.0 + self.nausea * 7.0) / (self.portal + self.nausea);
self.spinning_time += self.spinning_speed;
} else {
self.spinning_speed = 0.0;
}
}
pub fn build(&self, input: &ScreenInputs) -> Vec<ScreenQuad> {
let mut out = Vec::new();
let sprite = |name: &str| input.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
+8 -1
View File
@@ -167,6 +167,8 @@ pub struct Settings {
/// Every option in its stored form (the typed fields above mirror it).
pub file: OptionsFile,
pub invert_x: bool,
/// Allow widgets to replace the default mouse cursor icon.
pub allow_cursor_changes: bool,
pub toggle_attack: bool,
pub toggle_use: bool,
/// Taps of the sprint key counted as double taps (sprintWindow).
@@ -222,6 +224,7 @@ impl Default for Settings {
invert_y: false,
mouse_wheel_sensitivity: 1.0,
discrete_scroll: false,
allow_cursor_changes: true,
toggle_crouch: false,
toggle_sprint: false,
auto_jump: false,
@@ -408,6 +411,7 @@ impl Settings {
invert_y: f.bool("invertYMouse"),
mouse_wheel_sensitivity: f.double("mouseWheelSensitivity") as f32,
discrete_scroll: f.bool("discrete_mouse_scroll"),
allow_cursor_changes: f.bool("allowCursorChanges"),
toggle_crouch: f.bool("toggleCrouch"),
toggle_sprint: f.bool("toggleSprint"),
toggle_attack: f.bool("toggleAttack"),
@@ -497,6 +501,7 @@ impl Settings {
self.mouse_wheel_sensitivity,
);
put_b(&mut f, "discrete_mouse_scroll", self.discrete_scroll);
put_b(&mut f, "allowCursorChanges", self.allow_cursor_changes);
put_b(&mut f, "toggleCrouch", self.toggle_crouch);
put_b(&mut f, "toggleSprint", self.toggle_sprint);
put_b(&mut f, "toggleAttack", self.toggle_attack);
@@ -616,8 +621,10 @@ pub enum Opt {
SimulationDistance,
Sensitivity,
WheelSensitivity,
InvertY,
DiscreteScroll,
InvertX,
InvertY,
AllowCursorChanges,
Volume(usize),
GuiScale,
ViewBobbing,
+24
View File
@@ -17,6 +17,8 @@ pub enum Signature {
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Textures {
pub skin: Option<String>,
/// The skin's `metadata.model` is `slim` (`PlayerModelType.SLIM`).
pub slim: bool,
pub cape: Option<String>,
pub elytra: Option<String>,
pub signed: bool,
@@ -101,6 +103,13 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Texture
if !allowed {
return empty;
}
if kind == "SKIN" {
out.slim = texture
.get("metadata")
.and_then(|m| m.get("model"))
.and_then(|m| m.as_str())
== Some("slim");
}
let slot = match kind.as_str() {
"SKIN" => &mut out.skin,
"CAPE" => &mut out.cape,
@@ -809,6 +818,21 @@ pub fn sha1(data: &[u8]) -> [u8; 20] {
#[cfg(test)]
mod tests {
/// `MinecraftProfileTexture.getMetadata("model")`: `slim` marks a slim
/// skin; no metadata is the wide (classic) model.
#[test]
fn a_skins_model_comes_from_its_metadata() {
use super::{Signature, TEXTURE_URIS, unpack};
let slim = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2FiYyIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19";
let wide = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2FiYyJ9fX0=";
let t = unpack(slim, Signature::Signed, &TEXTURE_URIS);
assert_eq!(
t.skin.as_deref(),
Some("https://textures.minecraft.net/texture/abc")
);
assert!(t.slim);
assert!(!unpack(wide, Signature::Signed, &TEXTURE_URIS).slim);
}
#[test]
fn profiles_parse_like_resolvable_profiles() {
use super::{NameOrId, Signature, parse_profile};
+5 -5
View File
@@ -632,9 +632,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
@@ -950,14 +950,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
"webpki-roots 1.0.10",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [
"rustls-pki-types",
]
+42 -4
View File
@@ -265,7 +265,7 @@ impl App {
/// The address requests are counted under, for the rate limits only: kept in memory, never
/// stored or logged. Behind a proxy (Railway) the TCP peer is the proxy, so it is the proxy's
/// `X-Real-IP`, else the last `X-Forwarded-For` entry: the one the proxy added, which a client
/// can't choose (the first one it can).
/// can't choose (the first one it can). An IPv6 client counts by its /64.
fn client(&self, headers: &HeaderMap, peer: Option<SocketAddr>) -> IpAddr {
let header = |name: &str| headers.get(name)?.to_str().ok();
let forwarded = self
@@ -284,9 +284,11 @@ impl App {
})
})
.flatten();
forwarded
.or(peer.map(|p| p.ip()))
.unwrap_or(IpAddr::from([0, 0, 0, 0]))
rate_key(
forwarded
.or(peer.map(|p| p.ip()))
.unwrap_or(IpAddr::from([0, 0, 0, 0])),
)
}
/// The day an install's newest row came in, if it has any left.
@@ -339,6 +341,22 @@ fn take<K: Hash + Eq>(buckets: &Mutex<HashMap<K, (f64, Instant)>>, per_hour: f64
true
}
/// The address a client's requests count under: IPv4 (an IPv4-mapped IPv6 address as IPv4 too),
/// or an IPv6 address's /64, which one home or host holds whole, so addresses rotated inside it
/// don't get fresh buckets.
fn rate_key(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
Some(v4) => IpAddr::V4(v4),
None => {
let s = v6.segments();
IpAddr::V6(std::net::Ipv6Addr::new(s[0], s[1], s[2], s[3], 0, 0, 0, 0))
}
},
v4 => v4,
}
}
fn peer(req: &Request) -> Option<SocketAddr> {
req.extensions()
.get::<ConnectInfo<SocketAddr>>()
@@ -580,3 +598,23 @@ pub fn router(app: Arc<App>) -> Router {
.route("/healthz", get(|| async { "ok" }))
.with_state(app)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ipv6_rates_count_by_64() {
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
// Rotated inside a /64: one key. Another /64: another key.
assert_eq!(rate_key(ip("2001:db8::1")), rate_key(ip("2001:db8::99")));
assert_ne!(rate_key(ip("2001:db8::1")), rate_key(ip("2001:db8:1::1")));
// IPv4-mapped counts as the IPv4 it is.
assert_eq!(
rate_key(ip("::ffff:203.0.113.7")),
IpAddr::from([203, 0, 113, 7])
);
// Plain IPv4 unchanged.
assert_eq!(rate_key(ip("203.0.113.8")), IpAddr::from([203, 0, 113, 8]));
}
}
@@ -261,6 +261,21 @@ async fn an_address_is_rate_limited() {
s.send(from("203.0.113.9, 198.51.100.7", None)).await.0,
StatusCode::NO_CONTENT
);
// Two IPv6 addresses of one /64 share a bucket; another /64 doesn't.
for forwarded in ["2001:db8::1", "2001:db8::2", "2001:db8::3"] {
assert_eq!(
s.send(from(forwarded, None)).await.0,
StatusCode::NO_CONTENT
);
}
assert_eq!(
s.send(from("2001:db8::ffff:ffff", None)).await.0,
StatusCode::TOO_MANY_REQUESTS
);
assert_eq!(
s.send(from("2001:db8:1::1", None)).await.0,
StatusCode::NO_CONTENT
);
}
#[tokio::test]
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -1
View File
@@ -158,7 +158,9 @@ of the main action. The game's menus keep `LIGHT` and `DARK`.
- **Pixel type for what names and what you act on.** Headings, navigation, buttons and the
wordmark use Pixelify Sans (OFL; Medium, SemiBold, Bold); paragraphs and numbers stay in Geist
and Geist Mono so long text reads easily.
and Geist Mono so long text reads easily. The bundled copies add the two uppercase Cyrillic
letters upstream leaves out, О and П: the Latin O's glyph and the lowercase п lifted to that
weight's cap height, so a Russian heading stays in the pixel face.
- **Blocks, not rounded cards.** Every surface is square: a 2 px dark outline, a 2 px light bevel
along the top and a darker lip along the bottom. The main action is the violet face with its own
bevel and a 4 px lip; it sinks a pixel when pressed. Code and fields are sunk wells.
+36 -7
View File
@@ -119,10 +119,39 @@ ones only there.
## Merging
Pull requests merge with a merge commit. The orchestrator sessions review a pull request, then turn on
auto-merge (`gh pr merge <n> --auto --merge`): GitHub merges it once the required checks pass. Branch
protection on main requires `verify`. Branches don't have to be up to date with main: two sessions merge in
parallel, and main's own `checks` run catches the rare semantic conflict.
Pull requests merge with a merge commit, by hand: never `gh pr merge --auto`. Branch protection on main
requires `verify` for everyone, and Ludovic's review for the files `.github/CODEOWNERS` lists (what builds, signs
and ships releases). Branches don't have to be up to date with main: main's own `checks` run catches the rare
semantic conflict.
**Who merges.** The Horizon Team (GitHub team `horizon-team`: kacpherek, levzzz5154, rdbtCVS, Online5277) and
Ludovic. The orchestrator's agents prove only the risky pull requests (below), so a contributor's ordinary pull
request doesn't wait for them.
**An ordinary pull request merges on CI.** A Team member:
1. reads the whole diff (for a fork, before adding `run-parity`: that runs the fork's code on a maintainer's machine);
2. adds `run-parity` to a fork's pull request; our own branches run `parity` by themselves. Since #2448 it tests
the pull request merged into main;
3. merges once `verify` and `parity` pass, nobody's change request is open, and every comment has been read (a
"don't merge" from anyone holds it).
Never merge your own pull request: another Team member reviews and merges it. Never force-push or rebase a
contributor's branch; push a fixup commit, or merge main into it.
**A risky pull request gets the `needs-proof` label** and waits for an agent's local proof (both single-player
paths, `lan_e2e`, a screenshot for anything visible) before it merges. Risky means any of:
- it changes what is saved or loaded (`save.rs`, `entity_nbt.rs`, `block_entity_nbt.rs`, `region.rs`, `datafix/`):
a bug there damages players' worlds;
- the network or the server thread: `horizon-protocol`, `net.rs`, `remote*.rs`, `lan/`, `lan*.rs`,
`integrated.rs`, `levels.rs`, `dedicated*`;
- world generation (`worldgen/`): seeds must keep generating the same terrain;
- the launcher's installs and updates, or the mod runtime;
- the harnesses themselves (`tools/test_*_parity.py`, the Java oracles): a wrong harness hides regressions;
- over ~500 lines, or `parity` failed, was skipped or ran no harness for gameplay code.
Unsure? Add `needs-proof`: it costs a few hours, a broken release costs more.
`parity` becomes required once it has run green on pull requests for a while (a week without a false failure):
@@ -131,7 +160,7 @@ gh api -X POST repos/horizon-rust/horizon/branches/main/protection/required_stat
```
A skipped `parity` (a fork's pull request without the label) counts as passed, so requiring it never blocks a
fork's pull request; the maintainer decides whether to run it.
fork's pull request: the Team member decides whether to run it, and a skipped run makes the pull request risky.
## Parity on main
@@ -423,7 +452,7 @@ before the gate.
### Trusted reviewers
One list, `.github/trusted-reviewers.txt`, governs two things for the people on it (kacpherek, rdbtCVS and
Online5277 since 2026-10-27) and for Ludovic, with no repository role: `/nightly` (below) and triage by comment.
Online5277 since 2026-10-27, levzzz5154 since 2026-10-28) and for Ludovic, with no repository role: `/nightly` (below) and triage by comment.
```text
/close closes an issue as completed; a pull request is closed without merging
@@ -449,7 +478,7 @@ Online5277 since 2026-10-27) and for Ludovic, with no repository role: `/nightly
### Trusted reviewers pick the Nightly's pull requests
The Nightly is where contributors test pull requests before the gate. The trusted reviewers listed in
`.github/trusted-reviewers.txt` (kacpherek, rdbtCVS and Online5277 since 2026-10-27) and Ludovic pick them with a
`.github/trusted-reviewers.txt` (kacpherek, rdbtCVS and Online5277 since 2026-10-27, levzzz5154 since 2026-10-28) and Ludovic pick them with a
comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `tools/ci/nightly_set.py`):
```text
+5 -5
View File
@@ -5062,9 +5062,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
@@ -6871,14 +6871,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
"webpki-roots 1.0.10",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [
"rustls-pki-types",
]
+1 -1
View File
@@ -73,7 +73,7 @@ class AllowTests(unittest.TestCase):
def test_the_repos_list_names_the_three_reviewers(self):
reviewers = ns.read_reviewers((ROOT / ns.REVIEWERS).read_text())
self.assertEqual(reviewers.logins, {"kacpherek", "rdbtcvs", "online5277"})
self.assertEqual(reviewers.logins, {"kacpherek", "rdbtcvs", "online5277", "levzzz5154"})
self.assertFalse(reviewers.paused)
class RefusalTests(unittest.TestCase):
+1 -1
View File
@@ -104,7 +104,7 @@ class AllowTests(unittest.TestCase):
def test_the_repos_list_is_the_nightlys(self):
self.assertEqual(ns.REVIEWERS, ".github/trusted-reviewers.txt")
listed = ns.read_reviewers((ROOT / ns.REVIEWERS).read_text())
self.assertEqual(listed.logins, {"kacpherek", "rdbtcvs", "online5277"})
self.assertEqual(listed.logins, {"kacpherek", "rdbtcvs", "online5277", "levzzz5154"})
self.assertFalse(listed.paused)
+15 -1
View File
@@ -86,25 +86,39 @@ impl Window {
struct Mouse { ignore_first: bool }
impl Mouse { fn set_ignore_first_move(&mut self) { self.ignore_first = true; } }
struct Remote;
#[derive(Default)]
struct ServerPacks { prompt: Option<()> }
struct Game {
focused: bool, agent_pointer: bool, window: Option<Window>, mouse: Mouse,
captured: bool, title: Option<()>, screen: Screen, remote: Option<Remote>,
mining: bool, lan_acting: Option<u32>, renderer: Option<Renderer>, cursor: (f32, f32),
server_packs: ServerPacks,
}
impl Default for Game {
fn default() -> Self {
Self { focused: true, agent_pointer: false, window: Some(Window::default()),
mouse: Mouse::default(), captured: false, title: None,
screen: Screen::Playing, remote: None, mining: false, lan_acting: None,
renderer: None, cursor: (0.0, 0.0) }
renderer: None, cursor: (0.0, 0.0), server_packs: ServerPacks::default() }
}
}
impl Game {
fn stop_mining(&mut self) { self.mining = false; }
fn reset_inventory_clicks(&mut self) {}
fn dialog_open(&self) -> bool { false }
METHODS
}
fn main() {
// The server pack prompt keeps the pointer: nothing grabs it while it is open.
{
let mut game = Game::default();
game.server_packs.prompt = Some(());
game.capture(true);
assert!(!game.captured);
game.server_packs.prompt = None;
game.capture(true);
assert!(game.captured);
}
// New and existing worlds, returning to title, and reopening a world.
for remote in [false, true] {
let mut game = Game::default();
+7 -5
View File
@@ -274,8 +274,8 @@ both sides go to --out (host-*.png, guest-*.png).
python3 tools/lan_e2e.py --check chest --check tab
python3 tools/lan_e2e.py --server-thread --check portals # the host's world on its server thread
Needs the prepared resources in .local/horizon-client and, on Linux, vscreen (the desktop); on
macOS both games render offscreen. Games started here have no audio (HORIZON_NO_AUDIO=1).
Needs the prepared resources in .local/horizon-client. On Linux the games run on vscreen (the
desktop), or headless (offscreen, no pause) where there is none; on macOS both games render offscreen. Games started here have no audio (HORIZON_NO_AUDIO=1).
"""
import argparse
import json
@@ -4989,9 +4989,11 @@ def run(ctl, out, resources, checks):
host, guest = Game('host', ctl, resources), Game('guest', ctl, resources)
# Either way explicitly: the game's default is the server thread.
host.d.env['HORIZON_SERVER_THREAD'] = '1' if SERVER_THREAD else '0'
# A headless run (HORIZON_E2E_HEADLESS) has no focused window to draw in: the games render
# offscreen and never pause on the focus loss, or the world would stand still.
headless = bool(os.environ.get('HORIZON_E2E_HEADLESS'))
# A headless run (HORIZON_E2E_HEADLESS, or a Linux without vscreen) has no focused window to draw
# in: the games render offscreen and never pause on the focus loss, or the world would stand still
# (a host paused before its world loads waits at "Connecting to the server..." for ever).
headless = bool(os.environ.get('HORIZON_E2E_HEADLESS')) or (
sys.platform.startswith('linux') and not shutil.which('vscreen'))
launch = ['launch']
if headless or sys.platform == 'darwin':
launch.append('--offscreen')
+2 -1
View File
@@ -347,7 +347,8 @@ def horizon_resave(source, destination, bin_dir):
socket = Path(tempfile.mkdtemp(prefix='hzrt')) / 'control.sock' # sockets need short paths
ctl = [str(bin_dir / 'horizon-ctl'), '--socket', str(socket)]
extra = []
if sys.platform == 'darwin':
# Offscreen on macOS, and on a Linux without vscreen (never a window on a real monitor).
if sys.platform == 'darwin' or not shutil.which('vscreen'):
env = lab.game_env()
extra = ['--offscreen']
else:
+3 -2
View File
@@ -34,9 +34,10 @@ CYCLES = 3
def game_env():
"""macOS renders offscreen; Linux uses the virtual screen (as test_save_roundtrip_parity)."""
"""macOS renders offscreen; Linux uses the virtual screen (as test_save_roundtrip_parity), or renders
offscreen too where there is no vscreen."""
env = lab.game_env()
if sys.platform == 'darwin':
if sys.platform == 'darwin' or not shutil.which('vscreen'):
return env, ['--offscreen']
subprocess.run(['vscreen', 'up'], check=False, capture_output=True)
text = subprocess.run(['vscreen', 'env'], capture_output=True, text=True, check=True).stdout