Merge remote-tracking branch 'origin/main' into refactor/furnace-cooking
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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.
@@ -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
@@ -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
|
||||
|
||||
Generated
+5
-5
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user