diff --git a/.github/trusted-reviewers.txt b/.github/trusted-reviewers.txt index 550c528cc..48b98391f 100644 --- a/.github/trusted-reviewers.txt +++ b/.github/trusted-reviewers.txt @@ -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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42959f950..abd343896 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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). diff --git a/Cargo.lock b/Cargo.lock index 18d12726f..f048c88d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/LICENSE b/LICENSE index df2363379..e959a076e 100644 --- a/LICENSE +++ b/LICENSE @@ -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 diff --git a/crates/horizon-client-input/src/keybinds.rs b/crates/horizon-client-input/src/keybinds.rs index 2047b9117..433b37161 100644 --- a/crates/horizon-client-input/src/keybinds.rs +++ b/crates/horizon-client-input/src/keybinds.rs @@ -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), diff --git a/crates/horizon-client/src/assets.rs b/crates/horizon-client/src/assets.rs index 99d169c93..63c237c9e 100644 --- a/crates/horizon-client/src/assets.rs +++ b/crates/horizon-client/src/assets.rs @@ -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, diff --git a/crates/horizon-client/src/block_entity_item.rs b/crates/horizon-client/src/block_entity_item.rs index 2361dcbfb..6eb99ae84 100644 --- a/crates/horizon-client/src/block_entity_item.rs +++ b/crates/horizon-client/src/block_entity_item.rs @@ -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. diff --git a/crates/horizon-client/src/block_entity_render.rs b/crates/horizon-client/src/block_entity_render.rs index ecad13fb1..280dd9d21 100644 --- a/crates/horizon-client/src/block_entity_render.rs +++ b/crates/horizon-client/src/block_entity_render.rs @@ -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 = 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", diff --git a/crates/horizon-client/src/camera.rs b/crates/horizon-client/src/camera.rs index 7da08f2c8..75cbc025e 100644 --- a/crates/horizon-client/src/camera.rs +++ b/crates/horizon-client/src/camera.rs @@ -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] diff --git a/crates/horizon-client/src/creative.rs b/crates/horizon-client/src/creative.rs index 1a227db65..a262ad1d5 100644 --- a/crates/horizon-client/src/creative.rs +++ b/crates/horizon-client/src/creative.rs @@ -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)) diff --git a/crates/horizon-client/src/effects.rs b/crates/horizon-client/src/effects.rs index 5365a9d5c..7378de7d8 100644 --- a/crates/horizon-client/src/effects.rs +++ b/crates/horizon-client/src/effects.rs @@ -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()) } diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 6b240d715..dddd4f56b 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -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 + '_ { 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, + /// The slim model's right arm (3 pixels wide). + player_arm_slim: Option, player_type: Option>, /// `SpinAttackEffectModel` (Riptide's spinning boxes). spin_attack: Option, @@ -1250,8 +1271,12 @@ impl Resources { fn threads(&self) -> usize { self.core.threads } - fn player_arm(&self) -> &Option { - &self.rest().player_arm + fn player_arm(&self, slim: bool) -> &Option { + if slim { + &self.rest().player_arm_slim + } else { + &self.rest().player_arm + } } fn player_type(&self) -> &Option> { &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, diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index 79e836322..bc066c6bc 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -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(); diff --git a/crates/horizon-client/src/game/beacon.rs b/crates/horizon-client/src/game/beacon.rs index 6de017c33..710b743ac 100644 --- a/crates/horizon-client/src/game/beacon.rs +++ b/crates/horizon-client/src/game/beacon.rs @@ -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 { diff --git a/crates/horizon-client/src/game/books.rs b/crates/horizon-client/src/game/books.rs index 07d65c5a4..4c1a03129 100644 --- a/crates/horizon-client/src/game/books.rs +++ b/crates/horizon-client/src/game/books.rs @@ -76,9 +76,11 @@ fn view_pages(data: Option<&StackData>) -> Vec { .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); + } _ => {} } } diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index 49ad2be44..4ed7a0571 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -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; diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 3145c2f75..f7d4828f1 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -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); } } } diff --git a/crates/horizon-client/src/game/fov.rs b/crates/horizon-client/src/game/fov.rs index 364f89189..bda915aac 100644 --- a/crates/horizon-client/src/game/fov.rs +++ b/crates/horizon-client/src/game/fov.rs @@ -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 { let p = self.player.as_ref()?; diff --git a/crates/horizon-client/src/game/frames.rs b/crates/horizon-client/src/game/frames.rs index 4b7465bd0..99e82cf6d 100644 --- a/crates/horizon-client/src/game/frames.rs +++ b/crates/horizon-client/src/game/frames.rs @@ -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( diff --git a/crates/horizon-client/src/game/item_draw.rs b/crates/horizon-client/src/game/item_draw.rs index be0ca56fb..354e837b1 100644 --- a/crates/horizon-client/src/game/item_draw.rs +++ b/crates/horizon-client/src/game/item_draw.rs @@ -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); diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index da4b6a1a8..abe655c65 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -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; diff --git a/crates/horizon-client/src/game/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index 9dead141c..a56f107a2 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -202,7 +202,7 @@ impl Minecart { self.velocity.x.hypot(self.velocity.z) } /// `AbstractContainerMenu.getRedstoneSignalFromContainer` of `slots`. - fn container_signal(slots: &[Option], items: &Items) -> i32 { + pub(super) fn container_signal(slots: &[Option], 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 { diff --git a/crates/horizon-client/src/game/mod_recipes.rs b/crates/horizon-client/src/game/mod_recipes.rs index b97944e39..726f90145 100644 --- a/crates/horizon-client/src/game/mod_recipes.rs +++ b/crates/horizon-client/src/game/mod_recipes.rs @@ -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<[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() { diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index ceb4507de..9eb56338d 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -158,7 +158,10 @@ pub(super) fn player_type(root: &Path) -> Option> { 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 }, diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 988fb438c..22b7c13df 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -213,7 +213,8 @@ pub(super) struct Remote { /// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`. show_death_screen: bool, player_type: Option>, - /// Inventory as last synchronised, for creative-mode slot updates. + /// Inventory (then the off hand) as last synchronised, for creative-mode + /// slot updates. synced: Vec>, decode_errors: u32, clock: Option, @@ -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 { + 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, 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::>(), 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)> = Vec::new(); + let mut resolved: Vec<(u32, Option, 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) diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs index 26a5a81cd..6807d7718 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -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; diff --git a/crates/horizon-client/src/game/server_packs.rs b/crates/horizon-client/src/game/server_packs.rs index a616be199..5650c58e2 100644 --- a/crates/horizon-client/src/game/server_packs.rs +++ b/crates/horizon-client/src/game/server_packs.rs @@ -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. diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index 1734cb3ee..38064caad 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -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 diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index ad1fd0561..beb8b9060 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -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 { diff --git a/crates/horizon-client/src/gfx/native_post.rs b/crates/horizon-client/src/gfx/native_post.rs index 89515f5db..bf4293157 100644 --- a/crates/horizon-client/src/gfx/native_post.rs +++ b/crates/horizon-client/src/gfx/native_post.rs @@ -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| { diff --git a/crates/horizon-client/src/gfx/pack_uniforms.rs b/crates/horizon-client/src/gfx/pack_uniforms.rs index 8f5f1a2de..4da492b00 100644 --- a/crates/horizon-client/src/gfx/pack_uniforms.rs +++ b/crates/horizon-client/src/gfx/pack_uniforms.rs @@ -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 diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index db617e7c9..5d3b8dfc1 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -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, + /// `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, @@ -1908,6 +1910,7 @@ impl Mob { rider: None, outbox: Vec::new(), skin: None, + slim: false, profile: None, attrs: Attrs { speed: kind.speed, diff --git a/crates/horizon-client/src/mobs/armadillo.rs b/crates/horizon-client/src/mobs/armadillo.rs index 8837d8cf8..1deb7f11b 100644 --- a/crates/horizon-client/src/mobs/armadillo.rs +++ b/crates/horizon-client/src/mobs/armadillo.rs @@ -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"); + } +} diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index aa47b2a96..68f6bb4e7 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -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 { /// `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) { - 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); diff --git a/crates/horizon-client/src/mobs/species.rs b/crates/horizon-client/src/mobs/species.rs index 201632706..b2e206d77 100644 --- a/crates/horizon-client/src/mobs/species.rs +++ b/crates/horizon-client/src/mobs/species.rs @@ -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", diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index e9899ad40..9cc757931 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -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") diff --git a/crates/horizon-client/src/options_ui.rs b/crates/horizon-client/src/options_ui.rs index fbcf8ba98..5a7acab15 100644 --- a/crates/horizon-client/src/options_ui.rs +++ b/crates/horizon-client/src/options_ui.rs @@ -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(); diff --git a/crates/horizon-client/src/pose.rs b/crates/horizon-client/src/pose.rs index 2a01579f3..1bad4898d 100644 --- a/crates/horizon-client/src/pose.rs +++ b/crates/horizon-client/src/pose.rs @@ -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 { Ok(pose.dimensions(self.attribute("minecraft:scale")? as f32)) } diff --git a/crates/horizon-client/src/screen_effects.rs b/crates/horizon-client/src/screen_effects.rs index 269a534e7..996f3f9c2 100644 --- a/crates/horizon-client/src/screen_effects.rs +++ b/crates/horizon-client/src/screen_effects.rs @@ -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) { + 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 { let mut out = Vec::new(); let sprite = |name: &str| input.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]); diff --git a/crates/horizon-client/src/settings.rs b/crates/horizon-client/src/settings.rs index aeb366947..eea11d562 100644 --- a/crates/horizon-client/src/settings.rs +++ b/crates/horizon-client/src/settings.rs @@ -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, diff --git a/crates/horizon-client/src/skins.rs b/crates/horizon-client/src/skins.rs index 76bb79f6c..0393e75d0 100644 --- a/crates/horizon-client/src/skins.rs +++ b/crates/horizon-client/src/skins.rs @@ -17,6 +17,8 @@ pub enum Signature { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Textures { pub skin: Option, + /// The skin's `metadata.model` is `slim` (`PlayerModelType.SLIM`). + pub slim: bool, pub cape: Option, pub elytra: Option, 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}; diff --git a/crates/horizon-telemetry-server/Cargo.lock b/crates/horizon-telemetry-server/Cargo.lock index 9b5fc960d..cec980da1 100644 --- a/crates/horizon-telemetry-server/Cargo.lock +++ b/crates/horizon-telemetry-server/Cargo.lock @@ -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", ] diff --git a/crates/horizon-telemetry-server/src/lib.rs b/crates/horizon-telemetry-server/src/lib.rs index 50a27f8e1..d00d9c5b6 100644 --- a/crates/horizon-telemetry-server/src/lib.rs +++ b/crates/horizon-telemetry-server/src/lib.rs @@ -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) -> 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(buckets: &Mutex>, 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 { req.extensions() .get::>() @@ -580,3 +598,23 @@ pub fn router(app: Arc) -> 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::().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])); + } +} diff --git a/crates/horizon-telemetry-server/tests/server.rs b/crates/horizon-telemetry-server/tests/server.rs index 575e4b1a1..764306d58 100644 --- a/crates/horizon-telemetry-server/tests/server.rs +++ b/crates/horizon-telemetry-server/tests/server.rs @@ -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] diff --git a/crates/horizon-theme/fonts/PixelifySans-Bold.ttf b/crates/horizon-theme/fonts/PixelifySans-Bold.ttf index 5f8040b71..0b1ee555a 100644 Binary files a/crates/horizon-theme/fonts/PixelifySans-Bold.ttf and b/crates/horizon-theme/fonts/PixelifySans-Bold.ttf differ diff --git a/crates/horizon-theme/fonts/PixelifySans-Medium.ttf b/crates/horizon-theme/fonts/PixelifySans-Medium.ttf index 5bf69961d..68cec5d96 100644 Binary files a/crates/horizon-theme/fonts/PixelifySans-Medium.ttf and b/crates/horizon-theme/fonts/PixelifySans-Medium.ttf differ diff --git a/crates/horizon-theme/fonts/PixelifySans-SemiBold.ttf b/crates/horizon-theme/fonts/PixelifySans-SemiBold.ttf index 45cd4e2af..2a4c5c1dc 100644 Binary files a/crates/horizon-theme/fonts/PixelifySans-SemiBold.ttf and b/crates/horizon-theme/fonts/PixelifySans-SemiBold.ttf differ diff --git a/docs/design-system.md b/docs/design-system.md index afd46bf37..11ea46c1c 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -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. diff --git a/docs/release-flow.md b/docs/release-flow.md index 3ea4b3fcb..e5201d089 100644 --- a/docs/release-flow.md +++ b/docs/release-flow.md @@ -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 --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 diff --git a/launcher/Cargo.lock b/launcher/Cargo.lock index c0fa55f15..d76785d9b 100644 --- a/launcher/Cargo.lock +++ b/launcher/Cargo.lock @@ -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", ] diff --git a/tests/test_nightly_set.py b/tests/test_nightly_set.py index 4ba8d43d3..975857668 100644 --- a/tests/test_nightly_set.py +++ b/tests/test_nightly_set.py @@ -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): diff --git a/tests/test_triage_command.py b/tests/test_triage_command.py index ce73ea4de..331f9b803 100644 --- a/tests/test_triage_command.py +++ b/tests/test_triage_command.py @@ -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) diff --git a/tests/test_world_input_capture.py b/tests/test_world_input_capture.py index 5607c3684..e9a93ee4e 100644 --- a/tests/test_world_input_capture.py +++ b/tests/test_world_input_capture.py @@ -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, mouse: Mouse, captured: bool, title: Option<()>, screen: Screen, remote: Option, mining: bool, lan_acting: Option, renderer: Option, 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(); diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 1a0b25a9d..69a22fe48 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -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') diff --git a/tools/test_save_roundtrip_parity.py b/tools/test_save_roundtrip_parity.py index 7b3c72e92..2390e2bfc 100644 --- a/tools/test_save_roundtrip_parity.py +++ b/tools/test_save_roundtrip_parity.py @@ -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: diff --git a/tools/test_world_reopen_parity.py b/tools/test_world_reopen_parity.py index 9487fd1b7..9c06c257c 100644 --- a/tools/test_world_reopen_parity.py +++ b/tools/test_world_reopen_parity.py @@ -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