From 7906dcd4f459a8de5a66b17fe599e7460ead03be Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:14:30 +0000 Subject: [PATCH 01/37] Give every mod API Host method a default body that panics with its name --- .../src/mod_api/generated_host.rs | 625 +++++++++++++++--- xtask/src/mod_api.rs | 11 +- 2 files changed, 532 insertions(+), 104 deletions(-) diff --git a/crates/horizon-client/src/mod_api/generated_host.rs b/crates/horizon-client/src/mod_api/generated_host.rs index 623599693f..0d7c25ace2 100644 --- a/crates/horizon-client/src/mod_api/generated_host.rs +++ b/crates/horizon-client/src/mod_api/generated_host.rs @@ -3,6 +3,7 @@ use super::*; use std::path::PathBuf; use std::sync::Arc; /// Services supplied by the game to trait mods. +/// Each method's default panics with its name, so a test double implements only what its test calls; the game implements every one. pub trait Host { /// Draws a quad with `corners` in blocks under `draw`'s pose, in a model /// face's order (top left, bottom left, bottom right, top right as seen @@ -17,7 +18,14 @@ pub trait Host { corners: [[f32; 3]; 4], uv: [f32; 4], tint: u32, - ) -> bool; + ) -> bool { + let _ = draw; + let _ = sprite; + let _ = corners; + let _ = uv; + let _ = tint; + unimplemented!("Host::draw_quad") + } /// Draws a box from `from` to `to` (texels of 16 per block, as a block /// model's element) under `draw`'s pose, with the faces given, by /// `Direction` (down, up, north, south, west, east), baked as a model @@ -31,7 +39,14 @@ pub trait Host { to: [f32; 3], faces: [Option>; 6], tint: u32, - ) -> bool; + ) -> bool { + let _ = draw; + let _ = from; + let _ = to; + let _ = faces; + let _ = tint; + unimplemented!("Host::draw_cube") + } /// Draws block `block`'s model (`mymod:cog`, any block with a model) in /// the state with `properties` (`[("axis", "x")]`, the rest default) /// under `draw`'s pose, as vanilla's `submitBlockModel` draws a block in @@ -43,7 +58,12 @@ pub trait Host { draw: &mut BlockEntityDraw, block: &str, properties: &[(&str, &str)], - ) -> bool; + ) -> bool { + let _ = draw; + let _ = block; + let _ = properties; + unimplemented!("Host::draw_block") + } /// Draws `stack` under `draw`'s pose as an item is drawn in the world /// (`ItemModelResolver.updateForTopItem` and `ItemStackRenderState.submit`), centred on the /// pose's origin, in its model's `display` transform: `Fixed` as a @@ -65,26 +85,59 @@ pub trait Host { display: DisplayContext, owner_yaw: Option, seed: i32, - ) -> bool; + ) -> bool { + let _ = draw; + let _ = stack; + let _ = display; + let _ = owner_yaw; + let _ = seed; + unimplemented!("Host::draw_item") + } /// Access a server-owned holder's attachment map. - fn attachments(&mut self, holder: Holder) -> AttachmentResult<&mut Attachments>; + fn attachments(&mut self, holder: Holder) -> AttachmentResult<&mut Attachments> { + let _ = holder; + unimplemented!("Host::attachments") + } /// Moves mob `id` to `pos` at once, as `/tp` does: it dismounts, drops its /// path and resets its fall. False for no such mob, another dimension or a /// position outside `/summon`'s bounds. API 37. - fn teleport_mob(&mut self, dimension: &str, id: u32, pos: [f64; 3]) -> bool; + fn teleport_mob(&mut self, dimension: &str, id: u32, pos: [f64; 3]) -> bool { + let _ = dimension; + let _ = id; + let _ = pos; + unimplemented!("Host::teleport_mob") + } /// Adds `velocity` to mob `id`'s: false for no such mob, another dimension /// or a velocity that isn't finite. API 37. - fn push_mob(&mut self, dimension: &str, id: u32, velocity: [f64; 3]) -> bool; + fn push_mob(&mut self, dimension: &str, id: u32, velocity: [f64; 3]) -> bool { + let _ = dimension; + let _ = id; + let _ = velocity; + unimplemented!("Host::push_mob") + } /// Sets the living mob `id`'s health, cut to its maximum: false for no such /// mob, another dimension or a health that isn't above 0 (`native_kill_mob` /// kills). API 37. - fn set_mob_health(&mut self, dimension: &str, id: u32, health: f32) -> bool; + fn set_mob_health(&mut self, dimension: &str, id: u32, health: f32) -> bool { + let _ = dimension; + let _ = id; + let _ = health; + unimplemented!("Host::set_mob_health") + } /// Kills mob `id` as `/kill` does: it falls and drops its loot. False for /// no such living mob or another dimension. API 37. - fn kill_mob(&mut self, dimension: &str, id: u32) -> bool; + fn kill_mob(&mut self, dimension: &str, id: u32) -> bool { + let _ = dimension; + let _ = id; + unimplemented!("Host::kill_mob") + } /// Takes mob `id` out of the world with no death and no loot: false for no /// such mob or another dimension. API 37. - fn remove_mob(&mut self, dimension: &str, id: u32) -> bool; + fn remove_mob(&mut self, dimension: &str, id: u32) -> bool { + let _ = dimension; + let _ = id; + unimplemented!("Host::remove_mob") + } /// Spawns an entity of `kind` (`minecraft:item_display`) at `pos` in the /// level in place, `dimension`, from `nbt` (its compound, as `/summon /// ` takes it; passengers included): its id. None on a @@ -92,7 +145,13 @@ pub trait Host { /// `/summon`'s bounds, a chunk that isn't loaded, a kind Horizon can't /// load, or one a mod kept out (`native_entity_join_level`). API /// 59. - fn summon(&mut self, dimension: &str, kind: &str, pos: [f64; 3], nbt: Compound) -> Option; + fn summon(&mut self, dimension: &str, kind: &str, pos: [f64; 3], nbt: Compound) -> Option { + let _ = dimension; + let _ = kind; + let _ = pos; + let _ = nbt; + unimplemented!("Host::summon") + } /// Moves entity `id`, one Horizon keeps without simulating (a display, a /// marker, an interaction), to `pos` facing `yaw` and `pitch` in degrees; /// a display eases there over its `teleport` ticks. False for no such @@ -105,37 +164,69 @@ pub trait Host { pos: [f64; 3], yaw: f32, pitch: f32, - ) -> bool; + ) -> bool { + let _ = dimension; + let _ = id; + let _ = pos; + let _ = yaw; + let _ = pitch; + unimplemented!("Host::move_entity") + } /// Removes entity `id`, one Horizon keeps without simulating, as `/kill` /// does (it drops nothing and isn't saved). False for no such entity or /// another dimension. Mobs go with `native_remove_mob`. API /// 59. - fn remove_entity(&mut self, dimension: &str, id: u32) -> bool; + fn remove_entity(&mut self, dimension: &str, id: u32) -> bool { + let _ = dimension; + let _ = id; + unimplemented!("Host::remove_entity") + } /// The players on this server, in the order they joined. Empty on a /// client and in a world nobody else can join. - fn server_players(&self) -> Vec; + fn server_players(&self) -> Vec { + unimplemented!("Host::server_players") + } /// A system line in that player's chat, also inside a player's hook for /// the acting player; false when they aren't on. - fn tell(&mut self, uuid: u128, line: Text) -> bool; + fn tell(&mut self, uuid: u128, line: Text) -> bool { + let _ = uuid; + let _ = line; + unimplemented!("Host::tell") + } /// A line over that player's hotbar (the action bar), also inside a /// player's hook for the acting player; false when they aren't on. API 21. - fn action_bar(&mut self, uuid: u128, line: Text) -> bool; + fn action_bar(&mut self, uuid: u128, line: Text) -> bool { + let _ = uuid; + let _ = line; + unimplemented!("Host::action_bar") + } /// The lines over and under that player's list of players (Tab): /// `ClientboundTabListPacket`, an empty text for none. False when they /// aren't on. API 49. - fn tab_list(&mut self, uuid: u128, header: &Text, footer: &Text) -> bool; + fn tab_list(&mut self, uuid: u128, header: &Text, footer: &Text) -> bool { + let _ = uuid; + let _ = header; + let _ = footer; + unimplemented!("Host::tab_list") + } /// The server's tick rate (20 unless `/tick rate` changed it) and the /// milliseconds its ticks took on average lately, as `/tick query` /// reports them. The ticks a second it keeps up is the rate, or /// 1000 / milliseconds when that is lower. API 49. - fn tick_stats(&self) -> (f32, f64); + fn tick_stats(&self) -> (f32, f64) { + unimplemented!("Host::tick_stats") + } /// Whether that player counts for `players_sleeping_percentage`: an /// ignored player who isn't in bed is left out of the players the night /// needs asleep, as a spectator is (an away-from-keyboard mod). Kept /// until a mod sets it back or the server stops, also if they leave and /// come back. Only where no player's state is swapped in, as /// `native_run_command`; false there and when they aren't on. API 49. - fn sleep_ignored(&mut self, uuid: u128, ignored: bool) -> bool; + fn sleep_ignored(&mut self, uuid: u128, ignored: bool) -> bool { + let _ = uuid; + let _ = ignored; + unimplemented!("Host::sleep_ignored") + } /// Shows `nickname` in place of that player's name in chat (their /// messages, `/me`, `/msg`) and in the Tab list; None shows their name /// again. Hovering the shown name gives the real one, which stays in the @@ -145,22 +236,37 @@ pub trait Host { /// other players' names). Only where no player's state is swapped in, /// as `native_run_command`; false there and when they aren't on. API /// 62. - fn set_nickname(&mut self, uuid: u128, nickname: Option<&str>) -> bool; + fn set_nickname(&mut self, uuid: u128, nickname: Option<&str>) -> bool { + let _ = uuid; + let _ = nickname; + unimplemented!("Host::set_nickname") + } /// Whether the server's chat filter (`server.toml` `[chat_filter]`) /// lets `text` through: true without a filter, None inside a player's /// hook (where the server is out of reach). API 62. - fn text_allowed(&self, text: &str) -> Option; + fn text_allowed(&self, text: &str) -> Option { + let _ = text; + unimplemented!("Host::text_allowed") + } /// A system line in every player's chat and the server's log. - fn broadcast(&self, line: Text); + fn broadcast(&self, line: Text) { + let _ = line; + unimplemented!("Host::broadcast") + } /// A line of the command's answer, inside `native_perform_command`: /// sent to the player whose command it is after the call, as vanilla /// commands' feedback is. - fn feedback(&mut self, line: Text); + fn feedback(&mut self, line: Text) { + let _ = line; + unimplemented!("Host::feedback") + } /// The player whose action or command a hook hears, as their state is /// swapped in (`native_perform_command`, `native_right_click_block`, /// `native_use_item_on`, `native_destroy_block`...): None outside such a /// hook. API 21. - fn acting_player(&self) -> Option; + fn acting_player(&self) -> Option { + unimplemented!("Host::acting_player") + } /// Runs `line` (no slash) as the server's console does: permission /// level 4, from the overworld's spawn, with no output. A teleport to /// another level (`execute in minecraft:the_nether run tp Alex 0 70 0`) @@ -168,25 +274,48 @@ pub trait Host { /// without failing. Only where no player's state is swapped in /// (`native_tick`, `native_menu_clicked`): false inside a player's hook, /// where the other players are out of reach. API 21. - fn run_command(&mut self, line: &str) -> bool; + fn run_command(&mut self, line: &str) -> bool { + let _ = line; + unimplemented!("Host::run_command") + } /// The open world's folder (`world/`, beside its `level.dat`): a server /// mod keeps its data in a folder of its own there, so the world's /// backups carry it. None when no world is open. - fn world_dir(&self) -> Option; + fn world_dir(&self) -> Option { + unimplemented!("Host::world_dir") + } /// The main hand's stack: its item, count, and whether it is plain. On a /// client, the player's own. - fn held(&self) -> Option<(String, u32, bool)>; + fn held(&self) -> Option<(String, u32, bool)> { + unimplemented!("Host::held") + } /// How many of plain `item` (`minecraft:diamond`) the player carries. - fn count_items(&mut self, item: &str) -> u32; + fn count_items(&mut self, item: &str) -> u32 { + let _ = item; + unimplemented!("Host::count_items") + } /// Every plain item the player carries, with how many, by id. - fn carried_items(&self) -> Vec<(String, u32)>; + fn carried_items(&self) -> Vec<(String, u32)> { + unimplemented!("Host::carried_items") + } /// Takes up to `count` plain `item`, the main hand first; what it took. - fn take_items(&mut self, item: &str, count: u32) -> u32; + fn take_items(&mut self, item: &str, count: u32) -> u32 { + let _ = item; + let _ = count; + unimplemented!("Host::take_items") + } /// How many plain `item` still fit; 0 for an unknown item. - fn room_for(&mut self, item: &str) -> u32; + fn room_for(&mut self, item: &str) -> u32 { + let _ = item; + unimplemented!("Host::room_for") + } /// Gives up to `count` plain `item` where they fit, as `Inventory.add` /// places them; what it gave (nothing is dropped). 0 for an unknown item. - fn give_items(&mut self, item: &str, count: u32) -> u32; + fn give_items(&mut self, item: &str, count: u32) -> u32 { + let _ = item; + let _ = count; + unimplemented!("Host::give_items") + } /// Gives `stack` whole (its damage, name, enchantments... kept) to the /// player `uuid`, as `Inventory.add` places it: onto equal stacks first, /// then into empty inventory slots, never the armour or an empty off @@ -196,7 +325,11 @@ pub trait Host { /// The player's screen follows as for any change to their inventory. /// What an auto-pickup or a machine's output to a player needs, from any /// server hook. API 98. - fn give_stack(&mut self, uuid: u128, stack: ItemStack) -> Option; + fn give_stack(&mut self, uuid: u128, stack: ItemStack) -> Option { + let _ = uuid; + let _ = stack; + unimplemented!("Host::give_stack") + } /// Gives `points` of experience to the player `uuid` as an orb they touch /// does (`ExperienceOrb.playerTouch`): Mending repairs their damaged items /// with it first (`repairPlayerItems`), then the rest goes to their @@ -207,24 +340,42 @@ pub trait Host { /// reached), and while the server's player list is busy elsewhere in the /// tick: give first, and cancel an orb only when this returned true. /// Their bar follows as for any change to their experience. - fn give_experience(&mut self, uuid: u128, points: u32) -> bool; + fn give_experience(&mut self, uuid: u128, points: u32) -> bool { + let _ = uuid; + let _ = points; + unimplemented!("Host::give_experience") + } /// Adds `levels` to the player `uuid`'s level, or takes them off when /// negative (`Player.giveExperienceLevels`): what the anvil and the /// enchanting table charge with, and a mod can charge or reward with. /// Below level 0 the level and the points are 0. The progress toward the /// next level is kept. False, and nothing changed, as for /// `native_give_experience`. - fn give_levels(&mut self, uuid: u128, levels: i32) -> bool; + fn give_levels(&mut self, uuid: u128, levels: i32) -> bool { + let _ = uuid; + let _ = levels; + unimplemented!("Host::give_levels") + } /// Read a loaded, light-emitting block. No implicit chunk loading. - fn lit_block_name(&self, pos: [i32; 3]) -> Option<&str>; + fn lit_block_name(&self, pos: [i32; 3]) -> Option<&str> { + let _ = pos; + unimplemented!("Host::lit_block_name") + } /// A small local vanilla particle; caller should rate-limit emission. - fn flame_particle(&mut self, at: [f64; 3], soul: bool); + fn flame_particle(&mut self, at: [f64; 3], soul: bool) { + let _ = at; + let _ = soul; + unimplemented!("Host::flame_particle") + } /// The stack the acting player carries in `slot`, by /// `native_inventory_tick`'s numbers: none for an empty slot or another /// number. Change it as you like: a count of 0 empties the slot at the end /// of `native_inventory_tick`, and the player's client gets the change. /// API 39. - fn carried_stack(&mut self, slot: i32) -> Option<&mut ItemStack>; + fn carried_stack(&mut self, slot: i32) -> Option<&mut ItemStack> { + let _ = slot; + unimplemented!("Host::carried_stack") + } /// Sets a block in the current authoritative level through `Level.setBlock`. /// Returns false for another dimension, an invalid state, or no change. /// `flags` selects vanilla's neighbour and shape updates; Horizon sends @@ -236,7 +387,13 @@ pub trait Host { pos: [i32; 3], state: BlockStateId, flags: i32, - ) -> bool; + ) -> bool { + let _ = dimension; + let _ = pos; + let _ = state; + let _ = flags; + unimplemented!("Host::set_block") + } /// For a server mod: the runs made at once, straight into the chunks of the authoritative level `dimension` /// (the one being ticked, as for `native_set_block`), with what `fill ... strict` does for block entities (a /// block replaced by another loses its block entity, and a new block gets the one `native_set_block` would @@ -245,7 +402,11 @@ pub trait Host { /// why the edit was left out, with nothing changed: another dimension, a client of a server, a palette entry /// or filter that doesn't read or carries NBT (refused), a run naming a palette entry or filter past the end, /// or more than 65536 filters. API 126. - fn edit_blocks(&mut self, dimension: &str, edit: &NativeBlockEdit) -> Result; + fn edit_blocks(&mut self, dimension: &str, edit: &NativeBlockEdit) -> Result { + let _ = dimension; + let _ = edit; + unimplemented!("Host::edit_blocks") + } /// For a server mod: each column's height by `heightmap` in the authoritative level `dimension` (the one being /// ticked), none for a column not loaded. `Err` says why nothing was read: another dimension or a client of a /// server, or more than 65,536 columns (`MAX_COLUMNS`). API 127. @@ -254,7 +415,12 @@ pub trait Host { dimension: &str, columns: &[(i32, i32)], heightmap: NativeHeightmap, - ) -> Result>, String>; + ) -> Result>, String> { + let _ = dimension; + let _ = columns; + let _ = heightmap; + unimplemented!("Host::surface_heights") + } /// For a server mod: `below` blocks under and `above` blocks from each column's height by `heightmap`, as a /// palette and indexes, with the biome's grass, foliage and water tints (air outside the column and in columns /// not loaded). The tints are the biome's at the column's top, not blended over the biome blend radius as the @@ -268,45 +434,69 @@ pub trait Host { columns: &[(i32, i32)], heightmap: NativeHeightmap, range: (u32, u32), - ) -> Result; + ) -> Result { + let _ = dimension; + let _ = columns; + let _ = heightmap; + let _ = range; + unimplemented!("Host::read_columns") + } /// Asks `native_biome_colors` again for every biome and, when the colours /// differ from those in use, rebuilds every loaded section and the far /// terrain with them (`LevelRenderer.allChanged`, as changing Biome Blend /// does). Call it when your colours change (a new season, a new day of a /// slow fade) and once when the player joins, not every frame: a rebuild /// takes the workers a few seconds. API 107. - fn refresh_biome_colors(&mut self); + fn refresh_biome_colors(&mut self) { + unimplemented!("Host::refresh_biome_colors") + } /// The strongest redstone signal reaching the block at `pos` from its six /// neighbours, 0 to 15 (`SignalGetter.getBestNeighborSignal`, what a /// dispenser or a piston reads), in the level in place, `dimension`. 0 on /// a client's copy of the world and for another dimension. A mod's /// explosive, launcher or machine reads it in its block entity's tick to /// go off or switch on. API 96. - fn neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32; + fn neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32 { + let _ = dimension; + let _ = pos; + unimplemented!("Host::neighbor_signal") + } /// The light at `pos` in the world in place, as `(block, sky)`, each 0 to /// 15 (`Level.getBrightness` for `LightLayer.BLOCK` and `LightLayer.SKY`). /// A solar panel checks for full sky light above it, a lamp or a mob farm /// for darkness. `(0, 0)` in a chunk that isn't loaded. Like the other /// reads here it reads the world this game holds: the level in place on /// a server, the server's copy on a client. API 97. - fn light(&self, pos: [i32; 3]) -> (u8, u8); + fn light(&self, pos: [i32; 3]) -> (u8, u8) { + let _ = pos; + unimplemented!("Host::light") + } /// The rain and thunder levels of the world in place, each 0.0 to 1.0 /// (`Level.getRainLevel` and `Level.getThunderLevel`): what a solar panel /// or a wind turbine reads. Both 0 in the Nether and the End, which have /// no weather (`Level.canHaveWeather`). API 97. - fn weather(&self) -> (f32, f32); + fn weather(&self) -> (f32, f32) { + unimplemented!("Host::weather") + } /// The world's time of day in ticks (`Level.getOverworldClockTime`), the /// same as `native_clock`'s `day_time`; it keeps counting past 24000, so /// take it modulo 24000 for the hour. API 97. - fn day_time(&self) -> i64; + fn day_time(&self) -> i64 { + unimplemented!("Host::day_time") + } /// The lowest block y and the height of the world in place /// (`LevelHeightAccessor.getMinY` and `getHeight`): how deep a quarry or a /// miner may dig. API 97. - fn world_height(&self) -> (i32, i32); + fn world_height(&self) -> (i32, i32) { + unimplemented!("Host::world_height") + } /// The most of the item `item` one slot holds (`Item.getDefaultMaxStackSize`), /// or `None` for an unknown item: for a mod's container that fills or /// merges stacks itself. API 97. - fn max_stack_size(&self, item: &str) -> Option; + fn max_stack_size(&self, item: &str) -> Option { + let _ = item; + unimplemented!("Host::max_stack_size") + } /// Item `item`'s icon drawn with model `model` (`minecraft:paper` as /// `mymod:wheel_icon`, an `item_model` of a content mod's), as `native_item` /// draws one; false for an unknown item. API 111. @@ -317,11 +507,23 @@ pub trait Host { model: &str, at: [f32; 2], size: f32, - ) -> bool; + ) -> bool { + let _ = batch; + let _ = item; + let _ = model; + let _ = at; + let _ = size; + unimplemented!("Host::item_model") + } /// A quad of corners `points` (in order round it) in GUI pixels, `argb`: /// a wedge, a slanted bar, what `native_fill`'s rectangles can't draw. /// API 111. - fn quad(&self, batch: &mut HudBatch, points: [[f32; 2]; 4], argb: u32); + fn quad(&self, batch: &mut HudBatch, points: [[f32; 2]; 4], argb: u32) { + let _ = batch; + let _ = points; + let _ = argb; + unimplemented!("Host::quad") + } /// Opens a screen of mod `mod_id`'s own over the world: the screen hooks /// fire over it as over any other, so the mod draws it in /// `native_screen_render_post` and takes its clicks, scroll, keys and @@ -334,12 +536,20 @@ pub trait Host { /// loads, and anywhere but the window's own client (the server thread, a /// dedicated server). A gamepad can close it (B, Start) but not click on /// it: its clicks don't reach the mouse hooks. API 111. - fn mod_screen_open(&mut self, mod_id: &str) -> bool; + fn mod_screen_open(&mut self, mod_id: &str) -> bool { + let _ = mod_id; + unimplemented!("Host::mod_screen_open") + } /// Closes mod `mod_id`'s own screen, if it is open. API 111. - fn mod_screen_close(&mut self, mod_id: &str); + fn mod_screen_close(&mut self, mod_id: &str) { + let _ = mod_id; + unimplemented!("Host::mod_screen_close") + } /// The mod whose own screen is open, if one is (Escape, or another screen /// replacing it, closes it). API 111. - fn mod_screen(&self) -> Option<&str>; + fn mod_screen(&self) -> Option<&str> { + unimplemented!("Host::mod_screen") + } /// Writes the experience orb `id`'s `value` (the points a pickup gives), `count` /// (orbs merged into it, each worth `value`) and `age` (ticks lived; an orb /// despawns at 6000); a `None` is left as it is. API 128. @@ -352,7 +562,14 @@ pub trait Host { value: Option, count: Option, age: Option, - ) -> bool; + ) -> bool { + let _ = dimension; + let _ = id; + let _ = value; + let _ = count; + let _ = age; + unimplemented!("Host::set_experience_orb") + } /// The experience orbs of the server level `dimension` whose box meets /// the box `min`..`max`, in the level's order: what a mod reads before it /// cancels an orb in `native_entity_join_level`, to give its points with @@ -363,12 +580,19 @@ pub trait Host { dimension: &str, min: [f64; 3], max: [f64; 3], - ) -> Vec; + ) -> Vec { + let _ = dimension; + let _ = min; + let _ = max; + unimplemented!("Host::experience_orbs") + } /// The clock of the world this window shows, for client mods: its game /// time and time of day as the server last sent them /// (`ClientboundSetTimePacket`), advanced each tick between packets; none /// on the title screen. API 92. - fn clock(&self) -> Option; + fn clock(&self) -> Option { + unimplemented!("Host::clock") + } /// Throws a falling block of `state` from `pos` (its bottom centre) at /// `velocity` (blocks a tick) in the level in place, `dimension`: it /// falls, lands and places itself as sand does, or drops as an item @@ -385,39 +609,75 @@ pub trait Host { pos: [f64; 3], velocity: [f64; 3], falling: NativeFalling, - ) -> bool; + ) -> bool { + let _ = dimension; + let _ = state; + let _ = pos; + let _ = velocity; + let _ = falling; + unimplemented!("Host::spawn_falling_block") + } /// Property `key` of block state `state` (a machine's `facing`, from the /// `state` `native_block_entity_submit` gets), if it has one: no lookup in /// the world, so cheap each frame. API 91. - fn block_state_property(&self, state: BlockStateId, key: &str) -> Option<&str>; + fn block_state_property(&self, state: BlockStateId, key: &str) -> Option<&str> { + let _ = state; + let _ = key; + unimplemented!("Host::block_state_property") + } /// The first block along the view within `distance` (at most 1024), past the loaded chunks over the distant terrain. API 116. - fn raycast(&self, distance: f32) -> Option; + fn raycast(&self, distance: f32) -> Option { + let _ = distance; + unimplemented!("Host::raycast") + } /// The first block along the view within `RAYCAST_MAX`, and its distance. API 116. - fn view_hit(&self) -> Option<(f32, NativeHit)>; + fn view_hit(&self) -> Option<(f32, NativeHit)> { + unimplemented!("Host::view_hit") + } /// Right-clicks with these items (at most 64 ids) are mod `mod_id`'s; a later call replaces the list. API 116. - fn claim_items(&mut self, mod_id: &str, ids: &[String]); + fn claim_items(&mut self, mod_id: &str, ids: &[String]) { + let _ = mod_id; + let _ = ids; + unimplemented!("Host::claim_items") + } /// Mod `mod_id`'s events since its last poll (at most 256 wait). API 116. - fn poll(&mut self, mod_id: &str) -> Vec; + fn poll(&mut self, mod_id: &str) -> Vec { + let _ = mod_id; + unimplemented!("Host::poll") + } /// Sets the stack of item entity `id` in the server level `dimension`, as /// a hopper's `addItem` leaves what it couldn't take (`ItemEntity.setItem`); /// an empty stack (count 0) removes the entity (`setItem(EMPTY)`, then /// `discard`). Players see it on their next tracker update. False when the /// level in place has no such item entity, or on a client. API 42. - fn set_item_entity(&mut self, dimension: &str, id: u32, stack: ItemStack) -> bool; + fn set_item_entity(&mut self, dimension: &str, id: u32, stack: ItemStack) -> bool { + let _ = dimension; + let _ = id; + let _ = stack; + unimplemented!("Host::set_item_entity") + } /// The block at `pos` changed its comparator output /// (`native_analog_output`) without changing its state: the comparators /// beside it, or behind a conductor there, read it again /// (`Level.updateNeighbourForOutputSignal`). Acts on the level in place: /// false (and nothing happens) when `dimension` is another, or this /// window isn't a server. API 64. - fn update_output_neighbors(&mut self, dimension: &str, pos: [i32; 3]) -> bool; + fn update_output_neighbors(&mut self, dimension: &str, pos: [i32; 3]) -> bool { + let _ = dimension; + let _ = pos; + unimplemented!("Host::update_output_neighbors") + } /// The block at `pos` changed the signal it gives: its neighbours, and /// theirs (a block it powers passes the signal on), read it again, as /// around a lever (`Level.updateNeighborsAt`). Call it when the answer /// changes and once the block is placed. Acts on the level in place: /// false (and nothing happens) when `dimension` is another, or this /// window isn't a server. API 38. - fn update_signal_neighbors(&mut self, dimension: &str, pos: [i32; 3]) -> bool; + fn update_signal_neighbors(&mut self, dimension: &str, pos: [i32; 3]) -> bool { + let _ = dimension; + let _ = pos; + unimplemented!("Host::update_signal_neighbors") + } /// Sends clients the block at `pos` and its block entity's update tag /// (`native_block_entity_update_tag`) again, as `Level.sendBlockUpdated` /// does: with the level's other block changes, to the players who see its @@ -427,7 +687,10 @@ pub trait Host { /// entities). Call it when what clients show changed (a machine started), /// not for every tick's progress: a menu's data slots carry that. Nothing /// happens for an unloaded block. API 52. - fn block_entity_sync(&mut self, pos: [i32; 3]); + fn block_entity_sync(&mut self, pos: [i32; 3]) { + let _ = pos; + unimplemented!("Host::block_entity_sync") + } /// Plays sound event `sound` (`minecraft:entity.generic.explode`, or a /// mod's own from its `assets//sounds.json`) at `at` for this /// player only, `delay` ticks from now (0 to 1200, a minute): a client @@ -444,31 +707,67 @@ pub trait Host { volume: f32, pitch: f32, delay: i32, - ) -> bool; + ) -> bool { + let _ = sound; + let _ = at; + let _ = volume; + let _ = pitch; + let _ = delay; + unimplemented!("Host::play_local_sound") + } /// A rectangle in GUI pixels, `argb` (0x78000000 is the extras' box). - fn fill(&self, batch: &mut HudBatch, rect: [f32; 4], argb: u32); + fn fill(&self, batch: &mut HudBatch, rect: [f32; 4], argb: u32) { + let _ = batch; + let _ = rect; + let _ = argb; + unimplemented!("Host::fill") + } /// Part `src` (`[x, y, w, h]` in the sprite's own pixels) of sprite /// `name` (vanilla's `gui/...`, or a content mod's GUI texture /// `mymod:gui/...`), drawn stretched into `dst` (`[x, y, w, h]` in GUI /// pixels). False when there is no such sprite. API 19. - fn sprite(&self, batch: &mut HudBatch, name: &str, src: [f32; 4], dst: [f32; 4]) -> bool; + fn sprite(&self, batch: &mut HudBatch, name: &str, src: [f32; 4], dst: [f32; 4]) -> bool { + let _ = batch; + let _ = name; + let _ = src; + let _ = dst; + unimplemented!("Host::sprite") + } /// Item `item`'s icon (`mymod:machine`) with its top left at `at`, /// `size` GUI pixels square (16 in a slot); false for an unknown item. /// API 20. - fn item(&self, batch: &mut HudBatch, item: &str, at: [f32; 2], size: f32) -> bool; + fn item(&self, batch: &mut HudBatch, item: &str, at: [f32; 2], size: f32) -> bool { + let _ = batch; + let _ = item; + let _ = at; + let _ = size; + unimplemented!("Host::item") + } /// The open menu's title (the block's name, or the one it was given), /// as plain text. API 20. - fn screen_title(&self) -> Option; + fn screen_title(&self) -> Option { + unimplemented!("Host::screen_title") + } /// A data file of the game's resources, content mods' included /// (`data/mymod/machines.json`), when there is one: what a mod's content /// says, read at runtime. Only relative paths of plain names. API 20. - fn data(&self, path: &str) -> Option>; + fn data(&self, path: &str) -> Option> { + let _ = path; + unimplemented!("Host::data") + } /// The block at `pos` (`mymod:cable`; air where nothing is loaded). /// API 20. - fn block(&self, pos: [i32; 3]) -> String; + fn block(&self, pos: [i32; 3]) -> String { + let _ = pos; + unimplemented!("Host::block") + } /// Property `key` of the block at `pos` (a machine's `facing`), if it /// has one. API 20. - fn block_property(&self, pos: [i32; 3], key: &str) -> Option; + fn block_property(&self, pos: [i32; 3], key: &str) -> Option { + let _ = pos; + let _ = key; + unimplemented!("Host::block_property") + } /// Puts `stack` into the container at `pos` (a chest, a furnace, a mod /// container) through its side `face`, as a hopper would, one by one, and /// returns what did not fit: a machine's auto-output. A composter takes @@ -478,21 +777,42 @@ pub trait Host { pos: [i32; 3], face: Direction, stack: ItemStack, - ) -> Option; + ) -> Option { + let _ = pos; + let _ = face; + let _ = stack; + unimplemented!("Host::insert_item") + } /// Where the mouse is, in GUI pixels. API 20. - fn cursor(&self) -> [f32; 2]; + fn cursor(&self) -> [f32; 2] { + unimplemented!("Host::cursor") + } /// A line of text at `(x, y)` in GUI pixels, opaque `rgb`. - fn text(&self, batch: &mut HudBatch, text: &str, at: [f32; 2], rgb: u32, shadow: bool); + fn text(&self, batch: &mut HudBatch, text: &str, at: [f32; 2], rgb: u32, shadow: bool) { + let _ = batch; + let _ = text; + let _ = at; + let _ = rgb; + let _ = shadow; + unimplemented!("Host::text") + } /// The folder mod `mod_id`'s post-build step wrote into when the launcher built the game /// (`native-data//` in the build's version folder); None when the game's own path is /// unknown. API 41. - fn built_data(&self, mod_id: &str) -> Option; + fn built_data(&self, mod_id: &str) -> Option { + let _ = mod_id; + unimplemented!("Host::built_data") + } /// Shows the panel beside container screens with these items (at most /// 8192), as the game's own slots in pages, with a search box; the /// player's clicks, searches and keys over items come back as /// `native_panel_event`. The panel is the last mod's to show one; empty /// items and no recipe page take yours away. - fn panel_items(&mut self, mod_id: &str, items: Vec); + fn panel_items(&mut self, mod_id: &str, items: Vec) { + let _ = mod_id; + let _ = items; + unimplemented!("Host::panel_items") + } /// The recipes of type `kind` (`mymod:crusher`), a type outside `minecraft`, /// from `data//recipe/*.json` of the content mods (`.hmod`) and /// the world's data packs, in file order: each one's id and its file as @@ -503,42 +823,68 @@ pub trait Host { /// single-player window included, sees only the content mods' recipes, not /// the world's data packs', unless a mod had the server send it the type /// (`native_datapack_sync`). API 36. - fn recipes_of_type(&self, kind: &str) -> &[CustomRecipe]; + fn recipes_of_type(&self, kind: &str) -> &[CustomRecipe] { + let _ = kind; + unimplemented!("Host::recipes_of_type") + } /// An ingredient as a recipe's JSON names it: an item, a `#tag`, or a list /// of them, a bare id as `minecraft:` (as vanilla reads it); `test` says /// whether a stack fits. Items the game doesn't know are left out; None /// when nothing is left (an unknown or empty tag, for one) and outside a /// world. API 36. - fn recipe_ingredient(&self, json: &serde_json::Value) -> Option; + fn recipe_ingredient(&self, json: &serde_json::Value) -> Option { + let _ = json; + unimplemented!("Host::recipe_ingredient") + } /// A stack as a recipe's `result` names it: `id` (a bare one as /// `minecraft:`), `count` (1 by default, up to 99) and `components`. None /// without an `id`, for an item the game doesn't know or a count outside /// that, and outside a world. API 36. - fn recipe_result(&self, json: &serde_json::Value) -> Option; + fn recipe_result(&self, json: &serde_json::Value) -> Option { + let _ = json; + unimplemented!("Host::recipe_result") + } /// The `book_id`s of the recipes the player's recipe book holds now /// (the server's book on a server); empty outside a world. - fn known_recipes(&mut self) -> Arc<[i32]>; + fn known_recipes(&mut self) -> Arc<[i32]> { + unimplemented!("Host::known_recipes") + } /// Places a recipe in the open screen's grid as a click in the recipe /// book does (`ServerboundPlaceRecipePacket`): the inventory's grid or a /// crafting table's. `all` places as many as the items allow, as with /// Shift. Refused while no such screen is open and for a recipe the /// player's book doesn't hold; one missing items shows as a ghost. - fn place_recipe(&mut self, book_id: i32, all: bool) -> Result<(), String>; + fn place_recipe(&mut self, book_id: i32, all: bool) -> Result<(), String> { + let _ = book_id; + let _ = all; + unimplemented!("Host::place_recipe") + } /// Creative mode only: gives the player `count` of `item` /// (`minecraft:diamond`) as taking it from the creative inventory does /// (`ServerboundSetCreativeModeSlot`): the selected slot first, then the /// hotbar, then the inventory. The count is cut to one stack. - fn give(&mut self, item: &str, count: u32) -> Result<(), String>; + fn give(&mut self, item: &str, count: u32) -> Result<(), String> { + let _ = item; + let _ = count; + unimplemented!("Host::give") + } /// For a client mod: a push added to the local player's velocity, in blocks a tick, as an /// explosion's knockback is (the window's player moves itself, as vanilla's client does). Each component is /// clamped to -4..4; a spectator, or a value that is not a number, is left alone. API 105. - fn push_player(&mut self, kick: [f32; 3]); + fn push_player(&mut self, kick: [f32; 3]) { + let _ = kick; + unimplemented!("Host::push_player") + } /// For a server mod: `player`'s creative flying speed (vanilla's 0.05 when none, at most `FLYING_MAX`), kept on /// the server's player and sent to their client. A respawn or a game mode change keeps it, and it is saved /// with the player (`abilities.flySpeed`, as vanilla saves it). False for a player not here or a client of a /// server, and inside a player's hook, where the server's player list is busy: call it from a tick hook. /// API 105. - fn set_flying_speed(&mut self, player: u128, speed: Option) -> bool; + fn set_flying_speed(&mut self, player: u128, speed: Option) -> bool { + let _ = player; + let _ = speed; + unimplemented!("Host::set_flying_speed") + } /// Sends `data` on `channel` (`mod_id:name`, at most 30 000 bytes, at /// most 32 a player a tick) to `player`'s client, at the tick's end, from /// any server hook. Ok means accepted: a player who left or isn't on a @@ -548,12 +894,21 @@ pub trait Host { player: u128, channel: &str, data: &[u8], - ) -> Result<(), String>; + ) -> Result<(), String> { + let _ = player; + let _ = channel; + let _ = data; + unimplemented!("Host::net_send_to_player") + } /// The client half: sends `data` on `channel` to the server (the same /// caps, 32 a tick). An error when the server doesn't carry mod messages /// (a vanilla server), and until the player has joined: a server says it /// carries them during configuration, before it can take one. API 40. - fn net_send_to_server(&mut self, channel: &str, data: &[u8]) -> Result<(), String>; + fn net_send_to_server(&mut self, channel: &str, data: &[u8]) -> Result<(), String> { + let _ = channel; + let _ = data; + unimplemented!("Host::net_send_to_server") + } /// Drop one item as DefaultDispenseItemBehavior does. For a dispenser hook: /// return this remainder in its answer with `CLICK`; the caller plays the /// answer's event and smoke once. A client, another dimension or an unloaded @@ -564,7 +919,13 @@ pub trait Host { pos: [i32; 3], facing: Direction, stack: ItemStack, - ) -> Option; + ) -> Option { + let _ = dimension; + let _ = pos; + let _ = facing; + let _ = stack; + unimplemented!("Host::dispense_default") + } /// Opens a chest menu of `rows` rows (1 to 6) titled `title` for the /// player, showing `items` (by slot, padded with empty ones), in place of /// the menu they had open: its window id. @@ -574,15 +935,31 @@ pub trait Host { rows: u8, title: Text, items: Vec>, - ) -> Option; + ) -> Option { + let _ = uuid; + let _ = rows; + let _ = title; + let _ = items; + unimplemented!("Host::open_menu") + } /// Replaces what the player's open native menu shows; sent with the /// tick's container sync. - fn set_menu_items(&mut self, uuid: u128, items: Vec>); + fn set_menu_items(&mut self, uuid: u128, items: Vec>) { + let _ = uuid; + let _ = items; + unimplemented!("Host::set_menu_items") + } /// Closes the player's native menu (a `native_menu_closed` follows). - fn close_menu(&mut self, uuid: u128); + fn close_menu(&mut self, uuid: u128) { + let _ = uuid; + unimplemented!("Host::close_menu") + } /// The player's 36 inventory slots (0..9 the hotbar), to show or offer /// them; None for a player not here, dead, or whose state is swapped in. - fn player_stacks(&self, uuid: u128) -> Option>>; + fn player_stacks(&self, uuid: u128) -> Option>> { + let _ = uuid; + unimplemented!("Host::player_stacks") + } /// Swaps two players' offers (inventory slots, each with the stack it /// must still hold, exactly) in one step: the stacks leave their slots /// and go to the other player where they fit whole, or nothing moves and @@ -594,14 +971,26 @@ pub trait Host { a_offer: &[(usize, ItemStack)], b: u128, b_offer: &[(usize, ItemStack)], - ) -> Result<(), String>; + ) -> Result<(), String> { + let _ = a; + let _ = a_offer; + let _ = b; + let _ = b_offer; + unimplemented!("Host::exchange_items") + } /// `native_fluid_insert`: up to `amount` millibuckets of `fluid` /// (`minecraft:water`, `minecraft:lava`) into the block at `pos`, through /// its side `face`, in the level in place: a mod block's tanks first /// those with the same fluid, then empty ones, by the sides and tanks its /// table gave; a cauldron by thirds of a bucket, a lava one only whole. /// What did not fit. API 54. - fn fluid_insert(&mut self, pos: [i32; 3], face: Direction, fluid: &str, amount: i32) -> i32; + fn fluid_insert(&mut self, pos: [i32; 3], face: Direction, fluid: &str, amount: i32) -> i32 { + let _ = pos; + let _ = face; + let _ = fluid; + let _ = amount; + unimplemented!("Host::fluid_insert") + } /// Takes up to `amount` millibuckets out of the block at `pos` in the /// level in place, `dimension`, through its side `face`: a mod block's /// drainable tanks, one fluid only (the first that's there), or a @@ -614,7 +1003,13 @@ pub trait Host { pos: [i32; 3], face: Direction, amount: i32, - ) -> Option<(String, i32)>; + ) -> Option<(String, i32)> { + let _ = dimension; + let _ = pos; + let _ = face; + let _ = amount; + unimplemented!("Host::fluid_extract") + } /// Moves up to `max` millibuckets from the block at `from`, through its /// side `from_face`, into the one at `to`, through its side `to_face`, as /// `native_move_items` moves items: what the target won't take goes back @@ -631,10 +1026,20 @@ pub trait Host { to: [i32; 3], to_face: Direction, max: i32, - ) -> Option<(String, i32)>; + ) -> Option<(String, i32)> { + let _ = dimension; + let _ = from; + let _ = from_face; + let _ = to; + let _ = to_face; + let _ = max; + unimplemented!("Host::move_fluids") + } /// The level in place (`minecraft:overworld`), for a hook that isn't told /// which it runs in. API 48. - fn dimension(&self) -> &'static str; + fn dimension(&self) -> &'static str { + unimplemented!("Host::dimension") + } /// Takes up to `count` items out of slot `slot` of the container at `pos`, /// through its side `face`; they're the mod's to place then, as a hopper's /// are: to hand them on without losing any, use `native_move_items`. None @@ -646,7 +1051,14 @@ pub trait Host { face: Direction, slot: usize, count: u32, - ) -> Option; + ) -> Option { + let _ = dimension; + let _ = pos; + let _ = face; + let _ = slot; + let _ = count; + unimplemented!("Host::extract_item") + } /// Moves up to `max` items from the container at `from`, through its side /// `from_face`, into the one at `to`, through its side `to_face`, one by /// one as a hopper does: an item the target refuses stays in its slot, so @@ -663,5 +1075,14 @@ pub trait Host { to_face: Direction, item: Option<&str>, max: u32, - ) -> u32; + ) -> u32 { + let _ = dimension; + let _ = from; + let _ = from_face; + let _ = to; + let _ = to_face; + let _ = item; + let _ = max; + unimplemented!("Host::move_items") + } } diff --git a/xtask/src/mod_api.rs b/xtask/src/mod_api.rs index d17ea63a88..07c5ddd6d8 100644 --- a/xtask/src/mod_api.rs +++ b/xtask/src/mod_api.rs @@ -272,8 +272,15 @@ fn host_method( args.push(pat.ident.to_string()); } let docs = doc_comments(&method.attrs, name, true)?; + let unused = args + .iter() + .map(|arg| format!("let _ = {arg};")) + .collect::(); Ok(( - format!("{docs}{};\n", sig.to_token_stream()), + format!( + "{docs}{} {{ {unused} unimplemented!(\"Host::{name}\") }}\n", + sig.to_token_stream() + ), format!( "{} {{ self.{original}({}) }}\n", sig.to_token_stream(), @@ -933,7 +940,7 @@ fn generate(root: &Path, check: bool) -> Result<(), String> { imports.push_str("use std::sync::Arc;\n"); } let host = format!( - "// @generated by cargo run -p xtask -- mod-api; do not edit.\nuse super::*;\n{imports}/// Services supplied by the game to trait mods.\npub trait Host {{\n{host_traits}}}\n" + "// @generated by cargo run -p xtask -- mod-api; do not edit.\nuse super::*;\n{imports}/// Services supplied by the game to trait mods.\n/// Each method's default panics with its name, so a test double implements only what its test calls; the game implements every one.\npub trait Host {{\n{host_traits}}}\n" ); let implementation = format!( "// @generated by cargo run -p xtask -- mod-api; do not edit.\nuse horizon_client::mod_api::*;\n{imports}impl Host for super::Game<'_> {{\n{host_impl}}}\n" From 57693fbf63cb1c799802c8c35ef2c04b43a08cad Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:15:00 +0000 Subject: [PATCH 02/37] Test a trait mod's hook with a partial Host double --- crates/horizon-client/src/mod_api.rs | 98 ++++++++++++++++++++++++++++ docs/native-mods.md | 22 +++++++ 2 files changed, 120 insertions(+) diff --git a/crates/horizon-client/src/mod_api.rs b/crates/horizon-client/src/mod_api.rs index 6a77dd0464..664de26a80 100644 --- a/crates/horizon-client/src/mod_api.rs +++ b/crates/horizon-client/src/mod_api.rs @@ -470,3 +470,101 @@ mod generated; pub use generated::Hooks; #[doc(hidden)] pub use generated::*; + +#[cfg(test)] +mod tests { + use super::*; + + /// A double with the three `Host` methods the probe calls. + struct Reader { + block: &'static str, + lines: Vec, + } + + impl Host for Reader { + fn block(&self, _pos: [i32; 3]) -> String { + self.block.to_string() + } + fn acting_player(&self) -> Option { + Some(NativeServerPlayer { + uuid: 7, + name: "Probe".to_string(), + permission: 0, + dimension: "minecraft:overworld".to_string(), + position: [0.0; 3], + game_mode: 0, + health: 20.0, + yaw: 0.0, + pitch: 0.0, + }) + } + fn action_bar(&mut self, _uuid: u128, line: Text) -> bool { + self.lines.push(line); + true + } + } + + /// Claims a use on stone, after telling the player what the block is. + struct Probe; + + impl Hooks for Probe { + fn use_item_on(&self, host: &mut dyn Host, event: &mut UseItemOnEvent<'_>) -> Flow { + let block = host.block(event.pos); + let player = host.acting_player().expect("a player acts"); + host.action_bar(player.uuid, Text::literal(block.clone())); + if block == "minecraft:stone" { + Flow::Cancel + } else { + Flow::Continue + } + } + } + + /// Asks the host to tell a player without anyone having implemented it. + struct Chatty; + + impl Hooks for Chatty { + fn use_item_on(&self, host: &mut dyn Host, _event: &mut UseItemOnEvent<'_>) -> Flow { + host.tell(7, Text::literal("hello")); + Flow::Continue + } + } + + fn stick() -> ItemStack { + ItemStack { + item: Arc::from("minecraft:stick"), + count: 1, + damage: 0, + extra: None, + } + } + + #[test] + fn a_test_host_implements_only_what_its_hook_calls() { + let mods = [Box::new(Probe) as Box]; + let stack = stick(); + for (block, expected) in [("minecraft:stone", true), ("minecraft:dirt", false)] { + let mut host = Reader { + block, + lines: Vec::new(), + }; + let mut claimed = false; + dispatch_use_item_on(&mut host, &mods, &stack, [1, 2, 3], [0, 1, 0], &mut claimed); + assert_eq!(claimed, expected, "{block}"); + let told: Vec = host.lines.iter().map(Text::plain).collect(); + assert_eq!(told, [block], "{block}"); + } + } + + #[test] + #[should_panic(expected = "Host::tell")] + fn an_unexpected_host_call_names_the_method() { + let mods = [Box::new(Chatty) as Box]; + let mut host = Reader { + block: "minecraft:stone", + lines: Vec::new(), + }; + let mut claimed = false; + dispatch_use_item_on(&mut host, &mods, &stick(), [0; 3], [0, 1, 0], &mut claimed); + } +} diff --git a/docs/native-mods.md b/docs/native-mods.md index 0b235ef3dd..55b21bbbf7 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -2109,6 +2109,28 @@ sees the changes before it. Because no mod can build or match the whole struct, a later version can add a field without breaking any mod, as NeoForge's event objects do; the cost is that a mod's own tests can't build an event yet. +#### Testing a trait mod + +Every `Host` method defaults to a panic naming it (`not implemented: Host::tell`), so a test double implements +only what its hook calls, and an unexpected call fails loudly. Drive the hook through +`horizon_client::mod_api::dispatch_(&mut host, &mods, args…)`, which builds the event as the game does. Game +values that are `#[non_exhaustive]`, `NativeServerPlayer` and the events, can't be built outside horizon-client: +return `None` for them, or test the logic beneath the hook. The level hosts are small enough to implement whole. +NeoForge's GameTest is the in-game counterpart. + +```rust +struct Stone; +impl Host for Stone { + fn block(&self, _pos: [i32; 3]) -> String { + "minecraft:stone".to_string() + } +} +let mods = [Box::new(MyMod) as Box]; +let mut claimed = false; +dispatch_use_item_on(&mut Stone, &mods, &stack, [0, 64, 0], [0, 1, 0], &mut claimed); +assert!(claimed); +``` + Hook methods take `&self` and require `Send + Sync`. The process-wide mod list is installed once, like mixin module statics. One mod instance serves both the window's game and the server-thread game. A hook can also fire again inside the same mod: for example, `set_block` fires `block_changed`. Mods keep mutable From dc14ce3c6e0759cecd9f58269d8d51829a86b4fe Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:38:18 +0000 Subject: [PATCH 03/37] Move the trait-mod testing section before Adding a hook --- docs/native-mods.md | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/native-mods.md b/docs/native-mods.md index 55b21bbbf7..1ac13acaa0 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -2109,28 +2109,6 @@ sees the changes before it. Because no mod can build or match the whole struct, a later version can add a field without breaking any mod, as NeoForge's event objects do; the cost is that a mod's own tests can't build an event yet. -#### Testing a trait mod - -Every `Host` method defaults to a panic naming it (`not implemented: Host::tell`), so a test double implements -only what its hook calls, and an unexpected call fails loudly. Drive the hook through -`horizon_client::mod_api::dispatch_(&mut host, &mods, args…)`, which builds the event as the game does. Game -values that are `#[non_exhaustive]`, `NativeServerPlayer` and the events, can't be built outside horizon-client: -return `None` for them, or test the logic beneath the hook. The level hosts are small enough to implement whole. -NeoForge's GameTest is the in-game counterpart. - -```rust -struct Stone; -impl Host for Stone { - fn block(&self, _pos: [i32; 3]) -> String { - "minecraft:stone".to_string() - } -} -let mods = [Box::new(MyMod) as Box]; -let mut claimed = false; -dispatch_use_item_on(&mut Stone, &mods, &stack, [0, 64, 0], [0, 1, 0], &mut claimed); -assert!(claimed); -``` - Hook methods take `&self` and require `Send + Sync`. The process-wide mod list is installed once, like mixin module statics. One mod instance serves both the window's game and the server-thread game. A hook can also fire again inside the same mod: for example, `set_block` fires `block_changed`. Mods keep mutable @@ -2152,6 +2130,28 @@ renderer list is installed once alongside the mod list. `render_needs` collects run on the render thread while the frame is built, before any deferred GPU submission. The renderer module re-exports the game's wgpu types. +### Testing a trait mod + +Every `Host` method defaults to a panic naming it (`not implemented: Host::tell`), so a test double implements +only what its hook calls, and an unexpected call fails loudly. Drive the hook through +`horizon_client::mod_api::dispatch_(&mut host, &mods, args…)`, which builds the event as the game does. Game +values that are `#[non_exhaustive]`, `NativeServerPlayer` and the events, can't be built outside horizon-client: +return `None` for them, or test the logic beneath the hook. The level hosts are small enough to implement whole. +NeoForge's GameTest is the in-game counterpart. + +```rust +struct Stone; +impl Host for Stone { + fn block(&self, _pos: [i32; 3]) -> String { + "minecraft:stone".to_string() + } +} +let mods = [Box::new(MyMod) as Box]; +let mut claimed = false; +dispatch_use_item_on(&mut Stone, &mods, &stack, [0, 64, 0], [0, 1, 0], &mut claimed); +assert!(claimed); +``` + ### Adding a hook A new hook needs its fragment in `tools/native-hook-fragments/` with its `mod_api` entry (for example From 9db36edb93d89062e5555a6f7c1ca19c37914a64 Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:22:23 +0000 Subject: [PATCH 04/37] docs: list the native-mod values a test outside horizon-client can't build --- docs/native-mods.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/native-mods.md b/docs/native-mods.md index 1ac13acaa0..d5f52b8cca 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -2134,9 +2134,13 @@ The renderer module re-exports the game's wgpu types. Every `Host` method defaults to a panic naming it (`not implemented: Host::tell`), so a test double implements only what its hook calls, and an unexpected call fails loudly. Drive the hook through -`horizon_client::mod_api::dispatch_(&mut host, &mods, args…)`, which builds the event as the game does. Game -values that are `#[non_exhaustive]`, `NativeServerPlayer` and the events, can't be built outside horizon-client: -return `None` for them, or test the logic beneath the hook. The level hosts are small enough to implement whole. +`horizon_client::mod_api::dispatch_(&mut host, &mods, args…)`, which builds the event as the game does. The +events and these `#[non_exhaustive]` values can't be built outside horizon-client: `NativeScreenView` (the +`dispatch_screen_*` hooks), `NativeLifecycleContext` (`dispatch_world_lifecycle`, `dispatch_server_tick`), +`NativeLootDrops` (`dispatch_loot_drops`) and `NativeServerPlayer` (`dispatch_perform_command` takes one as an +argument). A test outside can't call those dispatches, and a `Host` answer doesn't help; test the logic beneath the +hook instead. `NativeServerPlayer` is also returned by `Host::server_players` and `Host::acting_player`, which a test +double can answer with an empty list or `None`. The level hosts are small enough to implement whole. NeoForge's GameTest is the in-game counterpart. ```rust From 22dd5f9a80251d170fcf5a101fa0286916803d0a Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:22:37 +0000 Subject: [PATCH 05/37] Add native item expiration hook --- crates/horizon-client/src/game.rs | 15 ++++++++++++--- crates/horizon-client/src/game/native_hooks.rs | 12 ++++++++++++ docs/native-mods.md | 2 ++ .../native_item_expire.json | 17 +++++++++++++++++ 4 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 tools/native-hook-fragments/native_item_expire.json diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 46a554249d..08ff8ecec2 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4074,9 +4074,6 @@ impl Game<'_> { let ticking = self.entity_ticking_area(); let mut removed = vec![false; items.len()]; let mut cells = horizon_client::entities::ItemCells::new(&items); - let catalog = world_items!(self); - let max_stack = - |stack: &ItemStack| catalog.get(&stack.item).map_or(64, |i| i.max_stack).max(1); // A LAN or server guest only shows the host's items. let merging = self.remote.is_none(); let obstacles = horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions()); @@ -4150,6 +4147,9 @@ impl Game<'_> { && (alive || item.age == 6000 && item.position.y > -128.0) && item.ticks.is_multiple_of(rate) { + let catalog = world_items!(self); + let max_stack = + |stack: &ItemStack| catalog.get(&stack.item).map_or(64, |i| i.max_stack).max(1); horizon_client::entities::merge_with_neighbours( &mut items, &mut removed, @@ -4159,6 +4159,15 @@ impl Game<'_> { ); } let item = &mut items[i]; + let mut alive = alive || (item.age < 6000 && item.position.y > -128.0); + if merging && !removed[i] && item.age >= 6000 && item.position.y > -128.0 { + let mut extra_life = 0; + self.native_item_expire(item.id, &item.stack, &mut extra_life); + if extra_life > 0 { + item.age = item.age.saturating_sub(extra_life as u32); + alive = true; + } + } if !alive || removed[i] { removed[i] = true; cells.remove(i, item.position); diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index f54b1868c1..af8cadb320 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -5554,6 +5554,18 @@ impl Game<'_> { ) { mod_api::dispatch_item_picked_up(self, mod_api::mods(), player, item, count, canceled); } + /// A dropped item reaches its lifetime on the server (`ItemEntity.tick`, + /// NeoForge's `ItemExpireEvent`). Set or add to `extra_life` to keep it + /// for more ticks; zero or less lets it expire. Because Horizon stores age + /// unsigned, extensions are capped at the current age. @API:item_expire@. + pub(super) fn native_item_expire( + &mut self, + entity: u32, + stack: &ItemStack, + extra_life: &mut i32, + ) { + let _ = (entity, stack, extra_life); + } /// A player took a crafting grid's result (`ResultSlot.onTake`, NeoForge's /// `PlayerEvent.ItemCraftedEvent`): `player` is the player's uuid, `recipe` /// the recipe the grid matched (`minecraft:oak_planks`) and `stack` the diff --git a/docs/native-mods.md b/docs/native-mods.md index 2d50f2ddeb..9eff320f63 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -905,6 +905,8 @@ API 142 adds an energy buffer on a mod block's block entity (`Capabilities.Energ API 143 adds data maps (`RegisterDataMapTypesEvent`). `native_data_map_types(types)` pushes the types a mod registers: an `id` (`mod_id:name`, not in `minecraft`) for a `registry`, `minecraft:item` or `minecraft:block` so far; asked once per load of the server's data (a world's first open and every `/reload`). The values come from `data//data_maps//.json` in the world's data packs and from files a mod pushes through `native_data_pack_files` under the key `data_maps/:/`: `{"replace": false, "values": {"minecraft:coal": ..., "#minecraft:logs": ...}, "remove": [...]}`, an entry's value being the data itself or `{"value": ..., "replace": false}`; `replace` clears what earlier files set, a tag gives every entry in it the value, `remove` applies after the file's own values, and a newer value overrides an older one. `native_data_map(registry, type, entry)` reads an entry's value (the JSON the file held) and `native_data_map_entries(registry, type)` lists them. Server side only and not synced to clients; mergers, conditions, other registries and mod datapack registries are not here yet. Bundles need "hooks": 143. +`native_item_expire(entity, stack, extra_life)` reports a dropped item reaching its lifetime on the server (NeoForge's `ItemExpireEvent`, vanilla `ItemEntity.tick`). Set or add positive ticks to `extra_life` to keep it; zero or less lets it expire. Horizon stores age unsigned, so an extension is capped at the current age (normally 6000 ticks). Bundles need `"hooks": @API:item_expire@`. + API 144 adds a player's or mob's breathing, on the single player server thread and on a dedicated server: `native_living_breathe(entity, kind, can_breathe, consume, refill)` every tick for a player and for a mob with the generic air logic (NeoForge's `LivingBreatheEvent`, vanilla's `LivingEntity.baseTick`), with Horizon's answers as the defaults: set `can_breathe` and how much air `consume` takes or `refill` gives (up to 300); at -20 the entity is set to 0 air and takes 2 drowning damage. It is a free function in `horizon_client::breathing`, so it answers and cannot call helpers. Axolotls and `WaterAnimal`s keep their own air logic. Bundles that use it need "hooks": 144. API 145 adds blocks and items a native mod declares in code, as NeoForge's `DeferredRegister.Blocks` and `.Items` drained by `RegisterEvent`, instead of one content file each: `native_blocks(blocks)` and `native_items(items)`, asked once at start on every side, take `horizon_mods::content::Block` and `Item`, the declarations a content mod's `data//horizon/block/` and `item/` files hold (a vanilla template and what differs; `item: true` gives a block its item). Ids are in the mod's namespace. They join the content mods' blocks and items, inside the content mod whose id is that namespace when it is on (its models, textures, loot and recipes), else on their own with the template's look, so their network ids, saves and the joining client's content check are the files' (docs/mods.md). They pass the files' checks; a namespace whose declarations don't is logged and left out. Bundles that use them need `"hooks": 145`. diff --git a/tools/native-hook-fragments/native_item_expire.json b/tools/native-hook-fragments/native_item_expire.json new file mode 100644 index 0000000000..942c44c2ba --- /dev/null +++ b/tools/native-hook-fragments/native_item_expire.json @@ -0,0 +1,17 @@ +{ + "hooks": [ + { + "name": "item_expire", + "target": "horizon_client::game::native_hooks::Game<'_>::native_item_expire", + "neoforge": "ItemExpireEvent", + "side": "server", + "cancellable": false, + "mod_api": { + "contract": "replace", + "out": "extra_life" + } + } + ], + "rust_summary": "A dropped item reaching its lifetime (`native_item_expire`).", + "doc_summary": "A server item reaches its lifetime: `native_item_expire(entity, stack, extra_life)` lets mods extend it. Extensions are capped at the current unsigned age. Bundles need \"hooks\": @API:item_expire@." +} From 12b38c91f71ebf4ab0a225bfd5eef3a6ec6b530b Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:23:28 +0000 Subject: [PATCH 06/37] Match replacement hook generator contract --- crates/horizon-client/src/game.rs | 3 ++- crates/horizon-client/src/game/native_hooks.rs | 6 +++--- docs/native-mods.md | 2 +- tools/native-hook-fragments/native_item_expire.json | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 08ff8ecec2..f1b9a02286 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4161,8 +4161,9 @@ impl Game<'_> { let item = &mut items[i]; let mut alive = alive || (item.age < 6000 && item.position.y > -128.0); if merging && !removed[i] && item.age >= 6000 && item.position.y > -128.0 { - let mut extra_life = 0; + let mut extra_life = None; self.native_item_expire(item.id, &item.stack, &mut extra_life); + let extra_life = extra_life.unwrap_or(0); if extra_life > 0 { item.age = item.age.saturating_sub(extra_life as u32); alive = true; diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index af8cadb320..9dfdc76aef 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -5555,14 +5555,14 @@ impl Game<'_> { mod_api::dispatch_item_picked_up(self, mod_api::mods(), player, item, count, canceled); } /// A dropped item reaches its lifetime on the server (`ItemEntity.tick`, - /// NeoForge's `ItemExpireEvent`). Set or add to `extra_life` to keep it - /// for more ticks; zero or less lets it expire. Because Horizon stores age + /// NeoForge's `ItemExpireEvent`). Return extra ticks to keep it; zero or + /// less lets it expire. The first replacement wins. Because Horizon stores age /// unsigned, extensions are capped at the current age. @API:item_expire@. pub(super) fn native_item_expire( &mut self, entity: u32, stack: &ItemStack, - extra_life: &mut i32, + extra_life: &mut Option, ) { let _ = (entity, stack, extra_life); } diff --git a/docs/native-mods.md b/docs/native-mods.md index 9eff320f63..2225bf24f8 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -905,7 +905,7 @@ API 142 adds an energy buffer on a mod block's block entity (`Capabilities.Energ API 143 adds data maps (`RegisterDataMapTypesEvent`). `native_data_map_types(types)` pushes the types a mod registers: an `id` (`mod_id:name`, not in `minecraft`) for a `registry`, `minecraft:item` or `minecraft:block` so far; asked once per load of the server's data (a world's first open and every `/reload`). The values come from `data//data_maps//.json` in the world's data packs and from files a mod pushes through `native_data_pack_files` under the key `data_maps/:/`: `{"replace": false, "values": {"minecraft:coal": ..., "#minecraft:logs": ...}, "remove": [...]}`, an entry's value being the data itself or `{"value": ..., "replace": false}`; `replace` clears what earlier files set, a tag gives every entry in it the value, `remove` applies after the file's own values, and a newer value overrides an older one. `native_data_map(registry, type, entry)` reads an entry's value (the JSON the file held) and `native_data_map_entries(registry, type)` lists them. Server side only and not synced to clients; mergers, conditions, other registries and mod datapack registries are not here yet. Bundles need "hooks": 143. -`native_item_expire(entity, stack, extra_life)` reports a dropped item reaching its lifetime on the server (NeoForge's `ItemExpireEvent`, vanilla `ItemEntity.tick`). Set or add positive ticks to `extra_life` to keep it; zero or less lets it expire. Horizon stores age unsigned, so an extension is capped at the current age (normally 6000 ticks). Bundles need `"hooks": @API:item_expire@`. +`native_item_expire(entity, stack, extra_life)` reports a dropped item reaching its lifetime on the server (NeoForge's `ItemExpireEvent`, vanilla `ItemEntity.tick`). Return positive ticks to keep it; zero or less lets it expire. The first replacement wins, so listeners cannot add extensions together. Horizon stores age unsigned, so an extension is capped at the current age (normally 6000 ticks). Bundles need `"hooks": @API:item_expire@`. API 144 adds a player's or mob's breathing, on the single player server thread and on a dedicated server: `native_living_breathe(entity, kind, can_breathe, consume, refill)` every tick for a player and for a mob with the generic air logic (NeoForge's `LivingBreatheEvent`, vanilla's `LivingEntity.baseTick`), with Horizon's answers as the defaults: set `can_breathe` and how much air `consume` takes or `refill` gives (up to 300); at -20 the entity is set to 0 air and takes 2 drowning damage. It is a free function in `horizon_client::breathing`, so it answers and cannot call helpers. Axolotls and `WaterAnimal`s keep their own air logic. Bundles that use it need "hooks": 144. diff --git a/tools/native-hook-fragments/native_item_expire.json b/tools/native-hook-fragments/native_item_expire.json index 942c44c2ba..712653022f 100644 --- a/tools/native-hook-fragments/native_item_expire.json +++ b/tools/native-hook-fragments/native_item_expire.json @@ -13,5 +13,5 @@ } ], "rust_summary": "A dropped item reaching its lifetime (`native_item_expire`).", - "doc_summary": "A server item reaches its lifetime: `native_item_expire(entity, stack, extra_life)` lets mods extend it. Extensions are capped at the current unsigned age. Bundles need \"hooks\": @API:item_expire@." + "doc_summary": "A server item reaches its lifetime: `native_item_expire(entity, stack, extra_life)` lets the first responding mod extend it. Extensions are capped at the current unsigned age. Bundles need \"hooks\": @API:item_expire@." } From 9fe4c746bb4512fbda4f54af7ac829a09e7856cb Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:27:31 +0000 Subject: [PATCH 07/37] Let every mod extend an expiring item --- crates/horizon-client/src/game.rs | 3 +-- crates/horizon-client/src/game/native_hooks.rs | 9 +++++---- docs/native-mods.md | 2 -- tools/native-hook-fragments/item_expire.json | 17 +++++++++++++++++ .../native_item_expire.json | 17 ----------------- 5 files changed, 23 insertions(+), 25 deletions(-) create mode 100644 tools/native-hook-fragments/item_expire.json delete mode 100644 tools/native-hook-fragments/native_item_expire.json diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index f1b9a02286..08ff8ecec2 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4161,9 +4161,8 @@ impl Game<'_> { let item = &mut items[i]; let mut alive = alive || (item.age < 6000 && item.position.y > -128.0); if merging && !removed[i] && item.age >= 6000 && item.position.y > -128.0 { - let mut extra_life = None; + let mut extra_life = 0; self.native_item_expire(item.id, &item.stack, &mut extra_life); - let extra_life = extra_life.unwrap_or(0); if extra_life > 0 { item.age = item.age.saturating_sub(extra_life as u32); alive = true; diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 9dfdc76aef..68027340e6 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -5555,14 +5555,15 @@ impl Game<'_> { mod_api::dispatch_item_picked_up(self, mod_api::mods(), player, item, count, canceled); } /// A dropped item reaches its lifetime on the server (`ItemEntity.tick`, - /// NeoForge's `ItemExpireEvent`). Return extra ticks to keep it; zero or - /// less lets it expire. The first replacement wins. Because Horizon stores age - /// unsigned, extensions are capped at the current age. @API:item_expire@. + /// NeoForge's `ItemExpireEvent`). Every mod may set or add to `extra_life`, + /// in ticks, which starts at zero; a positive total keeps the item that much + /// longer, zero or less lets it expire. Because Horizon stores age unsigned, + /// an extension is capped at the current age. @API:item_expire@. pub(super) fn native_item_expire( &mut self, entity: u32, stack: &ItemStack, - extra_life: &mut Option, + extra_life: &mut i32, ) { let _ = (entity, stack, extra_life); } diff --git a/docs/native-mods.md b/docs/native-mods.md index 2225bf24f8..2d50f2ddeb 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -905,8 +905,6 @@ API 142 adds an energy buffer on a mod block's block entity (`Capabilities.Energ API 143 adds data maps (`RegisterDataMapTypesEvent`). `native_data_map_types(types)` pushes the types a mod registers: an `id` (`mod_id:name`, not in `minecraft`) for a `registry`, `minecraft:item` or `minecraft:block` so far; asked once per load of the server's data (a world's first open and every `/reload`). The values come from `data//data_maps//.json` in the world's data packs and from files a mod pushes through `native_data_pack_files` under the key `data_maps/:/`: `{"replace": false, "values": {"minecraft:coal": ..., "#minecraft:logs": ...}, "remove": [...]}`, an entry's value being the data itself or `{"value": ..., "replace": false}`; `replace` clears what earlier files set, a tag gives every entry in it the value, `remove` applies after the file's own values, and a newer value overrides an older one. `native_data_map(registry, type, entry)` reads an entry's value (the JSON the file held) and `native_data_map_entries(registry, type)` lists them. Server side only and not synced to clients; mergers, conditions, other registries and mod datapack registries are not here yet. Bundles need "hooks": 143. -`native_item_expire(entity, stack, extra_life)` reports a dropped item reaching its lifetime on the server (NeoForge's `ItemExpireEvent`, vanilla `ItemEntity.tick`). Return positive ticks to keep it; zero or less lets it expire. The first replacement wins, so listeners cannot add extensions together. Horizon stores age unsigned, so an extension is capped at the current age (normally 6000 ticks). Bundles need `"hooks": @API:item_expire@`. - API 144 adds a player's or mob's breathing, on the single player server thread and on a dedicated server: `native_living_breathe(entity, kind, can_breathe, consume, refill)` every tick for a player and for a mob with the generic air logic (NeoForge's `LivingBreatheEvent`, vanilla's `LivingEntity.baseTick`), with Horizon's answers as the defaults: set `can_breathe` and how much air `consume` takes or `refill` gives (up to 300); at -20 the entity is set to 0 air and takes 2 drowning damage. It is a free function in `horizon_client::breathing`, so it answers and cannot call helpers. Axolotls and `WaterAnimal`s keep their own air logic. Bundles that use it need "hooks": 144. API 145 adds blocks and items a native mod declares in code, as NeoForge's `DeferredRegister.Blocks` and `.Items` drained by `RegisterEvent`, instead of one content file each: `native_blocks(blocks)` and `native_items(items)`, asked once at start on every side, take `horizon_mods::content::Block` and `Item`, the declarations a content mod's `data//horizon/block/` and `item/` files hold (a vanilla template and what differs; `item: true` gives a block its item). Ids are in the mod's namespace. They join the content mods' blocks and items, inside the content mod whose id is that namespace when it is on (its models, textures, loot and recipes), else on their own with the template's look, so their network ids, saves and the joining client's content check are the files' (docs/mods.md). They pass the files' checks; a namespace whose declarations don't is logged and left out. Bundles that use them need `"hooks": 145`. diff --git a/tools/native-hook-fragments/item_expire.json b/tools/native-hook-fragments/item_expire.json new file mode 100644 index 0000000000..8fed87008b --- /dev/null +++ b/tools/native-hook-fragments/item_expire.json @@ -0,0 +1,17 @@ +{ + "hooks": [ + { + "name": "item_expire", + "target": "horizon_client::game::native_hooks::Game<'_>::native_item_expire", + "neoforge": "ItemExpireEvent", + "side": "server", + "cancellable": false, + "mod_api": { + "contract": "collect", + "out": "extra_life" + } + } + ], + "rust_summary": "a dropped item reaching its lifetime (`native_item_expire`).", + "doc_summary": "`native_item_expire(entity, stack, extra_life)`, a dropped item reaching its lifetime on the server (NeoForge's `ItemExpireEvent`, vanilla `ItemEntity.tick`): every mod may set or add to `extra_life`, in ticks from zero; a positive total keeps the item that much longer, zero or less lets it expire. Horizon stores age unsigned, so an extension is capped at the current age (normally 6000 ticks). Bundles need \"hooks\": @API:item_expire@." +} diff --git a/tools/native-hook-fragments/native_item_expire.json b/tools/native-hook-fragments/native_item_expire.json deleted file mode 100644 index 712653022f..0000000000 --- a/tools/native-hook-fragments/native_item_expire.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "hooks": [ - { - "name": "item_expire", - "target": "horizon_client::game::native_hooks::Game<'_>::native_item_expire", - "neoforge": "ItemExpireEvent", - "side": "server", - "cancellable": false, - "mod_api": { - "contract": "replace", - "out": "extra_life" - } - } - ], - "rust_summary": "A dropped item reaching its lifetime (`native_item_expire`).", - "doc_summary": "A server item reaches its lifetime: `native_item_expire(entity, stack, extra_life)` lets the first responding mod extend it. Extensions are capped at the current unsigned age. Bundles need \"hooks\": @API:item_expire@." -} From 75c640c9a63691929fff5322667d5e30e7ce828b Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:30:42 +0000 Subject: [PATCH 08/37] Preserve extended item lifetime in signed age --- crates/horizon-client/src/entities.rs | 28 ++++++++++++------- crates/horizon-client/src/entity_nbt.rs | 8 ++++-- crates/horizon-client/src/game.rs | 2 +- crates/horizon-client/src/game/machines.rs | 2 +- .../horizon-client/src/game/native_hooks.rs | 4 +-- tools/native-hook-fragments/item_expire.json | 2 +- 6 files changed, 28 insertions(+), 18 deletions(-) diff --git a/crates/horizon-client/src/entities.rs b/crates/horizon-client/src/entities.rs index b93dcd1940..3dc194c460 100644 --- a/crates/horizon-client/src/entities.rs +++ b/crates/horizon-client/src/entities.rs @@ -77,7 +77,7 @@ pub struct ItemEntity { pub previous: Vec3, pub velocity: Vec3, pub on_ground: bool, - pub age: u32, + pub age: i32, pub pickup_delay: u32, pub bob_offset: f32, /// `Entity.tickCount`: ticks since the item entered the level, which @@ -99,8 +99,8 @@ pub struct ItemEnvironmentTick { pub lava_hurt: bool, } impl ItemEnvironmentTick { - pub fn should_play_lava_hurt_sound(&self, tick: u32) -> bool { - self.lava_hurt && (!self.alive || tick.is_multiple_of(10)) + pub fn should_play_lava_hurt_sound(&self, tick: i32) -> bool { + self.lava_hurt && (!self.alive || tick % 10 == 0) } } impl ItemEntity { @@ -240,7 +240,10 @@ impl ItemEntity { // horizontal speed. The idle branch still runs bubble-column push // (`applyEffectsFromBlocksForLastMovements`), so lift builds up and // the item moves on the next wake-up (#4558). - if !self.on_ground || horizontal > 1.0e-5 || (self.age + self.id).is_multiple_of(4) { + if !self.on_ground + || horizontal > 1.0e-5 + || (self.age as u32).wrapping_add(self.id).is_multiple_of(4) + { let mut scene = SceneCollisions { world, binding: collision, @@ -367,7 +370,9 @@ impl ItemEntity { if self.velocity.subtract(old_velocity).length_squared() > 0.01 { self.needs_sync = true; } - self.age += 1; + if self.age != i16::MIN as i32 { + self.age += 1; + } // ItemEntity.tick refreshes immersion after moving. self.update_fluids(world, fluids); self.age < 6000 && self.position.y > -128.0 @@ -454,8 +459,11 @@ impl ItemEntity { } /// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup /// delay isn't infinite, before its lifetime ends. - fn mergeable(&self, age: u32, max_stack: u32) -> bool { - self.pickup_delay != u32::MAX && age < 6000 && self.stack.count < max_stack + fn mergeable(&self, age: i32, max_stack: u32) -> bool { + self.pickup_delay != u32::MAX + && age != i16::MIN as i32 + && age < 6000 + && self.stack.count < max_stack } } /// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast), @@ -591,7 +599,7 @@ pub fn merge_with_neighbours( max_stack: &dyn Fn(&ItemStack) -> u32, ) { let age = |items: &[ItemEntity], j: usize| { - if j == i { + if j == i && items[j].age != i16::MIN as i32 { items[j].age - 1 } else { items[j].age @@ -635,7 +643,7 @@ pub fn merge_with_neighbours( let to_item = &mut items[to]; to_item.pickup_delay = to_item.pickup_delay.max(delay); // This item's age goes up once its tick ends. - to_item.age = to_age.min(from_age) + u32::from(to == i); + to_item.age = to_age.min(from_age) + i32::from(to == i); if items[from].stack.count == 0 { removed[from] = true; cells.remove(from, items[from].position); @@ -1632,7 +1640,7 @@ mod armor_stand_render_tests { mod merge_tests { use super::*; - fn item(id: &str, count: u32, x: f64, age: u32) -> ItemEntity { + fn item(id: &str, count: u32, x: f64, age: i32) -> ItemEntity { let at = Vec3::new(x, 64.0, 0.5); ItemEntity { no_gravity: false, diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index dedd0a8a2a..a5c1694d0c 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -2033,7 +2033,10 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) -> ("Item", item_to_nbt(&e.stack)), ("Health", Tag::Short(e.health as i16)), ("Fire", Tag::Short(e.fire_ticks as i16)), - ("Age", Tag::Short(e.age.min(i16::MAX as u32) as i16)), + ( + "Age", + Tag::Short(e.age.clamp(i16::MIN as i32, i16::MAX as i32) as i16), + ), ( "PickupDelay", Tag::Short(e.pickup_delay.min(i16::MAX as u32) as i16), @@ -2094,8 +2097,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option { let mut extra_life = 0; self.native_item_expire(item.id, &item.stack, &mut extra_life); if extra_life > 0 { - item.age = item.age.saturating_sub(extra_life as u32); + item.age = item.age.saturating_sub(extra_life.min(32766)); alive = true; } } diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index 7aebf7e04d..b0b8d9aedf 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -766,7 +766,7 @@ impl Game<'_> { // `ItemEntity.tick` moves 1 of 4 ticks while resting (age was // counted already); `checkInsideBlocks` only follows a move. let moved = item.position != item.previous - || (item.age.wrapping_sub(1).wrapping_add(item.id)).is_multiple_of(4); + || ((item.age as u32).wrapping_sub(1).wrapping_add(item.id)).is_multiple_of(4); if !moved || item.stack.count == 0 { i += 1; continue; diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 68027340e6..cb7c19a647 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -5557,8 +5557,8 @@ impl Game<'_> { /// A dropped item reaches its lifetime on the server (`ItemEntity.tick`, /// NeoForge's `ItemExpireEvent`). Every mod may set or add to `extra_life`, /// in ticks, which starts at zero; a positive total keeps the item that much - /// longer, zero or less lets it expire. Because Horizon stores age unsigned, - /// an extension is capped at the current age. @API:item_expire@. + /// longer, zero or less lets it expire. Extensions are capped at 32766 + /// ticks. @API:item_expire@. pub(super) fn native_item_expire( &mut self, entity: u32, diff --git a/tools/native-hook-fragments/item_expire.json b/tools/native-hook-fragments/item_expire.json index 8fed87008b..cd72a01c51 100644 --- a/tools/native-hook-fragments/item_expire.json +++ b/tools/native-hook-fragments/item_expire.json @@ -13,5 +13,5 @@ } ], "rust_summary": "a dropped item reaching its lifetime (`native_item_expire`).", - "doc_summary": "`native_item_expire(entity, stack, extra_life)`, a dropped item reaching its lifetime on the server (NeoForge's `ItemExpireEvent`, vanilla `ItemEntity.tick`): every mod may set or add to `extra_life`, in ticks from zero; a positive total keeps the item that much longer, zero or less lets it expire. Horizon stores age unsigned, so an extension is capped at the current age (normally 6000 ticks). Bundles need \"hooks\": @API:item_expire@." + "doc_summary": "`native_item_expire(entity, stack, extra_life)`, a dropped item reaching its lifetime on the server (NeoForge's `ItemExpireEvent`, vanilla `ItemEntity.tick`): every mod may set or add to `extra_life`, in ticks from zero; a positive total keeps the item that much longer, zero or less lets it expire. Extensions are capped at 32766 ticks. Bundles need \"hooks\": @API:item_expire@." } From 039cf9ab29c57bbb1b0f9b415e9eaa461d944551 Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:36:05 +0000 Subject: [PATCH 09/37] Fix extended item lifetime behavior --- crates/horizon-client/src/entities.rs | 6 +++--- crates/horizon-client/src/game.rs | 5 ++--- crates/horizon-client/src/game/machines.rs | 6 +++--- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/horizon-client/src/entities.rs b/crates/horizon-client/src/entities.rs index 3dc194c460..9accf73129 100644 --- a/crates/horizon-client/src/entities.rs +++ b/crates/horizon-client/src/entities.rs @@ -99,8 +99,8 @@ pub struct ItemEnvironmentTick { pub lava_hurt: bool, } impl ItemEnvironmentTick { - pub fn should_play_lava_hurt_sound(&self, tick: i32) -> bool { - self.lava_hurt && (!self.alive || tick % 10 == 0) + pub fn should_play_lava_hurt_sound(&self, tick: u32) -> bool { + self.lava_hurt && (!self.alive || tick.is_multiple_of(10)) } } impl ItemEntity { @@ -242,7 +242,7 @@ impl ItemEntity { // the item moves on the next wake-up (#4558). if !self.on_ground || horizontal > 1.0e-5 - || (self.age as u32).wrapping_add(self.id).is_multiple_of(4) + || self.ticks.wrapping_add(self.id).is_multiple_of(4) { let mut scene = SceneCollisions { world, diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 0502d91d43..202292b54a 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4164,7 +4164,7 @@ impl Game<'_> { let mut extra_life = 0; self.native_item_expire(item.id, &item.stack, &mut extra_life); if extra_life > 0 { - item.age = item.age.saturating_sub(extra_life.min(32766)); + item.age = 6000 - extra_life.min(32766); alive = true; } } @@ -4179,8 +4179,7 @@ impl Game<'_> { self.pack.assets.registry, world_items!(self), ); - // shortcut: age is the fresh-drop clock; use entity ticks for saved/frozen-age phase parity. - if environment.should_play_lava_hurt_sound(item.age) { + if environment.should_play_lava_hurt_sound(item.ticks) { let pitch = 2.0 + self.world.random.next_float() * 0.4; self.audio.play( "minecraft:entity.generic.burn", diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index b0b8d9aedf..e2250482d4 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -763,10 +763,10 @@ impl Game<'_> { let mut i = 0; while i < self.dropped.len() { let item = &self.dropped[i]; - // `ItemEntity.tick` moves 1 of 4 ticks while resting (age was - // counted already); `checkInsideBlocks` only follows a move. + // `ItemEntity.tick` moves 1 of 4 ticks while resting; + // `checkInsideBlocks` only follows a move. let moved = item.position != item.previous - || ((item.age as u32).wrapping_sub(1).wrapping_add(item.id)).is_multiple_of(4); + || item.ticks.wrapping_add(item.id).is_multiple_of(4); if !moved || item.stack.count == 0 { i += 1; continue; From c16e5e3c3311e290c972800723d8c7305e477f6a Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:18:25 +0000 Subject: [PATCH 10/37] docs: correct the native-mod values a test outside horizon-client can't build --- docs/native-mods.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/native-mods.md b/docs/native-mods.md index d5f52b8cca..b9523c2940 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -2135,8 +2135,11 @@ The renderer module re-exports the game's wgpu types. Every `Host` method defaults to a panic naming it (`not implemented: Host::tell`), so a test double implements only what its hook calls, and an unexpected call fails loudly. Drive the hook through `horizon_client::mod_api::dispatch_(&mut host, &mods, args…)`, which builds the event as the game does. The -events and these `#[non_exhaustive]` values can't be built outside horizon-client: `NativeScreenView` (the -`dispatch_screen_*` hooks), `NativeLifecycleContext` (`dispatch_world_lifecycle`, `dispatch_server_tick`), +events and these values can't be built outside horizon-client: `NativeScreenView` (`dispatch_screen_render_post` +and the `dispatch_screen_*_pre` input hooks; `dispatch_screen`, `dispatch_screen_draw`, `dispatch_screen_click` and +`dispatch_screen_tooltip` take plain values and can be driven), `BlockEntityDraw` (`dispatch_block_entity_submit`, +`dispatch_mob_submit`, `dispatch_mob_layer`, `dispatch_player_submit`), `winit::event::KeyEvent` +(`dispatch_input_key`), `NativeLifecycleContext` (`dispatch_world_lifecycle`, `dispatch_server_tick`), `NativeLootDrops` (`dispatch_loot_drops`) and `NativeServerPlayer` (`dispatch_perform_command` takes one as an argument). A test outside can't call those dispatches, and a `Host` answer doesn't help; test the logic beneath the hook instead. `NativeServerPlayer` is also returned by `Host::server_players` and `Host::acting_player`, which a test From 1ec8980e6a9d3ad5f8faf0579c98f5269705eeb8 Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:41:17 +0000 Subject: [PATCH 11/37] Document testing a trait mod through dispatch functions --- crates/horizon-client/src/mod_api.rs | 1 - docs/native-mods.md | 17 +++++------------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/mod_api.rs b/crates/horizon-client/src/mod_api.rs index 664de26a80..612a4f5d3a 100644 --- a/crates/horizon-client/src/mod_api.rs +++ b/crates/horizon-client/src/mod_api.rs @@ -468,7 +468,6 @@ level_attachment_host!(LevelRandomHost, LevelWriteHost); mod generated; pub use generated::Hooks; -#[doc(hidden)] pub use generated::*; #[cfg(test)] diff --git a/docs/native-mods.md b/docs/native-mods.md index b9523c2940..62ceab32dd 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -2107,7 +2107,7 @@ anything else a mod may change, a game struct or a list, is a `&mut` field. A ca the return value; a collection hook's list is a field (`event.lines`). One event goes through every mod, so each sees the changes before it. Because no mod can build or match the whole struct, a later version can add a field without breaking any mod, as NeoForge's event objects do; the cost is that -a mod's own tests can't build an event yet. +a mod's own tests drive a hook through its dispatch function ([Testing a trait mod](#testing-a-trait-mod)). Hook methods take `&self` and require `Send + Sync`. The process-wide mod list is installed once, like mixin module statics. One mod instance serves both the window's game and the server-thread game. A hook can also fire again inside @@ -2134,17 +2134,10 @@ The renderer module re-exports the game's wgpu types. Every `Host` method defaults to a panic naming it (`not implemented: Host::tell`), so a test double implements only what its hook calls, and an unexpected call fails loudly. Drive the hook through -`horizon_client::mod_api::dispatch_(&mut host, &mods, args…)`, which builds the event as the game does. The -events and these values can't be built outside horizon-client: `NativeScreenView` (`dispatch_screen_render_post` -and the `dispatch_screen_*_pre` input hooks; `dispatch_screen`, `dispatch_screen_draw`, `dispatch_screen_click` and -`dispatch_screen_tooltip` take plain values and can be driven), `BlockEntityDraw` (`dispatch_block_entity_submit`, -`dispatch_mob_submit`, `dispatch_mob_layer`, `dispatch_player_submit`), `winit::event::KeyEvent` -(`dispatch_input_key`), `NativeLifecycleContext` (`dispatch_world_lifecycle`, `dispatch_server_tick`), -`NativeLootDrops` (`dispatch_loot_drops`) and `NativeServerPlayer` (`dispatch_perform_command` takes one as an -argument). A test outside can't call those dispatches, and a `Host` answer doesn't help; test the logic beneath the -hook instead. `NativeServerPlayer` is also returned by `Host::server_players` and `Host::acting_player`, which a test -double can answer with an empty list or `None`. The level hosts are small enough to implement whole. -NeoForge's GameTest is the in-game counterpart. +`horizon_client::mod_api::dispatch_(&mut host, &mods, args…)`, which builds the event as the game does. A +hook that takes a `#[non_exhaustive]` game value a test can't build, such as `NativeServerPlayer`, can't be driven +this way; return `None` from the `Host` method that supplies it, or test the logic beneath the hook. The level hosts +are small enough to implement whole. NeoForge's GameTest is the in-game counterpart. ```rust struct Stone; From 45ffb1d479d61f0a2ad6d307f578b8640fbaf77f Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:51:01 +0000 Subject: [PATCH 12/37] Regenerate the mod API --- .../horizon-client/src/game/native_hooks.rs | 2 +- .../horizon-client/src/mod_api/generated.rs | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index cb7c19a647..ba1c7102b8 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -5565,7 +5565,7 @@ impl Game<'_> { stack: &ItemStack, extra_life: &mut i32, ) { - let _ = (entity, stack, extra_life); + mod_api::dispatch_item_expire(self, mod_api::mods(), entity, stack, extra_life); } /// A player took a crafting grid's result (`ResultSlot.onTake`, NeoForge's /// `PlayerEvent.ItemCraftedEvent`): `player` is the player's uuid, `recipe` diff --git a/crates/horizon-client/src/mod_api/generated.rs b/crates/horizon-client/src/mod_api/generated.rs index 867bac4af7..8abed4fcdf 100644 --- a/crates/horizon-client/src/mod_api/generated.rs +++ b/crates/horizon-client/src/mod_api/generated.rs @@ -2953,6 +2953,17 @@ pub trait Hooks: Send + Sync { let _ = host; let _ = event; } + /// A dropped item reaches its lifetime on the server (`ItemEntity.tick`, + /// NeoForge's `ItemExpireEvent`). Every mod may set or add to `extra_life`, + /// in ticks, which starts at zero; a positive total keeps the item that much + /// longer, zero or less lets it expire. Extensions are capped at 32766 + /// ticks. @API:item_expire@. + /// + /// Every mod gets `event.extra_life` in order and may add, change or remove extra_life. + fn item_expire(&self, host: &mut dyn Host, event: &mut ItemExpireEvent<'_>) { + let _ = host; + let _ = event; + } /// A dropped item entity was successfully added to the server level. /// @API:native_item_spawn@. fn item_spawn(&self, host: &mut dyn Host, event: &mut ItemSpawnEvent<'_>) { @@ -9773,6 +9784,30 @@ pub fn dispatch_item_crafted( mod_hook.item_crafted(host, &mut event); } } +/// What [`Hooks::item_expire`] is told. Fields may be added: the struct is `#[non_exhaustive]`. +#[non_exhaustive] +pub struct ItemExpireEvent<'a> { + pub entity: u32, + pub stack: &'a ItemStack, + pub extra_life: i32, +} +pub fn dispatch_item_expire( + host: &mut dyn Host, + mods: &[Box], + entity: u32, + stack: &ItemStack, + extra_life: &mut i32, +) { + let mut event = ItemExpireEvent { + entity, + stack, + extra_life: *extra_life, + }; + for mod_hook in mods { + mod_hook.item_expire(host, &mut event); + } + *extra_life = event.extra_life; +} /// What [`Hooks::item_spawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`. #[non_exhaustive] pub struct ItemSpawnEvent<'a> { From 6be9ab6017782055fd03d7d58a7118df4df279f0 Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:00:22 +0000 Subject: [PATCH 13/37] Use a listen contract for item expiry extensions --- crates/horizon-client/src/mod_api/generated.rs | 2 -- tools/native-hook-fragments/item_expire.json | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/horizon-client/src/mod_api/generated.rs b/crates/horizon-client/src/mod_api/generated.rs index 8abed4fcdf..8ab8d58179 100644 --- a/crates/horizon-client/src/mod_api/generated.rs +++ b/crates/horizon-client/src/mod_api/generated.rs @@ -2958,8 +2958,6 @@ pub trait Hooks: Send + Sync { /// in ticks, which starts at zero; a positive total keeps the item that much /// longer, zero or less lets it expire. Extensions are capped at 32766 /// ticks. @API:item_expire@. - /// - /// Every mod gets `event.extra_life` in order and may add, change or remove extra_life. fn item_expire(&self, host: &mut dyn Host, event: &mut ItemExpireEvent<'_>) { let _ = host; let _ = event; diff --git a/tools/native-hook-fragments/item_expire.json b/tools/native-hook-fragments/item_expire.json index cd72a01c51..c3c685e977 100644 --- a/tools/native-hook-fragments/item_expire.json +++ b/tools/native-hook-fragments/item_expire.json @@ -7,8 +7,7 @@ "side": "server", "cancellable": false, "mod_api": { - "contract": "collect", - "out": "extra_life" + "contract": "listen" } } ], From 08a0f3e3b12d7b8a9cd407a668c71042d5749efc Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:00:22 +0000 Subject: [PATCH 14/37] Clarify testing unconstructible mod API values --- crates/horizon-client/src/mod_api.rs | 1 + docs/native-mods.md | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/mod_api.rs b/crates/horizon-client/src/mod_api.rs index 612a4f5d3a..664de26a80 100644 --- a/crates/horizon-client/src/mod_api.rs +++ b/crates/horizon-client/src/mod_api.rs @@ -468,6 +468,7 @@ level_attachment_host!(LevelRandomHost, LevelWriteHost); mod generated; pub use generated::Hooks; +#[doc(hidden)] pub use generated::*; #[cfg(test)] diff --git a/docs/native-mods.md b/docs/native-mods.md index 62ceab32dd..2215ec9cfb 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -2135,9 +2135,10 @@ The renderer module re-exports the game's wgpu types. Every `Host` method defaults to a panic naming it (`not implemented: Host::tell`), so a test double implements only what its hook calls, and an unexpected call fails loudly. Drive the hook through `horizon_client::mod_api::dispatch_(&mut host, &mods, args…)`, which builds the event as the game does. A -hook that takes a `#[non_exhaustive]` game value a test can't build, such as `NativeServerPlayer`, can't be driven -this way; return `None` from the `Host` method that supplies it, or test the logic beneath the hook. The level hosts -are small enough to implement whole. NeoForge's GameTest is the in-game counterpart. +If a `Host` method returns a game value a test can't build, a double can return `None` instead, as with +`acting_player() -> Option`. If the dispatch function itself takes an unconstructible value, +such as `dispatch_perform_command`'s `&NativeServerPlayer`, test the logic beneath the hook instead. The level +hosts are small enough to implement whole. NeoForge's GameTest is the in-game counterpart. ```rust struct Stone; From 3dd5956f106d246173dd617309c146bf518a6b27 Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:04:16 +0000 Subject: [PATCH 15/37] Fix signed age in item merge test fixture --- crates/horizon-client/src/entities.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/horizon-client/src/entities.rs b/crates/horizon-client/src/entities.rs index 9accf73129..5af571b6d8 100644 --- a/crates/horizon-client/src/entities.rs +++ b/crates/horizon-client/src/entities.rs @@ -1662,7 +1662,7 @@ mod merge_tests { age, pickup_delay: 0, bob_offset: 0.0, - ticks: age, + ticks: age.max(0) as u32, needs_sync: false, stuck: Vec3::ZERO, } From 7c7f781095bcf4171b0644d57c9f6fab6d9f2ca0 Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:15:05 +0000 Subject: [PATCH 16/37] Fix test guidance sentence --- docs/native-mods.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/native-mods.md b/docs/native-mods.md index 2215ec9cfb..0044d43aa1 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -2134,7 +2134,7 @@ The renderer module re-exports the game's wgpu types. Every `Host` method defaults to a panic naming it (`not implemented: Host::tell`), so a test double implements only what its hook calls, and an unexpected call fails loudly. Drive the hook through -`horizon_client::mod_api::dispatch_(&mut host, &mods, args…)`, which builds the event as the game does. A +`horizon_client::mod_api::dispatch_(&mut host, &mods, args…)`, which builds the event as the game does. If a `Host` method returns a game value a test can't build, a double can return `None` instead, as with `acting_player() -> Option`. If the dispatch function itself takes an unconstructible value, such as `dispatch_perform_command`'s `&NativeServerPlayer`, test the logic beneath the hook instead. The level From 1ed50d2e5b1b524cf5f06ffb5dd916844fa4b437 Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:16:50 +0000 Subject: [PATCH 17/37] Set tick phase explicitly in resting item test --- crates/horizon-client/src/entities.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/entities.rs b/crates/horizon-client/src/entities.rs index 5af571b6d8..8ddd570409 100644 --- a/crates/horizon-client/src/entities.rs +++ b/crates/horizon-client/src/entities.rs @@ -1847,7 +1847,8 @@ mod honey_slide_tests { let mut item = falling_item(at.x, at.y, at.z, 0.0, 0.0, 0.0); item.on_ground = true; item.age = 0; - item.id = 1; + item.ticks = 0; + item.id = 0; let mut ys = Vec::new(); for _ in 0..8 { item.tick( From eae6fc63662b73856f67ebf2d8635ac0ffe89cb4 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:28:52 +0100 Subject: [PATCH 18/37] Prepared data file: structure templates in it, texture folders listed from it The data file now holds worldgen/structure (1,511 templates, 4 MB): world generation loaded each template from its own file, and /place's suggestions listed the tree. The file's version goes to 2, so a version 1 file (without them) is refused and written again. prepared_files::files_under lists a held folder from the file's names, with no disk listing. The texture scanners that each walked the texture folders (the atlas's texture list, the HUD atlas, Fusion's .mcmeta scan, the GUI sprites' nine-slice metadata, OptiFine's random, blink and emissive scans) use it for the vanilla root; packs and mirrors are still walked. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + .../src/atlas_sources.rs | 10 ++ crates/horizon-client-render/src/fusion.rs | 97 +++++++++++-------- crates/horizon-client-render/src/hud.rs | 10 ++ crates/horizon-client-render/src/ui.rs | 81 ++++++++++------ crates/horizon-client/src/game/chat.rs | 12 +++ crates/horizon-core/src/prepared_files.rs | 61 +++++++++++- crates/horizon-optifine/Cargo.toml | 1 + crates/horizon-optifine/src/paths.rs | 12 +++ .../horizon-world/src/worldgen/structure.rs | 26 ++--- 10 files changed, 228 insertions(+), 83 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d1843a34ed..5f817a47e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1583,6 +1583,7 @@ name = "horizon-optifine" version = "0.1.0" dependencies = [ "horizon-bench", + "horizon-core", "log", "regex", ] diff --git a/crates/horizon-client-render/src/atlas_sources.rs b/crates/horizon-client-render/src/atlas_sources.rs index cf3217f4a4..4ab90033c7 100644 --- a/crates/horizon-client-render/src/atlas_sources.rs +++ b/crates/horizon-client-render/src/atlas_sources.rs @@ -180,6 +180,16 @@ pub fn list_textures(root: &Path) -> Vec { for ns in namespaces.flatten() { let namespace = ns.file_name().to_string_lossy().into_owned(); let base = ns.path().join("textures"); + // A prepared root's: from its data file, without listing the disk. + if let Some(files) = horizon_core::prepared_files::files_under(&base) { + out.extend( + files + .iter() + .filter_map(|file| file.strip_suffix(".png")) + .map(|rel| format!("{namespace}:{rel}")), + ); + continue; + } let mut stack = vec![base.clone()]; while let Some(dir) = stack.pop() { let Ok(entries) = fs::read_dir(&dir) else { diff --git a/crates/horizon-client-render/src/fusion.rs b/crates/horizon-client-render/src/fusion.rs index 42b23e3ccd..51df969e4b 100644 --- a/crates/horizon-client-render/src/fusion.rs +++ b/crates/horizon-client-render/src/fusion.rs @@ -23,7 +23,12 @@ use horizon_optifine::fusion::{ }; use horizon_world::block_state_properties::BlockProperties; use horizon_world::registry::{BlockRegistry, BlockStateId}; -use std::{collections::HashMap, fs, num::NonZeroU16, path::Path}; +use std::{ + collections::HashMap, + fs, + num::NonZeroU16, + path::{Path, PathBuf}, +}; /// A connecting texture as drawn: its layout and its tiles, `None` for an empty one. struct Sheet { @@ -172,44 +177,60 @@ fn connecting_textures(root: &Path) -> HashMap { for ns in namespaces.flatten() { let ns_name = ns.file_name().to_string_lossy().into_owned(); let textures = ns.path().join("textures"); - let mut dirs = vec![textures.clone()]; - while let Some(dir) = dirs.pop() { - for entry in fs::read_dir(&dir).into_iter().flatten().flatten() { - let path = entry.path(); - // Linked folders too: mods' content links into the packs' tree. - let dir = match entry.file_type() { - Ok(t) if t.is_symlink() => path.is_dir(), - Ok(t) => t.is_dir(), - Err(_) => path.is_dir(), - }; - if dir { - dirs.push(path); - continue; - } - let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else { - continue; - }; - let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else { - continue; - }; - if !text.contains("fusion") { - continue; - } - let Some(rel) = Path::new(png) - .strip_prefix(&textures) - .ok() - .and_then(|p| p.to_str()) - else { - continue; - }; - let name = format!("{ns_name}:{}", rel.replace('\\', "/")); - match Meta::from_mcmeta(&text) { - Ok(Some(meta)) => { - out.insert(name, meta); - } - Ok(None) => {} - Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"), + for path in mcmeta_files(&textures) { + let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else { + continue; + }; + let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else { + continue; + }; + if !text.contains("fusion") { + continue; + } + let Some(rel) = Path::new(png) + .strip_prefix(&textures) + .ok() + .and_then(|p| p.to_str()) + else { + continue; + }; + let name = format!("{ns_name}:{}", rel.replace('\\', "/")); + match Meta::from_mcmeta(&text) { + Ok(Some(meta)) => { + out.insert(name, meta); } + Ok(None) => {} + Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"), + } + } + } + out +} + +/// The `.png.mcmeta` files under `textures`: a prepared root's from its data file, others +/// from the folders (linked folders too: mods' content links into the packs' tree). +fn mcmeta_files(textures: &Path) -> Vec { + if let Some(files) = horizon_core::prepared_files::files_under(textures) { + return files + .iter() + .filter(|file| file.ends_with(".png.mcmeta")) + .map(|file| textures.join(file)) + .collect(); + } + let mut out = Vec::new(); + let mut dirs = vec![textures.to_owned()]; + while let Some(dir) = dirs.pop() { + for entry in fs::read_dir(&dir).into_iter().flatten().flatten() { + let path = entry.path(); + let dir = match entry.file_type() { + Ok(t) if t.is_symlink() => path.is_dir(), + Ok(t) => t.is_dir(), + Err(_) => path.is_dir(), + }; + if dir { + dirs.push(path); + } else if path.to_str().is_some_and(|p| p.ends_with(".png.mcmeta")) { + out.push(path); } } } diff --git a/crates/horizon-client-render/src/hud.rs b/crates/horizon-client-render/src/hud.rs index 083ab40fde..8c6c8458c0 100644 --- a/crates/horizon-client-render/src/hud.rs +++ b/crates/horizon-client-render/src/hud.rs @@ -193,6 +193,16 @@ fn png_size(path: &Path) -> Option<(u32, u32)> { .then(|| (field(16), field(20))) } fn walk(dir: &Path, out: &mut Vec) { + // A prepared root's folder: from its data file, without listing the disk. + if let Some(files) = horizon_core::prepared_files::files_under(dir) { + out.extend( + files + .iter() + .filter(|file| file.ends_with(".png")) + .map(|file| dir.join(file)), + ); + return; + } let Ok(entries) = fs::read_dir(dir) else { return; }; diff --git a/crates/horizon-client-render/src/ui.rs b/crates/horizon-client-render/src/ui.rs index 0988712f12..30a722a6df 100644 --- a/crates/horizon-client-render/src/ui.rs +++ b/crates/horizon-client-render/src/ui.rs @@ -5,7 +5,12 @@ use crate::{ hud::{HudAtlas, HudBatch, Rect, rgba}, text::Text, }; -use std::{collections::HashMap, path::Path, sync::RwLock, time::Instant}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::RwLock, + time::Instant, +}; /// `GuiSpriteScaling.NineSlice`: the sprite's size in GUI pixels, whatever /// its texture's resolution, its borders (left, top, right, bottom) in the @@ -52,37 +57,53 @@ fn parse_nine_slice(v: &serde_json::Value) -> Option { pub fn load_metadata(root: &Path) { let mut map = HashMap::new(); let base = root.join("assets/minecraft/textures"); - let mut stack = vec![base.join("gui/sprites")]; - while let Some(dir) = stack.pop() { - let Ok(entries) = std::fs::read_dir(&dir) else { + let sprites = base.join("gui/sprites"); + // A prepared root's: from its data file, without listing the disk. + let files: Vec = match horizon_core::prepared_files::files_under(&sprites) { + Some(files) => files + .iter() + .filter(|file| file.ends_with(".png.mcmeta")) + .map(|file| sprites.join(file)) + .collect(), + None => { + let mut files = Vec::new(); + let mut stack = vec![sprites]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for e in entries.flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + } else if p.to_string_lossy().ends_with(".png.mcmeta") { + files.push(p); + } + } + } + files + } + }; + for p in files { + let name = p.to_string_lossy().into_owned(); + let Some(stripped) = name.strip_suffix(".png.mcmeta") else { continue; }; - for e in entries.flatten() { - let p = e.path(); - if p.is_dir() { - stack.push(p); - continue; - } - let name = p.to_string_lossy().into_owned(); - let Some(stripped) = name.strip_suffix(".png.mcmeta") else { - continue; - }; - let Ok(text) = std::fs::read_to_string(&p) else { - continue; - }; - let Some(nine_slice) = serde_json::from_str(&text) - .ok() - .as_ref() - .and_then(parse_nine_slice) - else { - continue; - }; - let key = Path::new(stripped) - .strip_prefix(&base) - .map(|r| r.to_string_lossy().replace('\\', "/")) - .unwrap_or_default(); - map.insert(key, nine_slice); - } + let Ok(text) = horizon_core::prepared_files::read_to_string(&p) else { + continue; + }; + let Some(nine_slice) = serde_json::from_str(&text) + .ok() + .as_ref() + .and_then(parse_nine_slice) + else { + continue; + }; + let key = Path::new(stripped) + .strip_prefix(&base) + .map(|r| r.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + map.insert(key, nine_slice); } *NINE_SLICE.write().unwrap_or_else(|e| e.into_inner()) = Some(map); } diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index d97860089e..4a3e6f1760 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -381,6 +381,18 @@ fn templates_under(root: &Path) -> Vec { /// The `namespace:` ids of the `extension` files under `root`, sorted. fn ids_in(root: &Path, extension: &str, namespace: &str) -> Vec { + let suffix = format!(".{extension}"); + // A prepared root's folder: from its data file, without listing the disk. + if let Some(files) = horizon_core::prepared_files::files_under(root) { + let mut out: Vec = files + .iter() + .filter_map(|file| file.strip_suffix(&suffix)) + .filter(|path| !path.is_empty() && !path.ends_with('/')) + .map(|path| format!("{namespace}:{path}")) + .collect(); + out.sort(); + return out; + } let mut out = Vec::new(); let mut stack = vec![root.to_owned()]; while let Some(dir) = stack.pop() { diff --git a/crates/horizon-core/src/prepared_files.rs b/crates/horizon-core/src/prepared_files.rs index ccb2306145..3ca571995a 100644 --- a/crates/horizon-core/src/prepared_files.rs +++ b/crates/horizon-core/src/prepared_files.rs @@ -32,10 +32,12 @@ use std::{ pub const FILE_NAME: &str = "horizon-data.bin"; /// The file it checks its preparation against. const SOURCE: &str = "horizon-resources.json"; -/// The folders it holds, with their subfolders. -const FOLDERS: [&str; 2] = ["data", "assets"]; +/// The folders it holds, with their subfolders: the resources, and the +/// structure templates world generation loads one by one. +const FOLDERS: [&str; 3] = ["data", "assets", "worldgen/structure"]; const MAGIC: [u8; 8] = *b"HZDATA\0\0"; -const VERSION: u32 = 1; +/// 2: `worldgen/structure` joined the folders (a version 1 file lacks it). +const VERSION: u32 = 2; const HEADER: usize = 40; const ENTRY: usize = 24; @@ -224,6 +226,21 @@ pub fn read(path: &Path) -> io::Result> { } } +/// The files under `dir` (a folder of a prepared root) and its subfolders, as +/// `/`-separated paths from `dir` in name order, from the root's data file +/// without listing the disk: empty when it holds none there. `None` when no +/// data file holds `dir` (walk the folder). +pub fn files_under(dir: &Path) -> Option> { + let (files, rel) = find(dir)?; + let names = files.list(&rel).unwrap_or_default(); + Some( + names + .into_iter() + .map(|name| name[rel.len() + 1..].to_owned()) + .collect(), + ) +} + /// At most the first `max` bytes of `path`, from where [`read`] takes them: /// a header, without reading the whole file. pub fn read_head(path: &Path, max: u64) -> io::Result> { @@ -392,6 +409,11 @@ mod tests { ("data/minecraft/tags/item/logs.json", b"[1,2,3]"), ("assets/minecraft/textures/block/stone.png", b"\x89PNG..."), ("assets/minecraft/sounds/ambient/cave1.ogg", b"OggS"), + ( + "worldgen/structure/minecraft/fossil/spine_1.nbt", + b"\x1f\x8b", + ), + ("worldgen/biomes.json", b"{}"), ("facts.json", b"{}"), ] { let path = root.join(name); @@ -425,6 +447,14 @@ mod tests { .is_none() ); assert!(files.read("facts.json").is_none()); + assert!(files.read("worldgen/biomes.json").is_none()); + assert_eq!( + files + .read("worldgen/structure/minecraft/fossil/spine_1.nbt") + .unwrap() + .unwrap(), + b"\x1f\x8b" + ); assert!(files.read("data/minecraft/recipe/missing.json").is_none()); assert_eq!( files.list("data/minecraft/recipe").unwrap(), @@ -483,6 +513,31 @@ mod tests { let _ = fs::remove_dir_all(&root); } + /// A folder's files come from the file, without the disk: one added since + /// isn't listed (as `read` doesn't find it), and a folder it holds nothing + /// in is empty. Outside its folders, the disk is walked. + #[test] + fn files_under_lists_a_folder_from_the_file() { + let root = root("under"); + write(&root).unwrap(); + fs::write(root.join("assets/minecraft/textures/block/added.png"), b"x").unwrap(); + assert_eq!( + files_under(&root.join("assets/minecraft/textures")).unwrap(), + ["block/stone.png"] + ); + assert_eq!( + files_under(&root.join("worldgen/structure")).unwrap(), + ["minecraft/fossil/spine_1.nbt"] + ); + assert!( + files_under(&root.join("assets/minecraft/textures/entity")) + .unwrap() + .is_empty() + ); + assert_eq!(files_under(&root.join("worldgen")), None); + let _ = fs::remove_dir_all(&root); + } + /// `find` gives the file and the path from its root for a folder under a /// root that has one, and nothing elsewhere. #[test] diff --git a/crates/horizon-optifine/Cargo.toml b/crates/horizon-optifine/Cargo.toml index a4de2b3b9e..a15595b286 100644 --- a/crates/horizon-optifine/Cargo.toml +++ b/crates/horizon-optifine/Cargo.toml @@ -7,6 +7,7 @@ rust-version.workspace = true description = "OptiFine-format resource pack files: .properties, the name, range, biome and block matchers, paths and the optifine/ folders; CEM entity models and connected-texture rules, and the Fusion mod's connected-texture format" [dependencies] +horizon-core = { path = "../horizon-core" } log = "0.4" regex = { version = "1", default-features = false, features = ["std", "unicode-perl", "unicode-case"] } diff --git a/crates/horizon-optifine/src/paths.rs b/crates/horizon-optifine/src/paths.rs index 2fcec0c245..a351d85089 100644 --- a/crates/horizon-optifine/src/paths.rs +++ b/crates/horizon-optifine/src/paths.rs @@ -117,6 +117,18 @@ fn walk( seen: &mut HashSet, out: &mut Vec, ) { + // A prepared root's folder: from its data file, without listing the disk. + if depth == 0 + && let Some(files) = horizon_core::prepared_files::files_under(dir) + { + out.extend( + files + .into_iter() + .filter(|file| endings.iter().any(|end| file.ends_with(end))) + .map(|file| format!("{rel}/{file}")), + ); + return; + } // Links are followed (overlay folders are links into the packs and vanilla), so a link back // to a parent would loop: each real folder is read once, and never deeper than MAX_DEPTH. if depth > MAX_DEPTH || !fs::canonicalize(dir).is_ok_and(|real| seen.insert(real)) { diff --git a/crates/horizon-world/src/worldgen/structure.rs b/crates/horizon-world/src/worldgen/structure.rs index a11c2d8971..2883880d94 100644 --- a/crates/horizon-world/src/worldgen/structure.rs +++ b/crates/horizon-world/src/worldgen/structure.rs @@ -368,18 +368,20 @@ impl StructureResources { } let (namespace, path) = id.split_once(':').unwrap(); let file = self.dir.join(namespace).join(format!("{path}.nbt")); - let loaded = std::fs::read(&file).ok().and_then(|bytes| { - let mut raw = Vec::new(); - flate2::read::GzDecoder::new(bytes.as_slice()) - .read_to_end(&mut raw) - .ok()?; - let tag = nbt::read_named(&raw, Limits::templates()).ok()?.0.tag; - Some(StructureTemplate::from_nbt( - &tag, - self.registry, - &self.transforms, - )) - }); + let loaded = horizon_core::prepared_files::read(&file) + .ok() + .and_then(|bytes| { + let mut raw = Vec::new(); + flate2::read::GzDecoder::new(bytes.as_slice()) + .read_to_end(&mut raw) + .ok()?; + let tag = nbt::read_named(&raw, Limits::templates()).ok()?.0.tag; + Some(StructureTemplate::from_nbt( + &tag, + self.registry, + &self.transforms, + )) + }); // StructureTemplateManager.getOrCreate: missing templates are empty. let mut loaded = loaded.unwrap_or_else(StructureTemplate::empty); loaded.location = id.as_str().into(); From 8cf71732d032460401c503e712b7b4a3a4b2a9be Mon Sep 17 00:00:00 2001 From: hui Date: Mon, 2 Nov 2026 12:35:22 +0100 Subject: [PATCH 19/37] Fix two catalog hooks the package split broke: living_breathe and menu_slot_rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #5446, breathing.rs and menu.rs compile into horizon-client-lib, but native-hooks.json still targeted horizon_client::breathing::… and horizon_client::menu::…, so horizon-mixins refused a bundle naming either hook — no mod could use them. The targets name horizon_client_lib now, and catalog_matches_hook_points expects that. The test also checks every target's crate really compiles its module: the first path segment after horizon_client or horizon_client_lib must be a module of that crate's lib root. A file moved between the two packages then fails the test even if the catalog string and the expectation agree — which is how these two went unnoticed. Proven with horizon-mixins apply on this branch: minimal bundles on living_breathe and menu_slot_rules by name both reach status ok, their helpers compiled into the staged breathing.rs and menu.rs. The module check was proven the same way: with the old target and the expectation made to agree, the test still fails on horizon_client::menu. Fixes #6662. Co-Authored-By: ZCode AI agent (GLM-5.3) --- .../horizon-client/src/game/native_hooks.rs | 33 +++++++++++++++++-- native-hooks.json | 4 +-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index b724831d95..2e52b04a30 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -9070,9 +9070,12 @@ mod tests { // split): horizon-mixins resolves the crate by its package. format!("horizon_client_lib::crafting::native_{name}") } else if menu_hooks.contains(name) { - format!("horizon_client::menu::native_{name}") + // menu.rs compiles into horizon-client-lib (#5446's + // split): horizon-mixins resolves the crate by its package. + format!("horizon_client_lib::menu::native_{name}") } else if breath_hooks.contains(name) { - format!("horizon_client::breathing::native_{name}") + // breathing.rs compiles into horizon-client-lib too. + format!("horizon_client_lib::breathing::native_{name}") } else if level_hooks.contains(name) { format!("horizon_client::game::native_hooks::GameLevel<'_>::native_{name}") } else { @@ -9081,6 +9084,32 @@ mod tests { assert_eq!(hook["target"].as_str().unwrap(), target); } assert_eq!(names, declared); + // Every target's crate really compiles its module: #5446 moved + // files between horizon-client and horizon-client-lib, and a + // target left on the old crate applies to no bundle — the string + // comparison above shares its expectation with the catalog, so it + // alone cannot catch a move. The first path segment after the + // crate must be a module of that crate's lib root. + let client_root = include_str!("../lib.rs"); + let lib_root = include_str!("../client_lib.rs"); + for hook in &hooks { + let target = hook["target"].as_str().unwrap(); + let (root, crate_name, rest) = + if let Some(rest) = target.strip_prefix("horizon_client::") { + (client_root, "horizon_client", rest) + } else if let Some(rest) = target.strip_prefix("horizon_client_lib::") { + (lib_root, "horizon_client_lib", rest) + } else { + // The other crates (render, world) are their own packages, + // never split. + continue; + }; + let module = rest.split("::").next().unwrap_or(rest); + assert!( + root.contains(&format!("mod {module};")), + "{crate_name} does not compile module `{module}` (target {target})" + ); + } } /// The damage hook gets a mob's source from its damage: the causing mob, diff --git a/native-hooks.json b/native-hooks.json index cb8bcea4f8..68abeec33c 100644 --- a/native-hooks.json +++ b/native-hooks.json @@ -1758,7 +1758,7 @@ { "name": "menu_slot_rules", "version": 1, - "target": "horizon_client::menu::native_menu_slot_rules", + "target": "horizon_client_lib::menu::native_menu_slot_rules", "side": "server", "cancellable": false, "mod_api": { @@ -1993,7 +1993,7 @@ { "name": "living_breathe", "version": 1, - "target": "horizon_client::breathing::native_living_breathe", + "target": "horizon_client_lib::breathing::native_living_breathe", "neoforge": "LivingBreatheEvent", "side": "server", "cancellable": false, From e78f71a47b815e317896aea84af97899d53dc2ed Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:38:23 +0100 Subject: [PATCH 20/37] Telemetry: what slow frames spent their time in A player who allows performance statistics now also says what froze: each frame of 250 ms or more while playing counts under the step of the render thread it spent most of its time in (the slowest outer span, then the span inside it that took at least half of it: renderer/r_uploads, tick/tick_mobs). The session's perf item carries at most 16 such steps (slow_frames), with their frame count, longest frame and longest step. spans: a thread can record its own spans with spans off (record_here), wall time only, and each span keeps its depth so culprit() can follow the nesting. The render thread records only with performance statistics on: about 5 us a frame of 100 spans on an M4 Pro, nothing without them. Server: perf.slow_frames (JSON, migrated onto old databases), and /v1/aggregate's slow_frames per release and step, shown from as many installs as the groups need. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/spans.rs | 185 ++++++++++++++++-- crates/horizon-client-render/src/telemetry.rs | 59 +++++- crates/horizon-client/src/game.rs | 20 +- crates/horizon-telemetry-server/src/store.rs | 114 ++++++++++- .../horizon-telemetry-server/tests/server.rs | 53 ++++- crates/horizon-telemetry/src/payload.rs | 122 ++++++++++++ docs/telemetry.md | 6 +- 7 files changed, 527 insertions(+), 32 deletions(-) diff --git a/crates/horizon-client-render/src/spans.rs b/crates/horizon-client-render/src/spans.rs index 1727808619..de3cca1e90 100644 --- a/crates/horizon-client-render/src/spans.rs +++ b/crates/horizon-client-render/src/spans.rs @@ -6,7 +6,7 @@ //! or a core on a busy machine) shows apart from one that worked. Costs two //! clock reads per span when enabled and a relaxed load otherwise. use std::{ - cell::RefCell, + cell::{Cell, RefCell}, sync::atomic::{AtomicBool, Ordering}, time::{Duration, Instant}, }; @@ -14,6 +14,10 @@ use std::{ static ENABLED: AtomicBool = AtomicBool::new(false); thread_local! { static SPANS: RefCell> = const { RefCell::new(Vec::new()) }; + /// Spans recorded on this thread alone, wall time only ([`record_here`]). + static HERE: Cell = const { Cell::new(false) }; + /// How many timed spans the thread is inside: the next span's [`Span::depth`]. + static DEPTH: Cell = const { Cell::new(0) }; } /// A named timing: wall milliseconds and the thread's CPU milliseconds in it. @@ -22,6 +26,8 @@ pub struct Span { pub name: &'static str, pub ms: f32, pub cpu_ms: f32, + /// How many timed spans it ran inside (0: none): `tick_mobs` is inside `tick`. + pub depth: u8, } pub fn enable(on: bool) { @@ -31,19 +37,49 @@ pub fn enable(on: bool) { pub fn enabled() -> bool { ENABLED.load(Ordering::Relaxed) } +/// Record this thread's spans even with spans off, their wall time only (no CPU clock read): +/// what the render thread's slow frames spent their time in, for the opt-in performance +/// statistics (`telemetry::Session::frame`). The thread takes them every frame ([`take`]). +pub fn record_here(on: bool) { + HERE.with(|h| h.set(on)); +} +/// Whether this thread records spans: all of them on, or [`record_here`]. +#[inline] +pub fn recording_here() -> bool { + ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get) +} +/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): only they +/// print it. +#[inline] +fn cpu_if_enabled() -> Option { + if ENABLED.load(Ordering::Relaxed) { + thread_cpu() + } else { + None + } +} /// Run `f`, recording its duration under `name` when spans are enabled. #[inline] pub fn time(name: &'static str, f: impl FnOnce() -> T) -> T { - if !ENABLED.load(Ordering::Relaxed) { + if !recording_here() { return f(); } - let (started, cpu) = (Instant::now(), thread_cpu()); + let depth = DEPTH.with(|d| d.replace(d.get().saturating_add(1))); + let (started, cpu) = (Instant::now(), cpu_if_enabled()); let out = f(); let ms = started.elapsed().as_secs_f32() * 1000.0; - let cpu_ms = thread_cpu() - .zip(cpu) + let cpu_ms = cpu + .and_then(|start| Some((thread_cpu()?, start))) .map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0); - SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms })); + DEPTH.with(|d| d.set(depth)); + SPANS.with(|s| { + s.borrow_mut().push(Span { + name, + ms, + cpu_ms, + depth, + }) + }); out } /// Consecutive steps of one function timed as spans: each [`Laps::lap`] records the time since @@ -52,7 +88,7 @@ pub struct Laps(Option<(Instant, Option)>); /// Start timing the steps of a function (see [`Laps`]). #[inline] pub fn laps() -> Laps { - Laps(enabled().then(|| (Instant::now(), thread_cpu()))) + Laps(recording_here().then(|| (Instant::now(), cpu_if_enabled()))) } impl Laps { /// Record the step that just ended under `name`, and start the next. @@ -61,12 +97,21 @@ impl Laps { let Some((started, cpu)) = &mut self.0 else { return; }; - let (now, now_cpu) = (Instant::now(), thread_cpu()); + let now = Instant::now(); + let now_cpu = cpu.and_then(|_| thread_cpu()); let ms = (now - *started).as_secs_f32() * 1000.0; let cpu_ms = now_cpu .zip(*cpu) .map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0); - SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms })); + let depth = DEPTH.with(Cell::get); + SPANS.with(|s| { + s.borrow_mut().push(Span { + name, + ms, + cpu_ms, + depth, + }) + }); (*started, *cpu) = (now, now_cpu); } } @@ -83,11 +128,62 @@ pub fn thread_cpu() -> Option { horizon_threads::thread_cpu() } +/// The frame's spans as they were recorded, each after the spans inside it; clears them. +pub fn take_raw() -> Vec { + SPANS.with(|s| std::mem::take(&mut *s.borrow_mut())) +} /// The frame's spans, summed by name, slowest first; clears them. pub fn take() -> Vec { - let spans = SPANS.with(|s| std::mem::take(&mut *s.borrow_mut())); + merge(&take_raw()) +} +/// What a frame spent its time in, from its spans as recorded ([`take_raw`]): the slowest of +/// the outermost spans (summed by name, as a frame can tick several times), then the slowest +/// span inside it while that one took at least half of it, and so on. The path of names +/// (`tick/tick_mobs`) and the last one's milliseconds; `None` without spans. +pub fn culprit(raw: &[Span]) -> Option<(String, f32)> { + // The spans directly inside each one: recorded just before it, one level deeper. + let mut inside: Vec> = vec![Vec::new(); raw.len()]; + let mut outer: Vec = Vec::new(); + for (i, span) in raw.iter().enumerate() { + while let Some(&j) = outer.last() + && raw[j].depth > span.depth + { + outer.pop(); + inside[i].push(j); + } + outer.push(i); + } + let slowest = |group: &[usize]| { + let mut sums: Vec<(&'static str, f32, Vec)> = Vec::new(); + for &i in group { + match sums.iter_mut().find(|s| s.0 == raw[i].name) { + Some(sum) => { + sum.1 += raw[i].ms; + sum.2.push(i); + } + None => sums.push((raw[i].name, raw[i].ms, vec![i])), + } + } + sums.into_iter().max_by(|a, b| a.1.total_cmp(&b.1)) + }; + let (name, mut ms, mut at) = slowest(&outer)?; + let mut path = name.to_owned(); + loop { + let next: Vec = at.iter().flat_map(|&i| inside[i].iter().copied()).collect(); + match slowest(&next) { + Some((name, inner_ms, spans)) if inner_ms >= ms * 0.5 => { + path.push('/'); + path.push_str(name); + (ms, at) = (inner_ms, spans); + } + _ => return Some((path, ms)), + } + } +} +/// Spans summed by name, slowest first. +pub fn merge(spans: &[Span]) -> Vec { let mut sums: Vec = Vec::new(); - for span in spans { + for &span in spans { match sums.iter_mut().find(|s| s.name == span.name) { Some(sum) => { sum.ms += span.ms; @@ -309,21 +405,25 @@ mod tests { name: "tick", ms: 2.0, cpu_ms: 2.0, + depth: 0, }, Span { name: "submit", ms: 30.0, cpu_ms: 0.2, + depth: 0, }, Span { name: "tick", ms: 3.0, cpu_ms: 2.9, + depth: 0, }, Span { name: "hud", ms: 0.3, cpu_ms: 0.3, + depth: 0, }, ]); }); @@ -333,7 +433,8 @@ mod tests { Span { name: "submit", ms: 30.0, - cpu_ms: 0.2 + cpu_ms: 0.2, + depth: 0, } ); assert_eq!((spans[1].name, spans[1].ms), ("tick", 5.0)); @@ -341,6 +442,66 @@ mod tests { assert!(super::take().is_empty()); } + /// A thread that records on its own keeps its spans with spans off, each with how many + /// spans it ran inside, after them. + #[test] + fn a_recording_thread_keeps_its_spans_and_their_nesting() { + std::thread::spawn(|| { + super::record_here(true); + super::time("tick", || { + super::time("tick_mobs", || ()); + super::time("tick_items", || ()); + }); + super::time("renderer", || ()); + let raw = super::take_raw(); + let seen: Vec<_> = raw.iter().map(|s| (s.name, s.depth)).collect(); + assert_eq!( + seen, + [ + ("tick_mobs", 1), + ("tick_items", 1), + ("tick", 0), + ("renderer", 0) + ] + ); + }) + .join() + .unwrap(); + } + + /// A slow frame is put down to the slowest outer span, then to the span inside it that took + /// at least half of it; repeated spans (several ticks a frame) add up. + #[test] + fn a_frame_is_put_down_to_the_step_that_took_most_of_it() { + let span = |name, ms, depth| Span { + name, + ms, + cpu_ms: ms, + depth, + }; + let frame = [ + span("tick_mobs", 90.0, 1), + span("tick_items", 5.0, 1), + span("tick", 100.0, 0), + span("tick_mobs", 90.0, 1), + span("tick", 100.0, 0), + span("r_uploads", 120.0, 1), + span("renderer", 150.0, 0), + ]; + assert_eq!( + super::culprit(&frame), + Some(("tick/tick_mobs".to_owned(), 180.0)) + ); + // Spread over many steps: the outer span alone. + let spread = [ + span("tick_mobs", 40.0, 1), + span("tick_items", 40.0, 1), + span("tick", 300.0, 0), + ]; + assert_eq!(super::culprit(&spread), Some(("tick".to_owned(), 300.0))); + assert_eq!(super::culprit(&[]), None); + } + /// Laps record the steps between them while spans are on, and nothing while off. #[test] fn laps_time_consecutive_steps() { diff --git a/crates/horizon-client-render/src/telemetry.rs b/crates/horizon-client-render/src/telemetry.rs index d0047a1e79..89fd1f324e 100644 --- a/crates/horizon-client-render/src/telemetry.rs +++ b/crates/horizon-client-render/src/telemetry.rs @@ -223,6 +223,7 @@ pub struct Session { title_measured: bool, to_world_ms: Option, opening: Option, + slow_frames: Vec, } impl Session { @@ -250,8 +251,21 @@ impl Session { self.opening = None; } - /// A frame drawn while playing (not in menus or loading). - pub fn frame(&mut self, ms: f32) { + /// A frame drawn while playing (not in menus or loading), with the spans the render thread + /// recorded in it ([`crate::spans::take_raw`]): a frame of `SLOW_FRAME_MS` or more counts + /// under the step it spent most of its time in ([`crate::spans::culprit`]). + pub fn frame(&mut self, ms: f32, spans: &[crate::spans::Span]) { + use horizon_telemetry::payload::{SLOW_FRAME_MS, SlowFrames}; + if ms >= SLOW_FRAME_MS { + let (mut path, span_ms) = crate::spans::culprit(spans).unwrap_or_default(); + // A path too long for the item keeps its last steps. + while path.len() > SlowFrames::MAX_NAME + && let Some((_, rest)) = path.split_once('/') + { + path = rest.to_owned(); + } + SlowFrames::record(&mut self.slow_frames, ms, &path, span_ms); + } if let Some(opening) = self.opening.take() { crate::spans::startup::mark("world.first_frame"); if self.to_world_ms.is_none() { @@ -281,6 +295,7 @@ impl Session { to_world_ms: self.to_world_ms, to_core_ms: self.to_title_ms.and_then(|_| readiness_ms(&CORE_READY_MS)), to_rest_ms: self.to_title_ms.and_then(|_| readiness_ms(&REST_READY_MS)), + slow_frames: self.slow_frames.clone(), }) } @@ -490,10 +505,10 @@ mod tests { assert!(session.opening.is_some()); session.cancel_world_opening(); assert!(session.opening.is_none()); - session.frame(16.0); + session.frame(16.0, &[]); assert!(session.to_world_ms.is_none()); session.world_opening(); - session.frame(16.0); + session.frame(16.0, &[]); assert!(session.to_world_ms.is_some()); } @@ -502,7 +517,7 @@ mod tests { let home = home("session"); let mut session = Session::default(); session.world_opening(); - (0..2000).for_each(|_| session.frame(16.6)); + (0..2000).for_each(|_| session.frame(16.6, &[])); session.finish(&home, Settings::default()); assert!(outbox::pending(&home).is_empty(), "33 s, and no consent"); allow( @@ -512,11 +527,11 @@ mod tests { ..Choices::NONE }, ); - (0..2000).for_each(|_| session.frame(16.6)); + (0..2000).for_each(|_| session.frame(16.6, &[])); session.finish(&home, Settings::default()); assert!(outbox::pending(&home).is_empty(), "33 s is too short"); session.world_opening(); - (0..4000).for_each(|_| session.frame(16.6)); + (0..4000).for_each(|_| session.frame(16.6, &[])); (0..1200).for_each(|_| session.tick(4.0)); session.finish( &home, @@ -537,4 +552,34 @@ mod tests { assert!(perf.to_world_ms.is_some()); assert!(serde_json::to_vec(&pending[0].1).unwrap().len() < 1024); } + + /// Frames of 250 ms or more count under the step that took most of them; a frame with no + /// span counts under none. Faster frames leave no entry. + #[test] + fn slow_frames_are_put_down_to_their_step() { + use crate::spans::Span; + let span = |name, ms, depth| Span { + name, + ms, + cpu_ms: ms, + depth, + }; + let mut session = Session::default(); + (0..4000).for_each(|_| session.frame(16.6, &[span("tick", 10.0, 0)])); + let frozen = [span("r_uploads", 280.0, 1), span("renderer", 300.0, 0)]; + session.frame(320.0, &frozen); + session.frame(900.0, &frozen); + session.frame(260.0, &[]); + session.frame(249.0, &frozen); + let perf = session.summary(Settings::default()).unwrap(); + let seen: Vec<_> = perf + .slow_frames + .iter() + .map(|s| (s.span.as_str(), s.frames, s.frame_max_ms, s.span_max_ms)) + .collect(); + assert_eq!( + seen, + [("renderer/r_uploads", 2, 900.0, 280.0), ("", 1, 260.0, 0.0)] + ); + } } diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1fc32d219d..5c5643b2a0 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -17744,12 +17744,19 @@ impl Game<'_> { result => result?, }; self.apply_cursor_icon(); + // The frame's spans, taken every frame they are recorded so they never + // pile up: the slow-frame line's, and the session's (`perf.frame`). + let frame_spans = if horizon_client::spans::recording_here() { + horizon_client::spans::take_raw() + } else { + Vec::new() + }; if let Some(threshold) = self.options.spikes && let Some(r) = self.renderer.as_ref() { let cpu = now.elapsed().saturating_sub(r.last_wait).as_secs_f32() * 1000.0; // Spans timed between frames (`idle`) come first. - let spans = horizon_client::spans::take(); + let spans = horizon_client::spans::merge(&frame_spans); let between = between.as_secs_f32() * 1000.0; if cpu > threshold || between > threshold { // The render thread's own CPU time in the frame: @@ -17853,7 +17860,8 @@ impl Game<'_> { if self.title.is_some() { self.perf.title_shown(); } else if self.screen == Screen::Playing && self.player.is_some() { - self.perf.frame(elapsed.as_secs_f32() * 1000.0); + self.perf + .frame(elapsed.as_secs_f32() * 1000.0, &frame_spans); if startup::filling() { let mut fill: Vec<(&'static str, usize)> = self.streaming.stages().to_vec(); @@ -20686,6 +20694,14 @@ pub fn run_with() -> Result<()> { options.spikes = Some(dedicated::SLOW_TICK_MS); } horizon_client::spans::enable(options.spikes.is_some()); + // A player who allows performance statistics also says what their slow frames spent their + // time in: this thread, which draws the frames, times its steps (wall time only). + if options.execution_role.is_client() + && horizon_telemetry::Consent::load(&horizon_client::logging::game_home()) + .allows(horizon_telemetry::Kind::Perf) + { + horizon_client::spans::record_here(true); + } let vanilla_root = options.resources.clone(); if let Some(error) = missing_resources(&vanilla_root) { // The game build started by hand (the release's zip), not by the diff --git a/crates/horizon-telemetry-server/src/store.rs b/crates/horizon-telemetry-server/src/store.rs index 63bf499968..786c5a961a 100644 --- a/crates/horizon-telemetry-server/src/store.rs +++ b/crates/horizon-telemetry-server/src/store.rs @@ -4,7 +4,8 @@ //! name isn't stored again with every session. `extra` holds the later hardware fields //! (cores, clocks, GPU maker and kind, VRAM, drive, screen, power, form) as JSON. //! - `perf`: one row per play session, in columns. A frame or tick summary is an 80-byte blob -//! ([`pack`]), not JSON. +//! ([`pack`]), not JSON; `slow_frames` (the frames of 250 ms or more by the step they spent +//! their time in) is JSON, empty for most sessions. //! - `reports`: crash and bug reports and benchmark uploads, their `data` zstd-compressed. //! - `perf_daily`: the anonymous totals per day, version, OS, GPU vendor and render distance //! that `/v1/aggregate` reads. Rebuilt from `perf` when a day changes (new sessions, a @@ -39,7 +40,7 @@ CREATE TABLE IF NOT EXISTS perf ( render_distance INTEGER NOT NULL, simulation_distance INTEGER NOT NULL, max_fps INTEGER NOT NULL, vsync INTEGER NOT NULL, fullscreen INTEGER NOT NULL, graphics TEXT NOT NULL, seconds INTEGER NOT NULL, frames BLOB NOT NULL, ticks BLOB, - peak_ram_mb INTEGER NOT NULL, to_title_ms INTEGER, to_world_ms INTEGER + peak_ram_mb INTEGER NOT NULL, to_title_ms INTEGER, to_world_ms INTEGER, slow_frames TEXT ); CREATE INDEX IF NOT EXISTS perf_install ON perf (install); CREATE INDEX IF NOT EXISTS perf_day ON perf (day); @@ -160,13 +161,16 @@ impl Store { tx.execute( "INSERT INTO perf (install, day, version, system, render_distance, simulation_distance, max_fps, vsync, fullscreen, graphics, seconds, frames, ticks, peak_ram_mb, - to_title_ms, to_world_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", + to_title_ms, to_world_ms, slow_frames) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", params![ install, day, item.version, system, p.settings.render_distance, p.settings.simulation_distance, p.settings.max_fps, p.settings.vsync, p.settings.fullscreen, p.settings.graphics, p.seconds, pack(&p.frames), p.ticks.as_ref().map(pack), p.peak_ram_mb, p.to_title_ms, p.to_world_ms, + (!p.slow_frames.is_empty()) + .then(|| serde_json::to_string(&p.slow_frames).ok()) + .flatten(), ], )?; } @@ -366,14 +370,67 @@ impl Store { .collect::>()? }; let breakdowns = self.breakdowns(from, to)?; + let slow_frames = self.slow_frames(from, to, k)?; Ok(serde_json::json!({ "from_day": from, "to_day": to, "min_install_days": k, "bucket_edges_ms": BUCKET_EDGES_MS.map(|e| (f64::from(e) * 10.0).round() / 10.0), "totals": { "sessions": sessions, "hours": (hours * 10.0).round() / 10.0 }, "groups": out, "crashes_by_version": crashes, "breakdowns": breakdowns, + "slow_frames": slow_frames, })) } + /// What froze in `from..=to`: per release and step (`span`, `""` when no step was timed), + /// the frames of 250 ms or more spent mostly in it, the sessions and installs that had one, + /// and the longest frame and step. Only steps seen by at least `k` installs. From the raw + /// rows, so only within the retention period. + fn slow_frames(&self, from: u32, to: u32, k: u64) -> rusqlite::Result { + #[derive(Default)] + struct Step { + frames: u64, + sessions: u64, + installs: HashSet>, + frame_max_ms: f32, + span_max_ms: f32, + } + let mut steps: HashMap<(String, String), Step> = HashMap::new(); + let mut q = self.db.prepare( + "SELECT install, version, slow_frames FROM perf + WHERE day BETWEEN ?1 AND ?2 AND slow_frames IS NOT NULL", + )?; + let mut rows = q.query([from, to])?; + while let Some(r) = rows.next()? { + let install: Vec = r.get(0)?; + let version: String = r.get(1)?; + let list: Vec = + serde_json::from_str(&r.get::<_, String>(2)?).unwrap_or_default(); + for entry in list { + let step = steps.entry((version.clone(), entry.span)).or_default(); + step.frames += u64::from(entry.frames); + step.sessions += 1; + step.installs.insert(install.clone()); + step.frame_max_ms = step.frame_max_ms.max(entry.frame_max_ms); + step.span_max_ms = step.span_max_ms.max(entry.span_max_ms); + } + } + let mut out: Vec<((String, String), Step)> = steps + .into_iter() + .filter(|(_, s)| s.installs.len() as u64 >= k) + .collect(); + out.sort_by(|a, b| b.1.frames.cmp(&a.1.frames).then(a.0.cmp(&b.0))); + let round = |ms: f32| (f64::from(ms) * 10.0).round() / 10.0; + Ok(out + .into_iter() + .map(|((version, span), s)| { + serde_json::json!({ + "version": version, "span": span, "frames": s.frames, "sessions": s.sessions, + "installs": s.installs.len(), "frame_max_ms": round(s.frame_max_ms), + "span_max_ms": round(s.span_max_ms), + }) + }) + .collect()) + } + /// Play sessions in `from..=to` per hardware class (`classes`): for each RAM, VRAM, drive, /// OS and GPU kind class with at least `MIN_INSTALLS` different installs, its installs, /// sessions, hours and mean p50 and p99 frame times. From the raw rows, so only within the @@ -516,12 +573,17 @@ impl Extra { } } -/// Databases from before `systems.extra`: the table rebuilt with it (ids kept), once. +/// Databases from before a column: `perf.slow_frames` added, and `systems` rebuilt with +/// `extra` (ids kept), once each. fn migrate(db: &mut Connection) -> rusqlite::Result<()> { - let has_extra = db - .prepare("SELECT 1 FROM pragma_table_info('systems') WHERE name = 'extra'")? - .exists([])?; - if has_extra { + let has = |db: &Connection, table: &str, column: &str| { + db.prepare("SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2")? + .exists([table, column]) + }; + if !has(db, "perf", "slow_frames")? { + db.execute_batch("ALTER TABLE perf ADD COLUMN slow_frames TEXT")?; + } + if has(db, "systems", "extra")? { return Ok(()); } let tx = db.transaction()?; @@ -582,6 +644,42 @@ pub fn inflate(body: &[u8]) -> serde_json::Value { mod tests { use super::*; + /// A database from before `perf.slow_frames` gains the column and keeps its sessions. + #[test] + fn perf_from_before_slow_frames_gains_the_column() { + let path = std::env::temp_dir().join(format!( + "horizon-telemetry-slow-{}.sqlite", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + { + let db = Connection::open(&path).unwrap(); + db.execute_batch( + "CREATE TABLE perf ( + install BLOB NOT NULL, day INTEGER NOT NULL, version TEXT NOT NULL, system INTEGER NOT NULL, + render_distance INTEGER NOT NULL, simulation_distance INTEGER NOT NULL, max_fps INTEGER NOT NULL, + vsync INTEGER NOT NULL, fullscreen INTEGER NOT NULL, graphics TEXT NOT NULL, + seconds INTEGER NOT NULL, frames BLOB NOT NULL, ticks BLOB, + peak_ram_mb INTEGER NOT NULL, to_title_ms INTEGER, to_world_ms INTEGER + ); + INSERT INTO perf VALUES (x'00', 1, 'v1', 1, 12, 12, 0, 0, 0, 'fancy', 60, x'00', NULL, 1, NULL, NULL);", + ) + .unwrap(); + } + let path_str = path.to_str().unwrap(); + let store = Store::open(path_str).unwrap(); + let old: Option = store + .db + .query_row("SELECT slow_frames FROM perf", [], |r| r.get(0)) + .unwrap(); + assert_eq!(old, None, "the old session is kept, with none"); + drop(store); + Store::open(path_str).unwrap(); // a second open adds nothing + for suffix in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{path_str}{suffix}")); + } + } + /// A database from before `systems.extra` opens, keeps its machines' ids and takes new ones. #[test] fn systems_from_before_extra_are_kept() { diff --git a/crates/horizon-telemetry-server/tests/server.rs b/crates/horizon-telemetry-server/tests/server.rs index 764306d58b..3103f8097c 100644 --- a/crates/horizon-telemetry-server/tests/server.rs +++ b/crates/horizon-telemetry-server/tests/server.rs @@ -5,7 +5,9 @@ use std::sync::atomic::{AtomicU32, Ordering}; use axum::body::Body; use axum::http::{Request, StatusCode}; use horizon_telemetry::batch::{self, Batch}; -use horizon_telemetry::payload::{Crash, Payload, Perf, Settings, Source, System, Timings}; +use horizon_telemetry::payload::{ + Crash, Payload, Perf, Settings, SlowFrames, Source, System, Timings, +}; use horizon_telemetry::{Item, outbox}; use horizon_telemetry_server::store::{self, Store}; use horizon_telemetry_server::{App, Config, router}; @@ -196,6 +198,55 @@ async fn totals_break_down_by_hardware_class() { } } +/// What froze: slow frames by release and step, summed over sessions; a step shows only once +/// enough installs had it, and never with an install or a machine. +#[tokio::test] +async fn slow_frames_show_what_froze_with_enough_installs() { + let s = server(Config { + min_install_days: 2, + ..generous() + }); + let slow = |span: &str, frames, frame_max_ms, span_max_ms| { + let mut item = perf("NVIDIA GeForce RTX 4070", 12); + if let Payload::Perf(p) = &mut item.payload { + p.slow_frames = vec![SlowFrames { + span: span.into(), + frames, + frame_max_ms, + span_max_ms, + }]; + } + item + }; + for (n, frame_max_ms) in [(0, 900.0), (1, 400.0)] { + let item = slow("renderer/r_uploads", 3, frame_max_ms, 300.0); + assert_eq!( + s.post(&install(n), vec![item]).await, + StatusCode::NO_CONTENT + ); + } + let lone = slow("tick/tick_mobs", 1, 260.0, 200.0); + assert_eq!( + s.post(&install(2), vec![lone]).await, + StatusCode::NO_CONTENT + ); + let quiet = perf("NVIDIA GeForce RTX 4070", 12); + assert_eq!( + s.post(&install(3), vec![quiet]).await, + StatusCode::NO_CONTENT + ); + let totals = s.aggregate().await; + assert_eq!( + totals["slow_frames"], + serde_json::json!([{ + "version": "v2026.10.15", "span": "renderer/r_uploads", "frames": 6, "sessions": 2, + "installs": 2, "frame_max_ms": 900.0, "span_max_ms": 300.0, + }]), + "one install is too few for tick_mobs: {totals}" + ); + assert!(!totals.to_string().contains(&install(0))); +} + #[tokio::test] async fn bad_batches_are_refused() { let s = server(generous()); diff --git a/crates/horizon-telemetry/src/payload.rs b/crates/horizon-telemetry/src/payload.rs index 46f4a9af0b..658f5d2bc0 100644 --- a/crates/horizon-telemetry/src/payload.rs +++ b/crates/horizon-telemetry/src/payload.rs @@ -124,6 +124,19 @@ impl Item { Payload::Benchmark(report) if !report.is_object() => { Err("a benchmark report is a JSON object".into()) } + Payload::Perf(perf) + if perf.slow_frames.len() > SlowFrames::MAX_SPANS + || perf + .slow_frames + .iter() + .any(|s| s.span.len() > SlowFrames::MAX_NAME) => + { + Err(format!( + "at most {} slow-frame spans of {} bytes", + SlowFrames::MAX_SPANS, + SlowFrames::MAX_NAME + )) + } _ => Ok(()), } } @@ -217,6 +230,69 @@ pub struct Perf { /// From choosing a world to playing in it, the first time this session. #[serde(skip_serializing_if = "Option::is_none")] pub to_world_ms: Option, + /// The frames of [`SLOW_FRAME_MS`] or more while playing, by what took longest in each: + /// what froze. At most [`SlowFrames::MAX_SPANS`] entries. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub slow_frames: Vec, +} + +/// A frame this long or longer is a freeze worth explaining ([`Perf::slow_frames`]): the +/// edge of [`Summary::buckets`]' last bucket. +pub const SLOW_FRAME_MS: f32 = 250.0; + +/// The slow frames whose longest step was one span: a named step of the render thread's frame +/// (`spans.rs` in the client: `render.upload`, `tick`, `world.stream`…), the same names in +/// every game of a release. `span` is empty when no step was timed in the frame (it waited +/// outside them). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct SlowFrames { + pub span: String, + pub frames: u32, + /// The longest of these frames and the longest this span took in one of them. + pub frame_max_ms: f32, + pub span_max_ms: f32, +} + +impl SlowFrames { + /// Distinct spans a session keeps; the later ones count under [`SlowFrames::OTHER`]. + pub const MAX_SPANS: usize = 16; + pub const MAX_NAME: usize = 64; + pub const OTHER: &'static str = "other"; + + /// Counts a slow frame of `frame_ms` whose longest span was `span` (`""`: none) for + /// `span_ms`, in a session's list. + pub fn record(list: &mut Vec, frame_ms: f32, span: &str, span_ms: f32) { + let span = if span.len() > Self::MAX_NAME { + Self::OTHER + } else { + span + }; + let at = match list.iter().position(|s| s.span == span) { + Some(at) => at, + None if list.len() + 1 < Self::MAX_SPANS => { + list.push(Self { + span: span.to_owned(), + ..Self::default() + }); + list.len() - 1 + } + None => match list.iter().position(|s| s.span == Self::OTHER) { + Some(at) => at, + None => { + list.push(Self { + span: Self::OTHER.to_owned(), + ..Self::default() + }); + list.len() - 1 + } + }, + }; + let entry = &mut list[at]; + entry.frames = entry.frames.saturating_add(1); + entry.frame_max_ms = entry.frame_max_ms.max(frame_ms); + entry.span_max_ms = entry.span_max_ms.max(span_ms); + } } /// A crash, scrubbed: the error and its stack, the thread, and the details that say what the @@ -453,4 +529,50 @@ mod tests { assert!(size < 1024, "{size} bytes"); assert!(perf.validate().is_ok()); } + + /// Slow frames count under their longest span; past the cap, new spans share one entry, + /// so a session full of different freezes still fits its item. + #[test] + fn slow_frames_are_counted_by_span_within_a_cap() { + let mut list = Vec::new(); + SlowFrames::record(&mut list, 300.0, "render.upload", 200.0); + SlowFrames::record(&mut list, 900.0, "render.upload", 120.0); + SlowFrames::record(&mut list, 260.0, "", 0.0); + assert_eq!( + list[0], + SlowFrames { + span: "render.upload".into(), + frames: 2, + frame_max_ms: 900.0, + span_max_ms: 200.0, + } + ); + assert_eq!((list[1].span.as_str(), list[1].frames), ("", 1)); + for i in 0..40 { + SlowFrames::record(&mut list, 400.0, &format!("step.{i}"), 300.0); + } + SlowFrames::record(&mut list, 400.0, &"x".repeat(200), 300.0); + assert_eq!(list.len(), SlowFrames::MAX_SPANS); + let other = list.iter().find(|s| s.span == SlowFrames::OTHER).unwrap(); + // 40 spans, 13 of which found room after the first two. + assert_eq!(other.frames, 40 - 13 + 1); + let perf = Item::new( + Source::Game, + "v0.4.2", + Payload::Perf(Perf { + slow_frames: list.clone(), + ..Perf::default() + }), + ); + assert!(perf.validate().is_ok()); + let too_many = Item::new( + Source::Game, + "v0.4.2", + Payload::Perf(Perf { + slow_frames: [list.clone(), list].concat(), + ..Perf::default() + }), + ); + assert!(too_many.validate().is_err()); + } } diff --git a/docs/telemetry.md b/docs/telemetry.md index fc932713b9..69cda4d564 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -29,7 +29,7 @@ Every item has `day` (days since 1970, UTC: no finer time), `source` (`launcher` | Kind | Switch | `data` | Largest | A day | |---|---|---|---|---| -| `perf` | performance | `system`, `settings` (render and simulation distance, max FPS, vsync, fullscreen, graphics mode), `seconds` played, `frames` and `ticks` summaries, `peak_ram_mb`, `to_title_ms`, `to_world_ms` | 4 KiB | 12 | +| `perf` | performance | `system`, `settings` (render and simulation distance, max FPS, vsync, fullscreen, graphics mode), `seconds` played, `frames` and `ticks` summaries, `peak_ram_mb`, `to_title_ms`, `to_world_ms`, `slow_frames` | 4 KiB | 12 | | `crash` | crash reports | `system`, `error`, `stack` (one frame a line), `thread`, the graphics and settings details, all scrubbed. No log, no world, no server | 32 KiB | 5 | | `bug` | bug reports | `system`, the player's `text` (up to 4000 characters), and the `log` only if they add it after reading it | 80 KiB | 5 | | `benchmark` | benchmark uploads | the benchmark report as `docs/benchmark-report.md` defines it, as it is, with its own schema field | 128 KiB | 10 | @@ -55,6 +55,8 @@ No serial number, host name, user name, MAC address or path is read. Settings A summary (`frames`, `ticks`) is the count, the 50th, 95th and 99th percentile and the maximum in milliseconds (to the tenth), and 14 bucket counts: under 4 ms, 4–6, 6–8, 8–10, 10–12, 12–14, 14–16.7, 16.7–20, 20–25, 25–33.3, 33.3–50, 50–100, 100–250, and 250 ms and more. Raw frame times never leave: `Timings` records them in tenths of a millisecond (20 KB however long the session) and only its summary is kept. A perf item is under 1 KiB. +`slow_frames` says what froze: the frames of 250 ms or more while playing, counted by the step of the frame they spent most of their time in. A step is a span of the render thread (`spans.rs`), named in the code and the same in every game of a release: the slowest outer span, then the span inside it that took at least half of it, as a path (`renderer/r_uploads`, `tick/tick_mobs`; empty when no step was timed). Each entry has the step, its frame count, the longest of those frames and the longest the step took. At most 16 entries; later steps count under `other`. With performance statistics on, the render thread times its steps (wall time only, two clock reads a span); with them off it times nothing. Most sessions have no slow frame and send no `slow_frames`. + Never sent: account names or UUIDs, world names, server or LAN addresses, chat, file paths with the user's name, IP addresses. `scrub::Scrubber` replaces the user folder in paths (`/Users//…`, `C:\Users\\…`), the user name, e-mail and IP addresses and UUIDs. It also replaces the terms the caller passes (the world's name, the server's address, account names). The server does not store the sender's IP address. ## Storage and sending @@ -106,7 +108,7 @@ The launcher reads the count when it opens and every minute after, and shows "N - `POST /v1/batch` - `DELETE /v1/installs/`: deletes everything sent under that ID. -- `GET /v1/aggregate`: the anonymous totals the website's results page reads. Its `breakdowns` split the play sessions of the period by hardware class (below), each class with its installs, sessions, hours and mean p50 and p99 frame times. +- `GET /v1/aggregate`: the anonymous totals the website's results page reads. Its `breakdowns` split the play sessions of the period by hardware class (below), each class with its installs, sessions, hours and mean p50 and p99 frame times. Its `slow_frames` list what froze, per release and step: the slow frames, the sessions and installs that had one, and the longest frame and step, for steps at least as many installs had as the groups need. - `GET /v1/benchmarks/summary`: players' benchmark uploads grouped by GPU, CPU, render distance and game for playhorizon.dev/benchmarks. A group shows only with at least 3 different installs, and only its medians and counts: no install ID, day, OS version, driver, exact memory or screen. Uploads are never published one by one. Each group's `breakdowns` count its installs per hardware class. - `GET /v1/export/benchmarks` and `GET /v1/export/hardware`: for the maintainers' performance work only, behind a bearer token the public cannot use (404 while the server has none). The raw benchmark reports and the play sessions per machine description, including CPU threads and physical cores, with no minimum group size. Nothing identifying: no install ID, and only what a player already agreed to send (this page's lists). - `POST /v1/live` and `GET /v1/live`: the "I'm playing" ping and the live count. From f69a77827e0c8d1a132aff9a197abb07d02bccd1 Mon Sep 17 00:00:00 2001 From: Akirabane Date: Mon, 2 Nov 2026 12:38:29 +0100 Subject: [PATCH 21/37] Items and experience orbs stop at the world border Entity.collectColliders adds WorldBorder.getCollisionShape for any entity inside and close to the border, but ItemEntity::tick built its EntityCollisions with `border: None` and ExperienceOrb::tick resolved against the blocks alone, so both went through the border. Their ticks now take the level's border (the server's and a client's copy), and EntityCollisions gains resolve_with_context for callers that carry their own collision context. Refs #7080 Co-Authored-By: Claude Opus 5.5 --- .../horizon-client-render/src/collisions.rs | 31 ++++--- crates/horizon-client/src/entities.rs | 92 +++++++++++++++++-- crates/horizon-client/src/entities/fluids.rs | 7 +- crates/horizon-client/src/game.rs | 2 + crates/horizon-client/src/game/remote.rs | 15 ++- 5 files changed, 119 insertions(+), 28 deletions(-) diff --git a/crates/horizon-client-render/src/collisions.rs b/crates/horizon-client-render/src/collisions.rs index 51ffac4048..4474d59bc3 100644 --- a/crates/horizon-client-render/src/collisions.rs +++ b/crates/horizon-client-render/src/collisions.rs @@ -182,12 +182,24 @@ where movement: Vec3, max_step: f32, on_ground: bool, + ) -> Result { + let context = CollisionContext::position(bounds.min_position().y); + self.resolve_with_context(bounds, movement, max_step, on_ground, context) + } + /// [`Self::resolve`] with the entity's captured collision context. + pub fn resolve_with_context( + &mut self, + bounds: Aabb, + movement: Vec3, + max_step: f32, + on_ground: bool, + context: CollisionContext<'_>, ) -> Result { Ok(resolve_world_movement( self, MovementRequest { entity: EntityIdentity(0), - context: CollisionContext::position(bounds.min_position().y), + context, bounds, movement, max_step, @@ -223,23 +235,12 @@ impl SceneCollisions<'_, '_> { border: Option<(&BorderGeometry, [f64; 2])>, ) -> Result { let context = self.context(bounds); - let mut scene = EntityCollisions { + EntityCollisions { blocks: self, entities: &[], border, - }; - Ok(resolve_world_movement( - &mut scene, - MovementRequest { - entity: EntityIdentity(0), - context, - bounds, - movement, - max_step, - on_ground, - candidate_budget: 1_000_000, - }, - )?) + } + .resolve_with_context(bounds, movement, max_step, on_ground, context) } /// Resolve movement with the entity's captured collision context. Most /// callers only need the position context; entities such as falling blocks diff --git a/crates/horizon-client/src/entities.rs b/crates/horizon-client/src/entities.rs index 2cee59a0f7..976ec62406 100644 --- a/crates/horizon-client/src/entities.rs +++ b/crates/horizon-client/src/entities.rs @@ -12,7 +12,7 @@ use crate::{ world::World, }; use horizon_core::{ - geometry::{Aabb, Direction, Vec3, VoxelShape}, + geometry::{Aabb, Direction, Vec3, VoxelShape, border::BorderGeometry}, position::BlockPos, }; use horizon_world::{ @@ -261,6 +261,7 @@ impl ItemEntity { no_physics: bool, fluids: &crate::motion::FluidResources, entity_boxes: &[Aabb], + border: Option<&BorderGeometry>, ) -> bool { self.ticks += 1; self.previous = self.position; @@ -292,7 +293,7 @@ impl ItemEntity { // (`applyEffectsFromBlocksForLastMovements`), so lift builds up and // the item moves on the next wake-up (#4558). if !self.on_ground || horizontal > 1.0e-5 || (self.age + self.id).is_multiple_of(4) { - let mut scene = SceneCollisions { + let scene = SceneCollisions { world, binding: collision, subject: None, @@ -308,17 +309,14 @@ impl ItemEntity { // Entity.move with noPhysics: straight through, the ground as it was. let moved = if no_physics { wanted - } else if entity_boxes.is_empty() { - scene - .resolve(self.bounds(), wanted, 0.0, self.on_ground) - .unwrap_or(Vec3::ZERO) } else { // `Entity.collide`: the entities that `canBeCollidedWith` the item stop it - // too (a boat, a minecart, a shulker, a happy ghast held still). + // too (a boat, a minecart, a shulker, a happy ghast held still), and + // so does the world border (`Entity.collectColliders`). crate::collisions::EntityCollisions { blocks: scene, entities: entity_boxes, - border: None, + border: border.map(|b| (b, [self.position.x, self.position.z])), } .resolve(self.bounds(), wanted, 0.0, self.on_ground) .unwrap_or(Vec3::ZERO) @@ -852,6 +850,7 @@ impl ExperienceOrb { player: Option<[f64; 3]>, server: bool, next_float: &mut dyn FnMut() -> f32, + border: Option<&BorderGeometry>, ) -> bool { self.ticks += 1; self.previous = self.position; @@ -916,8 +915,10 @@ impl ExperienceOrb { // `double fallSpeed = getDeltaMovement().y`, taken after the stuck // push and before the move. let wanted = self.velocity; + // Entity.collide: the world border's walls stop the orb too. + let border = border.map(|b| (b, [self.position.x, self.position.z])); let moved = scene - .resolve(bounds, wanted, 0.0, self.on_ground) + .resolve_in_border(bounds, wanted, 0.0, self.on_ground, border) .unwrap_or(Vec3::ZERO); self.position = Vec3::new( self.position.x + moved.x, @@ -1943,6 +1944,73 @@ mod merge_tests { } } +#[cfg(test)] +mod world_border_tests { + use super::*; + + /// `Entity.collectColliders` (#7080): the world border's wall stops an + /// item and an experience orb as it stops the player. A border of size + /// 10 around (20, 20) has its east wall at x = 25; each body starts at + /// x = 24 on the flat ground of the test scene, moving 1.5 east. + #[test] + fn the_world_border_stops_items_and_experience_orbs() { + let root = std::path::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 = crate::assets::Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap(); + let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap(); + let collision = crate::collisions::load(&root, assets.registry).unwrap(); + let world = World::test_scene(&assets, &render, 3).unwrap(); + let border = BorderGeometry::new([20.0, 20.0], 10.0, 29_999_984).unwrap(); + for bordered in [false, true] { + let border = bordered.then_some(&border); + let mut item = super::honey_slide_tests::falling_item(24.0, 4.0, 20.5, 1.5, 0.0, 0.0); + item.tick( + &world, + assets.registry, + &facts, + &collision, + false, + &fluids, + &[], + border, + ); + let mut orb = ExperienceOrb { + no_gravity: false, + id: 2, + position: Vec3::new(24.0, 4.0, 20.5), + previous: Vec3::new(24.0, 4.0, 20.5), + velocity: Vec3::new(1.5, 0.0, 0.0), + value: 1, + age: 0, + on_ground: true, + count: 1, + ticks: 0, + health: 5, + }; + orb.tick(&world, &facts, &collision, None, true, &mut || 0.0, border); + for (what, east) in [ + ("item", item.bounds().max_position().x), + ("orb", orb.bounds().max_position().x), + ] { + if bordered { + assert!( + (east - 25.0).abs() < 1.0e-7, + "the {what} stops on the wall at x = 25, got {east}" + ); + } else { + assert!(east > 25.0, "no border: the {what} goes on, got {east}"); + } + } + } + } +} + #[cfg(test)] mod honey_slide_tests { use super::*; @@ -1984,6 +2052,7 @@ mod honey_slide_tests { false, &fluids, &[], + None, ); ys.push(item.velocity.y); } @@ -2048,6 +2117,7 @@ mod honey_slide_tests { false, &fluids, &[], + None, ); } assert!( @@ -2344,6 +2414,7 @@ mod slime_bounce_tests { false, &fluids, &[], + None, ); assert_eq!(falling.position.y, 4.0); assert!(falling.velocity.y > 0.0); @@ -2362,6 +2433,7 @@ mod slime_bounce_tests { false, &fluids, &[], + None, ); assert!(!falling.needs_sync); } @@ -2454,6 +2526,7 @@ mod entity_collision_tests { false, &fluids, boxes, + None, ); } item.position.y @@ -2502,6 +2575,7 @@ mod entity_collision_tests { false, &fluids, &[], + None, ); } item.position.x diff --git a/crates/horizon-client/src/entities/fluids.rs b/crates/horizon-client/src/entities/fluids.rs index 6635d6084d..3d4791bf29 100644 --- a/crates/horizon-client/src/entities/fluids.rs +++ b/crates/horizon-client/src/entities/fluids.rs @@ -262,7 +262,8 @@ mod tests { &collision, false, &fluids, - &[] + &[], + None )); assert_eq!(still.position.x, at.x); assert_eq!(still.velocity.x, 0.0); @@ -275,7 +276,8 @@ mod tests { &collision, false, &fluids, - &[] + &[], + None )); // First push is damped by buoyancy before motion, second follows drag. assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12); @@ -289,6 +291,7 @@ mod tests { false, &fluids, &[], + None, ); } assert!(carried.position.x > at.x + 0.5); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1fc32d219d..10f4f0f51b 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4062,6 +4062,7 @@ impl Game<'_> { no_physics, &self.res.motion().fluids, &nearby, + Some(self.border.geometry()), ); cells.moved(i, item.previous, item.position); // Entity.handleOnAboveBubbleColumn: the server sends the particles of @@ -13244,6 +13245,7 @@ impl Game<'_> { target, self.remote.is_none(), &mut next_float, + Some(self.border.geometry()), ) }); self.orbs = orbs; diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3f42cd62be..bafc5f493a 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -6468,6 +6468,7 @@ impl Game<'_> { // collisions and the pull of the nearest player between the server's // updates; the server removes it on pickup. let (world, facts, collision) = (&self.world, self.res.facts(), self.collision); + let border = Some(self.border.geometry()); let mut lava_random = None; let mut next_float = || { lava_random @@ -6501,7 +6502,15 @@ impl Game<'_> { l.steps -= 1; } let before = o.position; - let kept = o.tick(world, facts, collision, target, false, &mut next_float); + let kept = o.tick( + world, + facts, + collision, + target, + false, + &mut next_float, + border, + ); // `tick` set `previous` to the stepped position: keep the // pre-step one, as `xo` is in vanilla. o.previous = old; @@ -6519,11 +6528,12 @@ impl Game<'_> { // ItemEntity.tick runs on a client too: gravity, collisions and // friction move the item between the server's updates (every 20 // ticks), and only the server removes it. - let (world, registry, facts, collision) = ( + let (world, registry, facts, collision, border) = ( &self.world, self.pack.assets.registry, self.res.facts(), self.collision, + Some(self.border.geometry()), ); for d in self.dropped.iter_mut().filter(|_| running) { d.tick( @@ -6535,6 +6545,7 @@ impl Game<'_> { &self.res.motion().fluids, // The server decides what an item rests on; this copy only moves between updates. &[], + border, ); } head_lerps.retain(|_, (_, steps)| *steps > 0); From 0ec75f0dd8d8998d5aa22fe44a1e13d18881b859 Mon Sep 17 00:00:00 2001 From: Akirabane Date: Mon, 2 Nov 2026 12:38:30 +0100 Subject: [PATCH 22/37] Primed TNT stops at the world border PrimedTnt::tick resolved its move against the blocks alone; it now passes the world border too (Entity.collectColliders), on the server and in a client's copy between updates. Refs #7080 Co-Authored-By: Claude Opus 5.5 --- .../src/game/remote_projectiles.rs | 5 +- crates/horizon-client/src/game/tnt.rs | 75 ++++++++++++++++--- 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index a2eea9ceaf..9d3c7f0392 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -611,16 +611,17 @@ impl Game<'_> { // fuse down (the swell and flash follow it) between the server's // updates (every 10 ticks), and a client discards it when the fuse // runs out; only the server explodes it. - let (world, collision, assets, facts, fluids) = ( + let (world, collision, assets, facts, fluids, border) = ( &self.world, self.collision, &self.pack.assets, self.res.facts(), &self.res.motion().fluids, + Some(self.border.geometry()), ); self.tnts.retain_mut(|t| { // A fluid lookup that fails leaves it where its move put it. - let _ = t.tick(world, collision, assets, facts, fluids); + let _ = t.tick(world, collision, assets, facts, fluids, border); t.fuse > 0 }); // FallingBlockEntity.tick runs on a client too: the block falls by diff --git a/crates/horizon-client/src/game/tnt.rs b/crates/horizon-client/src/game/tnt.rs index e4f0bb8799..044e868ea4 100644 --- a/crates/horizon-client/src/game/tnt.rs +++ b/crates/horizon-client/src/game/tnt.rs @@ -50,17 +50,20 @@ impl PrimedTnt { assets: &horizon_client::assets::Assets, facts: &horizon_client::blocks::BlockFacts, fluids: &horizon_client::motion::FluidResources, + border: Option<&horizon_core::geometry::border::BorderGeometry>, ) -> Result> { self.previous = self.position; self.velocity.y -= self.get_gravity(); - let mut scene = SceneCollisions { + let scene = SceneCollisions { world, binding: collision, subject: None, }; let wanted = self.velocity; + // Entity.collide: the world border's walls stop it too. + let border = border.map(|b| (b, [self.position.x, self.position.z])); let moved = scene - .resolve(self.bounds(), wanted, 0.0, self.on_ground) + .resolve_in_border(self.bounds(), wanted, 0.0, self.on_ground, border) .unwrap_or(Vec3::ZERO); if horizon_client::entities::move_applies(wanted, moved) { self.position = Vec3::new( @@ -246,6 +249,7 @@ impl Game<'_> { &self.pack.assets, self.res.facts(), &self.res.motion().fluids, + Some(self.border.geometry()), )?; for pos in tops { self.bubble_column_particles(pos); @@ -541,6 +545,55 @@ mod tests { assert!(!tnt.on_ground); } + /// `Entity.collectColliders` (#7080): the world border's wall stops primed + /// TNT. A border of size 10 around (20, 20) has its east wall at x = 25; + /// the TNT starts at x = 24 on the test scene's flat ground, 1.5 east. + #[test] + fn the_world_border_stops_primed_tnt() { + let root = std::path::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 = horizon_client::assets::Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap(); + let fluids = horizon_client::motion::FluidResources::new(&assets, &facts).unwrap(); + let collision = horizon_client::collisions::load(&root, assets.registry).unwrap(); + let world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap(); + let border = + horizon_core::geometry::border::BorderGeometry::new([20.0, 20.0], 10.0, 29_999_984) + .unwrap(); + for bordered in [false, true] { + let at = Vec3::new(24.0, 4.0, 20.5); + let mut tnt = PrimedTnt { + no_gravity: false, + id: 0, + position: at, + previous: at, + velocity: Vec3::new(1.5, 0.0, 0.0), + fuse: 80, + on_ground: true, + owner: None, + guest: None, + power: 4.0, + }; + let border = bordered.then_some(&border); + tnt.tick(&world, &collision, &assets, &facts, &fluids, border) + .unwrap(); + let east = tnt.bounds().max_position().x; + if bordered { + assert!( + (east - 25.0).abs() < 1.0e-7, + "stops on the wall, got {east}" + ); + } else { + assert!(east > 25.0, "no border: goes on, got {east}"); + } + } + } + #[test] fn primed_tnt_follows_water_currents_after_moving() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -580,7 +633,7 @@ mod tests { guest: None, power: 4.0, }; - tnt.tick(&world, &collision, &assets, &facts, &fluids) + tnt.tick(&world, &collision, &assets, &facts, &fluids, None) .unwrap(); assert_eq!( tnt.position.x, at.x, @@ -592,14 +645,14 @@ mod tests { tnt.velocity ); assert_eq!(tnt.fuse, 79); - tnt.tick(&world, &collision, &assets, &facts, &fluids) + tnt.tick(&world, &collision, &assets, &facts, &fluids, None) .unwrap(); assert!((tnt.position.x - at.x - 0.014).abs() < 1e-12); assert!(tnt.on_ground); tnt.position = at; tnt.velocity = Vec3::ZERO; tnt.fuse = 1; - tnt.tick(&world, &collision, &assets, &facts, &fluids) + tnt.tick(&world, &collision, &assets, &facts, &fluids, None) .unwrap(); assert_eq!(tnt.fuse, 0); assert_eq!( @@ -610,7 +663,7 @@ mod tests { tnt.position = at; tnt.velocity = Vec3::ZERO; tnt.fuse = 80; - tnt.tick(&world, &collision, &assets, &facts, &fluids) + tnt.tick(&world, &collision, &assets, &facts, &fluids, None) .unwrap(); assert_eq!( tnt.velocity.x, 0.0, @@ -626,7 +679,7 @@ mod tests { .unwrap(); tnt.on_ground = false; tnt.velocity = Vec3::ZERO; - tnt.tick(&world, &collision, &assets, &facts, &fluids) + tnt.tick(&world, &collision, &assets, &facts, &fluids, None) .unwrap(); assert!( tnt.position.y < at.y && tnt.velocity.y < 0.0, @@ -683,7 +736,7 @@ mod tests { power: 4.0, }; for _ in 0..10 { - tnt.tick(&world, &collision, &assets, &facts, &fluids) + tnt.tick(&world, &collision, &assets, &facts, &fluids, None) .unwrap(); } assert!( @@ -704,7 +757,7 @@ mod tests { tnt.velocity = Vec3::ZERO; tnt.on_ground = false; for _ in 0..4 { - tnt.tick(&world, &collision, &assets, &facts, &fluids) + tnt.tick(&world, &collision, &assets, &facts, &fluids, None) .unwrap(); } assert!( @@ -764,7 +817,7 @@ mod tests { let mut tnt = primed(); for _ in 0..30 { tops.extend( - tnt.tick(&world, &collision, &assets, &facts, &fluids) + tnt.tick(&world, &collision, &assets, &facts, &fluids, None) .unwrap(), ); } @@ -778,7 +831,7 @@ mod tests { let mut tnt = primed(); for _ in 0..10 { assert!( - tnt.tick(&world, &collision, &assets, &facts, &fluids) + tnt.tick(&world, &collision, &assets, &facts, &fluids, None) .unwrap() .is_empty() ); From 832a188847ba8cfcdda3dcb1c4181bf3289ff280 Mon Sep 17 00:00:00 2001 From: Akirabane Date: Mon, 2 Nov 2026 12:38:30 +0100 Subject: [PATCH 23/37] Falling blocks stop at the world border Game::fall_step resolved a falling block's move with its own collision context but without the world border (Entity.collectColliders); it now goes through EntityCollisions with the border, on the server and in a client's copy. Refs #7080 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 10f4f0f51b..e1e1d43937 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -5539,7 +5539,7 @@ impl Game<'_> { f.stuck = Vec3::ZERO; f.velocity = Vec3::ZERO; } - let mut scene = collisions::SceneCollisions { + let scene = collisions::SceneCollisions { world: &self.world, binding: self.collision, subject: Some(collisions::PowderSnowSubject { @@ -5558,7 +5558,13 @@ impl Game<'_> { false, ); // On an error the caller keeps the block as it was before this tick. - let movement = scene.resolve_with_context( + // Entity.collide: the world border's walls stop it too. + let movement = collisions::EntityCollisions { + blocks: scene, + entities: &[], + border: Some((self.border.geometry(), [f.position.x, f.position.z])), + } + .resolve_with_context( horizon_client::falling_block::bounds(f.position), wanted, 0.0, From 0c54f51081c1958300878d8c796dc12553db41fc Mon Sep 17 00:00:00 2001 From: Akirabane Date: Mon, 2 Nov 2026 12:42:16 +0100 Subject: [PATCH 24/37] horizon-server: the management protocol notifies world saves and players joining and leaving Step 2 of #7118. Vanilla's JsonRpcNotificationService sends serverSaveStarted and serverSaveCompleted around DedicatedServer.saveAllChunks (the save-all command, the autosave, the pause) and playerJoined and playerLeft with the player as a PlayerDto. The game records a typed event for each (a new "saving" and "saved", beside the "join" and "leave" it already records for the event stream), and the supervisor, which fetches those events, turns them into the notifications. A save is told only when it ends without failing, as vanilla's is. What the game saved while it started is left out: no client could hear of it. Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/game.rs | 9 +++ .../src/game/dedicated/events.rs | 20 +++++- crates/horizon-server/src/events.rs | 7 ++ crates/horizon-server/src/management.rs | 72 ++++++++++++++++++- docs/api/server.md | 3 +- docs/server.md | 5 +- 6 files changed, 110 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 141790fb02..5b92c898be 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -14813,10 +14813,19 @@ impl Game<'_> { if self.remote.is_some() { return Ok(()); } + // `DedicatedServer.saveAllChunks` tells the management protocol's clients when a save starts and when + // it ends without failing, for every save: the command, the autosave, the pause. + let dedicated = self.options.dedicated.is_some(); + if dedicated { + dedicated::events::record_server("saving"); + } self.save_level(wait)?; self.save_parked_levels(wait)?; self.save_game_metadata()?; self.native_world_saved(wait); + if dedicated { + dedicated::events::record_server("saved"); + } Ok(()) } fn save_game_metadata(&mut self) -> Result<()> { diff --git a/crates/horizon-client/src/game/dedicated/events.rs b/crates/horizon-client/src/game/dedicated/events.rs index 0daf4a5a8b..e324cdf7e4 100644 --- a/crates/horizon-client/src/game/dedicated/events.rs +++ b/crates/horizon-client/src/game/dedicated/events.rs @@ -21,11 +21,20 @@ static EVENTS: Mutex = Mutex::new(Events { /// Records one event: its kind, the player's name and UUID, and what else it says. pub(in crate::game) fn record(kind: &str, name: &str, uuid: u128, mut fields: Value) { + fields["name"] = name.into(); + fields["uuid"] = horizon_protocol::crypto::uuid_string(uuid).into(); + push(kind, fields); +} + +/// Records an event of the server itself, with no player (`saving` and `saved`, around a world save). +pub(in crate::game) fn record_server(kind: &str) { + push(kind, json!({})); +} + +fn push(kind: &str, mut fields: Value) { let mut events = EVENTS.lock().unwrap_or_else(|e| e.into_inner()); fields["seq"] = events.next.into(); fields["type"] = kind.into(); - fields["name"] = name.into(); - fields["uuid"] = horizon_protocol::crypto::uuid_string(uuid).into(); fields["time"] = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_millis() as u64) @@ -52,6 +61,7 @@ pub(super) fn since(since: u64) -> Value { mod tests { use super::*; + // One test: the events are one process-wide list, so two tests recording at once would count each other's. #[test] fn events_are_numbered_and_read_from_a_number() { let first = since(u64::MAX)["next"].as_u64().unwrap(); @@ -65,5 +75,11 @@ mod tests { assert_eq!(events[1]["message"], "hi"); assert_eq!(events[1]["uuid"], "00000000-0000-0000-0000-000000000001"); assert_eq!(since(first + 1)["events"].as_array().unwrap().len(), 1); + // An event of the server itself (a world save) carries no player. + record_server("saving"); + let event = &since(first + 2)["events"][0]; + assert_eq!(event["type"], "saving"); + assert_eq!(event["seq"].as_u64(), Some(first + 3)); + assert!(event.get("name").is_none() && event.get("uuid").is_none()); } } diff --git a/crates/horizon-server/src/events.rs b/crates/horizon-server/src/events.rs index 0af4e6028f..3445ea40b9 100644 --- a/crates/horizon-server/src/events.rs +++ b/crates/horizon-server/src/events.rs @@ -87,9 +87,13 @@ pub fn poll_game(shared: Arc) { continue; }; // A new game numbers its events from 1 again. + let mut history = false; if control.as_ref().is_none_or(|(p, _)| *p != pid) { control = engine::Control::connect(&shared.dir).ok().map(|c| (pid, c)); since = 0; + // What the game recorded before this first fetch (its saves while it started) happened before + // the management protocol's clients could hear of it. + history = true; } let Some((_, game)) = control.as_mut() else { continue; @@ -105,6 +109,9 @@ pub fn poll_game(shared: Arc) { if let Some(fields) = event.as_object_mut() { fields.remove("seq"); } + if !(history && matches!(kind.as_str(), "saving" | "saved")) { + crate::management::game_event(&shared, &kind, &event); + } shared.emit(&kind, event); } } diff --git a/crates/horizon-server/src/management.rs b/crates/horizon-server/src/management.rs index 781d668059..419bce1fdd 100644 --- a/crates/horizon-server/src/management.rs +++ b/crates/horizon-server/src/management.rs @@ -72,6 +72,21 @@ pub fn started(shared: &Shared) { notify(shared, "notification/server/started", None); } +/// The notification a typed event of the game is, if vanilla sends one for it: a player who joined or left +/// (`playerJoined`, `playerLeft`, with the player as a `PlayerDto`), and a world save that began or ended +/// (`serverSaveStarted`, `serverSaveCompleted`). +pub fn game_event(shared: &Shared, kind: &str, event: &Value) { + let player = || Some(json!({ "id": event["uuid"], "name": event["name"] })); + let (method, params) = match kind { + "join" => ("notification/players/joined", player()), + "leave" => ("notification/players/left", player()), + "saving" => ("notification/server/saving", None), + "saved" => ("notification/server/saved", None), + _ => return, + }; + notify(shared, method, params); +} + /// `NotificationService.serverShuttingDown`, which `DedicatedServer.stopServer` sends first of all. pub fn stopping(shared: &Shared) { if !shared.management.stopping_told.swap(true, Ordering::SeqCst) { @@ -765,12 +780,20 @@ mod tests { stream } - /// The text of the next frame the server sends (unmasked, short enough for a 7-bit length). + /// The text of the next frame the server sends (unmasked, shorter than 65536 bytes). fn next_text(stream: &mut TcpStream) -> String { let mut head = [0u8; 2]; stream.read_exact(&mut head).unwrap(); assert_eq!(head[0], 0x81); - let mut payload = vec![0u8; usize::from(head[1])]; + let length = match head[1] { + 126 => { + let mut b = [0u8; 2]; + stream.read_exact(&mut b).unwrap(); + usize::from(u16::from_be_bytes(b)) + } + n => usize::from(n), + }; + let mut payload = vec![0u8; length]; stream.read_exact(&mut payload).unwrap(); String::from_utf8(payload).unwrap() } @@ -829,6 +852,47 @@ mod tests { assert_eq!(shared.management.sockets.lock().unwrap().len(), 1); } + #[test] + fn the_games_events_become_the_notifications_vanilla_sends() { + let shared = shared(); + let address = listen_on(shared.clone(), settings()); + let mut client = upgrade(address); + registered(&shared, 1); + let id = "0123abcd-0000-4000-8000-00000000000a"; + let joined = json!({ "type": "join", "name": "Kim0", "uuid": id }); + game_event(&shared, "join", &joined); + game_event( + &shared, + "chat", + &json!({ "name": "Kim0", "uuid": id, "message": "hi" }), + ); + game_event(&shared, "saving", &json!({ "type": "saving" })); + game_event(&shared, "saved", &json!({ "type": "saved" })); + game_event(&shared, "leave", &json!({ "name": "Kim0", "uuid": id })); + // The chat is not a notification; the rest come in order, the player as a `PlayerDto`. + let player = format!(r#"[{{"id":"{id}","name":"Kim0"}}]"#); + assert_eq!( + next_text(&mut client), + format!( + r#"{{"jsonrpc":"2.0","method":"minecraft:notification/players/joined","params":{player}}}"# + ) + ); + assert_eq!( + next_text(&mut client), + r#"{"jsonrpc":"2.0","method":"minecraft:notification/server/saving"}"# + ); + assert_eq!( + next_text(&mut client), + r#"{"jsonrpc":"2.0","method":"minecraft:notification/server/saved"}"# + ); + assert_eq!( + next_text(&mut client), + format!( + r#"{{"jsonrpc":"2.0","method":"minecraft:notification/players/left","params":{player}}}"# + ) + ); + } + #[test] fn every_notification_sent_is_one_discover_lists() { let listed = schema::discover(); @@ -841,6 +905,10 @@ mod tests { for sent in [ "notification/server/started", "notification/server/stopping", + "notification/server/saving", + "notification/server/saved", + "notification/players/joined", + "notification/players/left", ] { assert!( names.contains(&format!("minecraft:{sent}").as_str()), diff --git a/docs/api/server.md b/docs/api/server.md index 977bca615b..8187a9b674 100644 --- a/docs/api/server.md +++ b/docs/api/server.md @@ -80,7 +80,8 @@ addition; a change to one starts `/api/v2`, served beside `/api/v1`. - **Mods on a dedicated server**: list, turn on and off, and their settings, once native mods can be built for the server ([mods.md](../mods.md), [native-mods.md](../native-mods.md#multiplayer)). - **Vanilla's management protocol**: `serversettings/*`, `gamerules`, the notifications other than - `server/started` and `server/stopping`, and the status heartbeat. The methods answer an internal error saying + `server/started`, `server/stopping`, `server/saving`, `server/saved`, `players/joined` and `players/left`, + and the status heartbeat. The methods answer an internal error saying so; a kick's translatable message is sent as its key. - **Compatibility tests**: golden replies per endpoint, and a `server.toml` from each release line. diff --git a/docs/server.md b/docs/server.md index f66e0dfa90..d0aef565ea 100644 --- a/docs/server.md +++ b/docs/server.md @@ -509,7 +509,10 @@ runtime or new dependency. Server-sent events can replace the polling later if a - notifications: the supervisor pushes them to every connected client as vanilla's `JsonRpcNotificationService` does (a JSON-RPC request without an `id`, its one parameter in an array), through a queue and a thread so a slow client never holds the game's output. Built: `server/started` (the game's `Done`) and `server/stopping` - (when the supervisor or the game's own `stop` begins the shutdown, once per run); + (when the supervisor or the game's own `stop` begins the shutdown, once per run), `server/saving` and + `server/saved` (around every world save of the running game: `save-all`, the autosave, the pause when + empty; a save the game makes while it stops is after the last one the supervisor hears of), and + `players/joined` and `players/left` (from the game's typed events, within a quarter of a second); - planned: `serversettings/*` and `gamerules`, the other notifications and the heartbeat. ## 9. Benchmark plan (bots against vanilla and Paper) From 3b421a0b6cc3ad46a3de9811951ba387afbf3524 Mon Sep 17 00:00:00 2001 From: DUzzL <98381970+DUzzL@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:45:13 +0100 Subject: [PATCH 25/37] Creative search: match every tooltip line in the selected language --- crates/horizon-client/src/creative.rs | 61 +++++++++++++++++++-------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/crates/horizon-client/src/creative.rs b/crates/horizon-client/src/creative.rs index 3b49713dd0..d61f96a3bc 100644 --- a/crates/horizon-client/src/creative.rs +++ b/crates/horizon-client/src/creative.rs @@ -531,6 +531,17 @@ impl Creative { /// the name tree, both over vanilla's suffix arrays (substring matches, /// results in the tab's order). pub fn search_results(&self, term: &str, names: &Names) -> Vec { + let english = + names.display_get("itemGroup.search").as_deref() == names.get("itemGroup.search"); + self.search_results_in(term, names, &|k| names.display_get(k), english) + } + fn search_results_in( + &self, + term: &str, + names: &Names, + translate: &dyn Fn(&str) -> Option, + english: bool, + ) -> Vec { let all = 0..self.search_index.len(); if term.is_empty() { return all.collect(); @@ -552,17 +563,18 @@ impl Creative { }) .collect(); } - // FullTextSearchTree: tooltip lines, and the item id after a colon. - // The export's lines are English (they come from the vanilla jars); - // the hover name in the selected language is matched too, so a - // localized search finds the item. It is the whole name (a tipped - // arrow's potion, the ominous banner), as the first line is. + // FullTextSearchTree: the creative tooltip lines in the selected + // language, and the item id after a colon. The export's lines are + // English (they come from the vanilla jars), so they stand in only + // while the language is English; otherwise every line (name, lore, + // effects, ...) is rebuilt through the language, as vanilla's + // getTooltipLines does. let text = java_lower(term); - let localized = |i: usize| { - java_lower(&crate::item_tooltip::name_line( - &self.search_items[i], - &|k| names.display_get(k), - )) + let localized = |i: usize| -> Vec { + crate::item_tooltip::lines(&self.search_items[i], None, translate) + .iter() + .map(|l| java_lower(l)) + .collect() }; all.filter(|&i| { let e = &self.search_index[i]; @@ -573,16 +585,17 @@ impl Creative { } else { &e.lines }; + let exported = english || e.lines.is_empty(); + let local = localized(i); + let hit = |t: &str| { + (exported && any(lines.iter().map(String::as_str), t)) + || any(local.iter().map(String::as_str), t) + }; match text.split_once(':') { - None => { - any(lines.iter().map(String::as_str), &text) || localized(i).contains(&text) - } + None => hit(&text), Some((ns, path)) => { let (ns, path) = (java_trim(ns), java_trim(path)); - e.namespace.contains(ns) - && (e.path.contains(path) - || any(lines.iter().map(String::as_str), path) - || localized(i).contains(path)) + e.namespace.contains(ns) && (e.path.contains(path) || hit(path)) } } }) @@ -1405,6 +1418,20 @@ mod tests { assert_eq!(q("minecraft:铁砧"), [0]); } + #[test] + fn search_matches_every_localized_tooltip_line() { + // Vanilla's FullTextSearchTree indexes the selected language's + // tooltip lines; the export's English lines do not apply. + let c = creative(0); + let names = Names::default(); + let ru: HashMap<&str, &str> = HashMap::from([("item.minecraft.anvil", "Наковальня")]); + let tr = |k: &str| ru.get(k).map(|v| (*v).to_owned()); + let q = |s: &str, english| c.search_results_in(s, &names, &tr, english); + assert_eq!(q("наков", false), [0]); + assert!(q("anvil", false).is_empty()); + assert_eq!(q("anvil", true), [0]); + } + #[test] fn scrollbar_drag_follows_the_mouse_while_held() { let mut c = creative(90); From 6691b009ad67b08ce9e3d8e034f45514fa1654d9 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:46:51 +0100 Subject: [PATCH 26/37] prepared_files: the find test knows worldgen/structure is held now Co-Authored-By: Claude Opus 5.5 --- crates/horizon-core/src/prepared_files.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/horizon-core/src/prepared_files.rs b/crates/horizon-core/src/prepared_files.rs index 3ca571995a..2f9e872184 100644 --- a/crates/horizon-core/src/prepared_files.rs +++ b/crates/horizon-core/src/prepared_files.rs @@ -546,7 +546,11 @@ mod tests { write(&root).unwrap(); let (_, rel) = find(&root.join("data/minecraft/recipe")).unwrap(); assert_eq!(rel, "data/minecraft/recipe"); - assert!(find(&root.join("worldgen/structure")).is_none()); + let (_, rel) = find(&root.join("worldgen/structure/minecraft")).unwrap(); + assert_eq!(rel, "worldgen/structure/minecraft"); + // The top-level tables beside the structures stay on the disk. + assert!(find(&root.join("worldgen")).is_none()); + assert!(find(&root.join("worldgen/biomes.json")).is_none()); let _ = fs::remove_dir_all(&root); } From b661190137cbe0bd159b2ffc0fc0f26ee929b31a Mon Sep 17 00:00:00 2001 From: MSedgeMC <151820093+MSedgeMC@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:48:46 +0000 Subject: [PATCH 27/37] Sting the mobs a puffed pufferfish touches `Pufferfish.aiStep` stings every mob whose box meets the fish's own box inflated by 0.3, filtered by `TARGETING_CONDITIONS` (alive, and not in `#not_scary_for_pufferfish`), through `Pufferfish.touch`: a `mobAttack` hit for `getPuffState() + 1`, then the poison and `PUFFER_FISH_STING` when that hit lands. Horizon had only the player path (`playerTouch`), so a pufferfish that puffed up for a zombie never hurt it. `ai_step` now stings the touching mobs through a new `PufferfishSting` event, which applies the hit in the game layer and plays `entity.puffer_fish.sting` only when the hit landed, at the fish's position and not when it is silent, as `Entity.playSound` does. The tag moves into `not_scary_for_pufferfish`, read by the puff goal and the sting alike. The poison the mob gets is not modelled: an effect on another mob has no channel in the simulation, the same gap a cave spider's poison on a mob has. Corrects the `Pufferfish.touch` row of `tools/port_map/game-011.tsv`. --- crates/horizon-client/src/game/creatures.rs | 24 +++ crates/horizon-client/src/mobs.rs | 9 ++ crates/horizon-client/src/mobs/aquatic.rs | 157 +++++++++++++++++--- tools/port_map/game-011.tsv | 2 +- 4 files changed, 173 insertions(+), 19 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 27e92db8eb..ff411a5565 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2095,6 +2095,30 @@ impl Game<'_> { } } } + MobEvent::PufferfishSting { + mob, + target, + amount, + source, + } => { + // Pufferfish.touch: `playSound(PUFFER_FISH_STING, 1, 1)` + // sits inside the `hurtServer` branch, so only a hit that + // landed stings, at the fish's own position as + // `Entity.playSound` puts it, and not when it is silent. + if self.mob_hits_mob(target, amount, source) + && let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) + { + let at = [m.position.x, m.position.y, m.position.z]; + if !m.silent { + self.handle_mob_events(vec![MobEvent::Sound( + "entity.puffer_fish.sting", + at, + 1.0, + 1.0, + )])?; + } + } + } MobEvent::SonicBoom { target, attacker, diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 23581b4cc0..95277e41a5 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1534,6 +1534,15 @@ pub enum MobEvent { amount: f32, source: Damage, }, + /// `Pufferfish.touch` on a mob (`Pufferfish.aiStep`): the hit, then, only + /// when `hurtServer` let it land, the fish's `entity.puffer_fish.sting` + /// and the poison it gives. + PufferfishSting { + mob: u32, + target: u32, + amount: f32, + source: Damage, + }, /// `LivingEntity.stabAttack`'s `target.stopRiding()`: a spear knocks the /// target off its mount. StabDismount { diff --git a/crates/horizon-client/src/mobs/aquatic.rs b/crates/horizon-client/src/mobs/aquatic.rs index eff51a3184..ec71e2772f 100644 --- a/crates/horizon-client/src/mobs/aquatic.rs +++ b/crates/horizon-client/src/mobs/aquatic.rs @@ -2,7 +2,7 @@ //! tropical fish and pufferfish (`AbstractFish`, puffing), and dolphins //! (breathing, smooth swimming). use super::{ - Mob, MobEvent, MobWorld, Species, + Damage, Mob, MobEvent, MobWorld, Species, Who, anim::{Poses, State}, control::MoveKind, goals::{ @@ -333,6 +333,9 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { m.sound("entity.puffer_fish.sting", events); } } + if m.species() == S::Pufferfish { + sting_touching_mobs(m, w, events); + } } S::Dolphin => { // Dolphin.tick: moisture out of water, hop on land. @@ -364,6 +367,64 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { _ => {} } } + +/// `EntityTypeTags.NOT_SCARY_FOR_PUFFERFISH`: the mobs a pufferfish leaves +/// alone, its own kind and the water mobs it shares a tank with. +pub fn not_scary_for_pufferfish(s: Species) -> bool { + matches!( + s, + Species::Cod + | Species::Salmon + | Species::TropicalFish + | Species::Pufferfish + | Species::Squid + | Species::GlowSquid + | Species::Dolphin + | Species::Turtle + | Species::Guardian + | Species::ElderGuardian + | Species::Tadpole + | Species::Nautilus + | Species::ZombieNautilus + | Species::SulfurCube + ) +} + +/// `Pufferfish.aiStep` after `AbstractFish.aiStep`: a puffed pufferfish stings +/// every mob that touches it, not only the player. The query is `Mob.class` +/// in its own box inflated by 0.3, filtered by `TARGETING_CONDITIONS` (alive +/// and not `#not_scary_for_pufferfish`; invisibility and line of sight are +/// not tested), and each one takes `Pufferfish.touch`: `doTeamsAllowDamage`, +/// then a `mobAttack` hit for `getPuffState() + 1`. The poison and the sting +/// sound follow a hit that landed, which only the game layer knows. +fn sting_touching_mobs(m: &mut Mob, w: &MobWorld, events: &mut Vec) { + if !m.alive() || m.extra.size <= 0 { + return; + } + let area = m.bounds().inflate(Vec3::new(0.3, 0.3, 0.3)); + let own = w.teams.of(Who::Mob(m.id)); + let stung: Vec = w + .others_near(area) + .filter(|o| { + o.id != m.id + && o.alive + && !not_scary_for_pufferfish(o.species) + && o.bounds.intersects(area) + && crate::scoreboard::teams_allow_damage(own, w.teams.of(Who::Mob(o.id))) + }) + .map(|o| o.id) + .collect(); + let from = [m.position.x, m.position.y, m.position.z]; + for target in stung { + events.push(MobEvent::PufferfishSting { + mob: m.id, + target, + amount: 1.0 + m.extra.size as f32, + source: Damage::mob(m.id, from), + }); + } +} + /// Squid.aiStep: tentacle cycle, propulsion and body rotation. fn squid_ai_step(m: &mut Mob) { squid_step(m, false); @@ -881,23 +942,7 @@ impl Goal for PufferfishPuff { let mob = cx.w.others_near(area).any(|s| { s.id != m.id && s.alive - && !matches!( - s.species, - Species::Cod - | Species::Salmon - | Species::TropicalFish - | Species::Pufferfish - | Species::Squid - | Species::GlowSquid - | Species::Dolphin - | Species::Turtle - | Species::Guardian - | Species::ElderGuardian - | Species::Tadpole - | Species::Nautilus - | Species::ZombieNautilus - | Species::SulfurCube - ) + && !not_scary_for_pufferfish(s.species) && area.intersects(s.bounds) }); player || mob @@ -1099,3 +1144,79 @@ mod school_tests { assert!((100..=110).contains(&start)); } } + +#[cfg(test)] +mod pufferfish_sting_tests { + use super::*; + use crate::mobs::world::with_test_world; + use std::sync::Arc; + + /// What a pufferfish puffed to `puff` stings in one `ai_step`, as + /// `(target, damage)`, with `others` standing `x` blocks east of it. + fn stings(puff: i32, others: &[(&str, f64)]) -> Option> { + let mut built = None; + with_test_world(&[], |_, types| { + let mob = |id: u32, name: &str, at: Vec3| { + Mob::new(id, Arc::new(types.types[name].clone()), at, 0.0) + }; + let mut fish = mob(1, "minecraft:pufferfish", Vec3::new(0.5, 64.0, 0.5)); + fish.extra.size = puff; + let seen: Vec<_> = others + .iter() + .enumerate() + .map(|(i, (name, x))| { + mob(i as u32 + 2, name, Vec3::new(0.5 + x, 64.0, 0.5)).seen(None) + }) + .collect(); + built = Some((fish, seen)); + }); + let (mut fish, seen) = built?; + let mut events = Vec::new(); + with_test_world(&seen, |w, _| ai_step(&mut fish, w, &mut events)); + Some( + events + .iter() + .filter_map(|e| match e { + MobEvent::PufferfishSting { + mob: 1, + target, + amount, + source, + } => { + assert_eq!(source.attacker, Some(Who::Mob(1))); + Some((*target, *amount)) + } + _ => None, + }) + .collect(), + ) + } + + /// `Pufferfish.aiStep`: a puffed pufferfish stings every mob whose box + /// meets its own (inflated by 0.3), not only the player, for + /// `getPuffState() + 1`. + #[test] + fn a_puffed_pufferfish_stings_the_mobs_that_touch_it() { + let Some(half) = stings(1, &[("minecraft:zombie", 0.5)]) else { + return; + }; + assert_eq!(half, vec![(2, 2.0)], "puff state 1 stings for 1 + 1"); + let full = stings(2, &[("minecraft:zombie", 0.5)]).unwrap(); + assert_eq!(full, vec![(2, 3.0)], "puff state 2 stings for 1 + 2"); + assert!( + stings(0, &[("minecraft:zombie", 0.5)]).unwrap().is_empty(), + "a deflated pufferfish stings nobody" + ); + assert!( + stings(2, &[("minecraft:zombie", 1.5)]).unwrap().is_empty(), + "a zombie a block away never touches it" + ); + // `#not_scary_for_pufferfish`: its tank mates are left alone. + assert!( + stings(2, &[("minecraft:cod", 0.4), ("minecraft:squid", 0.3)]) + .unwrap() + .is_empty(), + "a cod and a squid are not scary" + ); + } +} diff --git a/tools/port_map/game-011.tsv b/tools/port_map/game-011.tsv index 868d82fce2..813ad8951c 100644 --- a/tools/port_map/game-011.tsv +++ b/tools/port_map/game-011.tsv @@ -129,7 +129,7 @@ AbstractSchoolingFish.addFollowers inline crates/horizon-client/src/mobs/aquatic AbstractSchoolingFish.finalizeSpawn inline crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize the group shares a leader id and variant, and FollowFlockLeader (crates/horizon-client/src/mobs/aquatic.rs) paths a follower to the leader every 10 ticks Pufferfish.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt `getIntOr("PuffState", 0)` at most 2; mob_to_nbt writes it, and a reload test covers it Pufferfish.registerGoals port crates/horizon-client/src/mobs/aquatic.rs::register the puff goal at priority 1 -Pufferfish.touch missing minor: only the player is stung (inside ai_step); other mobs touching a puffed fish are not hurt or poisoned +Pufferfish.touch inline crates/horizon-client/src/mobs/aquatic.rs::ai_step sting_touching_mobs: alive mobs outside #not_scary_for_pufferfish whose box meets the fish's inflated by 0.3, when teams allow damage, take a mobAttack hit for puff state + 1 and the sting sound once it lands; the poison has no path onto another mob (the cave spider's is the same) Salmon.defineSynchedData port crates/horizon-client/src/game/lan.rs::mob_meta size entry, small 0 to large 2 Salmon.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::read_variant the type tag, medium by default Salmon.get inline crates/horizon-client/src/entity_nbt.rs::bucket_components the size component goes onto the bucket From ad88c2584dc078f394772c960851fe460e8ba6e7 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:54:42 +0100 Subject: [PATCH 28/37] dedicated report test: spans carry their depth Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/dedicated/report.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/horizon-client/src/game/dedicated/report.rs b/crates/horizon-client/src/game/dedicated/report.rs index ae68d5023f..ef78d5480d 100644 --- a/crates/horizon-client/src/game/dedicated/report.rs +++ b/crates/horizon-client/src/game/dedicated/report.rs @@ -327,6 +327,7 @@ mod tests { name, ms, cpu_ms: ms, + depth: 0, } } From 2e1fae8f9fd06908a9fd73a795d8bacf98a007c2 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:03:40 +0100 Subject: [PATCH 29/37] Poison the mob a pufferfish stings Pufferfish.touch gives the stung mob POISON for 60 * getPuffState() once the hit lands, before the sting sound. Mob::add_effect carries it, so the event now holds the duration and the game applies it. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/creatures.rs | 16 ++++++++++------ crates/horizon-client/src/mobs.rs | 3 ++- crates/horizon-client/src/mobs/aquatic.rs | 7 +++++++ tools/port_map/game-011.tsv | 2 +- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index b68956780a..02a36f40cc 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2108,14 +2108,18 @@ impl Game<'_> { target, amount, source, + poison_ticks, } => { - // Pufferfish.touch: `playSound(PUFFER_FISH_STING, 1, 1)` - // sits inside the `hurtServer` branch, so only a hit that - // landed stings, at the fish's own position as + // Pufferfish.touch: the poison and + // `playSound(PUFFER_FISH_STING, 1, 1)` sit inside the + // `hurtServer` branch, so only a hit that landed poisons + // and stings, at the fish's own position as // `Entity.playSound` puts it, and not when it is silent. - if self.mob_hits_mob(target, amount, source) - && let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) - { + let landed = self.mob_hits_mob(target, amount, source); + if landed && let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) { + t.add_effect("minecraft:poison", poison_ticks, 0); + } + if landed && let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) { let at = [m.position.x, m.position.y, m.position.z]; if !m.silent { self.handle_mob_events(vec![MobEvent::Sound( diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 95277e41a5..a16c8bc48a 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1536,12 +1536,13 @@ pub enum MobEvent { }, /// `Pufferfish.touch` on a mob (`Pufferfish.aiStep`): the hit, then, only /// when `hurtServer` let it land, the fish's `entity.puffer_fish.sting` - /// and the poison it gives. + /// and the poison it gives (`poison_ticks`, `60 * getPuffState()`). PufferfishSting { mob: u32, target: u32, amount: f32, source: Damage, + poison_ticks: i32, }, /// `LivingEntity.stabAttack`'s `target.stopRiding()`: a spear knocks the /// target off its mount. diff --git a/crates/horizon-client/src/mobs/aquatic.rs b/crates/horizon-client/src/mobs/aquatic.rs index ec71e2772f..091a2fafd4 100644 --- a/crates/horizon-client/src/mobs/aquatic.rs +++ b/crates/horizon-client/src/mobs/aquatic.rs @@ -421,6 +421,7 @@ fn sting_touching_mobs(m: &mut Mob, w: &MobWorld, events: &mut Vec) { target, amount: 1.0 + m.extra.size as f32, source: Damage::mob(m.id, from), + poison_ticks: 60 * m.extra.size, }); } } @@ -1182,8 +1183,14 @@ mod pufferfish_sting_tests { target, amount, source, + poison_ticks, } => { assert_eq!(source.attacker, Some(Who::Mob(1))); + assert_eq!( + *poison_ticks, + 60 * puff, + "Pufferfish.touch's POISON lasts 60 * getPuffState()" + ); Some((*target, *amount)) } _ => None, diff --git a/tools/port_map/game-011.tsv b/tools/port_map/game-011.tsv index 813ad8951c..8dcc50e2dd 100644 --- a/tools/port_map/game-011.tsv +++ b/tools/port_map/game-011.tsv @@ -129,7 +129,7 @@ AbstractSchoolingFish.addFollowers inline crates/horizon-client/src/mobs/aquatic AbstractSchoolingFish.finalizeSpawn inline crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize the group shares a leader id and variant, and FollowFlockLeader (crates/horizon-client/src/mobs/aquatic.rs) paths a follower to the leader every 10 ticks Pufferfish.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt `getIntOr("PuffState", 0)` at most 2; mob_to_nbt writes it, and a reload test covers it Pufferfish.registerGoals port crates/horizon-client/src/mobs/aquatic.rs::register the puff goal at priority 1 -Pufferfish.touch inline crates/horizon-client/src/mobs/aquatic.rs::ai_step sting_touching_mobs: alive mobs outside #not_scary_for_pufferfish whose box meets the fish's inflated by 0.3, when teams allow damage, take a mobAttack hit for puff state + 1 and the sting sound once it lands; the poison has no path onto another mob (the cave spider's is the same) +Pufferfish.touch inline crates/horizon-client/src/mobs/aquatic.rs::ai_step sting_touching_mobs: alive mobs outside #not_scary_for_pufferfish whose box meets the fish's inflated by 0.3, when teams allow damage, take a mobAttack hit for puff state + 1, then, once it lands, POISON for 60 * puff state and the sting sound (game/creatures.rs PufferfishSting) Salmon.defineSynchedData port crates/horizon-client/src/game/lan.rs::mob_meta size entry, small 0 to large 2 Salmon.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::read_variant the type tag, medium by default Salmon.get inline crates/horizon-client/src/entity_nbt.rs::bucket_components the size component goes onto the bucket From 51d1ec6fc89dd667a5ce23554d34713e11e7c448 Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Mon, 2 Nov 2026 13:10:00 +0100 Subject: [PATCH 30/37] Cull the bat model's back faces like entityCutoutCull (#7139) Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client-render/src/entity.wgsl | 3 +- .../src/gfx/entity_skin.rs | 16 ++++- .../src/gfx/entity_skin.wgsl | 9 ++- crates/horizon-client-render/src/mesher.rs | 3 + crates/horizon-client/src/mobs.rs | 28 ++++++++- crates/horizon-client/src/mobs/cem.rs | 2 + crates/horizon-client/src/mobs/render.rs | 3 + crates/horizon-client/src/mobs/skin.rs | 62 ++++++++++++++++++- 8 files changed, 117 insertions(+), 9 deletions(-) diff --git a/crates/horizon-client-render/src/entity.wgsl b/crates/horizon-client-render/src/entity.wgsl index 97a27dd760..394d0b47b3 100644 --- a/crates/horizon-client-render/src/entity.wgsl +++ b/crates/horizon-client-render/src/entity.wgsl @@ -147,7 +147,8 @@ fn entity_vertex(position: vec3, uv: vec2, var back = c; // Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1 // keeps the baked colour but culls back faces (entitySolid models such as the decorated pot). - out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5); + // -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat). + out.cull = select(0u, 1u, (normal.w > 0.0 && normal.w < 0.5) || (normal.w < 0.0 && normal.w > -0.996)); if abs(normal.w) >= 0.5 { c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a); back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0); diff --git a/crates/horizon-client-render/src/gfx/entity_skin.rs b/crates/horizon-client-render/src/gfx/entity_skin.rs index 50eac81115..03d5ac7c4a 100644 --- a/crates/horizon-client-render/src/gfx/entity_skin.rs +++ b/crates/horizon-client-render/src/gfx/entity_skin.rs @@ -70,8 +70,11 @@ pub struct SkinJob { /// face's axis rides in the part's first 6 normal words ([`SkinPart::item`]), /// shaded on the CPU, and the normal is `mesher::CULL_BACK`. pub item: u32, - /// Always 0: the shader reads the last as a zero it cannot fold away. - pub spare: [u32; 2], + /// 1 for a model whose render type culls back faces (`mobs::Polygon::cull_back`): + /// the normal's w is `mesher::SHADED_CULL_W` instead of -127. + pub cull: u32, + /// Always 0: the shader reads it as a zero it cannot fold away. + pub spare: u32, } const _: () = assert!(std::mem::size_of::() == 44); @@ -376,7 +379,12 @@ pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec { let color = part.normal[v.axis as usize].to_bits(); (color, crate::mesher::CULL_BACK) } else { - (job.color, [packed[0], packed[1], packed[2], -127]) + let w = if job.cull != 0 { + crate::mesher::SHADED_CULL_W + } else { + -127 + }; + (job.color, [packed[0], packed[1], packed[2], w]) }; out[(job.dst + k) as usize] = Vertex { pos: pos.to_array(), @@ -490,6 +498,8 @@ mod tests { color: 0xff80_40ff, light: 0xf0_70, overlay: 1, + // The bat's encoding: the shader must write -126 like `expand`. + cull: 1, ..Default::default() }, SkinJob { diff --git a/crates/horizon-client-render/src/gfx/entity_skin.wgsl b/crates/horizon-client-render/src/gfx/entity_skin.wgsl index b090e1e9ac..cbdac28836 100644 --- a/crates/horizon-client-render/src/gfx/entity_skin.wgsl +++ b/crates/horizon-client-render/src/gfx/entity_skin.wgsl @@ -16,8 +16,8 @@ const PART_WORDS: u32 = 25u; // A job (16 words): first model vertex, vertex count, first part, first output // vertex, the texture rect (4 floats), scroll, colour, light, overlay, // whether the overlay is the armour glint (its model UV in `glint_uv`), whether -// it draws an item's quads (`SkinJob::item`), 2 spare (0: the last one is -// `keep`'s). +// it draws an item's quads (`SkinJob::item`), whether its quads cull back faces +// (`SkinJob::cull`), 1 spare (0: `keep`'s). const JOB_WORDS: u32 = 16u; // `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal // (4 x i8), glint_uv (2). @@ -89,6 +89,7 @@ fn skin( let overlay = jobs[j + 11u]; let glint = jobs[j + 12u] != 0u; let item = jobs[j + 13u] != 0u; + let cull = jobs[j + 14u] != 0u; keep = jobs[j + 15u]; for (var k = lane; k < count; k += 64u) { let v = (first + k) * MODEL_WORDS; @@ -110,6 +111,10 @@ fn skin( // An item's colour by axis, shaded on the CPU, and `mesher::CULL_BACK`. var vertex_color = color; var normal = pack(n); + if cull { + // `mesher::SHADED_CULL_W` (-126) in the normal's w byte. + normal = (normal & 0x00ffffffu) | (u32(-126 & 0xff) << 24u); + } if item { vertex_color = parts[part * PART_WORDS + 16u + axis]; normal = 1u << 24u; diff --git a/crates/horizon-client-render/src/mesher.rs b/crates/horizon-client-render/src/mesher.rs index ca72c00fb3..a5aff35259 100644 --- a/crates/horizon-client-render/src/mesher.rs +++ b/crates/horizon-client-render/src/mesher.rs @@ -50,6 +50,9 @@ pub struct Vertex { /// `Vertex::normal` of a baked-colour quad of an `entitySolid` model: the render type culls /// back faces, which the entity pipelines here do not, so `entity.wgsl` discards them. pub const CULL_BACK: [i8; 4] = [0, 0, 0, 1]; +/// `Vertex::normal.w` of a shaded quad of an `entityCutoutCull` model (the bat): shaded by the +/// normal like -127, and `entity.wgsl` also discards its back-facing pixels. +pub const SHADED_CULL_W: i8 = -126; /// A section vertex: vanilla's `DefaultVertexFormat.BLOCK` (position, colour, /// UV0, UV2) with the sprite's array layer beside the light, in 24 bytes /// rather than vanilla's 28: the UV is fixed point, which holds model UVs diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 23581b4cc0..3b2e4aef7b 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -117,6 +117,8 @@ use std::{collections::HashMap, fs, path::Path, sync::Arc}; pub struct Polygon { pub normal: [f32; 3], pub vertices: [[f32; 5]; 4], + /// The model's render type culls back faces (`entityCutoutCull`: the bat). + pub cull_back: bool, } impl Polygon { fn vertex_normal(&self, pose: ModelNormals) -> [i8; 4] { @@ -127,7 +129,12 @@ impl Polygon { n.normalize_or_zero() }; let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8); - [packed[0], packed[1], packed[2], -127] + let w = if self.cull_back { + crate::mesher::SHADED_CULL_W + } else { + -127 + }; + [packed[0], packed[1], packed[2], w] } } @@ -225,6 +232,15 @@ pub struct Part { pub children: Vec, } impl Part { + /// Marks every polygon as drawn by a back-face-culling render type. + fn set_cull_back(&mut self) { + for poly in &mut self.polygons { + poly.cull_back = true; + } + for child in &mut self.children { + child.set_cull_back(); + } + } fn parse(name: &str, v: &Value) -> Self { let pose: Vec = v["pose"] .as_array() @@ -271,6 +287,7 @@ impl Part { |n| std::array::from_fn(|i| n[i].as_f64().unwrap_or(0.0) as f32), ), vertices: [verts[0], verts[1], verts[2], verts[3]], + cull_back: false, }) }) .collect(); @@ -494,7 +511,14 @@ impl MobTypes { max_head_y_rot: def.max_head_y_rot(), ambient_interval: def.ambient_interval(), flags, - model: Arc::new(Part::parse("root", model)), + model: Arc::new({ + let mut part = Part::parse("root", model); + // BatModel: `RenderTypes::entityCutoutCull`. + if def.name == "bat" { + part.set_cull_back(); + } + part + }), layers, texture: def.texture.to_owned(), brain: def.brain(), diff --git a/crates/horizon-client/src/mobs/cem.rs b/crates/horizon-client/src/mobs/cem.rs index 48b06fc4f8..8c248db725 100644 --- a/crates/horizon-client/src/mobs/cem.rs +++ b/crates/horizon-client/src/mobs/cem.rs @@ -456,6 +456,7 @@ fn part(bone: &Bone, count: &mut usize, ids: &mut HashMap) -> Pa .map(|q| Polygon { normal: q.normal, vertices: q.vertices, + cull_back: false, }) .collect(), children: bone.children.iter().map(|c| part(c, count, ids)).collect(), @@ -473,6 +474,7 @@ mod tests { pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0], polygons: vec![Polygon { normal: [0.0, 0.0, -1.0], + cull_back: false, vertices: [[0.0; 5]; 4], }], children, diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 013982c879..eff68619b3 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -2602,6 +2602,7 @@ mod tests { .map(|_| super::super::Polygon { normal: [0.0; 3], vertices: [corner(min), corner(max), corner(min), corner(max)], + cull_back: false, }) .collect() } @@ -3380,6 +3381,7 @@ mod emissive_tests { pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0], polygons: vec![Polygon { normal: [0.0, 0.0, -1.0], + cull_back: false, vertices: [ [0.0, 0.0, 0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, 0.0], @@ -3670,6 +3672,7 @@ mod random_texture_tests { pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0], polygons: vec![Polygon { normal: [0.0, 0.0, -1.0], + cull_back: false, vertices: [ [0.0, 0.0, 0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, 0.0], diff --git a/crates/horizon-client/src/mobs/skin.rs b/crates/horizon-client/src/mobs/skin.rs index dd166cf93f..a1c6f73338 100644 --- a/crates/horizon-client/src/mobs/skin.rs +++ b/crates/horizon-client/src/mobs/skin.rs @@ -22,6 +22,8 @@ struct Stored { /// The model's bounds in its parts' own space, per part: min and max of /// its own vertices (none: no polygons). bounds: Vec>, + /// The model's render type culls back faces (the bat). + cull: bool, } #[derive(Clone, Copy)] @@ -241,9 +243,15 @@ fn flatten(model: &Part, first: u32, out: &mut Vec) -> Option bool { + part.polygons.iter().any(|p| p.cull_back) || part.children.iter().any(has_cull) +} + /// 0..2 for +x, +y, +z, 3..5 for -x, -y, -z; `None` for any other normal. fn axis(n: [f32; 3]) -> Option { let mut found = None; @@ -415,7 +423,8 @@ impl Walk<'_> { overlay: paint.overlay, glint: u32::from(paint.overlay == super::render::ARMOR_GLINT), item: 0, - spare: [0; 2], + cull: u32::from(self.stored.cull), + spare: 0, }); } *self.vertices += stored.count; @@ -533,6 +542,57 @@ mod tests { assert!(posed > 3 * 40, "only {posed} draws took the GPU path"); } + /// `BatModel` draws with `entityCutoutCull`: its zero-depth ears, wings and + /// feet put two quads in one place, and only culling tells them apart. The + /// bat's vertices ask for the shaded cull encoding, other mobs keep -127, + /// on both paths (the whole-mob test above holds them bit-equal). + #[test] + fn only_the_bat_asks_for_culled_back_faces() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client/26.3"); + if !root.join("entity-models.json").exists() { + eprintln!("no prepared resources: skipped"); + return; + } + let types = MobTypes::load(&root).unwrap(); + for (id, w) in [ + ("minecraft:bat", crate::mesher::SHADED_CULL_W), + ("minecraft:cow", -127), + ("minecraft:cat", -127), + ] { + let mut part = vec![&*types.types[id].model]; + let mut seen = 0; + while let Some(p) = part.pop() { + for poly in &p.polygons { + let n = poly.vertex_normal(ModelNormals::new(glam::Mat4::IDENTITY)); + assert_eq!(n[3], w, "{id}"); + seen += 1; + } + part.extend(&p.children); + } + assert!(seen > 0, "{id} has polygons"); + } + // The GPU path flags the job, the CPU path the polygons. + let mut models = SkinModels::default(); + let mut frame = SkinFrame::default(); + for (id, cull) in [("minecraft:bat", 1), ("minecraft:cow", 0)] { + models.begin(&mut frame, 0); + let mut skin = Skin { + models: &mut models, + frame: &mut frame, + bounds: None, + }; + assert!(skin.draw( + &types.types[id].model, + glam::Mat4::IDENTITY, + &Poses::default(), + Paint { uv: [0.0, 0.0, 1.0, 1.0], scroll: 0.0, color: u32::MAX, light: 0, overlay: 0 }, + )); + assert!(frame.jobs.iter().all(|j| j.cull == cull), "{id}"); + frame.clear_draws(); + } + } + /// The armor stand's models (`ArmorStandRenderer`: the stand, the small /// one, their armour layers) posed like a stand: turned arms and head, a /// hidden part (scale 0, as a stand without arms or base plate), the From 8e6896180ad5c751fea76472fa35194caa1410de Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Mon, 2 Nov 2026 13:13:06 +0100 Subject: [PATCH 31/37] Format Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/mobs/skin.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs/skin.rs b/crates/horizon-client/src/mobs/skin.rs index a1c6f73338..d2f111ef81 100644 --- a/crates/horizon-client/src/mobs/skin.rs +++ b/crates/horizon-client/src/mobs/skin.rs @@ -586,7 +586,13 @@ mod tests { &types.types[id].model, glam::Mat4::IDENTITY, &Poses::default(), - Paint { uv: [0.0, 0.0, 1.0, 1.0], scroll: 0.0, color: u32::MAX, light: 0, overlay: 0 }, + Paint { + uv: [0.0, 0.0, 1.0, 1.0], + scroll: 0.0, + color: u32::MAX, + light: 0, + overlay: 0 + }, )); assert!(frame.jobs.iter().all(|j| j.cull == cull), "{id}"); frame.clear_draws(); From 956494a51abf8e49e6b43ce45e581ff61bf0f67e Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Mon, 2 Nov 2026 13:04:40 +0100 Subject: [PATCH 32/37] Spawn eggs used on a block end the click client-side (#7147) SpawnEggItem.useOn returns SUCCESS (FAIL for a mob that cannot spawn) on a client level, so no UseItem follows and the server no longer spawns a second mob from the fluid ray when the player is underwater. Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/game/remote.rs | 33 +++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3f42cd62be..dd4eb171e0 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -7358,6 +7358,23 @@ impl Game<'_> { // and the click ends. self.with_hand(hand, Game::swing_arm); return true; + } else if let Some(stack) = held + .as_ref() + .filter(|s| s.item.ends_with("_spawn_egg")) + .filter(|_| { + self.may_build() + || self.with_hand(hand, |g| g.can_place_on_in_adventure(hit.pos)) + }) + { + // SpawnEggItem.useOn on a client level: FAIL for a mob that + // cannot spawn (the click ends, no swing), else SUCCESS (swing). + // Either way no UseItem follows, so the server does not spawn + // a second mob from the fluid ray (ItemStack.useOn passes in + // adventure mode off can_place_on, which the filter above keeps). + if spawn_egg_use_on_swings(self.spawn_egg_allowed(&stack.item)) { + self.with_hand(hand, Game::swing_arm); + } + return true; } else if let Some(stack) = held.as_ref() && (self.predict_item_on_block(stack, &hit, hand) || self.predict_map_on_banner(stack, &hit)) @@ -9971,6 +9988,12 @@ fn keep_client_state(old: Option<&BlockEntity>, new: &mut BlockEntity) { } } +/// `SpawnEggItem.useOn` on a client level: FAIL (no swing) when the mob cannot +/// spawn, SUCCESS (swing) otherwise. Neither lets `UseItem` follow. +fn spawn_egg_use_on_swings(can_spawn: bool) -> bool { + can_spawn +} + /// `LocalPlayer.onSyncedDataUpdated(DATA_LIVING_ENTITY_FLAGS)`: the server's /// flags (1 using, 2 the off hand) start a use the client is not running /// (`startUsingItem`, which needs an item in that hand), and stop one it is @@ -10222,7 +10245,7 @@ fn client_millis() -> u64 { #[cfg(test)] mod tests { - use super::{keep_client_state, remote_use_swings, synced_local_use}; + use super::{keep_client_state, remote_use_swings, spawn_egg_use_on_swings, synced_local_use}; use horizon_client::{ containers::{Beacon, BlockEntity}, items::ItemStack, @@ -10267,6 +10290,14 @@ mod tests { assert!(!remote_use_swings(&stack("minecraft:apple"), true)); } + /// A spawn egg used on a block ends the click: swinging when the mob can + /// spawn (SUCCESS), silent when it cannot (FAIL). + #[test] + fn a_spawn_egg_on_a_block_swings_only_for_a_mob_that_can_spawn() { + assert!(spawn_egg_use_on_swings(true)); + assert!(!spawn_egg_use_on_swings(false)); + } + #[test] fn a_beacon_update_tag_keeps_the_beam_the_client_walked() { let old = BlockEntity::Beacon(Box::new(Beacon { From 6d5ad6b6c41a1f2a25e7bb5153bfdcb36989b1da Mon Sep 17 00:00:00 2001 From: HrafnaValkyrja Date: Mon, 2 Nov 2026 07:08:08 -0500 Subject: [PATCH 33/37] Test: save and reload keeps riders Adds passengers (one, two, nested) on a pig, boat, minecart and horse to the save round-trip scene, and lets the passenger copies of simulation fields differ as the entity's own do. Follows the review of #7094. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m --- tools/save_roundtrip_scene.py | 6 ++++++ tools/test_save_roundtrip_parity.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/save_roundtrip_scene.py b/tools/save_roundtrip_scene.py index efa0834b61..e872f3afa1 100644 --- a/tools/save_roundtrip_scene.py +++ b/tools/save_roundtrip_scene.py @@ -130,4 +130,10 @@ ENTITIES = [ ('ghast', ''), ('piglin', ''), ('hoglin', ''), ('strider', 'Saddle:1b'), ('warden', ''), ('trader_llama', ''), ('mooshroom', 'Type:"brown"'), ('panda', 'MainGene:"lazy"'), ('copper_golem', ''), ('creaking', ''), + # Riders (an entity saves inside its vehicle). The riders + # have their own UUIDs, so a dropped or reordered one shows in the vehicle's Passengers diff. + ('pig', 'Passengers:[{id:"minecraft:zombie",UUID:[I;28673,0,0,1],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b}]'), + ('oak_boat', 'Passengers:[{id:"minecraft:pig",UUID:[I;28673,0,0,2],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b},{id:"minecraft:chicken",UUID:[I;28673,0,0,3],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b}]'), + ('minecart', 'Passengers:[{id:"minecraft:cow",UUID:[I;28673,0,0,4],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b}]'), + ('horse', 'Tame:1b,Passengers:[{id:"minecraft:skeleton",UUID:[I;28673,0,0,5],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b,Passengers:[{id:"minecraft:bat",UUID:[I;28673,0,0,6],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b}]}]'), ] diff --git a/tools/test_save_roundtrip_parity.py b/tools/test_save_roundtrip_parity.py index f32ff4cd99..3d00c02974 100644 --- a/tools/test_save_roundtrip_parity.py +++ b/tools/test_save_roundtrip_parity.py @@ -85,7 +85,7 @@ IGNORED = { r'^chunk\.block_entities\..*\.(ticks_since_song_started|Age|BrewTime|warning_level|ticks_in_hive|last_vibration_frequency|listener\..*|RecipesUsed\..*|' r'server_data\.state_updating_resumes_at)$': 'block entity timers advance', - r'^entity\.(Age|Motion\[\]|Rotation\[\]|Pos\[\]|OnGround|HurtTime|Health|TimeInOverworld|fall_distance|' + r'^entity\.(Passengers\[\]\.)*(Age|Motion\[\]|Rotation\[\]|Pos\[\]|OnGround|HurtTime|Health|TimeInOverworld|fall_distance|' r'wasOnGround|Fire|Air|DeathTime|Fuse|life|inGround|ticks_in_ground|Time)$': 'entity simulation advances', r'^level\.dat\.Data\.(Time|LastPlayed)$': 'clock and wall time of the save', r'^level\.dat\.Data\.(ServerBrands.*|singleplayer_uuid)$': 'a client world names its brand and its player', From 873ab776867878a343677fb071d6e50e363a1f7c Mon Sep 17 00:00:00 2001 From: TiggerTakkyTTT <212331760+TiggerTakkyTTT@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:25:45 +0700 Subject: [PATCH 34/37] Add live allowlist management settings Co-Authored-By: Codex (GPT-5.6) --- crates/horizon-client/src/game/dedicated.rs | 7 +++ .../src/game/dedicated/management.rs | 40 +++++++++++++ .../horizon-server/src/management_methods.rs | 58 +++++++++++++++++++ tools/port_map/rest-017.tsv | 4 +- tools/test_management_protocol_parity.py | 57 +++++++++++++++++- 5 files changed, 164 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index dbf966756d..a6c4ffe903 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -1780,6 +1780,13 @@ impl Game<'_> { } } + /// `DedicatedServer.setEnforceWhitelist`: the flag, and `enforce-whitelist` in + /// server.properties. + pub(super) fn set_enforce_whitelist(&mut self, lists: &Arc>, on: bool) { + lists.lock().unwrap().enforce_whitelist = on; + self.store_property("enforce-whitelist", on.to_string()); + } + /// DedicatedServerSettings.update: change one key and store the file. pub(super) fn store_property(&mut self, key: &str, value: String) { let path = PathBuf::from("server.properties"); diff --git a/crates/horizon-client/src/game/dedicated/management.rs b/crates/horizon-client/src/game/dedicated/management.rs index d0e20377e5..bda48060e1 100644 --- a/crates/horizon-client/src/game/dedicated/management.rs +++ b/crates/horizon-client/src/game/dedicated/management.rs @@ -105,6 +105,46 @@ impl Game<'_> { self.set_force_game_mode(force); return json!({ "value": state.force_game_mode }); } + "serversettings/use_allowlist" => { + return json!({ + "value": lists.lock().unwrap().use_whitelist + }); + } + "serversettings/use_allowlist/set" => { + let Some(use_allowlist) = body["value"].as_bool() else { + return json!({ "error": "using allowlist must be a boolean" }); + }; + let current = lists.lock().unwrap().use_whitelist; + rpc(format!( + "Update using allowlist from {} to {}", + current, use_allowlist + )); + self.set_using_whitelist(&lists, use_allowlist); + self.kick_unlisted_players(&lists); + return json!({ + "value": lists.lock().unwrap().use_whitelist + }); + } + "serversettings/enforce_allowlist" => { + return json!({ + "value": lists.lock().unwrap().enforce_whitelist + }); + } + "serversettings/enforce_allowlist/set" => { + let Some(enforce) = body["value"].as_bool() else { + return json!({ "error": "enforce allowlist must be a boolean" }); + }; + let current = lists.lock().unwrap().enforce_whitelist; + rpc(format!( + "Update enforce allowlist from {} to {}", + current, enforce + )); + self.set_enforce_whitelist(&lists, enforce); + self.kick_unlisted_players(&lists); + return json!({ + "value": lists.lock().unwrap().enforce_whitelist + }); + } "serversettings/difficulty" => { return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] }); } diff --git a/crates/horizon-server/src/management_methods.rs b/crates/horizon-server/src/management_methods.rs index 663f9e857a..843be221d0 100644 --- a/crates/horizon-server/src/management_methods.rs +++ b/crates/horizon-server/src/management_methods.rs @@ -68,6 +68,28 @@ pub fn call(shared: &Shared, client: u32, short: &str, argument: &Value) -> Resu .ok_or_else(|| invalid_params(format!("Not a boolean: {argument}")))?, ), ), + "serversettings/use_allowlist" => game.allowlist_setting(client, "use_allowlist", None), + "serversettings/use_allowlist/set" => game.allowlist_setting( + client, + "use_allowlist", + Some( + argument + .as_bool() + .ok_or_else(|| invalid_params(format!("Not a boolean: {argument}")))?, + ), + ), + "serversettings/enforce_allowlist" => { + game.allowlist_setting(client, "enforce_allowlist", None) + } + "serversettings/enforce_allowlist/set" => game.allowlist_setting( + client, + "enforce_allowlist", + Some( + argument + .as_bool() + .ok_or_else(|| invalid_params(format!("Not a boolean: {argument}")))?, + ), + ), "allowlist" => Ok(lists.allowlist()), "bans" => Ok(lists.bans()), "ip_bans" => Ok(lists.ip_bans()), @@ -299,6 +321,42 @@ struct Game<'a> { } impl Game<'_> { + /// `MinecraftServerSettingsServiceImpl.isUsingWhitelist`, `setUsingWhitelist`, + /// `isEnforceWhitelist` and `setEnforceWhitelist`. + fn allowlist_setting( + &self, + client: u32, + setting: &str, + value: Option, + ) -> Result { + let mut request = json!({ + "method": format!("serversettings/{setting}") + }); + if let Some(value) = value { + request["method"] = json!(format!("serversettings/{setting}/set")); + request["value"] = json!(value); + } + let reply = self.ask(&format!("management {client} {request}"))?; + if let Some(error) = reply["error"].as_str() { + return Err((INTERNAL_ERROR, Some(error.to_owned()))); + } + if let Some(value) = value { + let property = match setting { + "use_allowlist" => "whitelist", + "enforce_allowlist" => "enforce_whitelist", + _ => unreachable!("allowlist setting is validated by its callers"), + }; + crate::config::update( + &self.shared.dir, + "properties", + property, + toml::Value::Boolean(value), + ) + .map_err(|e| (INTERNAL_ERROR, Some(e)))?; + } + Ok(reply["value"].clone()) + } + /// `MinecraftServerSettingsServiceImpl.forceGameMode` and `setForceGameMode`. fn force_game_mode(&self, client: u32, value: Option) -> Result { let mut request = json!({ "method": "serversettings/force_game_mode" }); diff --git a/tools/port_map/rest-017.tsv b/tools/port_map/rest-017.tsv index cab6cefe22..806c054dcb 100644 --- a/tools/port_map/rest-017.tsv +++ b/tools/port_map/rest-017.tsv @@ -1,5 +1,5 @@ DedicatedServer.readCodeOfConducts port crates/horizon-client/src/lan/code_of_conduct.rs::read with enable-code-of-conduct the folder's files are read at startup (game/dedicated.rs::run_dedicated_inner) and handed to a joining player, whose client shows the prompt (game/code_of_conduct.rs) -DedicatedServer.setEnforceWhitelist skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API +DedicatedServer.setEnforceWhitelist port crates/horizon-client/src/game/dedicated.rs::Game::set_enforce_whitelist management stores enforce-whitelist and kicks unlisted players when enforcement is enabled DedicatedServer.setUsingWhitelist port crates/horizon-client/src/game/dedicated.rs::Game::console_whitelist console whitelist on|off; store_property writes white-list back DedicatedServer.saveAllChunks port crates/horizon-client/src/game.rs::Game::save_game gap: minor: no save notifications to the JSON-RPC management API DedicatedServer.sendLowDiskSpaceWarning port crates/horizon-client/src/lan/hub.rs::Hub::send_low_disk_space_warning chunk_io reports the low disk space and the operators are told (game/dedicated.rs); a client shows the toast (game/remote.rs) @@ -25,6 +25,8 @@ DedicatedServer.setEntityBroadcastRangePercentage skip:server JSON-RPC manageme DedicatedServer.createTextFilterForPlayer skip:service external chat filter service; Horizon has no text filter DedicatedServer.setForceGameMode port crates/horizon-client/src/game/dedicated/management.rs::Game::management stores force-gamemode, updates the host and applies the default mode to online players MinecraftServerSettingsServiceImpl.setForceGameMode port crates/horizon-server/src/management_methods.rs::Game::force_game_mode forwards the live setting to the game and persists it in server.toml +MinecraftServerSettingsServiceImpl.setUsingWhitelist port crates/horizon-server/src/management_methods.rs::Game::allowlist_setting forwards the live setting to the game and persists server.whitelist in server.toml +MinecraftServerSettingsServiceImpl.setEnforceWhitelist port crates/horizon-server/src/management_methods.rs::Game::allowlist_setting forwards the live setting to the game and persists server.enforce_whitelist in server.toml DedicatedServer.setGameMode port crates/horizon-client/src/game/lan.rs::Game::set_default_game_mode the /defaultgamemode command; gap: minor: the value is not written back to server.properties DedicatedServer.setAcceptsTransfers skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API DedicatedServer.setPauseWhenEmptySeconds skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API diff --git a/tools/test_management_protocol_parity.py b/tools/test_management_protocol_parity.py index 3b2303b3ab..9e19ef5619 100644 --- a/tools/test_management_protocol_parity.py +++ b/tools/test_management_protocol_parity.py @@ -16,7 +16,8 @@ sent the same things over TLS, and every answer is compared exactly: vanilla's data generator), `server/status`, the four lists, `players`, kicking nobody, `server/save` and `server/system_message` with no one online, the four lists changed (`add`, `remove`, `set`, `clear`; with no profile service, players named resolve to offline players on both), the live server - message, difficulty, default game mode and forced game mode read and changed (including saved properties), and + message, difficulty, default and forced game mode read and changed (including saved properties), and + allowlist use and enforcement read and changed (including saved properties and restart), and `server/stop`; - the TLS version each server agrees on. @@ -148,6 +149,24 @@ MESSAGES = [ '{"jsonrpc":"2.0","id":61,"method":"minecraft:serversettings/force_game_mode"}', '{"jsonrpc":"2.0","id":62,"method":"minecraft:serversettings/force_game_mode/set","params":["yes"]}', '{"jsonrpc":"2.0","id":63,"method":"minecraft:serversettings/force_game_mode"}', + '{"jsonrpc":"2.0","id":65,"method":"minecraft:serversettings/use_allowlist"}', + '{"jsonrpc":"2.0","id":66,"method":"minecraft:serversettings/use_allowlist/set","params":[true]}', + '{"jsonrpc":"2.0","id":67,"method":"minecraft:serversettings/use_allowlist"}', + '{"jsonrpc":"2.0","id":68,"method":"minecraft:serversettings/use_allowlist/set","params":[false]}', + '{"jsonrpc":"2.0","id":69,"method":"minecraft:serversettings/use_allowlist"}', + '{"jsonrpc":"2.0","id":70,"method":"minecraft:serversettings/use_allowlist/set","params":["yes"]}', + '{"jsonrpc":"2.0","id":71,"method":"minecraft:serversettings/use_allowlist"}', + '{"jsonrpc":"2.0","id":72,"method":"minecraft:serversettings/use_allowlist/set","params":[true]}', + '{"jsonrpc":"2.0","id":73,"method":"minecraft:serversettings/use_allowlist"}', + '{"jsonrpc":"2.0","id":74,"method":"minecraft:serversettings/enforce_allowlist"}', + '{"jsonrpc":"2.0","id":75,"method":"minecraft:serversettings/enforce_allowlist/set","params":[true]}', + '{"jsonrpc":"2.0","id":76,"method":"minecraft:serversettings/enforce_allowlist"}', + '{"jsonrpc":"2.0","id":77,"method":"minecraft:serversettings/enforce_allowlist/set","params":[false]}', + '{"jsonrpc":"2.0","id":78,"method":"minecraft:serversettings/enforce_allowlist"}', + '{"jsonrpc":"2.0","id":79,"method":"minecraft:serversettings/enforce_allowlist/set","params":["yes"]}', + '{"jsonrpc":"2.0","id":80,"method":"minecraft:serversettings/enforce_allowlist"}', + '{"jsonrpc":"2.0","id":81,"method":"minecraft:serversettings/enforce_allowlist/set","params":[true]}', + '{"jsonrpc":"2.0","id":82,"method":"minecraft:serversettings/enforce_allowlist"}', ] @@ -373,12 +392,22 @@ def main(): sides[side]['saved_force_game_mode'] = next( (line.partition('=')[2] for line in properties.splitlines() if line.startswith('force-gamemode=')), None) + sides[side]['saved_use_allowlist'] = next( + (line.partition('=')[2] for line in properties.splitlines() + if line.startswith('white-list=')), None) + sides[side]['saved_enforce_allowlist'] = next( + (line.partition('=')[2] for line in properties.splitlines() + if line.startswith('enforce-whitelist=')), None) if side == 'horizon': world = (folder / 'server.toml').read_text().partition('[world]')[2].split('\n[', 1)[0] sides[side]['saved_config_difficulty'] = 'difficulty = "peaceful"' in world sides[side]['saved_config_game_mode'] = 'game_mode = "spectator"' in world sides[side]['saved_config_force_game_mode'] = 'force-gamemode = true' in ( folder / 'server.toml').read_text() + sides[side]['saved_config_use_allowlist'] = 'whitelist = true' in ( + folder / 'server.toml').read_text() + sides[side]['saved_config_enforce_allowlist'] = 'enforce_whitelist = true' in ( + folder / 'server.toml').read_text() finally: server.stop() restarted = Server(command, folder, build / f'{side}-restart.log') @@ -392,6 +421,18 @@ def main(): if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply: break sides[side]['restarted_force_game_mode'] = reply + ws.send('{"jsonrpc":"2.0","id":83,"method":"minecraft:serversettings/use_allowlist"}') + while True: + reply = normal(ws.receive(timeout=60)) + if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply: + break + sides[side]['restarted_use_allowlist'] = reply + ws.send('{"jsonrpc":"2.0","id":84,"method":"minecraft:serversettings/enforce_allowlist"}') + while True: + reply = normal(ws.receive(timeout=60)) + if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply: + break + sides[side]['restarted_enforce_allowlist'] = reply finally: ws.sock.close() finally: @@ -416,9 +457,23 @@ def main(): failures.append(f"saved forced game mode: vanilla {vanilla['saved_force_game_mode']!r}, Horizon {horizon['saved_force_game_mode']!r}") if not horizon['saved_config_force_game_mode']: failures.append('Horizon did not save forced game mode in server.toml') + if vanilla['saved_use_allowlist'] != horizon['saved_use_allowlist'] or vanilla['saved_use_allowlist'] != 'true': + failures.append(f"saved allowlist use: vanilla {vanilla['saved_use_allowlist']!r}, Horizon {horizon['saved_use_allowlist']!r}") + if vanilla['saved_enforce_allowlist'] != horizon['saved_enforce_allowlist'] or vanilla['saved_enforce_allowlist'] != 'true': + failures.append(f"saved allowlist enforcement: vanilla {vanilla['saved_enforce_allowlist']!r}, Horizon {horizon['saved_enforce_allowlist']!r}") + if not horizon['saved_config_use_allowlist']: + failures.append('Horizon did not save allowlist use in server.toml') + if not horizon['saved_config_enforce_allowlist']: + failures.append('Horizon did not save allowlist enforcement in server.toml') if vanilla['restarted_force_game_mode'] != horizon['restarted_force_game_mode'] or \ vanilla['restarted_force_game_mode'] != {'jsonrpc': '2.0', 'id': 64, 'result': True}: failures.append(f"restarted forced game mode: vanilla {vanilla['restarted_force_game_mode']!r}, Horizon {horizon['restarted_force_game_mode']!r}") + if vanilla['restarted_use_allowlist'] != horizon['restarted_use_allowlist'] or \ + vanilla['restarted_use_allowlist'] != {'jsonrpc': '2.0', 'id': 83, 'result': True}: + failures.append(f"restarted allowlist use: vanilla {vanilla['restarted_use_allowlist']!r}, Horizon {horizon['restarted_use_allowlist']!r}") + if vanilla['restarted_enforce_allowlist'] != horizon['restarted_enforce_allowlist'] or \ + vanilla['restarted_enforce_allowlist'] != {'jsonrpc': '2.0', 'id': 84, 'result': True}: + failures.append(f"restarted allowlist enforcement: vanilla {vanilla['restarted_enforce_allowlist']!r}, Horizon {horizon['restarted_enforce_allowlist']!r}") (build / 'report.json').write_text(json.dumps(sides, indent=1)) if failures: sys.exit(f'{NAME}: {len(failures)} differences (logs in {build}):\n' + '\n'.join(failures)) From 5f656499f593d81c0fe5dc4ac19723f8cd662f62 Mon Sep 17 00:00:00 2001 From: DUzzL <98381970+DUzzL@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:29:56 +0100 Subject: [PATCH 35/37] Creative search: build the localized tooltip lines once per language --- crates/horizon-client/src/creative.rs | 60 +++++++++++++++++++++------ 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/creative.rs b/crates/horizon-client/src/creative.rs index d61f96a3bc..1e4472e512 100644 --- a/crates/horizon-client/src/creative.rs +++ b/crates/horizon-client/src/creative.rs @@ -91,6 +91,11 @@ pub struct Creative { /// ItemPickerMenu.items: what the grid shows, filled when the tab or the /// search changes (selectTab, refreshSearchResults), not on every frame. shown: Mutex>, + /// SessionSearchTrees.creativeByNameSearch: the lower-cased tooltip lines + /// of every search item in one language (keyed by the `itemGroup.search` + /// string), built on the first search and kept until the language or the + /// item list changes. + lines_cache: Mutex]>)>>, } /// The grid's stacks for one tab, search text and language. struct Shown { @@ -373,6 +378,7 @@ impl Creative { exported_search, permissions: false, shown: Mutex::new(None), + lines_cache: Mutex::new(None), }; creative.rebuild(); creative @@ -398,6 +404,7 @@ impl Creative { }) }) .collect(); + *self.lines_cache.lock().unwrap_or_else(|e| e.into_inner()) = None; (self.search_items, self.search_index) = self .exported_search .iter() @@ -533,13 +540,42 @@ impl Creative { pub fn search_results(&self, term: &str, names: &Names) -> Vec { let english = names.display_get("itemGroup.search").as_deref() == names.get("itemGroup.search"); - self.search_results_in(term, names, &|k| names.display_get(k), english) + let local = if term.is_empty() || term.starts_with('#') { + Arc::from([]) + } else if english && self.search_index.iter().all(|e| !e.lines.is_empty()) { + // The exported English lines are the whole index. + Arc::from([]) + } else { + let language = names.display_get("itemGroup.search").unwrap_or_default(); + let mut cache = self.lines_cache.lock().unwrap_or_else(|e| e.into_inner()); + match cache.as_ref().filter(|(l, _)| *l == language) { + Some((_, lines)) => lines.clone(), + None => { + let lines = self.localized_lines(&|k| names.display_get(k)); + *cache = Some((language, lines.clone())); + lines + } + } + }; + self.search_results_in(term, names, &local, english) + } + /// Every search item's tooltip lines through `translate`, lower-cased. + fn localized_lines(&self, translate: &dyn Fn(&str) -> Option) -> Arc<[Vec]> { + self.search_items + .iter() + .map(|s| { + crate::item_tooltip::lines(s, None, translate) + .iter() + .map(|l| java_lower(l)) + .collect() + }) + .collect() } fn search_results_in( &self, term: &str, names: &Names, - translate: &dyn Fn(&str) -> Option, + local: &[Vec], english: bool, ) -> Vec { let all = 0..self.search_index.len(); @@ -570,12 +606,6 @@ impl Creative { // effects, ...) is rebuilt through the language, as vanilla's // getTooltipLines does. let text = java_lower(term); - let localized = |i: usize| -> Vec { - crate::item_tooltip::lines(&self.search_items[i], None, translate) - .iter() - .map(|l| java_lower(l)) - .collect() - }; all.filter(|&i| { let e = &self.search_index[i]; let fallback; @@ -586,7 +616,7 @@ impl Creative { &e.lines }; let exported = english || e.lines.is_empty(); - let local = localized(i); + let local = local.get(i).map_or(&[][..], Vec::as_slice); let hit = |t: &str| { (exported && any(lines.iter().map(String::as_str), t)) || any(local.iter().map(String::as_str), t) @@ -1409,11 +1439,14 @@ mod tests { ("item.minecraft.wooden_hoe".to_owned(), "木锄".to_owned()), ]), }; - let q = |s: &str| c.search_results(s, &names); + let tr = |k: &str| names.get(k).map(str::to_owned); + let local = c.localized_lines(&tr); + let q = |s: &str| c.search_results_in(s, &names, &local, false); assert_eq!(q("铁砧"), [0]); assert_eq!(q("木锄"), [2]); - // The English tooltip lines the export indexed still match. - assert_eq!(q("anvil"), [0]); + // Only the selected language's lines count outside English. + assert!(q("anvil").is_empty()); + assert_eq!(c.search_results_in("anvil", &names, &local, true), [0]); // A colon's path searches the display name too. assert_eq!(q("minecraft:铁砧"), [0]); } @@ -1426,7 +1459,8 @@ mod tests { let names = Names::default(); let ru: HashMap<&str, &str> = HashMap::from([("item.minecraft.anvil", "Наковальня")]); let tr = |k: &str| ru.get(k).map(|v| (*v).to_owned()); - let q = |s: &str, english| c.search_results_in(s, &names, &tr, english); + let local = c.localized_lines(&tr); + let q = |s: &str, english| c.search_results_in(s, &names, &local, english); assert_eq!(q("наков", false), [0]); assert!(q("anvil", false).is_empty()); assert_eq!(q("anvil", true), [0]); From e8e813638a68cfd9ba8e8af7ebe899f79fd47192 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:39:25 +0100 Subject: [PATCH 36/37] Inline the spawn egg swing helper spawn_egg_use_on_swings returned its argument, so its test only checked that identity. The branch now reads spawn_egg_allowed directly. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/remote.rs | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index dd4eb171e0..7c8db526fe 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -7371,7 +7371,7 @@ impl Game<'_> { // Either way no UseItem follows, so the server does not spawn // a second mob from the fluid ray (ItemStack.useOn passes in // adventure mode off can_place_on, which the filter above keeps). - if spawn_egg_use_on_swings(self.spawn_egg_allowed(&stack.item)) { + if self.spawn_egg_allowed(&stack.item) { self.with_hand(hand, Game::swing_arm); } return true; @@ -9988,12 +9988,6 @@ fn keep_client_state(old: Option<&BlockEntity>, new: &mut BlockEntity) { } } -/// `SpawnEggItem.useOn` on a client level: FAIL (no swing) when the mob cannot -/// spawn, SUCCESS (swing) otherwise. Neither lets `UseItem` follow. -fn spawn_egg_use_on_swings(can_spawn: bool) -> bool { - can_spawn -} - /// `LocalPlayer.onSyncedDataUpdated(DATA_LIVING_ENTITY_FLAGS)`: the server's /// flags (1 using, 2 the off hand) start a use the client is not running /// (`startUsingItem`, which needs an item in that hand), and stop one it is @@ -10245,7 +10239,7 @@ fn client_millis() -> u64 { #[cfg(test)] mod tests { - use super::{keep_client_state, remote_use_swings, spawn_egg_use_on_swings, synced_local_use}; + use super::{keep_client_state, remote_use_swings, synced_local_use}; use horizon_client::{ containers::{Beacon, BlockEntity}, items::ItemStack, @@ -10290,14 +10284,6 @@ mod tests { assert!(!remote_use_swings(&stack("minecraft:apple"), true)); } - /// A spawn egg used on a block ends the click: swinging when the mob can - /// spawn (SUCCESS), silent when it cannot (FAIL). - #[test] - fn a_spawn_egg_on_a_block_swings_only_for_a_mob_that_can_spawn() { - assert!(spawn_egg_use_on_swings(true)); - assert!(!spawn_egg_use_on_swings(false)); - } - #[test] fn a_beacon_update_tag_keeps_the_beam_the_client_walked() { let old = BlockEntity::Beacon(Box::new(Beacon { From cf1ead6ba981b6f0e1a5b44d4a2a9bd64fb9038e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:52:48 +0100 Subject: [PATCH 37/37] Let particles in open air skip the collision resolution Every moving particle with physics ran the full entity movement resolution per tick (Entity.collideBoundingBox: a BlockCollisionScan iterator, the step machinery, its shape vectors), even in open air. SceneCollisions now tells first, from the same cells, blocks and section flags the scan reads, that no shape can be found: no block that can collide in the inner cells, no block reaching out of its cell in the outer cells' sections. The resolution then returns the movement unchanged, so the particle gets it directly. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client-render/src/collisions.rs | 175 +++++++++++++++++- crates/horizon-client-render/src/particles.rs | 9 +- crates/horizon-world/src/block_collision.rs | 7 + 3 files changed, 183 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client-render/src/collisions.rs b/crates/horizon-client-render/src/collisions.rs index 66e40140cd..96b2006724 100644 --- a/crates/horizon-client-render/src/collisions.rs +++ b/crates/horizon-client-render/src/collisions.rs @@ -326,6 +326,88 @@ where ) .unwrap_or(false) } +impl SceneCollisions<'_, '_> { + /// Whether a `BlockCollisionScan` of `bounds` would find no shape, told without + /// building one: no block that can collide in its inner cells, and no block that + /// reaches out of its cell in the sections of its outer ones (where unsure, false: + /// the scan decides). The same cells, blocks and section flags the scan reads. + pub fn cells_without_collision(&self, bounds: Aabb) -> bool { + let (min, max) = (bounds.min_position(), bounds.max_position()); + if !min.is_finite() || !max.is_finite() { + return false; + } + // The scan's cells: one past the box on every side (`block_collision_scan::cells`). + let lo = BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7)) + .offset(-1, -1, -1); + let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7)) + .offset(1, 1, 1); + let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1; + let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z); + if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 { + return false; + } + let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None; + let mut section: Option<((i32, i32), i32, bool)> = None; + for z in lo.z..=hi.z { + for y in lo.y..=hi.y { + for x in lo.x..=hi.x { + let outer = [ + x == lo.x || x == hi.x, + y == lo.y || y == hi.y, + z == lo.z || z == hi.z, + ]; + let kind = outer.into_iter().filter(|&o| o).count(); + if kind == 3 { + continue; + } + let at = (x >> 4, z >> 4); + let chunk = match column { + Some((c, chunk)) if c == at => chunk, + _ => { + let chunk = self.world.chunks.get(&at).map(|c| &**c); + column = Some((at, chunk)); + chunk + } + }; + // The scan passes the cells of a column that is not resident. + let Some(chunk) = chunk else { + continue; + }; + if kind != 0 { + let reaches = match section { + Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches, + _ => { + let reaches = self.section_reaches_out(chunk, y >> 4); + section = Some((at, y >> 4, reaches)); + reaches + } + }; + if reaches { + return false; + } + continue; + } + if self.binding.may_collide(chunk.block(x, y, z)) { + return false; + } + } + } + } + true + } + + /// `may_reach_out`: whether a section's palette holds a block that reaches out of + /// its cell; a global palette, or a section outside the column, may. + fn section_reaches_out(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool { + let blocks = usize::try_from(section_y - (chunk.min_y() >> 4)) + .ok() + .and_then(|s| chunk.sections.get(s)) + .map(|section| §ion.blocks); + blocks.is_none_or(|blocks| { + blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v))) + }) + } +} impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> { type Error = io::Error; /// The resident column, looked up once per chunk the scan enters. @@ -339,13 +421,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> { /// From the section's palette, without reading its blocks; a palette /// of the whole registry reads every cell. fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result { - let blocks = usize::try_from(section_y - (chunk.min_y() >> 4)) - .ok() - .and_then(|s| chunk.sections.get(s)) - .map(|section| §ion.blocks); - Ok(blocks.is_none_or(|blocks| { - blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v))) - })) + Ok(self.section_reaches_out(chunk, section_y)) } fn is_suffocating( &mut self, @@ -818,3 +894,88 @@ mod shulker_collision_tests { assert!((delta2[4] - 2.0).abs() < 1.0e-9); } } + +#[cfg(test)] +mod section_tests { + use super::*; + + /// Boxes swept through air, past torches (no collision shape), a fence (a shape + /// reaching into the cell above) and the scene's stone: wherever the cells say no + /// block can collide, the full resolution returns the movement, to the bit; the rest + /// is left to it. Needs the prepared resources. + #[test] + fn cells_without_collision_leave_the_movement_as_resolving_does() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("cached-collision.nbt").exists() { + eprintln!("no prepared resources: skipped"); + return; + } + let assets = crate::assets::Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let binding = load(&root, assets.registry).unwrap(); + let mut world = World::test_scene(&assets, &render, 3).unwrap(); + let state = |name: &str| assets.registry.default_state(name).unwrap(); + // Torches, and a fence at the bottom of a section above air. + for x in 17..30 { + world.set(x, 20, 20, state("minecraft:torch")).unwrap(); + } + world.set(5, 32, 5, state("minecraft:oak_fence")).unwrap(); + let scene = SceneCollisions { + world: &world, + binding: &binding, + subject: None, + }; + // Under the fence: its section's outer cells may hold a shape reaching in. + let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6)); + assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0)))); + // A torch's cell, and the air just over the scene's stone floor (y 0..4). + let torches = Aabb::new(Vec3::new(20.4, 20.1, 20.4), Vec3::new(20.6, 20.3, 20.6)); + assert!(scene.cells_without_collision(torches)); + let over_floor = Aabb::new(Vec3::new(30.4, 5.2, 30.4), Vec3::new(30.6, 5.4, 30.6)); + assert!(scene.cells_without_collision(over_floor)); + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as f64 / (1u64 << 53) as f64 + }; + let (mut skipped, mut resolved) = (0, 0); + for i in 0..4000 { + let size = if i % 2 == 0 { 0.2 } else { 0.6 }; + // Near the floor (y 0..4) and the stepped pyramid (y 4..11) for most, higher for the rest. + let height = if i % 4 == 0 { 40.0 } else { 14.0 }; + let at = Vec3::new(next() * 80.0 - 40.0, next() * height, next() * 80.0 - 40.0); + let bb = Aabb::new(at, Vec3::new(at.x + size, at.y + size, at.z + size)); + let reach = if i % 3 == 0 { 3.0 } else { 0.3 }; + let d = Vec3::new( + (next() - 0.5) * reach, + (next() - 0.5) * reach, + (next() - 0.5) * reach, + ); + let fast = scene.cells_without_collision(bb.expand_towards(d)); + let mut full = SceneCollisions { + world: &world, + binding: &binding, + subject: None, + }; + let moved = full.resolve(bb, d, 0.0, false).unwrap(); + if fast { + skipped += 1; + assert_eq!( + moved.components().map(f64::to_bits), + d.components().map(f64::to_bits), + "{bb:?} by {d:?}" + ); + } else { + resolved += 1; + } + } + assert!( + skipped > 500 && resolved > 500, + "{skipped} skipped, {resolved} resolved" + ); + } +} diff --git a/crates/horizon-client-render/src/particles.rs b/crates/horizon-client-render/src/particles.rs index 6e02419fd4..404d60f771 100644 --- a/crates/horizon-client-render/src/particles.rs +++ b/crates/horizon-client-render/src/particles.rs @@ -996,7 +996,14 @@ impl ParticleLevel<'_> { binding: self.collision, subject: None, }; - match scene.resolve(bb, Vec3::new(d[0], d[1], d[2]), 0.0, false) { + let movement = Vec3::new(d[0], d[1], d[2]); + // `Entity.collideBoundingBox(null, ...)`: no entity shapes, no border. Where no + // block of the swept box's cells can collide, it finds no shape and the + // movement stands, as the resolution below would return it. + if scene.cells_without_collision(bb.expand_towards(movement)) { + return d; + } + match scene.resolve(bb, movement, 0.0, false) { Ok(v) => [v.x, v.y, v.z], Err(_) => d, } diff --git a/crates/horizon-world/src/block_collision.rs b/crates/horizon-world/src/block_collision.rs index 9e2d81b788..797d6c2497 100644 --- a/crates/horizon-world/src/block_collision.rs +++ b/crates/horizon-world/src/block_collision.rs @@ -158,6 +158,13 @@ impl<'r> BlockCollisionRegistry<'r> { empty_shape: flags & 4 != 0, }) } + /// Whether `state` can have a collision shape: false for states whose shape is always + /// empty, whatever the context. States outside the registry count. + pub fn may_collide(&self, state: BlockStateId) -> bool { + self.scan_flags + .get(state.0 as usize) + .is_none_or(|flags| flags & 4 == 0) + } /// Whether a scan keeps `state` in the cells around its box: a large /// collision shape or a moving piston. States outside the registry count. pub fn reaches_out(&self, state: BlockStateId) -> bool {