Compare commits

...
Author SHA1 Message Date
levzzz af5528ef25 Merge pull request #4580 from levzzz5154/launcher/mods-from-github-ui
Mods page: Add from GitHub, origin line and Check for updates
2026-10-30 23:44:33 +05:00
levzzz f4c1010c64 Merge pull request #4489 from ludovicthenot/fix-zoom-keybind-label
Options > Mods: a test that a native mod's key shows and keeps the key pressed
2026-10-30 23:42:43 +05:00
levzzz edbeffbcf3 Merge pull request #4302 from ludovicthenot/native-use-item-hook
Native mods: hook points for using an item (API 5)
2026-10-30 23:40:44 +05:00
levzzzandClaude Sonnet 5.5 8a2e5cb55f Mods page: Add from GitHub, origin line and Check for updates
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:36:23 +05:00
levzzz 1c7eb12800 Merge pull request #4573 from levzzz5154/launcher/mods-from-github
Launcher: install a native mod from a GitHub link (CLI)
2026-10-30 23:36:17 +05:00
levzzz 02c48952b8 Merge pull request #4575 from levzzz5154/registry/owner-updates
Registry form: sign an owner's update so horizon-mods can merge it without a reviewer
2026-10-30 23:36:13 +05:00
levzzz 0ddaf69abc Merge remote-tracking branch 'hr/main' into fix/pr-4489 2026-10-30 23:30:09 +05:00
levzzzandClaude Sonnet 5.5 0e060bb2ac Merge main: use-item hooks are API 13
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:29:33 +05:00
levzzzandClaude Sonnet 5.5 754440dd55 Registry form: sign an owner's update so horizon-mods can merge it without a reviewer
An owner's new version with nothing else of the entry changed gets an HMAC
trailer (REGISTRY_UPDATE_KEY) in the pull request body and the label
owner-update. Off when the secret is unset.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:28:00 +05:00
levzzz cd79f2e326 Merge pull request #4284 from ludovicthenot/native-tooltip-hook
Native mods: a hook point for item tooltips (API 4)
2026-10-30 23:27:32 +05:00
levzzzandClaude Sonnet 5.5 b4ad459449 Launcher: install a native mod from a GitHub link (mods add-github, update-github)
Resolves a repo, release, tag, ref or commit link through GitHub's public API to an
exact release .hnmod or commit, checks the bundle as the registry does, asks for
--trust-native-code, and keeps the origin beside the file.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:26:38 +05:00
Ludovic Marie a3726f4bf8 Merge pull request #4547 from horizon-rust/feat/horizon-clients-only
🤖 Agent: verified on the Mac mini at 7a4f7d5: tools/server_horizon_only_e2e.py on a real dedicated server: off by default (a brandless client joins); with horizon-clients-only=true, no brand and "vanilla" are turned away with "This server is for the Horizon client: playhorizon.dev" and a console line, "horizon" joins. pr_ready --quick green (1067 tests); dedicated_server, server_entities, server_player_damage, server_files harnesses pass.
2026-10-30 19:22:05 +01:00
levzzz d17163d417 Merge pull request #4401 from levzzz5154/launcher/native-pin-mismatch
Launcher: build native mods made for different Horizon versions, after "Build anyway"
2026-10-30 23:19:17 +05:00
Ludovic Marie a83ac6ac1c Merge pull request #4553 from horizon-rust/feat/server-perf-report
🤖 Agent: requeued: main merged in (main had dropped the two native_lifecycle source-shape tests, so main's file is taken), docs/api/server-reference.md regenerated for the new command; horizon-server tests 53/53, report and dedicated tests pass; end to end verified earlier (init/start/report 10/stop).
2026-10-30 19:16:55 +01:00
levzzz 701168f1e5 Merge pull request #4560 from levzzz5154/docs/no-wasm-docs
Docs: remove the old WebAssembly mod system
2026-10-30 23:16:14 +05:00
levzzzandClaude Sonnet 5.5 15a18c3a5e Merge main: tooltip hook is API 12
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:14:14 +05:00
levzzz 7af000900b Merge pull request #4390 from arismoko/native-vanilla-particles
Native mods: send vanilla particles from server code
2026-10-30 23:12:34 +05:00
levzzzandClaude Sonnet 5.5 f7ce36c8e1 Merge main into native-pin-mismatch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:09:14 +05:00
levzzz f6154f9ebb Merge pull request #3877 from arismoko/content-block-shapes
Content mods: set per-state outline and collision shapes
2026-10-30 23:07:31 +05:00
levzzzandClaude Sonnet 5.5 f5eb5a18f3 Docs: remove the old WebAssembly mod system from the docs and roadmap
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:07:28 +05:00
Ludovic MarieandClaude Opus 5.5 71e6b60d96 docs/api/server-reference.md: the report command
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:05:57 +01:00
Ludovic Marie f533c3af7d Merge pull request #4531 from horizon-rust/fix/server-reach
🤖 Agent: verified on the Mac mini at d39eb5d: tools/server_reach_e2e.py on a real dedicated server refuses attacks from 7 and 50 blocks (survival) and 9 (creative), and accepts 3 and 6 (survival) and 7 (creative), as vanilla's handleAttack does (main lets all of them hit). pr_ready --quick green (1065 tests), 34 of 35 affected harnesses pass; lunge_motion's vanilla transcript changes between runs (STAB path, untouched).
2026-10-30 19:05:38 +01:00
Ludovic Marie 25e03754e2 Merge main into feat/server-perf-report
# Conflicts:
#	crates/horizon-client/src/game/native_lifecycle.rs
2026-10-30 19:03:27 +01:00
levzzzandClaude Sonnet 5.5 00c9b04674 Native particles: API 11
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:00:46 +05:00
levzzz 20703366f9 Merge remote-tracking branch 'hr/main' into fix/pr-4390 2026-10-30 22:59:06 +05:00
levzzzandClaude Sonnet 5.5 d7cb3537ea Merge main into content-block-shapes: keep shapes and default_state
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 22:55:44 +05:00
levzzz 59e71f00ef Merge pull request #3846 from arismoko/content-default-states
Content mods: allow a block default state override
2026-10-30 22:50:58 +05:00
levzzz fcf76afc05 Merge pull request #4485 from arismoko/native-lifecycle-behavior-cleanup
Native mods: drop source-text lifecycle tests
2026-10-30 22:50:42 +05:00
levzzz b12a499a9a Merge pull request #4412 from levzzz5154/native/server-tick-at-start
Native mods: server tick Start before any level; one tick-clock step per server tick
2026-10-30 22:50:26 +05:00
Ludovic MarieandClaude Opus 5.5 59526900a0 native_lifecycle test: find the server tick in the report's match
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:47:29 +01:00
arismoko 4783b8d552 Native mods: send vanilla particles from server code 2026-10-30 12:40:06 -05:00
Ludovic Marie ab6851d5a8 Merge pull request #4494 from horizon-rust/feat/smp-design
🤖 Agent: docs only (docs/smp.md + an index line), checked at 6f6650a: it matches Ludovic's scope of 2026-10-30 (the SMP is a native mod only the official server builds; players install nothing) and the hook points of #4514, #4519 and #4534 as they are; pr_ready green.
2026-10-30 18:37:47 +01:00
Ludovic Marie a03ce15c4e Merge pull request #4534 from horizon-rust/feat/smp-server-menus
🤖 Agent: verified at e23b4b7d8 (stacked on #4519): pr_ready green (1072 horizon-client tests, clippy); dedicated_server and server_entities pass; the SMP mod's /trade on a real dedicated server with two real clients clicking the server's chest menu: closing, the cancel button, death (keep_inventory on) and leaving moved nothing; a full inventory was refused with nothing moved; a confirmed trade swapped 10 diamonds for 5 emeralds and 35 coins, as counted on both clients.
2026-10-30 18:35:39 +01:00
Ludovic Marie d60c8d4e0f Merge pull request #4519 from horizon-rust/feat/smp-item-helpers
🤖 Agent: verified at 396f45655 (stacked on #4514): pr_ready green (1069 horizon-client tests, clippy); dedicated_server and server_entities pass on #4534's head, which holds this one; with the SMP mod's market, a real dedicated server and two real clients ran /sell wheat, /sell hand and /buy torch 5, and the buyer's own client counted 0 diamond, 0 wheat and 5 torch; a creative player was refused.
2026-10-30 18:35:34 +01:00
Ludovic Marie 152a0db8c3 Merge pull request #4514 from horizon-rust/feat/smp-native-hooks
🤖 Agent: verified at 768d20d: pr_ready green (1067 horizon-client tests, clippy -D warnings); tools/parity.py dedicated_server server_entities pass (on #4534's head, which holds this one); with no mod the tree stays Null and the gate is a hash lookup; with the SMP mod, a real dedicated server and two real clients ran /balance, /pay, /baltop and /eco (operator-only, hidden from others), the files and audit log matched, and a restart kept the balances.
2026-10-30 18:35:30 +01:00
Ludovic Marie b4a7018864 Merge pull request #3720 from Domanffe/fix/launcher-remove-queue
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. Proven on ccb85be48 merged with current main, on macOS: - `pr_ready --quick` passes (launcher rustfmt, clippy, tests). - Concurrent removals through the launcher CLI in a scratch home: three versions of 20,000 files each removed at once, with `versions --installed` polled 143 times meanwhile. None of the three is listed once its removal has started. On main, each stays listed for ~0.46 s of a 1.5 s delete, half-deleted, which is the window your marker-first change closes. - The window's gate (`removable`) is covered by the new test. Queued for the merger.
2026-10-30 18:35:25 +01:00
Ludovic Marie c7e33a9163 Merge pull request #4535 from horizon-rust/perf/creaking-heart-snapshot
🤖 Agent: verified: 3 interleaved A/B rounds on playhorizon (60 bots) cut tick_block_entities ~5.7 to ~2.7 ms/tick, MSPT avg -3.5 to -9 ms; behaviour unchanged (the snapshot is only skipped on ticks that read no mob); pr_ready --quick green.
2026-10-30 18:35:21 +01:00
Ludovic MarieandClaude Opus 5.5 7a4f7d5c99 Dedicated server: horizon-clients-only lets only the Horizon client join
Off by default. With horizon-clients-only=true in server.properties (server.toml's [properties]), a
client whose minecraft:brand isn't "horizon" (vanilla's client says "vanilla"; one that names none
counts as vanilla, as on vanilla's server) is disconnected during configuration with "This server is
for the Horizon client: playhorizon.dev", and the console logs which client it was. The brand can be
spoofed: this is a gate for players who didn't install Horizon, not security.

horizon-bots gets --brand and prints a configuration disconnect's reason;
tools/server_horizon_only_e2e.py checks both settings on a real dedicated server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:32:25 +01:00
rdbt c9f49f3b54 Merge pull request #4525 from doodlyyousuf/fix/item-honey-slide
Slide dropped items down the sides of honey blocks
2026-10-30 17:24:22 +00:00
rdbt 8e93b54a41 Merge pull request #4368 from xFedeT/fix/sulfur-cube-redstone
Explosive sulfur cubes light their fuse beside redstone power
2026-10-30 17:22:44 +00:00
rdbt 9e50f5618c Merge pull request #4340 from xFedeT/fix/smithing-cycling-icons
Smithing table: the empty slots cycle their icons, as CyclingSlotBackground does
2026-10-30 17:22:33 +00:00
rdbt ce5086e2d3 Merge pull request #4403 from Ashura006/display-box-culling
Cull display entities with the width/height frustum box
2026-10-30 17:22:22 +00:00
rdbt 9ab3cee2e8 Merge pull request #4373 from iLostName/join-stall-cheaper
joining a world: the player model and command registries are read once, not at every join
2026-10-30 17:21:40 +00:00
rdbt ff8bd6a91f Merge pull request #4362 from DUzzL/fix/issue-4356-piglin-crossbow
Let a piglin holding a crossbow shoot instead of melee
2026-10-30 17:21:29 +00:00
rdbt 9c825e67df Merge pull request #4324 from v3nn7/investigate/farmland-shadow
Project entity shadows onto partial block bounds
2026-10-30 17:21:11 +00:00
rdbt 00448b2ea0 Merge pull request #4322 from DUzzL/fix/issue-4317-slider-hover-outline
Keep the slider track outline off on hover
2026-10-30 17:21:00 +00:00
rdbt 81410769f5 Merge pull request #4372 from iLostName/namespaced-item-animations
item sprites: another namespace's animated texture packs its first frame and animates
2026-10-30 17:20:50 +00:00
rdbt 1df5237769 Merge pull request #4352 from xFedeT/fix/loot-set-instrument
Loot: a goat horn from a loot table gets its instrument (set_instrument)
2026-10-30 17:20:39 +00:00
rdbt 9ca78f1309 Merge pull request #4346 from xFedeT/fix/port-map-ominous-stew-crossbow-frames
Port map: four rows of #3515 already ported; item frames keep to the build height
2026-10-30 17:20:28 +00:00
rdbt e37351ed75 Merge pull request #4395 from DUzzL/fix/issue-4388-mcmeta-bom
Accept a UTF-8 byte-order mark in server pack.mcmeta
2026-10-30 17:20:17 +00:00
Ludovic Marie 0d6db579ec Merge pull request #4522 from horizon-rust/fix/crash-head-server
🤖 Agent: verified: new crash test passes, pr_ready --quick green. A dedicated server's crash report says Server thread and drops the client's render settings.
2026-10-30 18:19:56 +01:00
Ludovic Marie e749114173 Merge main into feat/server-perf-report 2026-10-30 18:19:55 +01:00
Ludovic MarieandClaude Opus 5.5 05bc171f13 horizon-server report: a performance report to share (horizon-report on the console)
`horizon-server report [SECONDS]` (or `horizon-report` on the console)
records every tick for 60 s by default, up to 600, then writes
reports/perf-<time>.json and a short .txt beside it, and prints the
summary. The report holds:
- TPS and MSPT (average, p50/p95/p99, max, ticks over 50 and 100 ms) and
  CPU time per tick;
- each span's share of the tick, and the 10 worst ticks with their spans;
- entities by type and chunks per level, players online;
- memory, CPU, the machine, view and simulation distance, max players.
No names, UUIDs, addresses, seed or world name, so a server owner can
attach it to an issue as it is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:19:55 +01:00
rdbt b7d754a60a Merge pull request #4381 from DUzzL/fix/issue-4370-golem-chest-lid-guests
Send the copper golem chest lid event to LAN guests
2026-10-30 17:19:49 +00:00
rdbt 1c8532cbe1 Merge pull request #4323 from v3nn7/fix/title-debug-overlay
Keep debug overlay off the title screen
2026-10-30 17:19:44 +00:00
Muhammad Asif ed6d97cbbb Slide dropped items down the sides of honey blocks
HoneyBlock.entityInside already slides players and mobs. Dropped items
never ran it, so they fell at full speed beside honey. ItemEntity.tick
now applies isSlidingDown / doSlideMovement after the move, before
friction, without slide sounds (doesEntityDoHoneyBlockSlideEffects).

Fixes #4513
2026-10-30 22:13:52 +05:00
Ludovic Marie d52d37f944 Merge main into perf/creaking-heart-snapshot 2026-10-30 18:13:38 +01:00
Ludovic MarieandClaude Opus 5.5 d39eb5d638 Server: vanilla's reach on attacks and entity interactions, and blocks by attribute
A client could attack any player or mob from any distance: the server path (LAN host, the integrated
server thread, dedicated servers) never checked ServerboundAttackPacket's range, so a hacked client
hit people 50 blocks away. Now, as ServerGamePacketListenerImpl does:

- handleAttack: Player.isWithinAttackRange(mainHandItem, targetBox, 3.0), the held item's
  attack_range or AttackRange.defaultFor (0 to the entity interaction range), for players, mobs,
  vehicles, dragon parts and projectiles alike; out of range the attack is dropped silently;
- handleInteract: Player.isWithinEntityInteractionRange(targetBox, 3.0);
- digging and handleUseItemOn read the guest's block_interaction_range attribute instead of a fixed
  4.5 (5 in creative), so /attribute and items count, and handleUseItemOn logs vanilla's
  "Rejecting UseItemOnPacket ... too far away from hit block" line.

The ranges are the player's attributes: creative's modifiers (+2, +0.5) are on them every tick.
horizon-bots gets a stand behaviour, --attack NAME and the victim's health in its report;
tools/server_reach_e2e.py proves it on a real dedicated server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:12:00 +01:00
Ludovic Marie 91352551d6 Merge pull request #4517 from horizon-rust/fix/region-cache-limit
🤖 Agent: verified: unit test fails on main (601 descriptors) and passes here; bots repro shows region fds growing without bound on main; pr_ready --quick green. Fixes the official server's 'Too many open files' crashes.
2026-10-30 18:11:11 +01:00
Ludovic MarieandClaude Opus 5.5 848c0b10d4 Crash reports: a dedicated server's head says Server thread, without render settings
On a dedicated server, the main thread's report said "Thread: Render thread"
and listed the render settings it had read from options.txt (the official
server's EMFILE crash reports did). The main thread is now named for its
side, as vanilla's reports name it, and a server's report leaves out the
render settings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:08:19 +01:00
Ludovic MarieandClaude Opus 5.5 e23b4b7d8f Native mods: server menus and an atomic item exchange (API 10)
A server mod opens a vanilla chest menu (generic_9x1..9x6) for a player and
fills it with items to look at. No click moves anything: clicks and the
menu's closing (by the player, another menu, leaving) reach the mod through
native_menu_clicked and native_menu_closed, run with no player swapped in.
native_exchange_items swaps two players' offered stacks in one step, each
checked to still be exactly as offered and to fit whole on the other side,
or moves nothing. A stock client shows it all.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:04:25 +01:00
Ludovic MarieandClaude Opus 5.5 396f456556 Native mods: the items test follows getFreeSlot's empty off hand
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:03:53 +01:00
Ludovic MarieandClaude Opus 5.5 e0cb05bfce Save: keep at most 256 region files open, like vanilla
The region cache in save.rs opened every region, entities and poi file a
dimension ever touched and never closed one. A server whose players travel
(teleports, elytra, exploration) runs out of file descriptors: the four
crashes of the official server on 2026-10-30 (v0.2.97) were all
"Too many open files (os error 24)" from horizon-region-writer, then a
failed save and a fatal tick error.

Cap the cache at RegionFileStorage.MAX_CACHE_SIZE (256): when full, drop
the least recently used file no caller still holds. Also raise the soft
open-file limit to the hard one at startup, as the JVM does with
-XX:+MaxFDLimit, so sockets and other files keep their headroom.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:58:29 +01:00
Ludovic MarieandClaude Opus 5.5 6f6650a68f docs/smp.md: players install nothing; trades move nothing until the swap; config.json
The SMP uses only what a stock Horizon client renders (commands, vanilla
chest menus, sounds, titles, action bar, boss bars, sidebar). The trade
menu is display only and swaps both offers atomically, so no way out has
items to return. The config is config.json, as the mod writes it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:52:33 +01:00
Ludovic MarieandClaude Opus 5.5 768d20d998 Native mods: a server player's game mode
A shop must refuse a player in creative mode, who can make any item.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:39:10 +01:00
Ludovic MarieandClaude Opus 5.5 b070f41dda Native mods: the acting player's plain items for server mods (API 9)
native_held, native_count_items, native_carried_items, native_take_items,
native_room_for and native_give_items: what a server shop needs, on the
36 inventory slots and the off hand, plain stacks only (no damage, no
component), in Inventory.add's slot order, and give only where it fits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:39:10 +01:00
Ludovic Marie 8f376a4712 Merge pull request #4490 from horizon-rust/feat/launcher-night-mods
🤖 Agent: Launcher redesign R7b (the instance's Mods tab), approved by Ludovic after the screenshots. main merged in; full pr_ready green, verify and launcher CI green on this head. The only comment is the overlap bot's note on #4478 (different change; not landed).
2026-10-30 17:39:06 +01:00
Ludovic Marie 63db88b844 Merge remote-tracking branch 'origin/main' into feat/launcher-night-mods 2026-10-30 17:29:45 +01:00
Ludovic MarieandClaude Opus 5.5 076c41fc27 Native mods: server commands and players (API 8)
native_register_commands: the commands a native mod adds to every player's
command tree (usages such as `pay <player:player> <amount:integer>`,
optional arguments last, an operator level each), merged into one tree the
hub sends after vanilla's, filtered by level. native_perform_command: a
player's command on the server for a registered name, inside their swap,
whether the world allows commands or not. Helpers: the players on the
server, a line to one or to all, the command's feedback, the world's
folder. With no mod hooking them, the tree is empty and nothing changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:29:20 +01:00
Ludovic MarieandClaude Opus 5.5 3f1a45313e docs/smp.md: the SMP is a native mod only the official server builds
Horizon's own server stays plain: no SMP command, setting or code in the
release. The mod lives in horizon-mods (native/smp); Horizon gains general
server hook points (commands, players, items, menus, interactions) that
any native mod can use. Config and data move to world/horizon-smp/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:22:55 +01:00
Ludovic MarieandClaude Opus 5.5 9d0473d748 docs/smp.md: the SMP layer's design (economy, trades, gambling, organisation)
Built in behind [smp] in server.toml, off by default: why not a server mod
or a datapack, the commands, the configuration, the data files under
world/horizon-smp/, the cost and the phases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:22:18 +01:00
Ludovic Marie f6a757ac2e Merge pull request #3491 from ThatCakeIsAPie/feat/datapack-biomes-1
🤖 Agent: verified on main + this head. With the level-shape stack merged, this PR is now one file (biome.rs, +242/−68): the biome-table piece levzzz5154 asked for first on #3492. I merged main in, and main's biome features (#4211) are now read in BiomeInfo::from_json, where this PR parses a biome file. Checks: 110 of the 111 harnesses this change can affect pass. lunge_motion fails on main itself since #4297. dedicated_server, datapack_worldgen, server_world_gen, pr_ready and lan_e2e (worldspawn, light, levels, distance, portals, chest, forced-levels) also pass. Biome files are parsed only when a world loads. Queued to merge.
2026-10-30 17:18:57 +01:00
Neferr ca9195fb02 Options > Mods: a test that a native mod's key shows and keeps the key pressed
#3539: on v0.2.97 the WebAssembly Zoom's key set to R showed another key. Since #3346 and #3350 a mod's keys are Key Binds rows of its native settings page; this test sets Zoom's key to R there and checks the row shows R and horizon-mods.txt keeps key.keyboard.r. Fixes #3539.
2026-10-30 17:07:38 +01:00
Ludovic MarieandClaude Opus 5.5 c1c646a326 Launcher: the instance's Mods tab (redesign R7b)
Home gets a Mods tab after Overview (plan R0, screens 5 and 6): every mod of the selected
instance, the browser's and the jars put in its folder by hand, with its version, where it comes
from (Modrinth, needed by another mod, or a local file), an amber Update pill when the browser
found a newer version, and a switch (a Java jar turned off is renamed .disabled, as the browser
already does; a Horizon instance's registry mods switch as the game's Options > Mods does). A
filter, Update all and Add mods (the Mods page) complete it. Java instances without a loader
have no Mods tab.

The Mods page's per-instance load moves into mods_load(), which the tab shares without the
page's first search.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:02:29 +01:00
Ludovic Marie fca2169de9 Merge pull request #4459 from horizon-rust/feat/launcher-night-instance
🤖 Agent: Launcher redesign R7a (the instance's tabs under Home's hero), approved by Ludovic after the screenshots. main merged in; full pr_ready green, verify and launcher CI green on this head.
2026-10-30 16:52:32 +01:00
Ludovic MarieandClaude Opus 5.5 aa906ecbe7 Creaking hearts: build the other mobs only on the ticks that read them
with_heart collected a snapshot of every mob for every heart, every tick,
while CreakingHeartBlockEntity.serverTick reads the level only when its
ticker runs out (every 20 to 24 ticks). tick_updates says when; the other
ticks get a mob world without the others.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:45:14 +01:00
Ludovic Marie c505204c24 Merge remote-tracking branch 'origin/main' into feat/launcher-night-instance 2026-10-30 16:44:27 +01:00
Ludovic Marie eeb1d3773d Merge remote-tracking branch 'origin/main' into pt1-3491 2026-10-30 16:43:22 +01:00
arismoko bdc422ad8c Remove source-pinned native lifecycle tests 2026-10-30 10:40:37 -05:00
Ludovic MarieandClaude Opus 5.5 91c77af9ae Merge main into datapack-biomes-1
main's biome features (BiomeGenerationSettings.features, #4211) are read
in BiomeInfo::from_json, where this branch parses a biome file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:38:14 +01:00
Ludovic Marie 594b50b510 Merge pull request #3490 from ThatCakeIsAPie/feat/level-shape-11
🤖 Agent: verified on main + this head. It carries #3488 and #3489, and merging it lands both. I merged main in. main's new native_world_read.rs read the build range from Dim::min_y and max_y, which #3488 replaces, so it now reads the shape of the world it reads from (736d22009, rustfmt in 6e5d821ee). Checks on main + this branch: the 122 harnesses these PRs can affect, plus dedicated_server, datapack_worldgen (with the tall fixture), server_world_gen, server_dimension_players, locator_bar, pr_ready and lan_e2e (worldspawn, light, levels, distance, portals, chest, forced-levels) pass. After the rustfmt fix, on today's main, 124 of 125 pass: lunge_motion fails on main itself since #4297 (noted there). Meshing time is unchanged. Queued to merge; the data-pack biomes (#3491–#3494) follow.
2026-10-30 16:38:09 +01:00
levzzz 1da354b67a Merge pull request #4211 from d3grando/fix-3518-chunks-biomes
/fillbiome recolours loaded chunks for every client (ClientboundChunksBiomesPacket)
2026-10-30 20:22:19 +05:00
levzzz d0a7c52171 Merge pull request #4076 from d3grando/fix-2912-void
The Void superflat preset gets its stone start platform
2026-10-30 20:22:13 +05:00
levzzz 92f69c1fca Merge pull request #4264 from d3grando/fix-3514-pet-death-message
A tame pet's death tells its owner how it died
2026-10-30 20:22:04 +05:00
levzzz 7c6bbc1cfd Merge pull request #4205 from d3grando/fix-3513-strider-dismount-lava
Get off a strider onto the shore, not into the lava beside it
2026-10-30 20:21:39 +05:00
levzzz 12299b07c9 Merge pull request #4214 from d3grando/fix-3518-delete-chat
A chat message the server deletes becomes the deleted marker
2026-10-30 20:18:35 +05:00
levzzz 0e780d404f Merge pull request #4069 from d3grando/fix-2755
Creative players are pushed by explosions and crystals
2026-10-30 20:18:27 +05:00
Neferr 4ee9816b82 Native mods: hook points for using an item (API 5)
native_use_item_on hears a right click with an item on a block once the block let it through (ItemStack.useOn); native_use_item hears the item's own use, in the air or after native_use_item_on let it through (Item.use). Each hears a click once, on the server for every player; a mod that claims one swings the hand instead of the item's use. Fixes #4290.
2026-10-30 16:18:20 +01:00
levzzz d4740f8621 Merge pull request #4315 from JulesB40/fix/rocket-block-hits
A firework rocket with stars bursts on any block it hits, as in vanilla
2026-10-30 20:18:09 +05:00
levzzz 20b0fd4eca Merge pull request #2990 from kacpherek/fix/item-damage-parity
Items and damage parity: arrow and potion effects, mob damage sources, adventure can_break and can_place_on, lily pads on water, mud from water bottles
2026-10-30 20:16:39 +05:00
Ludovic Marie 0ad879b601 Merge pull request #4455 from horizon-rust/ci/nightly-notes-launcher-line
🤖 Agent: Nightly notes now open with the existing launcher banner (release_train.PLAYERS) and the game archives are labelled as what the launcher installs; Python tests and verify green.
2026-10-30 16:12:22 +01:00
Ludovic Marie 842028904a Merge pull request #4461 from horizon-rust/fix/standalone-start-no-resources
🤖 Agent: verified on the Mac mini: a game started without prepared files (the release zip run by hand) now shows a 'Start Horizon from the launcher' window with Download (playhorizon.dev) and Quit, exits 0 with no crash report; pr_ready --quick green, verify green.
2026-10-30 16:12:18 +01:00
levzzz da1f0fb3a4 Merge hr/main into fix-3513-strider-dismount-lava 2026-10-30 20:10:05 +05:00
levzzzandClaude Sonnet 5.5 c3139d6de7 Merge hr/main into fix/item-damage-parity
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 20:08:20 +05:00
levzzz e296924abb Merge pull request #4083 from d3grando/fix-2885-wolfwet
Wet wolves darken and shake off the water on clients
2026-10-30 20:07:34 +05:00
levzzz c62cba3f17 Merge pull request #3957 from kacpherek/fix/split-misc-b
Fix a batch of small mob, block and world-list behaviours so they match vanilla
2026-10-30 20:07:17 +05:00
Neferr 4d3ee1ace0 Native mods: explain the tooltip hook's place from the game alone 2026-10-30 16:07:01 +01:00
levzzz a434ad7ab9 Merge pull request #3956 from kacpherek/fix/split-misc-a
Tipped arrows, leather armour, allays, mooshrooms and LAN guests behave like vanilla
2026-10-30 20:05:05 +05:00
levzzzandClaude Sonnet 5.5 33743fb052 Merge main; port put_biomes to the column layout
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 20:04:51 +05:00
levzzz 762ce6114c Merge pull request #4363 from 1038april/fix/mouse-tweaks-menus
Run the Mouse Tweaks wheel and drags in furnaces and crafting tables, and keep the cursor right under fast clicks
2026-10-30 20:03:23 +05:00
levzzz cbbcb4323b Merge pull request #2577 from levzzz5154/fix/root-vehicle-restore
Riding a horse survives a save and a reopen
2026-10-30 20:03:13 +05:00
levzzz d2c159e74d Merge pull request #4077 from septydev/fix/ukrainian-launcher-overflow
Fix account chip text overflow
2026-10-30 20:03:05 +05:00
levzzz 526c5f0b5b Merge pull request #4199 from kacpherek/fix/camel-horse-llama-riding
Camel, horse and llama riding physics as vanilla
2026-10-30 20:02:56 +05:00
levzzz 61dc245f09 Merge pull request #3550 from v3nn7/v3nn7
Fix teleport commands without an explicit target
2026-10-30 20:02:51 +05:00
levzzzandClaude Sonnet 5.5 8fb961c146 Merge main; place the void start platform at the generator's own min Y
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 20:02:38 +05:00
levzzzandClaude Sonnet 5.5 0b2ed9162a Merge hr/main into fix/rocket-block-hits
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 20:02:20 +05:00
levzzz 2c80e75d81 Merge pull request #4308 from JulesB40/fix/crossbow-firework-impact
A plain firework rocket from a crossbow flies past the first block, as in vanilla
2026-10-30 20:02:06 +05:00
levzzz 942e3b2ded Merge pull request #2417 from kacpherek/fix/goat-horns
Make goat horns play their sound and show the horn pose
2026-10-30 20:01:21 +05:00
levzzz f1807de201 Merge pull request #3951 from kacpherek/fix/split-rendering
Rendering parity: jeb_ sheep, golem walk tilt, spyglass particle hiding, skulls, brush, witch potion
2026-10-30 20:00:25 +05:00
levzzz fdc4a8b80e Merge remote-tracking branch 'hr/main' into fix/pr-4264 2026-10-30 19:59:58 +05:00
levzzz 381a1ec66e Merge hr/main into fix-3514-pet-death-message 2026-10-30 19:59:56 +05:00
levzzz 53259f6b07 Merge hr/main into fix-3513-strider-dismount-lava 2026-10-30 19:59:16 +05:00
Ludovic MarieandClaude Opus 5.5 e6f5246fdf Game started without its files: a launcher screen, not a crash
A player ran horizon.exe from the release's game zip, thinking it was the
launcher. v0.2.97 then failed with "resource loader stopped before
finishing" and a crash report: the loader's real error (no prepared files)
was taken by App::about_to_wait, and the next poll saw the closed channel
(fixed on main by 9f28fe890, which also checks horizon-resources.json
before any window). That check still ended in an error and a crash report.

- An interactive game with no prepared files now opens a window in Horizon's
  design language (bundled fonts, none of Minecraft's files needed): start
  Horizon from the launcher, Download (opens playhorizon.dev), Quit. The
  console says the same. Closing it quits with code 0 and no crash report.
  Offscreen, scripted and server runs keep the error.
- The heartbeat and servers.dat wait until the files are there.
- A loader channel that closes without a result always names why.
- PLAY.txt sends players to playhorizon.dev for the launcher.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:58:23 +01:00
levzzz 67e69ee447 Merge remote-tracking branch 'hr/main' into fix/pr-2990 2026-10-30 19:56:52 +05:00
levzzz a814e9d5a4 Merge remote-tracking branch 'hr/main' into fix/pr-3957 2026-10-30 19:55:53 +05:00
Ludovic MarieandClaude Opus 5.5 36c489208b Launcher: the instance's tabs under Home's hero (redesign R7a)
Home's lower part was one long page: the setup rows, the console, worlds, servers, packs and
screenshots one after the other. They become tabs under the hero: Overview (what Play needs),
Worlds, Multiplayer, Packs, then Screenshots and Console once they hold something. Each tab names
how many it holds; its own buttons sit at the strip's end. The "Ready to play" heading goes: the
hero's state line and the Overview tab already say it.

HORIZON_LAUNCHER_PAGE=worlds (multiplayer, packs, screenshots, console) opens Home on that tab,
for screenshots; it replaces HORIZON_LAUNCHER_CONSOLE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:55:51 +01:00
levzzz fae78ab1d0 Merge remote-tracking branch 'hr/main' into fix/pr-3956 2026-10-30 19:55:00 +05:00
Ludovic MarieandClaude Opus 5.5 0f8f59ec22 Nightly notes open with the way to the launcher
A player ran horizon.exe from the release's game zip, thinking it was the
launcher. Game releases already open with the "Want to play? Download the
Horizon launcher" banner (release_train.PLAYERS, #1647); the Nightly, the
only release since 10-28, did not. Its notes now start with the same banner
(raw HTML, which launchers' notes views skip), and its game archives are
labelled "Game files the launcher installs".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:54:56 +01:00
levzzzandClaude Sonnet 5.5 b513f1b152 Merge hr/main into fix-3518-delete-chat
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:54:43 +05:00
levzzz 988049b64f Merge hr/main into fix-2885-wolfwet 2026-10-30 19:54:37 +05:00
levzzzandClaude Sonnet 5.5 a712692527 Merge hr/main into fix-2755
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:54:29 +05:00
levzzzandClaude Sonnet 5.5 6f9467703a Merge hr/main into fix/crossbow-firework-impact
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:54:15 +05:00
levzzz edc5593c76 Merge remote-tracking branch 'hr/main' into fix/pr-4199 2026-10-30 19:54:04 +05:00
Ludovic Marie 04976d95eb Merge pull request #4320 from horizon-rust/fix/light-edge-neighbours
🤖 Agent: server light-edge fix (#4294): verify, launcher and parity green on the head merged into main; playhorizon dedicated_server 5/5 pass (main 5/5 fail), no idle/join regression.
2026-10-30 15:53:21 +01:00
levzzz 65b0155b8e Merge remote-tracking branch 'hr/main' into fix/pr-2417 2026-10-30 19:52:35 +05:00
levzzz 702f03a4b9 Merge remote-tracking branch 'hr/main' into fix/pr-3951 2026-10-30 19:51:34 +05:00
levzzz f83505a484 Merge remote-tracking branch 'hr/main' into fix/pr-4363 2026-10-30 19:51:07 +05:00
levzzz 9a644f3e72 Merge remote-tracking branch 'hr/main' into fix/pr-2577 2026-10-30 19:51:07 +05:00
levzzz a2d6d33d30 Merge remote-tracking branch 'hr/main' into fix/pr-4077 2026-10-30 19:51:07 +05:00
levzzz 1ab895ea01 Merge remote-tracking branch 'hr/main' into fix/pr-3550 2026-10-30 19:51:07 +05:00
levzzzandClaude Sonnet 5.5 f646dc1e0d Merge hr/main into fix-3518-delete-chat
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:50:58 +05:00
levzzz 3132240f21 Merge pull request #4438 from horizon-rust/fix/creatures-test-modules
Fix main's test build: duplicate tests module in creatures.rs, two stale test signatures
2026-10-30 19:50:42 +05:00
levzzzandClaude Sonnet 5.5 41d70dc6c5 Merge hr/main into fix-2755
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:49:23 +05:00
levzzzandClaude Sonnet 5.5 da5895b445 Merge hr/main into fix/item-damage-parity: keep only HotFloor and the PR's unique parts; heal to attrs.max_health
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:49:04 +05:00
levzzzandClaude Sonnet 5.5 7cee64a389 Fix main's test build: Aabb in_water stub in end_gateway
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:48:35 +05:00
levzzzandClaude Sonnet 5.5 1bb3647ed6 Merge hr/main into fix/split-misc-b; pass the held stack to armor_stand_spawn (fixes E0425)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:47:34 +05:00
levzzzandClaude Sonnet 5.5 f47cec589b Draw the mooshroom variant before the child is borrowed
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:46:02 +05:00
levzzzandClaude Sonnet 5.5 6286de3abd Merge hr/main into fix/split-misc-a; mooshroom offspring variant in vanilla's RNG order
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:45:25 +05:00
levzzz 17e5640d94 Merge remote-tracking branch 'hr/main' into fix/pr-4363 2026-10-30 19:45:23 +05:00
levzzzandClaude Sonnet 5.5 c30f519c16 Skip chunk copies only of vehicles restored from the player's file
The early skips in spawn_loaded_entities and load_mob matched any remembered UUID, so stale id to UUID entries dropped chunk minecarts after a reopen. Track the UUIDs restore_saved_vehicle brought in for this load instead.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:45:15 +05:00
Ludovic Marie 11d1936a5e Merge branch 'pt1-3490' into pt1-3491 2026-10-30 15:45:09 +01:00
Ludovic Marie 865ad0b89d Merge branch 'pt1-3489' into pt1-3490 2026-10-30 15:45:09 +01:00
Ludovic Marie 93bab2181c Merge branch 'pt1-3488' into pt1-3489 2026-10-30 15:45:09 +01:00
Ludovic MarieandClaude Opus 5.5 6e5d821eeb native_world_read: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:45:08 +01:00
levzzzandClaude Sonnet 5.5 bf016d1c54 Merge hr/main into fix/camel-horse-llama-riding; run onPlayerJump on the controlling client too (#4224)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:44:17 +05:00
levzzzandClaude Sonnet 5.5 b5ad463d2d Fix main's test build: Aabb in_water stub in end_gateway, duplicate tests module and Items::load args in creatures
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:44:10 +05:00
levzzz 83f723f666 Merge hr/main into fix-3513-strider-dismount-lava 2026-10-30 19:44:10 +05:00
levzzzandClaude Sonnet 5.5 bcdcc9a460 Merge hr/main into fix/goat-horns; drop duplicate horn tooltip (main has instrument_line)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:41:50 +05:00
levzzzandClaude Sonnet 5.5 6ca4b32d06 Fix main's test build: Aabb in_water stub in end_gateway, duplicate tests module and Items::load args in creatures
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:41:45 +05:00
levzzzandClaude Sonnet 5.5 d8b0934f65 Merge hr/main into fix/crossbow-firework-impact
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:41:13 +05:00
levzzz a2429b8864 Merge remote-tracking branch 'hr/main' into fix/pr-4083 2026-10-30 19:38:58 +05:00
levzzz 06460237d9 Merge hr/main into fix-2885-wolfwet 2026-10-30 19:38:57 +05:00
levzzz 22263abf91 Merge remote-tracking branch 'hr/main' into fix/pr-2577 2026-10-30 19:38:52 +05:00
levzzz 352d59f617 Merge pull request #3163 from echo-ilovech3ss/fix/sulfur-cube-spawn-egg-size
Sulfur cubes: synchronize size metadata and adult egg spawns
2026-10-30 19:38:00 +05:00
levzzzandClaude Sonnet 5.5 83a84b2637 Merge main; truncate the account type line in the redesigned chip
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:37:48 +05:00
levzzz 2fa88ac250 Merge remote-tracking branch 'hr/main' into fix/pr-3951 2026-10-30 19:37:22 +05:00
levzzz ce517e179a Merge pull request #3950 from kacpherek/fix/split-blocks
Block parity: candles, levers, redstone ore dust, note block heads, pots, bells, dripstone, hanging signs, vaults
2026-10-30 19:37:20 +05:00
Ludovic MarieandClaude Opus 5.5 3e2bcb00bc Fix main's test build: one tests module per file, Items::load and in_water signatures in tests
Merging #4222/#4082 and #4259 left two `mod tests` in game/creatures.rs, the
pearl test calling Items::load without its PackOverlay argument, and
end_gateway's ProjectileWorld test impl with the old in_water(Vec3) signature.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:36:54 +01:00
levzzz c27711cf2e Merge pull request #4282 from d3grando/fix-2826
Many mobs no longer grow the renderer's staging memory until it runs out
2026-10-30 19:36:43 +05:00
levzzz 0056f3445c Merge pull request #4275 from MSedgeMC/fix/4130-thrown-sprite-lift
Draw thrown items where vanilla puts them
2026-10-30 19:36:27 +05:00
levzzz 51b2104c1d Merge pull request #4228 from levzzz5154/fix/mob-ai-oracle-heap
parity: give the mob_ai oracle an 8g heap (4g runs out on a fresh run)
2026-10-30 19:36:10 +05:00
levzzz e2f04717c4 Merge pull request #4227 from abduvaliy-engineer/fix/spawner-data-sync
Sync spawner mob previews after block data commands
2026-10-30 19:36:00 +05:00
levzzz b8988e03b4 Merge pull request #4213 from d3grando/fix-3514-equipment-break-events
Tools and armour break with their sound and crumbs on servers
2026-10-30 19:35:36 +05:00
levzzz 9382a17fed Merge pull request #4209 from d3grando/fix-3514-conversion-keeps-gear
Drowned, zombie and stray conversions keep gear, name and flags
2026-10-30 19:35:19 +05:00
levzzz 15df84b505 Merge pull request #4208 from d3grando/fix-3520-read-timeout
Time out a server that stops answering, with vanilla's disconnect reasons
2026-10-30 19:35:03 +05:00
levzzz 8682bd5781 Merge remote-tracking branch 'hr/main' into fix/pr-3951 2026-10-30 19:35:00 +05:00
levzzzandClaude Sonnet 5.5 0da29407d1 Merge hr/main into fix/split-rendering; drop jeb_ sheep hunks (covered by #4274)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:35:00 +05:00
levzzz bdcc1d9f39 Merge pull request #4204 from d3grando/fix-3520-spectator-tracking
Spectating players are hidden from players who aren't spectators
2026-10-30 19:34:46 +05:00
levzzzandClaude Sonnet 5.5 2e2c11005a Replace the both-players console teleport step with single-target steps
The multi-target cross-dimension tracking gap is a separate issue.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:34:44 +05:00
levzzz bf5b9b089e Merge remote-tracking branch 'hr/main' into fix/pr-3550 2026-10-30 19:34:39 +05:00
levzzz 3caf55ae8a Merge pull request #4160 from JulesB40/perf/mimalloc-allocator
Chunk generation 18% faster on many cores: mimalloc as the game's allocator
2026-10-30 19:34:30 +05:00
levzzz 96824f711d Merge pull request #4144 from Fauxvale/fix/far-watery-and-nearly-full
Far terrain: kelp is water, and powder snow and paths are kept
2026-10-30 19:34:12 +05:00
levzzz 6daaf0449a Merge pull request #4110 from DUzzL/fix/issue-4094-block-break-speed
Apply the block_break_speed attribute to mining speed
2026-10-30 19:33:54 +05:00
levzzz 8fbe40df70 Merge pull request #4103 from DUzzL/fix/issue-4091-attack-speed
Read the attack speed base from the player's attribute
2026-10-30 19:33:37 +05:00
levzzz 02dc9e2e30 Merge pull request #4084 from d3grando/fix-2783
Runtime spawns run Mob.finalizeSpawn and wither skeletons hit for 4
2026-10-30 19:33:19 +05:00
levzzz f15c9098b6 Merge pull request #4406 from posok124521/fix/trident-block-clip
Thrown tridents: clip blocks with vanilla's centre ray, not the 0.5 box
2026-10-30 19:30:52 +05:00
levzzz bdbcbf2970 Merge pull request #4353 from CocofireHD/fix/shulker-container-sync
Shulker boxes show their contents to the player who holds them
2026-10-30 19:30:32 +05:00
levzzz 7a5bbcd7c8 Merge pull request #4328 from repositorykreml1n/codex/fix-tnt-duper-3856
TNT: stop client piston replay from creating a second primed entity
2026-10-30 19:30:01 +05:00
levzzz ed0d4931b0 Merge pull request #4325 from MSedgeMC/fix/4273-brush-arm-pose
Pose a brushing arm like vanilla, in both hands
2026-10-30 19:29:40 +05:00
levzzz 8f022a0b27 Merge pull request #4297 from hhamaj/fix/lunge-keeps-air-motion
A server's copy of a player jumps with them, so Lunge keeps the jump
2026-10-30 19:29:14 +05:00
levzzz e07b94681b Merge pull request #4274 from MSedgeMC/fix/3958-jeb-sheep
Render the jeb_ sheep's wool colour, as vanilla does
2026-10-30 19:28:02 +05:00
levzzz c679ef53ea Merge pull request #4252 from CocofireHD/fix/item-name-display-name
Item names in command feedback follow the item_name component
2026-10-30 19:27:22 +05:00
levzzz 7bc7f9e783 Merge pull request #4222 from hhamaj/fix/pearl-turns-wind-charge
A pearl turns the wind charge it hits, which then speeds up
2026-10-30 19:26:39 +05:00
levzzz 19327a55b0 Merge pull request #4219 from d3grando/fix-3517-corrupt-leveldat-list
List worlds with an unreadable level.dat and offer recovery
2026-10-30 19:26:05 +05:00
levzzz df1874cced Merge pull request #4206 from DenisHumen/fix/frog-walk-anim
Frogs walk their legs at vanilla's rate
2026-10-30 19:23:57 +05:00
levzzz 726f588668 Merge pull request #4183 from Username59138/fix/command-block-one-data-path
Command blocks: one data path; the command reaches only the player who opens it
2026-10-30 19:23:16 +05:00
levzzz dbc84adbd3 Merge pull request #4082 from d3grando/fix-3072-pearl
Thrown pearls feel the water their box touches, as vanilla's isInWater
2026-10-30 19:20:37 +05:00
levzzz dcc393acec Merge pull request #4073 from d3grando/fix-2866
Swapping the same item into the off hand plays the re-equip animation
2026-10-30 19:20:16 +05:00
levzzz bb6985ecb7 Merge pull request #4068 from d3grando/fix-3183
Glint reads the clock and options once per frame, not per item
2026-10-30 19:19:41 +05:00
levzzz 574d031d14 Merge pull request #4064 from oscargzss/nonconformant-adapter-retry
renderer: try a backend again with non-conformant adapters
2026-10-30 19:19:20 +05:00
levzzz 5edb5defbb Merge pull request #4052 from d3grando/fix-3393-optab
Creative inventory: the Operator Utilities tab for operators
2026-10-30 19:18:58 +05:00
levzzz 91dc405a0f Merge pull request #4048 from ult1mq/codex/lod-retirement
Fix detail requests for retiring fine LOD leaves
2026-10-30 19:18:14 +05:00
levzzz 587f81f9c6 Merge pull request #4019 from d3grando/fix-3788
Pose plugins hide the player's own body again
2026-10-30 19:17:34 +05:00
levzzz 73be91512c Merge pull request #4010 from WhyFovius/fix/fullscreen-loading-crash
Renderer: fullscreen loading crash (Validation acquire + animated sprites layout)
2026-10-30 19:17:15 +05:00
levzzz d10f82a7cb Merge pull request #3999 from d3grando/fix-2786
Light a column again when an edit darkens it beside an unlit one
2026-10-30 19:16:56 +05:00
levzzz 17dd36e93a Merge pull request #3994 from d3grando/fix-2740
Applying a resource pack no longer freezes the window
2026-10-30 19:16:37 +05:00
levzzz 81d38acadc Merge pull request #3936 from abit-foggy/fix/intel-gen11-section-flicker
fix(renderer): use flat draws on Intel Gen11 Vulkan to prevent section flicker (#3529)
2026-10-30 19:16:17 +05:00
levzzz 62ddcb7909 Merge pull request #3721 from xFedeT/fix/skeleton-path-stops-in-shade
Skeletons: a path out of the shade stops at the first node the sky sees
2026-10-30 19:15:58 +05:00
levzzz c3db4a63d5 Merge pull request #4262 from d3grando/fix-3514-pearl-gateway
Ender pearls go through End gateways
2026-10-30 19:14:35 +05:00
levzzz ffae67874b Merge pull request #4259 from d3grando/fix-3514-tame-wolf-kill-credit
Tame wolves' kills count for their owner
2026-10-30 19:14:17 +05:00
levzzz a52ce752a0 Merge pull request #4247 from d3grando/fix-3518-tag-query
F3+I copies the server's data on servers and LAN
2026-10-30 19:13:57 +05:00
levzzz 17bd76282b Merge pull request #4243 from d3grando/fix-3518-sound-slider-preview
Volume sliders play a preview sound on the title screen's options
2026-10-30 19:13:35 +05:00
levzzz 174df925ca Merge pull request #4241 from d3grando/fix-3513-enderman-carried-drop
Endermen drop the block they carry when killed
2026-10-30 19:13:18 +05:00
levzzz cb77d4cb54 Merge pull request #4239 from d3grando/fix-3518-set-camera
Spectators on a server see through the entity they spectate
2026-10-30 19:12:56 +05:00
levzzz cba5d53333 Merge pull request #4236 from d3grando/fix-3514-explosions-break-decorations
Explosions break paintings, cushions and armour stands
2026-10-30 19:12:38 +05:00
levzzz 50f2ca1636 Merge pull request #4229 from d3grando/fix-3518-custom-chat-completions
Chat completes the server's custom chat completions
2026-10-30 19:12:20 +05:00
levzzz 79efd6a2dc Merge pull request #4226 from d3grando/fix-3512-armadillo-breeding
Armadillos breed, follow spider eyes and their parents
2026-10-30 19:12:03 +05:00
levzzz 22115dfb11 Merge pull request #4225 from d3grando/fix-3513-cube-hurts-golem
Slimes and magma cubes hurt the iron golems they touch
2026-10-30 19:11:47 +05:00
levzzz 34abe889db Merge pull request #4224 from d3grando/fix-2920
Riders move their horses on servers, and jump them
2026-10-30 19:11:31 +05:00
Ludovic Marie 3ecec2ca7b Merge branch 'pt1-3490' into pt1-3491 2026-10-30 15:02:27 +01:00
Ludovic Marie 6b44f2ba99 Merge branch 'pt1-3489' into pt1-3490 2026-10-30 15:02:27 +01:00
Ludovic Marie f8f276748b Merge branch 'pt1-3488' into pt1-3489 2026-10-30 15:02:26 +01:00
Ludovic MarieandClaude Opus 5.5 736d22009e native_world_read: a block's bounds are its world's
main's read_block took the build range from Dim::min_y and max_y, which
this branch replaces with each level's shape: it now reads the bounds
of the world it reads from (the level's own, data packs included).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:01:35 +01:00
Ludovic Marie 32bb54e90c Merge remote-tracking branch 'origin/main' into pt1-3488 2026-10-30 14:58:25 +01:00
Ludovic Marie 22ad79ba01 Merge pull request #4410 from horizon-rust/fix/mod-submit-errors
🤖 Agent: Merged by Ludovic's AI agent (Claude). The registry now answers refused submissions after the upload (drained), sends 413 as JSON, and returns 504 'GitHub is slow' on timeouts. Proof: pr_ready --quick green, and the crate's own tests (mock GitHub, 413, timeout, a TCP drain test that fails without the fix). Railway redeploys horizon-mod-registry from main through its watchPatterns.
2026-10-30 14:52:08 +01:00
Ludovic Marie 235e3e97c6 Merge pull request #4409 from horizon-rust/chore/telemetry-drop-hub-suggestions
🤖 Agent: telemetry server only (its own workspace): cargo fmt, clippy -D warnings clean and cargo test all pass there; pr_ready --quick passes for the rest (Python tests, game workspace). Removes the hub's suggestion endpoints, now without a caller (horizon-site#21, kira#85). skip-release.
2026-10-30 14:52:04 +01:00
levzzzandClaude Opus 5.5 94a93ab7aa Native mods: server tick Start fires before any level; the tick clock ticks once per server tick
`ServerTickStart` now fires at the top of `tick_server`, before the first level's streaming
and world update, where NeoForge fires `ServerTickEvent.Pre`. Before, it fired inside the first
level's `tick`.

The tick rate manager now ticks once per server tick, before the levels, as
`MinecraftServer.tickServer` does; each level reuses its answer, like `ServerLevel.tick` reading
`runsNormally`. Before, every extra dimension called `world_runs` again, so a `/tick step` was
spent on the first level and the others stayed frozen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:49:47 +05:00
Ludovic Marie a827346dd5 Merge pull request #4399 from horizon-rust/feat/launcher-night-new-instance
🤖 Agent: Launcher redesign R6 (New instance with Horizon, Vanilla and Modded), approved by Ludovic. Full pr_ready green, verify and launcher CI green on this head. Contains #4382, #4391 and #4392; lands after them.
2026-10-30 14:48:44 +01:00
Ludovic Marie b853fd4599 Merge pull request #4392 from horizon-rust/feat/launcher-night-sign-in
🤖 Agent: Launcher redesign R5 (sign-in sheet), approved by Ludovic. Full pr_ready green, verify and launcher CI green on this head. Contains #4382 and #4391; lands after them.
2026-10-30 14:48:40 +01:00
Ludovic Marie f10edd4f40 Merge pull request #4391 from horizon-rust/feat/launcher-night-steps
🤖 Agent: Launcher redesign R4b (Play's steps while it gets ready), approved by Ludovic. Full pr_ready green, verify and launcher CI green on this head. Contains #4382; lands after it.
2026-10-30 14:48:37 +01:00
Ludovic Marie 52ff5ebdc6 Merge pull request #4382 from horizon-rust/feat/launcher-night-home
🤖 Agent: Launcher redesign R4 (Home's hero), approved by Ludovic after the screenshots. Full pr_ready green, verify and launcher CI green on this head. First of the stack #4382 → #4391 → #4392 → #4399.
2026-10-30 14:48:33 +01:00
Ludovic MarieandClaude Opus 5.5 4e9ddb17d9 Telemetry server: drop the hub's suggestion endpoints
The website's suggest form is gone (horizon-site#21) and Kira no longer relays it
(kira#85): roadmap suggestions come only from #horizon-ideas posts. This removes
POST /hub/suggest, GET /hub/suggestions, the hub_suggestions_per_day and hub_token
settings (HORIZON_TELEMETRY_HUB_SUGGESTIONS_PER_DAY, HORIZON_TELEMETRY_HUB_TOKEN),
and their rows in the server's README and roadmap/README.md. The rate-limit test now
uses bug reports; the keep-days expiry is tested on the store.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:44:09 +01:00
chi4aandDeepSeek 4.1 Flash 726d9e86ef Thrown tridents: clip blocks with vanilla's centre ray, not the 0.5 box
The server tick tested blocks with an AABB sweep of the trident's 0.5 x 0.5 box
and a per-axis moved != velocity test, so a trident passing up to 0.25 blocks to
the side of a block stuck in mid-air beside it. Use AbstractArrow.tick's
zero-width level().clip(...) and AbstractArrow.onHitBlock's 0.05 pull-back
along Math.signum, in ground and shakeTime, on the server and on the client's
predicted copy.

Fixes #4365

Co-Authored-By: DeepSeek 4.1 Flash (DeepSeek Harness) <[email protected]>
2026-10-30 15:41:24 +02:00
Ludovic MarieandClaude Opus 5.5 b086d4298b Mod registry: answer refused submissions after the upload, JSON 413, name a slow GitHub
A submission refused before its body is read (signed out, CSRF, the hourly limit) or partway through (a file over
64 MiB) answered and closed the connection while the browser was still sending the file. The browser then reports a
network error, and the form says "The service didn't answer" instead of the reason. The service now reads the rest
of the upload (up to its limit) before it answers.

A file over the limit gets 413 with a JSON {error}, as every other refusal, instead of a 400.

GitHub calls get 30 s each and the file's upload 5 minutes (it had one 60 s timeout for everything, a 64 MiB upload
included). A call that times out answers 504 "GitHub is slow right now: try again in a few minutes" instead of the
generic 502.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:35:03 +01:00
levzzz d2186eb9aa Merge pull request #3981 from arismoko/native-block-write-observation
Expose native server block change observations
2026-10-30 18:30:39 +05:00
levzzz 4ca814d896 Merge pull request #3941 from arismoko/native-neighbour-observation
Expose native neighbour-change observations
2026-10-30 18:30:30 +05:00
levzzz 41672b1221 Merge pull request #3923 from arismoko/native-server-block-read
Expose read-only native server block queries
2026-10-30 18:30:20 +05:00
levzzz 44471a42a8 Merge pull request #3878 from arismoko/native-lifecycle-server-ticks
Expose native server tick hooks
2026-10-30 18:30:11 +05:00
Ashura006andCursor Agent 8a806d23e0 Cull display entities with the width/height frustum box
Matches Display.updateCulling: width or height 0 keeps distance-only
noCulling; otherwise EntityRenderer.shouldRender uses the interpolated
AABB inflated by 0.5 for text, block and item displays.

Co-Authored-By: Cursor Agent <[email protected]>
2026-10-30 16:30:01 +03:00
Ludovic Marie e262d68fcb Merge pull request #3551 from GIGABAIT93/feat/launch-api-client
🤖 Agent: Merged by Ludovic's AI agent (Claude) after review against #3545 and a proof on macOS: full pr_ready on main + the whole Launch API stack (parity 111/112, 0 failed), at this head pr_ready --quick, parity on the changed files, lan_e2e chest and eat on both paths, and a real end to end (prepare into a scratch --output, then horizon-ctl launch with --game-dir/--home/--quickPlaySingleplayer in isolated homes). Thanks GIGABAIT93 for the Launch API and the quick answers to the review.
2026-10-30 14:26:59 +01:00
levzzzandClaude Opus 5.5 85dbd835d6 Launcher: build native mods made for different Horizon versions, after "Build anyway"
Mods pinning different Horizon commits used to be refused. They often still apply and
compile, so the build now picks the newest pinned commit (by commit time, from GitHub),
lists each mod made for another one, and builds only once the player chooses "Build
anyway" (CLI: native-build --allow-version-mismatch). Those mods are repinned on the
build's own copy, like hook-point mods. A failed apply or compile then says the mods were
made for other versions.

horizon-mixins pins that differ resolve to the newest of them and the launcher's own,
never one older than a mod was made with.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:25:41 +05:00
Ludovic Marie 941c4498a2 Merge branch 'feat/launcher-night-sign-in' into feat/launcher-night-new-instance
# Conflicts:
#	docs/design-system.md
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
#	launcher/src/main.rs
2026-10-30 14:24:26 +01:00
Ludovic Marie 16ae712381 Merge branch 'feat/launcher-night-steps' into feat/launcher-night-sign-in
# Conflicts:
#	docs/design-system.md
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
#	launcher/src/main.rs
2026-10-30 14:23:54 +01:00
Ludovic Marie 69937e5495 Merge branch 'feat/launcher-night-home' into feat/launcher-night-steps 2026-10-30 14:23:16 +01:00
Ludovic Marie 486e3001d7 Merge remote-tracking branch 'origin/main' into feat/launcher-night-home 2026-10-30 14:23:09 +01:00
Ludovic Marie edeecb4971 Merge branch 'feat/launcher-night-home' into feat/launcher-night-new-instance 2026-10-30 14:09:30 +01:00
Ludovic Marie 5e2d9e758b Merge branch 'feat/launcher-night-home' into feat/launcher-night-sign-in 2026-10-30 14:09:30 +01:00
Ludovic Marie f26519bb53 Merge branch 'feat/launcher-night-home' into feat/launcher-night-steps 2026-10-30 14:09:30 +01:00
Ludovic MarieandClaude Opus 5.5 c7cc6e1256 Launcher: Home says Ready to play only when Play would start the game
Not while the version needs preparing, prepares, waits for an account that
owns the game, or runs (review: it showed beside Needs preparing and over
the steps at 34%).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:09:10 +01:00
DUzzL fd21d6ee82 Cover no-BOM and partial-BOM pack.mcmeta (#4388) 2026-10-30 14:08:19 +01:00
Ludovic MarieandClaude Opus 5.5 e2479b2272 Launcher: New instance with Horizon, Vanilla and Modded kinds (R6)
The New instance dialog gets the night look (an opaque plate cut in steps,
night buttons) and tabs for the kind. Vanilla and Modded make a Java
instance: the version comes from Mojang's public list (search, Snapshots,
Old; the newest release chosen), Modded picks Fabric or Quilt at its
newest stable version (asked of the loader's service on Create, so an
unsupported version says so in the dialog). The name follows the choice
("Fabric 1.21.4") until the player types one. Horizon instances are made as
before. The game itself still comes with the first Play, for an account
that owns it. HORIZON_LAUNCHER_PAGE=new-instance opens it for screenshots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:03:46 +01:00
Ludovic Marie d5f484c89f Merge pull request #4387 from horizon-rust/fix/windows-credential-manager-full
🤖 Agent: merging after CI. Launcher-only fix: Windows Credential Manager error 8 (store full) now says what is wrong and how to make room, and a save that stops partway keeps the old sign-in instead of a half-written one. Unit-tested with a fake store that fills up; pr_ready --quick green.
2026-10-30 14:00:04 +01:00
DUzzL c274dad88d Read pack.mcmeta with a UTF-8 byte order mark (#4388) 2026-10-30 13:59:36 +01:00
Ludovic MarieandClaude Opus 5.5 66f4bfbaae Launcher: say when Windows Credential Manager is full, and keep the old sign-in when a save stops partway
Windows players saw "Platform secure storage failure: Windows error code 8" when signing in
(#4313, #4366). CredWriteW returns ERROR_NOT_ENOUGH_MEMORY (8) once the user's Credential
Manager reaches its undocumented total size, for an item of any size; the Xbox app and the
Minecraft Launcher are known to fill it with XblGrts/Xbl| items. The launcher now says the store
is full and how to make room, instead of the bare code.

A secret is kept in several items; a write that failed after the first piece left new pieces in
front of old ones, which read back as no secret (signed out at the next start). The old secret
now goes back when a write stops partway.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:52:19 +01:00
Ludovic Marie e4472a7423 Merge pull request #4232 from horizon-rust/perf/load-time-faces
🤖 Agent: verify and both parity jobs are green at c8ee23f2b (merged-head rerun passed at 12:35Z); approved by rdbtCVS. Queued for the merger.
2026-10-30 13:50:33 +01:00
Ludovic MarieandClaude Opus 5.5 0cbb5bae35 Launcher: the sign-in sheet in the night look (R5)
The owner gate's screen becomes a sheet: an opaque plate cut in steps over
the dimmed launcher. "Sign in to play", why (the account that owns Java
Edition, like other launchers; no Minecraft install needed, Horizon
downloads it from Mojang for that account), the code in mono on a raised
box with Copy code, Open link as the main action, Not now, and "Waiting for
Microsoft… the window goes on by itself." The link reads as typed
(microsoft.com/link). The gate itself is unchanged: when it shows, what it
asks, and what Play does without an owner.
HORIZON_LAUNCHER_PAGE=sign-in shows the sheet with a made-up code for
screenshots; nothing asks Microsoft.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:43:34 +01:00
Ludovic MarieandClaude Opus 5.5 c81de0f908 Launcher: translate the steps' strings, drop the old preparing line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:36:20 +01:00
simonandClaude Opus 5.5 69ec4ccdd6 Keep the cursor and the items right under fast menu clicks
Rapid clicks, as the Mouse Tweaks wheel sends, could leave a stack in the
cursor, and in creative mode add real items. The server's inventory sync echoed
slots a click in another menu had settled, onto clicks predicted since, and it
never compared the click's state id (handleContainerClick does, and answers a
stale click with the whole menu). The crafting table answered every click with
the whole menu, which undid predictions. The client took state ids from windows
other than the open one, and in creative sent its predictions as inventory edits.

The wheel's clicks assumed the first of them worked: on a slot just emptied, as
when a furnace burns its fuel, the right click that followed picked up half a
stack. It now places with a one-slot drag, which does nothing with an empty hand.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:35:54 +10:00
simonandClaude Opus 5.5 217fb7af0d Run the Mouse Tweaks wheel and drags in furnaces and crafting tables
The gate only allowed the inventory and chest-like menus, so nothing moved
in furnaces, crafting tables, hoppers and dispensers. The wheel now takes its
target from the menu engine's quickMoveStack, so fuel, inputs and result slots
follow vanilla's routing, and every action is still ordinary slot clicks.

Fixes #4312

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:35:54 +10:00
Ludovic MarieandClaude Opus 5.5 5a42da4fe1 Launcher: test the time left and the steps' order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:32:17 +01:00
Ludovic MarieandClaude Opus 5.5 2b78aa1735 Launcher: Play's steps while it gets ready, in the night look (R4b)
While a version prepares, Play reads "Getting ready…" with the download's
percentage, Cancel beside it while a download from Mojang runs. Under them:
the bar, what is happening ("Downloading Minecraft 26.3 from Mojang · 1,754
of 5,160 files") with the time left from the download's pace, and the
steps: game files, Java, sounds, prepare, and play when Play was pressed.
backend::PrepareStep is set by the preparation as it moves on. The progress
bar takes the night look (6 px, plate track, accent fill) everywhere.
HORIZON_LAUNCHER_PAGE=getting-ready shows the state for screenshots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:32:17 +01:00
Ludovic MarieandClaude Opus 5.5 95825f818c Launcher: translate Folder and Version, drop the strings Home no longer has
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:32:07 +01:00
Ludovic Marie 5c34936e1b Merge pull request #4242 from horizon-rust/perf/dedicated-no-block-models
🤖 Agent: verify and both parity jobs are green at 3a11501e2 (the rerun passed dedicated_server and world_spawn#3); approved by rdbtCVS. Queued for the merger.
2026-10-30 13:31:55 +01:00
GIGABAIT93andClaude Opus 5.5 267e7063d5 Launch API: promise play only for an owner, name the option in errors (#3545)
- `--name` leaves OPTIONS: the game still takes it, but the contract doesn't
  promise offline play. The guide's first step is now the owner rule, and a
  session the game can't read is stated as a fact, not an option.
- A bad value names its option (`Horizon: --width: invalid digit ...`), since
  a launcher shows that line to the player.
- Session errors say "the session", true for --session-stdin too.
- --quickPlaySingleplayer takes one folder name of saves/, as vanilla's level
  id; a path is refused. --world still takes a path for harnesses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:26:24 +02:00
DUzzL 8a196eeb77 Copper golems: send the chest lid block event to guests too 2026-10-30 13:20:50 +01:00
Ludovic MarieandClaude Opus 5.5 b32541cb88 Map proto sections through the column layout
Main's level-shape work replaced vanillagen's fixed MIN_Y and SECTIONS
with ColumnLayout, so the branch merged with main no longer built.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:17:29 +01:00
Ludovic Marie 1df4c50f81 Merge pull request #4032 from horizon-rust/feat/launcher-night-shell
🤖 Agent: R3 night frame (sidebar, top bar, account chip). Ludovic approved the style on 2026-10-30; main merged in, pr_ready --quick green (launcher clippy, 278 launcher tests), CI green on fe89da2a2. Merging.
2026-10-30 13:17:16 +01:00
Ludovic Marie 7fb48c7154 Merge remote-tracking branch 'origin/main' into fix/light-edge-neighbours 2026-10-30 13:15:37 +01:00
Ludovic MarieandClaude Opus 5.5 ca7e8bed1a Launch API: rustfmt the exit code test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:11:41 +01:00
Ludovic MarieandClaude Opus 5.5 416678ba17 Launch API: test the exit codes on the real program (#3545)
tests/launch_api.rs runs horizon-client: --launch-api prints JSON and opens no
log; a refused command line (unknown option, bad value, missing value) exits 2
without a crash report; an error exits 1 with the report's path as the last
stderr line, under a relative --home, and creates a relative --game-dir
without writing saves or options.txt where the game was started. Paths have
spaces, as launchers' instance folders do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:11:16 +01:00
Ludovic MarieandClaude Opus 5.5 bbc9c46c17 Launcher: Home's hero in the night look (R4)
The selected instance's name in the display cut, a caption naming what it
plays and whether it is ready, when it was played, and the update line.
Play, the version picker, Mods and Folder in one row as night buttons
(shell::action: main, raised, quiet). A plate on the right holds a
release's notes line (not a Nightly's build line) and Join a friend, which
leaves the rows below. "Host a server" goes: Servers is in the top bar.
The picker names a Nightly as "Nightly · ba6344a" instead of its tag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:10:19 +01:00
Federico TaglianiandClaude Sonnet 5.5 1e14e33704 Explosive sulfur cubes light their fuse beside redstone power
SulfurCube.primeWhenOnPoweredPosition: each tick an explosive cube that is
alive and not primed checks the best neighbour signal at its block and
primes (SignalGetter.getBestOwnOrNeighbourSignal; no block has an own signal).
lan_e2e check sulfur_cube_power.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 13:00:05 +01:00
iLostName 7ee787f8de item sprites: another namespace's animated texture packs its first frame and animates
A server pack's animated item texture (lobby:item/..., frames stacked in one PNG) was packed whole into the GUI atlas: the path check kept the namespace, so models drawn from it (item display NPCs) sampled every frame at once, in stripes. It now cuts the mcmeta's first frame and animates like vanilla's own.
2026-10-30 12:44:42 +01:00
iLostName 737d74f592 joining a world: the player model and command registries are read once, not at every join
Each join parsed entity-models.json twice for the player's model, walked every block state for /setblock's completions and parsed the 1.6 MB command registries export again on the render thread: about 160 ms of one frozen frame per join. None of them changes while the game runs, so each is read once per resources root or block registry (the player model already as the world resources load), and the join itself now takes about 20 ms.
2026-10-30 12:44:34 +01:00
Ludovic Marie fe89da2a2c Merge remote-tracking branch 'origin/main' into feat/launcher-night-shell 2026-10-30 12:41:22 +01:00
DUzzL 1a37932efd Make crossbow piglins shoot instead of meleeing
register_piglin ran before the spawn weapon was equipped, so the crossbow goal was never added and the melee goal took over. Always register the crossbow goal (it checks the held item) and keep the melee goal off while a crossbow is held, as MeleeAttack does for usable projectile weapons. Fixes #4356.
2026-10-30 12:39:15 +01:00
Ludovic Marie 0a341cc353 Merge pull request #3487 from ThatCakeIsAPie/feat/level-shape-8
🤖 Agent: verified on main + this head. I merged main in. goals.rs keeps main's restricted check and max_h, now with the world's bounds. nether.rs keeps main's ghast fly-to target. main's new occupied_pois_in_range reads the world's bounds too. One harness failed: use_item_on. GameLevel's build range now reads the world's shape, but the dispense fixture only set the dimension, so a bed exploding in a Nether or End case broke the block at y -1, below the level. I added one line so the fixture sets the shape from the case's dimension, as enter_dimension does (f0651e754). Checks: 107 harnesses, dedicated_server, pr_ready and lan_e2e (worldspawn, light, levels, distance, portals, chest, forced-levels) passed on the head before that fix. On this head, the 32 harnesses that depend on the fixture (use_item_on among them) and pr_ready pass. Queued to merge; #3488 follows.
2026-10-30 12:33:13 +01:00
Federico TaglianiandClaude Sonnet 5.5 5866611176 Port map: SetInstrumentFunction.run is ported
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 12:19:59 +01:00
CocofireHDandHermes Agent da4db61ba9 Shulker boxes show their contents to the player who holds them
`ItemContainerContents` travels as the `minecraft:container` component, and the
client already decodes it into the tooltip's contents lines. The encoder that
hands a stack from the integrated or LAN server to a player never wrote it, so a
box that held items listed none: only "Shulker Box" in the tooltip, however full
it was (#2979).

Write the component as `ItemContainerContents.STREAM_CODEC` does: the slot
count, then one optional template per slot, each inner stack through the same
encoder so its own name, count and components survive.

Co-Authored-By: Hermes Agent <[email protected]>
2026-10-30 12:16:58 +01:00
Federico TaglianiandClaude Sonnet 5.5 fe8c64382d Loot: set_instrument puts the picked instrument on the item (a goat horn's tune)
SetInstrumentFunction drew the random option and dropped it, so a goat horn
from a loot table (pillager outpost chests) had no instrument.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 12:16:37 +01:00
Ludovic Marie 71571ed2cc Merge branch 'pt1-3490' into pt1-3491 2026-10-30 12:14:41 +01:00
Ludovic Marie 9d23d4e542 Merge branch 'pt1-3489' into pt1-3490 2026-10-30 12:14:40 +01:00
Ludovic Marie 2c39ef7cbf Merge branch 'pt1-3488' into pt1-3489 2026-10-30 12:14:40 +01:00
Ludovic Marie 21ecb7f80b Merge branch 'pt1-3487' into pt1-3488 2026-10-30 12:14:40 +01:00
Federico TaglianiandClaude Sonnet 5.5 5f7338178c Port map: four rows of #3515 that are already ported; an item frame keeps to the build height
OminousBottleAmplifier.addToTooltip, SuspiciousStewEffects.addToTooltip and
CrossbowItem.createProjectile (the crossbow hit sound) are in the game; a
frame may go on a vertical face (ItemFrameItem.mayPlace) and only needs the
build height, which place_item_frame now checks.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 12:02:31 +01:00
Ludovic MarieandClaude Opus 5.5 f0651e7540 Dispense fixture: the world's shape follows the case's dimension
GameLevel's build range now reads the world's shape, so a case in the
Nether or the End must set it as enter_dimension does: a bed exploding
there broke the block at y -1, below the level (use_item_on).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 12:00:25 +01:00
Federico TaglianiandClaude Sonnet 5.5 1c27a0c66d clippy: is_multiple_of in CyclingSlot
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 11:58:10 +01:00
Federico TaglianiandClaude Sonnet 5.5 cfcd6c9112 Smithing table: the empty slots cycle their icons, as CyclingSlotBackground does
The template slot cycles the armour trim and netherite upgrade template
icons; with a template in the table the base slot cycles what it takes (the
11 tools and armour of the netherite upgrade, the four armour pieces of a
trim) and the additions slot the ingot or the seven trim materials, one icon
every 30 ticks with a 4 tick fade. It showed one fixed netherite icon.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 11:53:06 +01:00
DUzzL c172ab7dae Drop unverified vanilla claim from the slider getSprite ledger line 2026-10-30 11:51:52 +01:00
DUzzL cf5290e835 Outline the slider track while dragged 2026-10-30 11:44:54 +01:00
Ludovic Marie a1dc00d466 Merge branch 'pt1-3490' into pt1-3491 2026-10-30 11:39:01 +01:00
Ludovic Marie fb51619741 Merge branch 'pt1-3489' into pt1-3490 2026-10-30 11:39:00 +01:00
Ludovic Marie 2c74f84b97 Merge branch 'pt1-3488' into pt1-3489 2026-10-30 11:39:00 +01:00
Ludovic Marie cc1e38ef22 Merge branch 'pt1-3487' into pt1-3488 2026-10-30 11:39:00 +01:00
Ludovic Marie 47a3b39fa9 Merge pull request #3486 from ThatCakeIsAPie/feat/level-shape-7
🤖 Agent: verified on main + this head. I merged main in (through the carried #3485). The crate split had moved chunk.rs, lod.rs and minimap.rs to horizon-client-render, so this PR's changes to them are reapplied there. Checks: 121 of the 122 harnesses this change can affect pass. dedicated_server differed once on a single light section in one column, the intermittent mismatch already seen on #2608, and passed on rerun. pr_ready and lan_e2e (worldspawn, light, levels, distance, portals, chest, forced-levels) pass. chunk_load_bench gives the same meshes as main, and meshing time is the same (median 42.4 vs 42.5 µs per section over 6 pairs). The random-tick cache allocates its Vec once per cached chunk, not per tick. Queued to merge; #3487 follows.
2026-10-30 11:38:20 +01:00
Ludovic MarieandClaude Opus 5.5 2867be0d53 Merge main into level-shape-8
goals.rs keeps main's restricted check and max_h, with the world's
bounds; nether.rs keeps main's ghast fly-to target (no heightmap mirror).
main's occupied_pois_in_range reads the world's bounds too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 11:37:14 +01:00
MSedgeMC 8a378f441c Pose a brushing arm like vanilla, in both hands
Vanilla's AvatarRenderer builds the arm pose per hand and gives a brush
that is in use the BRUSH pose, whose HumanoidModel case drops the arm
twice as far as an ordinary item: xRot * 0.5 - pi/5 rather than - pi/10,
with yRot zeroed. Horizon had no Brush pose, so a brushing player stood
there with an ordinary item arm while the dust flashed.

The pose is selected from the literal item id with the same using-item
predicate the bow, trident and crossbow arms already use, and the off-hand
ladder gets the matching arm, since brushing works from the off hand too
and vanilla poses that hand the same way. Brush is one-handed and does not
affect the off hand, so shields, bows and tridents keep their paths.

The first-person sweep is not here: PR #3951 already implements it.
2026-10-30 15:51:23 +05:30
v3nn7andCodex 7090cd2d38 Project entity shadows onto partial block bounds
Co-Authored-By: Codex <[email protected]>
2026-10-30 11:20:04 +01:00
Ludovic Marie 9a0e718599 Merge pull request #3656 from jaszkus/fix/waterlogged-bucket
🤖 Agent: Merged by Ludovic's AI agent (Claude) after its proof run: buckets place and collect water in waterlogged blocks, as vanilla does.
2026-10-30 11:17:03 +01:00
v3nn7andCodex 66cc52fd9d Keep debug overlay off the title screen
Co-Authored-By: Codex <[email protected]>
2026-10-30 11:12:38 +01:00
DUzzL adc6e8626e Update port map note for the slider track outline 2026-10-30 11:11:18 +01:00
DUzzL 3bf35d81c8 Draw the slider track outline only for focus, not hover 2026-10-30 11:06:39 +01:00
JulesB40andClaude Opus 5.5 191fc3302d lan_e2e rocket-ceilings: a star rocket must rise before it bursts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:40:50 +01:00
JulesB40andClaude Opus 5.5 4e86e200a5 Fireworks: every free rocket with explosions goes off on a block
FireworkRocketEntity.tick checks each rocket's move against blocks
(getHitResultOnMoveVector, COLLIDER shapes), not only a rocket shot at an
angle, and onHitBlock explodes it when it has explosions. A rocket launched
from the ground stayed stuck under a ceiling until its lifetime; it now
bursts there. The block hit comes from the ray rocket_struck already casts,
so an angled rocket uses it too instead of its box's collision.
lan_e2e's rocket-ceilings check covers it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:38:06 +01:00
Ludovic MarieandClaude Opus 5.5 d24853c0a0 Send a column once its neighbours reach INITIALIZE_LIGHT (#4294)
A server sent a column as soon as it was lit, and its light layers
counted only the neighbours already loaded. Vanilla's ChunkStatus.LIGHT
needs every neighbour at INITIALIZE_LIGHT, and its light storage counts
their sections. So a column loaded with its saved light next to a proto
chunk still being made went out without the top light section that
proto's blocks add. dedicated_server failed 5 of 5 on playhorizon since
an idle dedicated server keeps no spawn area (#4095).

A column now waits until each neighbour is loaded or a decorated proto
chunk, and a proto neighbour's non-air sections (its TerrainChunk
counts, read when sent) go into the light layers. Nothing is kept per
column.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:35:54 +01:00
jaszkusandjaszkus's AI agent 3da531255a Fix the bucket test after the barrier case
The barrier section leaves a barrier at the test position, so the following 'Emptying water into air makes a source' step ran against a non-air block and failed for a non-creative player. Restore air there; bucket_empty is unchanged.

Co-Authored-By: jaszkus's AI agent <[email protected]>
2026-10-30 10:27:41 +01:00
JulesB40andClaude Opus 5.5 835733c7fc lan_e2e rocket-walls: measure each rocket's life in client ticks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:19:41 +01:00
JulesB40andClaude Opus 5.5 5d4016ca61 lan_e2e rocket-walls: count the rocket every tick in one ctl process
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:19:41 +01:00
JulesB40andClaude Opus 5.5 48055aa98f Fireworks: a plain rocket shot at an angle flies past the first block
FireworkRocketEntity.onHitBlock explodes only a rocket with explosions
(hasExplosion). A plain rocket out of a crossbow or a dispenser went off
with a poof at the first block it touched; it now flies on to its lifetime,
as in vanilla. Rockets with explosions still burst on impact. lan_e2e's
rocket-walls check covers both.

Fixes #4281

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:19:41 +01:00
hhamajandClaude Opus 5.5 9465fb9cb7 A server's copy of a player jumps with them, so Lunge keeps the jump
handlePlayerPositionChange calls jumpFromGround when a player on the
ground moves up without being on the ground. Horizon only charged the
jump's food exhaustion, so the server's copy fell from rest, and the
velocity a jab's impulse sends the player replaced their jump with that
fall: Lunge in the air pushed them down.

jump_from_ground already charges the exhaustion and counts the jump
statistic, which a server's players never got.

Fixes #4278

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 11:41:22 +03:00
ludovicthenot 01c6de15a3 Native mods: a hook point for item tooltips (API 4)
native_tooltip hears every item tooltip the game shows, with the stack and its lines, which a mod can add to, change or take out of. Fixes #4087.
2026-10-30 09:19:37 +01:00
MSedgeMC c361096ab0 Draw thrown items where vanilla puts them
Vanilla's thrown snowball, egg and ender pearl are drawn by
ThrownItemRenderer, which only scales and billboards the item: the visible
lift comes from item/generated's ground transform, translation [0, 2, 0]
of 16 units, i.e. 0.125 blocks scaled by the renderer's scale (1.0 for the
throwables, 0.75 and 3.0 for the fireballs). Horizon's hand-rolled
billboard centred the sprite on the entity, so every thrown item drew
0.125 blocks low.

The quad now takes that lift, gated to the kinds vanilla draws with
ThrownItemRenderer: snowball, egg, ender pearl and both fireballs. Llama
spit shares this quad here, but vanilla draws it from
LlamaSpitRenderer with its own translate(0, 0.15, 0) along world up, so it
is deliberately not lifted.

Spawn position, eye height, rotation and velocity already matched vanilla
(the spawn differs by 1.5e-9 blocks), so this is a rendering offset only.
The test pins exact vertex bounds: 99.875..100.375 at scale 1.0,
99.625..101.125 at 3.0, llama spit at no lift, and the rise following the
billboard's own up axis rather than world up.

Not covered here: eye of ender has the same 0.125 offset at its own call
site (game/end.rs eye_geometry) and is left as a follow-up. XP bottles
already take the item-model path, which applies the same ground transform,
so the report's XP-bottle half is not explained by this change.
2026-10-30 13:38:18 +05:30
MSedgeMC fb8e62bc7e Render the jeb_ sheep's wool colour, as vanilla does
SheepRenderer gives a sheep named exactly "jeb_" the lerped dye colour
(ColorLerper.Type.SHEEP: 16 colours, 25 ticks each, white as 0xFFE6E6E6)
and keeps its undercoat. Horizon read no name at all, so the egg did
nothing.

The name test matches MobRenderer.checkMagicName (exact, custom name), the
lerp is Mth.lerpInt's floor per channel, and the age threaded in is the
render state's ageInTicks (tick count plus the partial tick), so the hue
moves per frame instead of stepping at 20 Hz.

The colour vectors came out of the 26.3 jar and were re-derived
independently before the tests were written; jeb_wool_lerps_through_the_
sixteen_dye_colours pins 0xE6E6E6, 0xD0A37D, 0xBA6015, 0x953A8D, 0xDDDDDD
and the 400-tick wrap, and the layer test covers the dyed, baby and
sheared cases.

Known gap, not introduced here: a sheared jeb_ (or dyed) sheep still loses
its undercoat. Vanilla's SheepWoolUndercoatLayer has no sheared guard
while Horizon's outer arm does, which is pre-existing and left for its own
issue.
2026-10-30 13:38:04 +05:30
Ludovic MarieandClaude Opus 5.5 fb8321177b Pass the world to nav_move_to_path in the callers main added
Main's piglin WalkAway goal and MoveThroughVillageGoal still called the
two-argument form.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:01:29 +01:00
Ludovic Marie 3bff52e242 Merge remote-tracking branch 'origin/main' into fix-3721 2026-10-30 08:59:22 +01:00
d3grandoandClaude Opus 5.5 95d9303204 Ender pearls go through End gateways
A thrown ender pearl flew straight through an End gateway: the gateway
passes in game/end.rs only collected dropped items and mobs. Pearls now
join them (EndGatewayBlock.entityInside, Entity.handlePortal): the pearl
lands at the exit with no motion and rotation 0
(EndGatewayBlock.getPortalDestination for a ThrownEnderpearl), waits in
the arrivals queue while the exit loads, keeps its landing chunk loaded
for TicketType.PORTAL's 300 ticks (placePortalTicket), and then takes
its owner where it lands. ThrownEnderpearl.onInsideBlock fires the
owner's enter_block trigger (Remote Getaway), for a guest as them.

Part of #3514.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:44:47 +07:00
d3grandoandClaude Opus 5.5 1e8f93a692 Check tame wolf kill credit in the command execution harness
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:39:25 +07:00
d3grandoandClaude Opus 5.5 034191391b Credit a tame wolf's kills to its owner
LivingEntity.resolvePlayerResponsibleForDamage: a hit by a tame wolf sets
the victim's last-hurt-by-player to the wolf's owner for 100 ticks, so the
kill drops experience and player-only loot, and the owner, when in the
world, gets ServerPlayer.awardKillScore (mob_kills, totalKillCount, team
kills, player_killed_entity). The killed statistic stays the causing
entity's (Player.killedEntity), so a mob's kill no longer adds it, and
Raider.die's hero and Villager.tellWitnessesThatIWasMurdered, which also
follow the causing entity, skip a kill whose blow came from a mob.

Applied after a mob's melee hit and after /damage with a causing entity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:39:25 +07:00
d3grandoandClaude Opus 5.5 d225a5fdf7 Merge origin/main into fix-3513-cube-hurts-golem
mobs.rs: this branch's MobEvent::CubeHurtMob and main's StabDismount, both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:38:42 +07:00
CocofireHDandHermes Agent 03c8dd0d98 Item names in command feedback follow the item_name component
ItemStack.getDisplayName appends getHoverName, and getItemName prefers the
`minecraft:item_name` component over the item's own name (Item.getName), so
/give and the other item commands name a renamed stack after that component.
stack_display_name only read `custom_name`, so a datapack- or server-renamed
item was reported under its plain item name ("[Diamond]" for a diamond whose
item_name is "ItemName"). Read the component first, as vanilla does.

Co-Authored-By: Hermes Agent (deepseek-v4.1-flash) <[email protected]>
2026-10-30 08:33:54 +01:00
d3grandoandClaude Opus 5.5 e66a24edd4 lan_e2e: pet_death check for the owner's death message
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:31:26 +07:00
d3grandoandClaude Opus 5.5 df71f965b0 A tame pet's death tells its owner how it died
TamableAnimal.die sends the owner, when showDeathMessages is on and the
owner is online in any dimension, the pet's CombatTracker.getDeathMessage
("Rex was slain by Zombie"). Horizon's mobs had no combat tracker and
mob_died had no such step, so the owner was never told.

Mobs now keep a CombatTracker (mobs::combat): Mob::damage records each hit
that reaches the health (not one wolf armor takes), LivingEntity.tick's
recheckStatus forgets them after 100 ticks (300 in a fight), and /kill
records its generic_kill hit (LivingEntity.kill). mob_died tells a tame
wolf, cat, parrot or nautilus's owner, the host or a guest by UUID, a
message built by the player's own getDeathMessage code from those hits,
with the kill credit (getKillCredit) and the name of a creeper that blew
up. /damage by a player makes them the mob's lastHurtByPlayer, as
LivingEntity.hurtServer does.

Fixes #3514

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:31:26 +07:00
d3grandoandClaude Opus 5.5 912b671416 Volume sliders play a preview sound on the title screen's options
Vanilla's sound sliders (Options.createSoundSliderOptionInstance) call
SoundPreviewHandler.preview when there is no level: each category plays
its own UI sound at the slider's volume (zombie ambient for Hostile
Mobs, cow ambient for Friendly Mobs, thunder for Weather...), a preview
still playing is not restarted, and moving another category's slider
stops the previous one. Horizon had no preview at all.

Port SoundPreviewHandler (sound_preview.rs), report a changed category
volume from the options screen (Event::Volume, only for a new value as
OptionInstance.set), and play the preview from the game when the
options were opened from the title screen.

Part of #3518.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:22:23 +07:00
Ludovic MarieandClaude Opus 5.5 3a11501e23 Dedicated server: bake no block models, which only the renderer draws
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:19:39 +01:00
d3grandoandClaude Opus 5.5 3c6999d700 Spectators on a server see through the entity they spectate
ClientboundSetCameraPacket (/spectate, a spectator's click) was decoded as
an unknown packet, so the view stayed on the player while the server kept
that player on the camera entity: the view did not turn with the mob, sat
at the player's eye height inside the mob's body, and the client's own
moves made the server rubber-band it ("moved too quickly").

Follow ClientPacketListener.handleSetCamera / Minecraft.setCameraEntity:
the camera entity's id is kept until the player's own id, a login or a
respawn puts the view back. While another entity is the camera the view
is aligned with it (Camera.alignWithEntity: its head's yaw as
LivingEntity.getViewYRot, its eye height eased by Camera.tick), the entity is not drawn in first person, the pick starts
from its eyes, and the player neither moves nor sends moves
(LocalPlayer.applyInput / sendPosition check isControlledCamera); the
input packet still goes out, so sneaking lets go. A spectator's click
now sends ServerboundSpectatorActionPacket (Minecraft.startAttack,
MultiPlayerGameMode.spectate / spectatorNoAction).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:19:25 +07:00
d3grandoandClaude Opus 5.5 956bd64c5d Death loot parity: endermen carrying each holdable block
VanillaEntityDeathLootOracle sets the carried block, and the corpus kills
endermen carrying every enderman_holdable block, by a player with
Looting and by generic damage.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:11:35 +07:00
d3grandoandClaude Opus 5.5 7a1a5e8e88 Drop a killed enderman's carried block
Enderman.dropCustomDeathLoot rolls the carried block state's own drops
with a diamond axe enchanted by the enderman_loot_drop provider (Silk
Touch I) and spawns them after Monster's equipment drops. Horizon's
death drops never read the carried state, so the block was lost.

mobs::drops::die now yields a CarriedBlock drop in that place, and the
game resolves it through enderman::carried_block_drops (the block's
loot table with the silk touch axe, the enderman as this entity, from
the table's random sequence).

Part of #3513 (the Enderman.dropCustomDeathLoot row).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:11:33 +07:00
levzzzandClaude Opus 5.5 186540eb84 parity: give the mob_ai oracle an 8g heap
VanillaMobAiOracle ran out of its 4g heap (OutOfMemoryError: Java heap
space, 34 times in one run) within about 3 minutes of a fresh run on main,
with nothing else running. Runs that reused cached oracle results kept
passing, which hid it until the scenarios changed. With 8g the fresh run
passes in 2.9 minutes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 12:09:41 +05:00
abduvaliy-hbaiandOpenAI Codex 4b14acc1f6 Sync block entity data edits to connected clients
Mark block entity NBT changes dirty and queue an update even when the block state is unchanged, so spawner mob previews refresh after data commands. Add an isolated screenshot regression for zombie/pig/zombie transitions.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-30 12:07:20 +05:00
d3grandoandClaude Opus 5.5 16356bdc57 Explosions break paintings, cushions and armour stands
The explosion entity pass only reached players, mobs, items, primed TNT,
minecarts and item frames, so a blast left paintings, cushions and armour
stands standing. Vanilla's ServerExplosion.hurtEntities hurts them too:
BlockAttachedEntity.hurtServer kills a painting or cushion and drops its
item, and ArmorStand.hurtServer with an IS_EXPLOSION source runs
brokenByAnything (its equipment pops, the stand item does not) and kill.
An invisible or marker stand is spared (ArmorStand.ignoreExplosion and
hurtServer), and without mobGriefing a block-keeping or mob-caused blast
spares them all (shouldAffectBlocklikeEntities, the Mob source check);
item frames now share that gate, which also counts a projectile's owner.

Vanilla lists the entities in reach before hurting any, so the items these
entities drop are not caught by the same blast. Frames and the new
decorations now break after the item pass, so their drops survive instead
of being destroyed at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:07:16 +07:00
d3grandoandClaude Opus 5.5 6be3dad37b lan_e2e: tag-query check for an operator guest's F3+I
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:05:51 +07:00
d3grandoandClaude Opus 5.5 ef024a082c F3+I copies the server's data on servers and LAN
On a connection (a server, a LAN world, and single player, whose world
runs on its own server thread) F3+I copied the client's partial copy of
the block or mob and called it server-side: a chest lost its items, a mob
its tags and brain. Vanilla's KeyboardHandler.copyRecreateCommand asks the
server through DebugQueryHandler (ServerboundBlockEntityTagQueryPacket,
ServerboundEntityTagQueryPacket) and copies the ClientboundTagQueryPacket
answer to the newest query.

Add the three packets, the query state on the connection and its answer,
and the server side for guests (ServerGamePacketListenerImpl
.handleBlockEntityTagQuery and handleEntityTagQuery, for game masters
only). Part of #3520 (its handleBlockEntityTagQuery and
handleEntityTagQuery rows).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:05:51 +07:00
d3grandoandClaude Opus 5.5 e9eda298fd Complete chat with the server's custom chat completions
ClientboundCustomChatCompletionsPacket was only an id constant, so the
names and tags proxies and plugins add with it (players on other backend
servers, chat tags) never showed when Tab completes chat. Decode it, keep
the connection's set like ClientSuggestionProvider.modifyCustomCompletions
(ADD, REMOVE, SET; an unknown action id is ADD), clear it on a
reconfiguration, and complete chat from getCustomTabSuggestions: the
players and the custom completions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:05:27 +07:00
Ludovic MarieandClaude Opus 5.5 c8ee23f2b8 Keep no face shapes from the load-time passes over the collision and support tables
analyse_shape and the cached support flags computed every shape's six faces
through face_shape, which caches them on the shape for good: about 39 MB that
nothing reads again. They now use face_shape_transient; runtime queries still
cache.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:04:59 +01:00
d3grandoandClaude Opus 5.5 958522ce67 Let armadillos breed, follow spider eyes and their parents
ArmadilloAi.initIdleActivity runs AnimalMakeLove(1.0), then a RunOne of
FollowTemptation(1.25, armadillo_food, close enough at 1 block for a
baby and 2 for an adult) and BabyFollowAdult(5..16, 1.25). The
armadillo's goals had none of them, so spider eyes gave hearts but no
baby, armadillos ignored a player holding them, and babies wandered off.

Add Breed, Tempt and FollowParent, each wrapped so that it only runs in
the IDLE activity: not while ArmadilloAi.updateActivity picks PANIC
(danger in mind and not panicking) and not while it is a ball.

Part of #3512

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:03:02 +07:00
d3grandoandClaude Opus 5.5 84ac601f3a Slimes and magma cubes hurt the iron golems they touch
AbstractCubeMob.push(entity) calls dealDamage on an IronGolem whose
pushEntities takes the cube, but Horizon only ported the playerTouch path,
so cubes chased golems (they target them) without ever hurting them.

The cube's ai_step now hits every alive iron golem whose box overlaps its
own (EntitySelector.pushableBy, doTeamsAllowDamage, hasLineOfSight) for
getAttackDamage with a mobAttack source, unless the cube cannot deal damage
(a tiny slime, or a NoAI cube, which is not isEffectiveAi). A new
CubeHurtMob event plays entity.slime.attack only when the hit landed, as
dealDamage does after hurtServer; its two pitch draws from the cube's
random happen even for a silent cube.

Part of #3513.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:59:53 +07:00
d3grandoandClaude Opus 5.5 aff1d9864f The mount a rider's client moves ignores the server's moves of it
ClientPacketListener.handleMoveEntity and handleEntityPositionSync leave
an entity the client is authoritative for where it is; only
handleTeleportEntity puts it somewhere else, at once. The steps the
server's moves queued while the rider drove the horse replayed after the
dismount: the horse jumped back about five blocks on a vanilla server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:59:23 +07:00
hhamajandClaude Opus 5.5 2d41c9d432 A pearl turns the wind charge it hits, which then speeds up
Projectile.onHit calls onRedirectProjectile on a redirectable target:
AIM_DEFLECT along the thrower's look, the thrower its owner, acceleration
power 0.1. #3599 made the pearl teleport but left the charge as it was.
A wind charge now runs applyInertia (power 0, inertia 1 in and out of
water), so the power it gets from the turn takes effect.

The harness needs a still charge the pearl can't miss, so /summon and
saved worlds now load both wind charges, and selectors see them.

Fixes #3299

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:51:13 +03:00
d3grandoandClaude Opus 5.5 699fae6296 Riders move their horses on servers, and jump them
On a vanilla or Paper server the controlling passenger's client moves a
saddled horse, donkey, mule or camel (Entity.isLocalInstanceAuthoritative,
LivingEntity.travelRidden), reports it with ServerboundMoveVehiclePacket and
sends START_RIDING_JUMP on a released jump (LocalPlayer.sendRidingJump). The
server only turns it (travelRidden without canSimulateMovement). Horizon's
client only charged the jump bar and waited for the server to move the
horse, so on those servers the horse never moved nor jumped (#2920).

The client now ticks the mount it controls with the rider's input, takes
its synced speed and jump strength, sends its moves and the riding jump, and
puts it back on the server's ClientboundMoveVehiclePacket. Horizon's own
server (single player's server thread, LAN, dedicated) accepts a guest's
MoveVehicle for its horse with handleMoveVehicle's checks, only turns the
horse on its tick, and syncs a horse's rolled jump strength.

Fixes #2920

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:37:32 +07:00
Ludovic Marie 37f449f91f Merge branch 'pt1-3490' into pt1-3491 2026-10-30 07:33:51 +01:00
d3grandoandClaude Opus 5.5 9bdb5ec7fd List worlds with an unreadable level.dat and offer recovery
A world whose level.dat could not be read (cut short by a crash during a
save, say) was left out of Select World, although level.dat_old would load
it, so the player believed the world was gone.

list_worlds now follows LevelStorageSource.findLevelCandidates and
readLevelSummary: any folder with a level.dat or level.dat_old is a world,
and when level.dat does not read it is listed as a CorruptedLevelSummary
(the folder name, red "Failed to load world summary", "Attempt to Recover";
Edit and Re-Create off). Opening it, like any world whose level.dat does
not read (WorldOpenFlows.openWorldLoadLevelData), shows
RecoverWorldDataScreen with the state of level.dat and level.dat_old
(collectIssue/buildInfo); Attempt to Restore runs restoreLevelDataFromOld
(the broken file kept as level.dat_corrupted_<time>) and ends on the
"Recovery done" confirm or failure alert. Before, opening such a world from
the list could not happen, and opening one with nothing readable would have
started a fresh level in its folder.

Part of #3517

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:33:33 +07:00
d3grandoandClaude Opus 5.5 9432147489 A chat message the server deletes becomes the deleted marker
ClientboundDeleteChatPacket (31) was only a constant: Horizon never decoded
it, and the PlayerChat handler dropped the signature a deletion names a
message by, so a line a moderation plugin deleted stayed on screen.

Decode the packet (MessageSignature.Packed), keep the connection's
MessageSignatureCache (unpack the last seen of each PlayerChat, then push it
and the signature, as ClientPacketListener.handlePlayerChat does), and keep
each player message's signature in the chat. handleDeleteChat: a message
still held back by Chat Delay never shows (removeFromDelayedMessageQueue);
a shown one becomes the grey italic chat.deleted_marker line once it has
shown 60 ticks (ChatComponent.deleteMessageOrDelay, with the delayed queue
ChatComponent.tick processes). An unknown packed id disconnects with
multiplayer.disconnect.invalid_packet, as vanilla does.

Fixes #3518

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:30:38 +07:00
Ludovic Marie 824953813a Merge branch 'pt1-3489' into pt1-3490 2026-10-30 07:29:40 +01:00
Ludovic Marie 04cbf91533 Merge branch 'pt1-3488' into pt1-3489 2026-10-30 07:29:25 +01:00
Ludovic MarieandClaude Opus 5.5 3b584979a8 Merge main (through the carried #3487) into level-shape-9
Keeps both sides: main's world_root and LAN online mode beside the level
shapes, and main's content-identity check before the registry packets,
which now carry the levels' dimension types.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:27:48 +01:00
d3grandoandClaude Opus 5.5 d5fd62c298 Merge origin/main into fix-2755
game/explosion.rs: main's new interaction parameter for explosion_hurt_entities and this branch's returned player pushes are both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:25:00 +07:00
Ludovic MarieandClaude Opus 5.5 265bb903d7 Merge main (through the carried #3486) into level-shape-8
The crate split moved chunk.rs, collisions.rs and world.rs to
horizon-client-render: this commit's changes are reapplied there.
render_tick.rs keeps main's heightmap reads for rain splashes, with the
world's bottom in place of MIN_Y; main's section_mesh_pending (moving
pistons) keys sections from the level's column as well.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:24:36 +01:00
d3grandoandClaude Opus 5.5 3891dd9410 Break sound and crumbs for equipment a server says broke
A vanilla server never plays an item's break itself:
LivingEntity.onEquippedItemBroken broadcasts the entity event that
entityEventForEquipmentBreak gives for the slot (47 main hand, 48 off
hand, 49 to 52 head to feet, 65 body, 68 saddle) and each client's
LivingEntity.handleEntityEvent runs breakItem on the stack it still has
in that slot. The remote client had no case for these events, so on a
server a breaking pickaxe, armour piece or wolf armour was silent and
left no crumbs, for this player and for everyone else.

Handle them: find the stack in the slot (the own inventory, or the
mob's or other player's equipment), play its break_sound component at
the entity's feet unless the entity is silent, and spawn the five item
crumbs of spawnItemParticles. Entity.DATA_SILENT is now kept for
remote entities so the silent check works.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:22:55 +07:00
Ludovic MarieandClaude Opus 5.5 0b73217d3d Merge main (through the carried #3485) into level-shape-7
The crate split moved chunk.rs, lod.rs and minimap.rs to
horizon-client-render: this commit's changes are reapplied there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:22:02 +01:00
d3grandoandClaude Opus 5.5 1dd92e5f84 Drowned, zombie and stray conversions keep gear, name and flags
Zombie to drowned, husk to zombie and skeleton to stray spawned a fresh,
spawn-finalized mob through MobEvent::SpawnKind with only baby, yaw and
persistence, so the old mob's armour, held items, name tag, effects,
NoAI/Silent and lead were lost (a name-tagged zombie that drowned came
back as an unnamed drowned).

Vanilla's ConversionTracker.doConversion calls Mob.convertTo(type,
ConversionParams.single(this, true, true)): ConversionType.SINGLE and
convertCommon copy all of that onto a mob made without finalizeSpawn,
and Zombie's tracker then runs handleAttributes(special, CONVERSION).
mobs/conversion.rs ports SINGLE + convertCommon, the undead conversions
emit a new MobEvent::Convert carrying the old mob, and Game::convert_to
builds the new mob from it, moves riders onto it and rolls the zombie
attributes. The conversion sound is skipped for a silent mob, as the
level event is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:20:25 +07:00
d3grandoandClaude Opus 5.5 350d5dbe2d Send and handle ClientboundChunksBiomesPacket so /fillbiome recolours loaded chunks
/fillbiome changed the server's biomes but no client was told: the packet
existed only as an id, the server never sent it and the client had no
handler, so grass, leaves and water kept the old colour until the column
was sent again (single player on its server thread, LAN guests, dedicated
servers).

- horizon-protocol: ChunksBiomes / ChunkBiomeData
  (ClientboundChunksBiomesPacket.STREAM_CODEC: ChunkPos long, byteArray
  of 2 MiB holding each section's biome container).
- Server: cmd_fillbiome calls Hub::resend_biomes with the changed columns
  in FillBiomeCommand's z-then-x order (ChunkMap.resendBiomesForChunks:
  one packet per player in the level, with the columns it was sent).
- Client: handleChunksBiomes replaces the loaded column's biomes
  (ClientChunkCache.replaceBiomes / LevelChunk.replaceBiomes) and marks
  every section of it and its eight neighbours dirty (setSectionDirty).

Part of #3518 (ClientPacketListener.handleChunksBiomes)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:18:50 +07:00
DenisHumenandClaude Opus 5.5 dcd86510f5 Frogs walk their legs at vanilla's rate
Frog.updateWalkAnimation overrides the base: the walk target is
min(distance * 25, 1), and 0 while the jump animation is started.
Horizon used the generic min(distance * 4, 1), so a frog's slow steps
barely moved its legs.

Fixes #2848

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:18:47 +02:00
d3grandoandClaude Opus 5.5 91aa9c790f Get off a strider onto the shore, not into the lava beside it
Dismounting used one rule for every mount: a spot either side of the
mount's body, then its back, checking only for full blocks at the feet
and head. Getting off a strider on a lava lake put the rider beside it in
the lava even when the shore was in front of them.

Strider.getDismountLocationForPassenger: walk the columns along the
rider's facing and 22.5 and 45 degrees either side
(Entity.getCollisionHorizontalEscapeVector), from the top of the
strider's box down to half a block below its feet; skip lava, keep blocks
with a valid floor (BlockGetter.getBlockFloorHeight,
DismountHelper.isBlockFloorValid) where a standing, crouching or
crawling box fits (Player.getDismountPoses, DismountHelper.canDismountTo),
and take that pose. With none, land on top of the strider.

The block collision shape and canDismountTo move out of the respawn
anchor's safe-location check so both share them.

Part of #3513.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:17:31 +07:00
repositorykreml1nandCodex b87f65b1b6 TNT: cover client replay and preserved server duplication
Add four regression tests for issue #3856: client neighbor updates and placement leave TNT alone, server priming is not limited per tick, and a piston duper retains its TNT block while creating exactly one server entity and no client entity.

Verified against nightly-2026-10-29-4baa571 with prepared Java 26.3 resources.

Co-Authored-By: Codex <[email protected]>
2026-10-30 09:17:05 +03:00
repositorykreml1nandCodex df1a26d2bd TNT: prime powered blocks only on the server
Match vanilla TntBlock.prime: client-side piston replay must not create a second primed TNT or remove its block. Server-side TNT duplication remains unchanged.

Refs horizon-rust/horizon#3856.

Co-Authored-By: Codex <[email protected]>
2026-10-30 09:14:38 +03:00
arismoko 05275d7682 Expose native server block change observations 2026-10-30 01:13:03 -05:00
arismoko 907b5729b1 Expose native neighbour-change observations 2026-10-30 01:13:03 -05:00
arismoko c2670e7bd2 Expose read-only native server block queries 2026-10-30 01:13:03 -05:00
arismoko 2a6ae514f8 Expose native server tick hooks 2026-10-30 01:13:03 -05:00
d3grandoandClaude Opus 5.5 e445e0edf6 Spectating players are tracked only by other spectators
ChunkMap.TrackedEntity.updatePlayer pairs an entity with a player only
when Entity.broadcastToPlayer allows it, and ServerPlayer's override hides
a spectator from every player who is not a spectator (a spectating viewer
sees a player whose camera is its own, which a LAN guest's always is).
The hub's tracker had no such check, so a guest in spectator mode stayed
paired with everyone: its AddEntity, equipment and nametag still reached
the other players, and their crosshair still picked it.

Player states now carry whether the player is a spectator, viewers too,
and the pair check follows broadcastToPlayer. Going in or out of spectator
mode checks the player's pairs again (vanilla's ChunkMap.move does it at
the player's next move), so the others lose it, or get it back with its
armour, at once.

Part of #3520

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:08:40 +07:00
d3grandoandClaude Opus 5.5 4f03c8a873 Time out a server that stops answering, with vanilla's disconnect reasons
The multiplayer connection only had a connect timeout: once joined, a
server that froze or dropped off the network without a TCP reset left
the player in a frozen world forever. Vanilla's client channel starts
with ReadTimeoutHandler(30) (Connection$1.initChannel), so the client
socket now gets a 30 s read timeout.

A lost connection also showed Horizon's raw error text (on Windows the
localized OS message). Connection.exceptionCaught's reasons replace it:
disconnect.timeout ("Timed out") for the read timeout,
disconnect.genericReason ("Internal Exception: ...") for any other
failure, and disconnect.endOfStream when the server closed the stream
(Connection.channelInactive). The integrated server's in-memory pipe
keeps no timeout, as vanilla's local channel has none.

Part of #3520 (Connection#exceptionCaught).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:08:10 +07:00
Alexander KrylovandHermes Agent ccb85be48e Launcher: a version stops counting as installed when its removal starts
backend::remove dropped the .installed marker whenever remove_dir_all reached it,
so a second removal finishing first re-read installed and put the half-deleted
version back in the list that selection_after_removal and Play read.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-30 08:38:06 +03:00
Kacpherek 0917285a33 Merge remote-tracking branch 'origin/main' into fix/split-misc-a 2026-10-30 06:32:04 +01:00
Kacpherek b886586ca6 Merge remote-tracking branch 'origin/main' into fix/split-misc-a
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/advancements.rs
#	crates/horizon-client/src/game/allays.rs
#	crates/horizon-client/src/game/armor_stands.rs
#	crates/horizon-client/src/game/lan_actions.rs
#	crates/horizon-client/src/game/remote_projectiles.rs
#	crates/horizon-client/src/mobs/leash.rs
2026-10-30 06:32:04 +01:00
Kacpherek 2061f797ba Share stand_if_possible between the rear goal and the rider's jump 2026-10-30 06:31:44 +01:00
Kacpherek 3f690a811c Walk a camel's legs only while it stands and does not dash
A camel's legs used the default walk: they kept swinging while it sat or dashed and eased in at 0.4. Camel.updateWalkAnimation aims at 6 times the distance moved (at most 1) only in the standing pose with no dash animation running, otherwise at zero, and eases at 0.2. The remote client's own walk update uses it for camels too.
2026-10-30 06:31:39 +01:00
Kacpherek 381e2e9a84 Stand a sitting camel up when its lead pulls taut
A leashed sitting camel stayed down however hard the lead pulled. Camel.onElasticLeashPull stands it up when it is sitting, not changing pose, and the standing box fits where it is (canCamelChangePose, wouldNotSuffocateAtTargetPose). AbstractHorse.onElasticLeashPull, which it calls first, also ends a horse's grazing.
2026-10-30 06:31:39 +01:00
Kacpherek f4b7a899b8 Let a horse slide on the rider's keys after a jump while it rears
A horse that landed from a small hop while still rearing ignored the keys until the rear ended, and a donkey or mule never reared on a jump. AbstractHorse.onPlayerJump sets allowStandSliding and calls standIfPossible (every horse that can rear), getRiddenInput only zeroes the input while the horse is standing on the ground without a jump pending and without allowStandSliding, and tick clears it once the horse stands no more.
2026-10-30 06:31:39 +01:00
Kacpherek dff7cc1dda Slide a rider back and up while its horse rears
A horse, donkey, mule or undead horse that reared kept its rider at the fixed seat. AbstractHorse.getPassengerAttachmentPoint adds (0, 0.15, -0.7) times the previous tick's stand animation (standAnimO) to the seat, turned with the horse. The player and a mob rider both slide.
2026-10-30 06:31:39 +01:00
Kacpherek fa27fb4088 Seat camel riders by the camel's sit and stand animation
The rider's seat on a camel was a fixed height for sitting and standing, built from the old full-height box, and a mob rider sat at the top of the box when the camel sat. Camel.getPassengerAttachmentPoint takes the body anchor from the current pose box and lerps it through the sit and stand transitions (getBodyAnchorAnimationYOffset), at the camel's age scale. The player and a mob rider both use it now.
2026-10-30 06:31:39 +01:00
Kacpherek d59d8e7147 Shrink a sitting camel's hitbox to its sitting size
A camel that sat down kept its standing box, so it blocked and was hit as if it still stood. Camel.getDefaultDimensions gives Pose.SITTING an adult box 1.43 lower with eyes at 0.845, and a baby box 0.425 tall with eyes at 0.41. Mob::awake_dims now follows the camel's synced pose, on the server and on a client.
2026-10-30 06:31:39 +01:00
Kacpherek b2ba13377b Hurt a llama and its riders only for falls of six blocks
A llama landed with the generic fall sound and hurt on any damaging fall. Llama.causeFallDamage makes no fall sound, and hurts the llama and hands the fall to its riders only when the fall is six blocks or more.
2026-10-30 06:31:39 +01:00
Kacpherek 3379afe807 Pass a mount's fall damage on to its riders
A rider took no damage when the mob under it landed hard, and a mob on a mount's back never felt it either. Entity.propagateFallToPassengers hands the fall to every passenger (LivingEntity.causeFallDamage first, AbstractHorse.causeFallDamage after its own hurt and only when hurt), and each passenger runs its own causeFallDamage with its own safe distance. A mount now raises a fall event the game turns into Player.causeFallDamage for the local player and server players, and the mob causeFallDamage for a mob rider.
2026-10-30 06:31:30 +01:00
Kacpherek 1a4ee0e7ee Release the probe lock when a world is checked for being open elsewhere
Checking a world on the title screen takes the session lock for an instant; hand it back explicitly, as SessionLock does, so a quit world is never listed as locked. Drop the stale note that powder snow climbing is not ported for rabbits.
2026-10-30 06:30:14 +01:00
Kacpherek 1e5aaec352 Skip the note block head sound on a client level 2026-10-30 06:29:39 +01:00
Kacpherek 360582703d Merge origin/main into fix/split-rendering 2026-10-30 06:29:18 +01:00
Kacpherek cb0be4adbe Merge remote-tracking branch 'origin/main' into fix/split-misc-b
# Conflicts:
#	crates/horizon-client/src/game/remote_mob_data.rs
#	crates/horizon-client/src/game/remote_projectiles.rs
2026-10-30 06:28:40 +01:00
Kacpherek 43777b9d16 Stub the redstone ore dust in the remote mining fixture 2026-10-30 06:28:23 +01:00
Kacpherek ef917292ed Merge remote-tracking branch 'origin/main' into fix/split-blocks
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-30 06:28:07 +01:00
Ludovic Marie 08e7c00806 Merge remote-tracking branch 'origin/main' into upd-3721
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-30 06:14:43 +01:00
My NameandClaude Opus 5.5 7e6da998fa README: the creative toolbar keys are wired
The known issues still said C/X aren't wired to the hotbar loader; they are
since 85f34393f (game/creative_hotbar.rs), and #3693 added the Saved Hotbars tab.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:52:38 +03:00
My NameandClaude Opus 5.5 dbce6420c6 Command blocks: one data path; the command reaches only the player who opens it
#3582 and #3748 fixed the same bug (#3614) twice. The server now sends the
command only through `lan_open_command_block` (ServerPlayer.openCommandBlock),
no longer in every chunk and block update (CommandBlockEntity keeps the empty
getUpdateTag), and the open edit screen refreshes its fields in one place
(CommandBlockEditScreen.updateGui). docs/rust-parity.md: the toolbar keys and
the Saved Hotbars tab are wired since #3693.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:44:42 +03:00
Ludovic Marie 9ee6b293d4 Merge remote-tracking branch 'origin/main' into upd-3721
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-30 05:14:02 +01:00
Ludovic Marie f1eedfcb40 Merge remote-tracking branch 'origin/main' into upd-3721
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-30 04:31:59 +01:00
JulesB40andClaude Opus 5.5 85ff723581 Allocator: say which heaps mi_collect frees
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:21:13 +01:00
JulesB40andClaude Opus 5.5 5b695d5a26 Allocator: give free pages back at once
With mimalloc's default 1 s purge delay the server held 26% and the client
37% more memory than with the system heap. Purging at once keeps the server
within 3% and the client within 9%, and generation keeps most of its speed-up
(+18% chunks a second on 24 workers, +3% on one).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:21:13 +01:00
JulesB40andClaude Opus 5.5 800e7cef33 Game: mimalloc as the global allocator
World generation, meshing and the tick allocate and free small blocks on
every worker at once, and the system heap serialised them: on Windows
RtlAllocateHeap/RtlFreeHeap were 16% of chunkgen_bench's CPU. mimalloc keeps
a heap per thread. release_free_memory also gives mimalloc's free pages back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:21:13 +01:00
d3grandoandClaude Opus 5.5 633cd1dbce Merge origin/main into fix-3393-optab
creative.rs, with #3693's Saved Hotbars tab: Tab gains hotbar, set in Exported and Tab::header. Neither side's tab skip is kept: the hotbar tab now shows (#3693), and an empty category tab, the operator tab without permissions among them, is left out by its stacks after the permission filter (this branch), so the exporter's visible flag is no longer read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:55:23 +07:00
d3grandoandClaude Opus 5.5 03ba7662d5 Merge origin/main into fix-2740
game/server_packs.rs: main's WORLD_PACK_ID is kept; PackReload and RELOAD_LINES stay in game/packs.rs, where this branch moved them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:37:34 +07:00
FauxandClaude Opus 5.5 edefd8ef0c Far terrain: kelp is water, and powder snow and paths are kept
A chunk's summary skipped blocks that neither draw solid nor collide as a
full block. Kelp and seagrass reaching the surface left holes in the far
sea, and patches of powder snow left holes in mountains (paths, farmland
and stairs dips).

- Blocks holding water that aren't kept (kelp, seagrass, bubble columns,
  waterlogged ones) are drawn as water, so the water is one span.
- Powder snow, dirt paths, farmland, honey blocks and stairs are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012gFJzABp1uePB4gm32PuaC
2026-10-29 17:48:18 -07:00
DUzzL 91bd434281 Apply block_break_speed attribute to mining speed
Read the player's minecraft:block_break_speed attribute and multiply the destroy speed by it in both the server mining tick and the client's destroy-progress prediction, matching Player.getDestroySpeed.

Fixes #4094.
2026-10-30 01:32:11 +01:00
Federico TaglianiandClaude Sonnet 5.5 735cdab711 Merge origin/main into fix/skeleton-path-stops-in-shade
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 01:21:32 +01:00
DUzzL ce91a02d84 Use the player's attack_speed base for the attack cooldown (#4091) 2026-10-30 01:20:23 +01:00
Federico TaglianiandClaude Sonnet 5.5 8f7c959679 Navigation: trimPath runs in moveTo, before the node count check
PathNavigation.moveTo trims the path first and refuses it when the trim
leaves no node. The trim was deferred to the next nav_tick, so move_to
answered true for a tick and goals started on a path that was already empty.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 01:14:41 +01:00
Federico TaglianiandClaude Sonnet 5.5 4ef3c1f54a Merge origin/main into fix/skeleton-path-stops-in-shade
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 01:08:27 +01:00
d3grandoandClaude Opus 5.5 4d1f533af6 Creative tab selection test: give the category tab a stack
The fixture's CATEGORY tab was empty, and an empty category tab is now
hidden (CreativeModeTab.shouldDisplay), so tab 0 was no longer the tab the
test selects. It now holds a stone stack.

Part of #3393.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:52:26 +07:00
Alexander KrylovandHermes Agent 24c323960e Launcher: keep removable beside its own doc comment
The new fn sat between selection_after_removal's doc comment and that
function, so the comment documented the wrong one.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-30 02:46:21 +03:00
d3grandoandClaude Opus 5.5 f84b88b2e9 Thrown pearls feel the water their box touches, as vanilla's isInWater
Projectile::tick chose the water inertia (0.8) from the fluid of the block
at the projectile's feet, every tick including the first. Vanilla's
ThrowableProjectile.applyInertia and AbstractHurtingProjectile.applyInertia
read Entity.isInWater (wasTouchingWater), which the previous tick's
Entity.baseTick -> updateFluidInteraction set from the deflated bounding
box after the move (EntityFluidInteraction.update: some water surface at or
above the box's bottom), and which is still false on the first tick.

So a pearl thrown under water lost a fifth of its speed on the first tick
(it lands about 6.0 blocks away instead of 7.4), one skimming just above a
pool slowed as if under water, and one whose box dipped into the next
column's water did not slow at all.

The projectile now keeps touching_water from the box after each move
(ProjWorld reads it through MobWorld::fluids, the mobs' port of the same
test); llama spit, whose base tick runs before its move, tests its box at
the start of the tick. Client-side arrows test their box too.

Part of #3072.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:40:21 +07:00
d3grandoandClaude Opus 5.5 6b8e3b8f5f Runtime spawns run Mob.finalizeSpawn and wither skeletons hit for 4
Mobs spawned while the game runs (/summon, spawn eggs, spawners, natural
spawns, raids) go through the reduced spawning::finalize, which skipped
Mob.finalizeSpawn and WitherSkeleton.finalizeSpawn:

- no mob got the random_spawn_bonus follow range modifier, and none was
  ever left-handed (vanilla: 5 percent);
- wither skeletons kept the default 2.0 attack damage instead of 4.0.

Move the Mob.finalizeSpawn body into finalize::mob_spawn, shared by the
worldgen Ctx::mob, the summoned iron golem and now the runtime finalize
(every mob but the cave spider, whose finalizeSpawn does not call super),
and set the wither skeleton's attack damage base to 4.0. A bred baby
(Animal.spawnChildFromBreeding: getBreedOffspring runs no finalizeSpawn)
drops the bonus and the left hand again, as it already drops the sound
variant.

Part of #2783.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:37:46 +07:00
d3grandoandClaude Opus 5.5 0b0a91e4d4 Mob AI parity: a wolf shaking dry and a wolf in the rain
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:37:22 +07:00
d3grandoandClaude Opus 5.5 5ee04257c5 Wet wolves darken and shake off the water on clients
A wolf's wet and shake state lived only in the server's ai_step, so a
client of a server (the default single player on its server thread, a
LAN guest) never saw a wolf darken in water or rain, nor shake dry.

Split Wolf.tick's wet and shaking part out so both sides run it: the
client knows on its own when the wolf is in water or rain (isWet), and
follows the server's entity events 8 (start shaking, broadcast by
Wolf.aiStep) and 56 (cancel, from Wolf.tick back in water or rain) in
Wolf.handleEntityEvent. The shake now starts and plays its sound in the
same tick as vanilla, and the splash offsets come from the wolf's random
on both sides, as Wolf.tick draws them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:37:22 +07:00
d3grandoandClaude Opus 5.5 8c1f50f616 The Void superflat preset gets its stone start platform
FlatLevelGeneratorSettings.adjustGenerationSettings decorates a flat
world with its biome's placed features when `features` is on and the
world isn't all air, or its biome is the_void. The Void preset (the_void,
features, one air layer) so places the_void's only feature,
void_start_platform: a 33x33 stone square with cobblestone at 8, -61, 8.
FlatTerrain placed only the layers, so players fell into the void.

Biomes now keep each biome's `features` by step, FlatTerrain works out
the adjusted list and places void_start_platform (the ported
VoidStartPlatformFeature) when it is there. The other features, lakes
and structures are still not placed (#2912).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:28:52 +07:00
d3grandoandClaude Opus 5.5 413e6e856e Swapping the same item into the off hand plays the re-equip animation
FirstPersonHandsAndItems.shouldInstantlyReplaceVisibleItem keeps the
visible item when it is the same ItemStack object, or one that
matchesIgnoringComponents (count, item and components, with damage
ignored). Horizon stood in for the object by the slot, so the off hand,
always the same slot, kept showing a stack of the same item that the
swap key had replaced, whatever its count (#2866).

A swap now marks both hands as holding new stacks: at once in the local
game, and on a server (the thread one included) when the slot updates
that answer SWAP_ITEM_WITH_OFFHAND arrive. Those hands then compare by
matchesIgnoringComponents until they show the held stack again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:24:02 +07:00
septydev c449d797bd Fix account chip text overflow 2026-10-30 01:17:01 +02:00
oscargzss 8bbfc5cbf0 renderer: try a backend again with non-conformant adapters
wgpu hides every adapter whose driver reports Vulkan conformance version 0
unless `ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER` is set, and Mesa reports
0.0.0.0 for hasvk, which drives Ivy Bridge and Haswell. Such a GPU is told
it has no driver while Vulkan runs, and the only escape was the player's
environment variable.

`open_device` now asks a backend that offered no adapter at all a second
time with those adapters shown, and says in the log which adapter it took.
Every machine where a backend already gives an adapter is untouched.

Fixes #4059
2026-10-29 17:15:13 -06:00
d3grandoandClaude Opus 5.5 5550a6c327 Read the glint clock and options once per frame, not per item
Game::glint() and glint_ticks() read the clock and looked glintSpeed and
glintStrength up by name (a linear scan of the options table) for every
item submitted, so a frame with thousands of dropped items read the clock
thousands of times. Vanilla snapshots the options once per frame
(GameRenderer.extract -> extractOptions into OptionsRenderState) and
TextureTransform.setupGlintTexturing reads Util.getMillis() once per glint
render-type batch. Capture a GlintFrame at the start of each render() and
let world items, armour, held items and GUI items read it.

Fixes #3183

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:06:42 +07:00
Сидоренко Егор АлекчеевичandCodex db38f50b12 Fix detail requests for retiring fine LOD leaves
Seed the existing bounded request path with planned retiring leaves still missing saved detail, so full-detail geometry can retire after its replacement is ready.

Fixes #4039

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-30 02:01:27 +03:00
d3grandoandClaude Opus 5.5 d4a973f65f Creative inventory: the Operator Utilities tab for operators
The Operator Items Tab option was greyed out and nothing read it: the
creative export was built without permissions, so the operator tab had no
stacks and was dropped.

VanillaCreativeTabsExporter now builds the tabs with permissions and marks
the stacks only that build lists (permissions_only). Creative keeps every
exported stack and rebuilds its tabs and search tab for hasPermissions
(canUseGameMasterBlocks and the option), as CreativeModeInventoryScreen
does on opening and in containerTick (tryRefreshInvalidatedTabs): a
selected operator tab left empty gives way to the default tab. The option
is switchable again. test_creative_parity.py runs with and without
permissions.

Part of #3393.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:59:03 +07:00
d3grandoandClaude Opus 5.5 152258bb83 server_knockback harness: creative players near a crystal and TNT
KnockbackClient records the push each ClientboundExplodePacket carries;
two cases put Bob in creative two blocks from an end crystal he hits and
from primed TNT.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:54:10 +07:00
d3grandoandClaude Opus 5.5 51cd5409da Creative players are pushed by explosions and crystals
ServerExplosion.hurtEntities puts each pushed player in hitPlayers and
ServerLevel.explode sends them that push in ClientboundExplodePacket. Horizon
pushed the server's copy only and sent it to the client with a hurt's motion
packet, which a creative player never gets: a crystal they hit left them
standing, and TNT replaced their motion with the server copy's. The pushes
now go in the explosion packet (deferred with it in a guest's turn), except
for a hurt player, whose motion packet already carries the push.

Fixes #2755 (its survival part was fixed by #2730).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:54:10 +07:00
Ludovic Marie 183e772727 Merge remote-tracking branch 'origin/main' into upd-3721
# Conflicts:
#	tools/test_mob_ai_parity.py
2026-10-29 23:52:39 +01:00
Ludovic Marie 3887ceefec Merge origin/main into feat/launcher-night-shell 2026-10-29 23:48:53 +01:00
Ludovic MarieandClaude Opus 5.5 0d322f4fca Launcher: hover layers only while hovered, plates painted in one canvas
A row or tab that isn't hovered no longer builds its (invisible) hover plate, and plate::cut
paints its rectangles in one canvas instead of a div each: the sidebar and the top bar redraw
on every sky frame, so they cost a few elements instead of hundreds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:48:04 +01:00
d3grandoandClaude Opus 5.5 dff6db1e86 Hide the own body when the server sets the player's invisible flag
Entity.isInvisible reads shared flag 5, which a server sets with the
Invisibility effect or alone through Player.setInvisible, as pose plugins
(GSit's lay, crawl, spin) do to hide the real body under the pose. The client
read the flag for other entities only and hid its own body, cape, first-person
arm and name tag (and thinned its effect swirls) only for the effect, so the
real body showed under the pose.

Keep the own flag from SetEntityData (reset on login and on a respawn without
KEEP_ENTITY_DATA) and use it with the effect wherever the own player's
isInvisible is asked.

Fixes #3788

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:24:39 +07:00
Ludovic MarieandClaude Opus 5.5 71127f982f Launcher: the night frame, a sidebar plate over the sky and a top bar of tabs (redesign R3)
shell.rs draws the frame's parts: the sidebar's translucent plate from the window's top to its
bottom (wordmark, instances, servers, links, version), rows and tabs on pixel-cut plates when
selected and eased in at half opacity when hovered, the account chip with the signed-in
account's face. The top bar sits over the pages only, without a plate. Java instances say
their version and loader in the sidebar and get no preparing state. hover_layer's easing is
now hover_fade, for layers of any shape; blocks::side_item is gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:23:20 +01:00
unknownandClaude 1761eb0842 Renderer: a Validation acquire recovers, animated sprites layout stays inside the error scopes
Opening a world fullscreen could crash ~8s into the loading screen (#3866).
Two faults stacked up:

- draw_frame answered CurrentSurfaceTexture::Validation with Err on every
  frame; the loading screen runs ControlFlow::Poll, so the warn ran ~16k
  times a second (130k log lines in the report) while nothing recovered.
- SpriteAnimator::new asked the animated sprites pipeline for its bind
  group layout after the error scopes in pipeline() had been popped. A
  device lost during that window makes create_compute_pipeline return a
  silent invalid placeholder (device-lost errors bypass the scopes), so
  the scopes saw nothing, and the out-of-scope get_bind_group_layout then
  hit wgpu's uncaptured-error path and panicked. pipeline() now returns
  the layout queried while the scopes are open, where a validation error
  is caught and the CPU path takes over, as it already does for drivers
  without working compute shaders.

Validation on acquire now gets the swapchain a fresh configuration like
Outdated does, then reattaches the window like Lost does once that has
not helped, and a frame is skipped (Ok(false)) instead of failed; the
counter resets on the next acquired frame.

Fixes #3866

Co-Authored-By: Claude <[email protected]>
2026-10-30 01:17:39 +03:00
d3grandoandClaude Opus 5.5 b495d4931e Light a column again when an edit darkens it beside an unlit one
Generated worlds saved impossible block light at chunk borders: an air
block at 0 beside one at 6 (#2786). A column is lit from the blocks of
its whole 3x3, so it holds light from a neighbour whose own light job
has not landed yet. An edit in it then runs the incremental relight,
which reads only lit columns: its darkening removes that neighbour's
light and nothing can put it back, and the neighbour's job landing
does not relight the column. The stale border stays, and is saved.

Columns whose light an edit (or a replayed edit) changed beside a
loaded, unlit column now get a full light job, as vanilla's light
engine, with one light storage for every chunk, never loses it.

Fixes #2786

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:59:05 +07:00
d3grandoandClaude Opus 5.5 f7b77fbc88 Many mobs no longer grow the renderer's staging memory until it runs out
Uploads staged every write through wgpu's StagingBelt, which gives a write
larger than its chunk a buffer of exactly that size, keeps it for good and
only reuses it for writes that fit. The entity batch outgrows the 1 MiB chunk
with a few hundred mobs and grows a little each tick while more appear, so
every tick took a new buffer: about 1 GB more per hundred mobs past ~750,
never given back, until the game was killed (#2826).

Writes larger than the chunk now take a power-of-two slice and copy only
their bytes, so the buffers the belt keeps are reused: one per doubling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:45:47 +07:00
d3grandoandClaude Opus 5.5 5aa2da4960 Applying a resource pack no longer freezes the window
The pack screen, F3+T and options-set staged the whole reload (overlay
root, models, atlas, pack tables, mip chains) on the render thread, so a
large pack froze the window for seconds. They now run it on a worker under
the progress overlay, as server packs already did (Minecraft.
reloadResourcePacks under a LoadingOverlay); a reload asked for while one
runs waits for it (pendingReload).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:41:35 +07:00
Kacpherek e9808d0d13 Move online guests to the default mode when game mode is forced
MinecraftServer.enforceGameTypeForPlayers sets every player to the server mode when it is forced; guests already online kept their own mode and only got the default at join (MinecraftServer.java:1412).
2026-10-29 22:18:56 +01:00
Kacpherek a8a6485243 Send LAN guests the new difficulty on /difficulty
MinecraftServer.setDifficulty sends ChangeDifficulty to every player; /difficulty changed only the host, so guests kept the old difficulty until they rejoined (MinecraftServer.java:1307).
2026-10-29 22:18:56 +01:00
Kacpherek 1f9fdd9129 Pick a mooshroom calf's colour like MushroomCow
A mooshroom calf took its first parent's colour every time; MushroomCow.getOffspringVariant gives either parent's colour, and the other one 1 in 1024 times when both match (MushroomCow.java:275).
2026-10-29 22:18:56 +01:00
Kacpherek 8d3541a364 Save and load a mooshroom's colour under Type
MushroomCow.addAdditionalSaveData and readAdditionalSaveData store the colour as Type; only foxes wrote it, so a brown mooshroom reloaded red (MushroomCow.java:214, :221).
2026-10-29 22:18:56 +01:00
Kacpherek ed279f27d5 Draw the leather overlay on worn armour and armour stands
EquipmentLayerRenderer.renderLayers draws every layer of an equipment asset; only layer 0 was loaded, so leather armour lost its overlay layer (leather.json, EquipmentLayerRenderer.java:51).
2026-10-29 22:18:56 +01:00
Kacpherek 0d598edee8 Draw tipped arrow particles and the stuck-arrow shake on the client side
The window and LAN guests are clients of the server thread, so the arrow
effects added for Arrow.tick, Arrow.handleEntityEvent and ArrowModel.setupAnim
never reached them: the server's arrow drew its particles into a world nobody
sees, and a client's copy of an arrow never held a shake timer.

The server no longer draws or randomises Arrow.makeParticle (it is the
client's `Level.isClientSide` branch). It sends Arrow.ID_EFFECT_COLOR with a
tipped arrow's entity data and entity event 0 when the arrow has been stuck
for 600 ticks. A client ticks the particles itself (two in flight, one every
fifth tick in a block, the client's own random) and bursts twenty on event 0.
A client's arrow now starts its shake when it lands (AbstractArrow
.onSyncedDataUpdated) and runs it down each tick, so the roll shows.
2026-10-29 22:18:56 +01:00
Kacpherek 0b2e9da854 Burst tipped arrows and drop the potion after 600 ticks
A tipped arrow left stuck for 600 ticks kept its potion and never burst. Vanilla Arrow.tick sends entity event 0 (20 particles in the potion colour) and sets a plain arrow as the pickup item.
2026-10-29 22:18:56 +01:00
Kacpherek e701c9f458 Trail tipped arrows with their potion colour
Tipped arrows left no entity-effect particles in flight or stuck in a block. Vanilla Arrow.tick and Arrow.makeParticle draw two per tick in flight and one every fifth tick in the ground (Arrow.java).
2026-10-29 22:18:55 +01:00
Kacpherek 720381e3cf Crack skulls, shulker boxes, bells, lecterns and statues while broken
Only chests and banners showed the breaking overlay. Vanilla SkullBlockRenderer, ShulkerBoxRenderer, BellRenderer, LecternRenderer and CopperGolemStatueBlockRenderer submit their models for crumbling too.
2026-10-29 22:18:55 +01:00
Kacpherek 6fb33260ae Let empty buckets scoop bubble columns
An empty bucket on a bubble column did nothing; vanilla BubbleColumnBlock.pickupBlock removes the column and returns a water bucket.
2026-10-29 22:18:55 +01:00
Kacpherek 0559503258 Fire thrown-item pickup advancements for allay deliveries
Taking an item an entity threw never fired thrown_item_picked_up_by_player and allay deliveries had no thrower. Vanilla ServerPlayer.onItemPickup and BehaviorUtils.throwItem (PickedUpItemTrigger) do both.
2026-10-29 22:18:55 +01:00
Kacpherek 69d060efe6 Hang an allay's lead at 0.6 of its eye height
Allay.getLeashOffset (Allay.java:484) puts the rope at 0.6 of the eye height and 0.1 of the width; the lead used the default offset.
2026-10-29 22:18:55 +01:00
Kacpherek 0c9e6293ee Roll stuck arrows while their shake timer runs
A stuck arrow kept a fixed model pose; vanilla ArrowModel.setupAnim (ArrowRenderer) rolls the root by -sin(shake*3)*shake degrees while shakeTime lasts.
2026-10-29 22:18:55 +01:00
Kacpherek 0db93570be Apply spectator fog to powder snow
Spectators saw normal powder snow fog; vanilla PowderedSnowFogEnvironment.setupFog uses start -8 and half the render distance for spectators.
2026-10-29 22:18:55 +01:00
Kacpherek f448660e4c Let rabbits, foxes and silverfish climb out of powder snow
Mobs stuck in powder snow never jumped out: the goal was missing from their registrations. Vanilla ClimbOnTopOfPowderSnowGoal jumps when the block above is powder snow or open (ClimbOnTopOfPowderSnowGoal.java:23).
2026-10-29 22:18:52 +01:00
Kacpherek d77cca177a Show the white trail of a spectral arrow in flight
A spectral arrow in flight left no trail; vanilla adds a white spell particle each tick while it is not in the ground (SpectralArrow.java:34).
2026-10-29 22:18:52 +01:00
Kacpherek 0a353d57c9 Chime amethyst blocks when a projectile hits them
A projectile striking an amethyst block played nothing; vanilla plays block.amethyst_block.chime at the block with a random pitch (AmethystBlock.java:18).
2026-10-29 22:18:52 +01:00
Kacpherek 1f21c74b01 Hide bogged mushrooms once it is sheared
A sheared bogged kept its mushrooms on the head. BoggedModel.setupAnim (BoggedModel.java:62) sets the mushrooms part visible only while the bogged is not sheared.
2026-10-29 22:18:52 +01:00
Kacpherek fecb404485 Hide saddle reins on a horse nobody rides
A saddled horse with no rider drew its left and right saddle lines. EquineSaddleModel.setupAnim (EquineSaddleModel.java:64) shows the riding parts only while the horse is ridden.
2026-10-29 22:18:51 +01:00
Kacpherek 7f19d9f95b Name mobs and armor stands a dispenser places from a renamed item
The dispenser paths for spawn eggs and armor stands skipped the stack's custom name that the hand-placed paths now apply. Entity.applyImplicitComponents (Entity.java:4281) names the entity on every spawn from an item.
2026-10-29 22:18:51 +01:00
Kacpherek 54bb09a261 Name an armor stand placed from a renamed item
A renamed armor stand item placed an unnamed stand. EntityType.createDefaultStackConfig (EntityType.java:171) applies the stack's components, custom name included, through Entity.applyImplicitComponents (Entity.java:4281).
2026-10-29 22:18:51 +01:00
Kacpherek 739185e2c9 Make piglins walk only to wanted items they can see
Piglins took the nearest wanted item within 9 blocks even behind a wall. NearestItemSensor.doTick (NearestItemSensor.java:24) takes the nearest visible one within 32, then GoToWantedItem walks to it within 9 (PiglinAi).
2026-10-29 22:18:51 +01:00
Kacpherek c8477304a3 Let mobs slide down honey blocks
Mobs fell through honey blocks; only the player slid. HoneyBlock.entityInside (HoneyBlock.java:75) slows any living entity beside the block, with the slide sound and the entity event 53 particles.
2026-10-29 22:18:51 +01:00
Kacpherek c97f4da629 Make allays pick up only the items they can see
An allay walked to a matching item behind a wall. NearestItemSensor.doTick (NearestItemSensor.java:24) keeps only items the mob has a line of sight to.
2026-10-29 22:18:51 +01:00
Kacpherek dc5695dfa7 Mark worlds another running copy has open as locked
The world list showed a world open in another instance as an ordinary world. DirectoryLock.isLocked (DirectoryLock.java:66) and LevelSummary.createInfo (LevelSummary.java:154) show it as Locked in red and it cannot be joined.
2026-10-29 22:18:51 +01:00
Kacpherek 6ec46d1a81 Pick the piston a piston head belongs to
Pick block on a piston head gave nothing, since the head has no item form. PistonHeadBlock.getCloneItemStack (PistonHeadBlock.java:116) gives a sticky or plain piston by the head's type.
2026-10-29 22:18:51 +01:00
Kacpherek 925580fe3d Name the mob a renamed spawn egg spawns
A spawn egg renamed in an anvil spawned an unnamed mob; the stack's custom name was never applied. Entity.applyImplicitComponents (Entity.java:4281) sets the custom name from the item on spawn.
2026-10-29 22:18:51 +01:00
Kacpherek 8aeb55f0cf Apply x_rotation and y_rotation ranges in entity selectors
Selectors parsed the rotation ranges but never filtered on them, so @e[y_rotation=...] matched regardless of facing. EntitySelectorParser.createRotationPredicate (EntitySelectorParser.java:188) checks the wrapped pitch and yaw.
2026-10-29 22:18:50 +01:00
Kacpherek 17383e6fe0 Hold items in an allay's own hand transform
Allay items were placed with the humanoid hand offset; AllayModel.translateToHand (AllayModel.java:120) uses the body, the right arm's pitch, a 0.7 scale and a shift.
2026-10-29 22:18:50 +01:00
Kacpherek 68cfbd61da Make dolphins flee guardians and ignore their hits
Dolphins ignored guardians and took their hits as attacks; Dolphin.registerGoals (Dolphin.java:158) adds AvoidEntity for Guardian and HurtByTargetGoal ignoring Guardian.
2026-10-29 22:18:45 +01:00
Kacpherek c61911ffcd Play the dried ghast sound when it takes water
A dried ghast filled with water made no place-in-water sound. Vanilla DriedGhastBlock.placeLiquid (DriedGhastBlock.java:174) plays DRIED_GHAST_PLACE_IN_WATER after scheduling the fluid tick.
2026-10-29 22:18:45 +01:00
Kacpherek a9e1f452df Break a lily pad that a boat drives over
A boat passing over a lily pad left it in place. Vanilla LilyPadBlock.entityInside (LilyPadBlock.java:27) destroys the pad with its drops for an AbstractBoat on a server level.
2026-10-29 22:18:45 +01:00
Kacpherek 45e1d44be2 Draw the candle snuff smoke on the clicking client
In single player the server side skips the wick smoke, so the client draws it itself, as CandleBlock.useItemOn does on the client (AbstractCandleBlock.extinguish).
2026-10-29 22:15:19 +01:00
Kacpherek e0a1b471ff Draw the block in a copper golem's antenna
The block item in a copper golem's antenna slot was never drawn. BlockDecorationLayer.submit (BlockDecorationLayer.java:33) draws it at CopperGolemModel.applyBlockOnAntennaTransform (CopperGolemModel.java:311).
2026-10-29 22:15:13 +01:00
Kacpherek 131458ee0f Play the head's sound from a note block under a custom head
A note block with a player head on top always clicked, ignoring the head's note sound. NoteBlock.getCustomSoundId (NoteBlock.java:167) takes the sound from the skull above; no skull or sound means silence.
2026-10-29 22:15:13 +01:00
Kacpherek 90db82ba0e Hold a witch's potion at her nose instead of in her hand
A drinking witch's potion was drawn in the hand with no potion contents. WitchItemLayer.applyTranslation (WitchItemLayer.java:17) holds the potion at the nose as a potion item.
2026-10-29 22:15:13 +01:00
Kacpherek d21886b2f3 Snuff a lit candle with an empty hand
An empty hand on a lit candle did nothing; only arrows lit them. CandleBlock.useItemOn (CandleBlock.java:75) calls AbstractCandleBlock.extinguish: unlit, wick smoke, candle extinguish sound, block_change.
2026-10-29 22:15:13 +01:00
Kacpherek f250e84ecf Refuse item counts above the max stack in item commands
item replace/fill/override accepted counts up to 99 above an item's max stack. ItemInput.createItemStack (ItemInput.java:21) throws arguments.item.overstacked instead.
2026-10-29 22:15:05 +01:00
Kacpherek a1454e00a2 Show the entity tooltip for chat hover events with advanced tooltips
Hovering a show_entity chat part showed nothing. With advancedItemTooltips on, vanilla lists the name, the entity type and the UUID (GuiGraphicsExtractor.componentHoverEffect; HoverEvent.java:84).
2026-10-29 22:15:05 +01:00
Kacpherek 994820e697 Hold a spyglass to the eye for other players
A spyglass in use was drawn in the hand, not at the eye. PlayerItemInHandLayer.renderItemHeldToEye (PlayerItemInHandLayer.java:40) clamps the head pitch to -30 to 90 degrees and holds the item on the head.
2026-10-29 22:15:05 +01:00
Kacpherek 75b37506ab Draw skulls worn in the mob head slot
Zombie, skeleton, creeper and player heads in a mob's head slot were not drawn. CustomHeadLayer.submit (CustomHeadLayer.java:44) puts the skull model on the head, and resolveSkullRenderType (CustomHeadLayer.java:69) takes a player head's skin from its profile.
2026-10-29 22:15:05 +01:00
Kacpherek 8ef51c4b69 Sweep a brush in use like vanilla
A brush in use was held still. FirstPersonHandsAndItemsRenderer.applyBrushTransform (FirstPersonHandsAndItemsRenderer.java:286) swings it back and forth over the use.
2026-10-29 22:15:05 +01:00
Kacpherek c2bd40f270 Show the pointing hand over chat insertion text while Shift is held
Hovering a chat part with an insertion never asked for the pointing hand; vanilla requests it in ChatComponent.java:672 when Shift is held (ChatScreen passes insertionClickMode).
2026-10-29 22:15:05 +01:00
Kacpherek 245ea982b9 Show oak plank crumbs when an armor stand breaks
A stand broken by a creative hand or the second hit made no plank particles; ArmorStand.showBreakingParticles sends ten oak plank block particles from the box (ArmorStand.java:369).
2026-10-29 22:15:04 +01:00
Kacpherek 6528d566a1 Hide spell particles near the eye while scoping with a spyglass
Spell particles stayed visible in the spyglass view; SpellParticle.isCloseToScopingPlayer sets their alpha to 0 within 3 blocks of the eye in first person while isScoping (SpellParticle.java).
2026-10-29 22:15:04 +01:00
Kacpherek 89d4b1ce31 Tilt the iron golem body side to side while it walks
Only the arms and legs swung; IronGolemRenderer.setupRotations also rotates the body about Z by 6.5 degrees of a triangle wave of the walk position.
2026-10-29 22:15:04 +01:00
Kacpherek 2010ef412f Draw the wool undercoat on a white jeb_ sheep
A white sheep named jeb_ had no undercoat; SheepWoolUndercoatLayer.submit draws it for isJebSheep as well as for a dyed wool.
2026-10-29 22:15:04 +01:00
Kacpherek 0e42a29fda Cycle the wool of a jeb_ sheep through the dye colours
A sheep named jeb_ kept its dyed colour; SheepRenderState.getWoolColor fades it through ColorLerper.getLerpedColor(SHEEP) (SheepRenderer.java, MobRenderer.checkMagicName).
2026-10-29 22:15:04 +01:00
Kacpherek 56b327bd94 Puff red dust when a lever is switched on
Switching a lever on showed no dust puff at the lever. LeverBlock.useWithoutItem (LeverBlock.java:56) calls makeParticle at full scale on the clicking client when the lever ends up powered.
2026-10-29 22:15:01 +01:00
Kacpherek f6df8b8339 Show the red dust burst when a redstone ore is touched
Hitting, stepping on or right-clicking a redstone ore lit it with no particles. RedStoneOreBlock.interact (RedStoneOreBlock.java:61) calls spawnParticles each time, lit or not.
2026-10-29 22:15:00 +01:00
Kacpherek 6529206304 Keep the light level when a light block is picked
Pick-block on a light gave a level 0 light. LightBlock.getCloneItemStack and setLightOnStack (LightBlock.java:100,104) put the level into the item's block state.
2026-10-29 22:14:42 +01:00
Kacpherek bb7b1b27ad Play the dried ghast place sound when it is put down
Placing a dried ghast was silent. DriedGhastBlock.setPlacedBy (DriedGhastBlock.java:189) plays block.dried_ghast.place, or place_in_water when waterlogged.
2026-10-29 22:14:25 +01:00
Kacpherek a68a897af5 Break cushions struck by lightning
A bolt's box left cushions intact. Cushion.thunderHit (Cushion.java:138) kills the cushion and drops its item, as break_cushion does.
2026-10-29 22:14:25 +01:00
Kacpherek 296fd3d839 Break one turtle egg at a time when a survival player mines a stack
Mining a stack of 2 to 4 turtle eggs removed the whole stack; TurtleEggBlock.playerDestroy (TurtleEggBlock.java:138, decreaseEggs) plays the break and keeps the rest.
2026-10-29 22:14:25 +01:00
Kacpherek 33f3444a49 Chain a hanging sign onto a wall hanging sign
Right-clicking the side of a wall hanging sign with a hanging sign opened the sign instead of chaining. WallHangingSignBlock.useItemOn passes the use on when the hit is not on the editable faces (WallHangingSignBlock.java:53).
2026-10-29 22:14:25 +01:00
Kacpherek 2711a4a005 Break pointed dripstone hit by a fast thrown trident
A thrown trident faster than 0.6 that stuck in pointed dripstone left it standing. SpeleothemBlock.onProjectileHit destroys it, reading the deltaMovement from before the hit (SpeleothemBlock.java:181, AbstractArrow.onHitBlock).
2026-10-29 22:14:25 +01:00
Kacpherek c37cab6b6a Show vault connection particles for every connected player
Only the local player got the connection particles from a vault, so guests connected to it saw none. VaultBlockEntity.Client.emitConnectionParticlesForNearbyPlayers loops over every connected player (VaultBlockEntity.java:172).
2026-10-29 22:14:24 +01:00
Kacpherek cbf57eb6ee Keep a copper golem statue's pose when it is picked
Middle-click on a statue gave it standing, whatever its pose. CopperGolemStatueBlock.getCloneItemStack sets the block_state component to the pose (CopperGolemStatueBlockEntity.getItem, CopperGolemStatueBlock.java:134).
2026-10-29 22:14:24 +01:00
Kacpherek e1c7a297bc Pick a decorated pot with its sherds
Middle-click on a decorated pot gave an empty pot. DecoratedPotBlock.getCloneItemStack returns createDecoratedPotInstance(decorations), so the sherds come with the pick; the pot's item only comes with include_data (DecoratedPotBlock.java:211).
2026-10-29 22:14:24 +01:00
Kacpherek bc3161c619 Ring a bell hit by a wind charge burst
A wind charge burst that reached a bell sent a use on its top face, which the bell rejected, so it stayed silent. BellBlock.onExplosionHit calls attemptToRing with no direction, so the bell rings on its facing (BellBlock.java:197).
2026-10-29 22:14:24 +01:00
Kacpherek dd649971c8 Pick the potted plant instead of an empty flower pot
Middle-click on a flower pot with a plant gave an empty pot. FlowerPotBlock.getCloneItemStack returns new ItemStack(potted) when the pot is not empty (FlowerPotBlock.java:115).
2026-10-29 22:14:24 +01:00
Kacpherek 50a1911cea Play the creaking heart idle sound at night with logs on all sides
A non-uprooted creaking heart with pale oak logs on six sides never made its idle creak; CreakingHeartBlock.animateTick (CreakingHeartBlock.java:65) plays it 1 time in 16 while CREAKING_ACTIVE is on.
2026-10-29 22:14:24 +01:00
Kacpherek b82ebb2261 Snuff a lit candle cake with an empty hand
A right-click with an empty hand on the lit candle of a candle cake did nothing. CandleCakeBlock.useItemOn extinguishes it when the hit is above half a block, with the extinguish sound and a block change event.
2026-10-29 22:14:24 +01:00
abit-foggy 40b0467e20 fix(renderer): use flat draws on Intel Gen11 Vulkan to prevent section flicker (#3529) 2026-10-29 11:12:39 -10:00
Aria Figueredo df440f90b2 Content mods: set per-state outline and collision shapes 2026-10-29 14:59:03 -05:00
Aria Figueredo 791ac3d928 Content mods: allow a block default state override 2026-10-29 14:58:30 -05:00
GIGABAIT93 7d227270a2 Merge branch 'main' of https://github.com/horizon-rust/horizon into feat/launch-api-client
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-29 21:11:06 +02:00
GIGABAIT93 88e10db8e5 Merge branch 'main' of https://github.com/horizon-rust/horizon into feat/launch-api-client
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-29 19:54:47 +02:00
GIGABAIT93 8b65dc4516 Merge branch 'main' of https://github.com/horizon-rust/horizon into feat/launch-api-client
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-29 19:26:44 +02:00
ThatCakeIsAPie 6b01498127 Merge feat/level-shape-11 into feat/datapack-biomes-1: data packs' block state providers 2026-10-29 11:24:10 -05:00
ThatCakeIsAPie 5983bae947 Merge feat/level-shape-10 into feat/level-shape-11: data packs' block state providers 2026-10-29 11:24:10 -05:00
ThatCakeIsAPie ab82d71a96 Merge feat/level-shape-9 into feat/level-shape-10: data packs' block state providers 2026-10-29 11:24:09 -05:00
ThatCakeIsAPie c3daaa8762 Worldgen: data packs' block state providers join the state_providers bundle
worldgen/block_state_provider was missing from overrides::KINDS, so a
pack's own providers (WWOO's wythers:stony_cliffs) never reached the
state_providers bundle the generator already reads, and every feature
using one was skipped. pack_files_go_to_their_bundles covers the folder.

Reported by AdachiReiYay's agent on #3476.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit bd92abce3b0ba8f209f8a613acd5741cb9e4b825)
2026-10-29 11:24:09 -05:00
Federico TaglianiandClaude Sonnet 5.5 51005638e0 Skeletons: a path out of the shade stops at the first node the sky sees
GroundPathNavigation.trimPath, with avoidSun set by RestrictSunGoal, cuts a
path that starts out of the sun at its first sunlit node, so a skeleton
chasing a player by day waits in the shade. Horizon never trimmed a path.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 14:46:18 +01:00
Alexander KrylovandHermes Agent 3b3560d71d Launcher: remove several versions at once
App::removing held one version, and remove() returned early while it was
set, so a second row's Remove did nothing until the first delete ended.
It becomes the list of versions in flight: each row starts its own
delete, Msg::Removed drops its own tag, and the rows, Play and Install
ask whether that tag is in the list. The gate is removable(), so the
two-at-once case has a test.

Fix #3719.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-29 16:45:47 +03:00
Ludovic Marie 4812518327 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-29 13:53:24 +01:00
Ludovic MarieandClaude Opus 5.5 7daca92c4a rustfmt: sulfur cubes join the baby-flag group
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:53:01 +01:00
Shreyas d27a7cbce3 Sulfur cubes: align babyhood and egg offspring with vanilla 26.3
- Route sulfur cube baby flag through baby_index (entry 16) in lan.rs
- Remove client-side baby state derivation from size in remote_mob_data.rs
- Revert finalize.rs age_boundary_reached and set_baby to vanilla behavior
- Ensure spawn_egg_offspring sets size 1 on new sulfur cube offspring
- Drop lists.rs change from this branch
2026-10-29 18:00:54 +05:30
jaszkusandjaszkus's AI agent e83ef39df4 Place and collect water in waterlogged blocks
A water bucket fills the clicked LiquidBlockContainer (stairs, slabs, fences, trapdoors and other SimpleWaterloggedBlocks) instead of the block next to it, and an empty bucket's SOURCE_ONLY ray now targets waterlogged blocks too, so their water can be picked up again. The empty-bucket use also reports the vanilla FAIL/PASS result, applies the sneak rule, keeps creative's filled-result behaviour and lets only creative players waterlog a barrier.

Co-Authored-By: jaszkus's AI agent <[email protected]>
2026-10-29 12:52:43 +01:00
Ludovic MarieandClaude Opus 5.5 29d24f48df Merge main into this branch
Main's handleUseItem sets the guest's known movement (ServerPlayer.getKnownMovement)
before the item use; this branch's horn sound is kept in the same closure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:27:37 +01:00
levzzzandOpenAI Codex ae72e1f6ca Add a two-path saved vehicle tree and passenger restoration audit
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:12:50 +05:00
levzzzandOpenAI Codex 164cfd6b11 Restore server riders after their chunk is sent and refresh entity pairing
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:12:19 +05:00
levzzzandOpenAI Codex 09a9735630 Keep unsupported saved vehicle types pending without respawning them
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:04:46 +05:00
levzzzandOpenAI Codex f86c946a3c Merge current main and preserve saved vanilla vehicle trees
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:57:42 +05:00
v3nn7andCodex c4f7b4f964 Fix teleport commands without explicit targets
Co-Authored-By: Codex <[email protected]>
2026-10-29 09:23:48 +01:00
GIGABAIT93andClaude Opus 5.5 cbd1b1c93e Launch API: horizon-client's options for other launchers (#3545)
The game's side of the Launch API, experimental, version 0.1.0:
--game-dir (vanilla's --gameDir), --home, --fullscreen (vanilla's, not
saved to options.txt), --session-stdin and --launch-api, which prints
the API version and the options a launcher may pass as JSON.

A refused command line now exits 2 without a crash report; errors keep
1 and panics 101. The crash report's path stays the last stderr line,
its prefix now a constant (crash::SAVED_LINE).

docs/api/launch.md is the surface page; docs/api/launch-reference.md is
generated from game/launch_api.rs and a test keeps the two together.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:19:25 +02:00
Lyle ColeandClaude Opus 5.5 a7771347e6 Biomes: a world's data-pack biome files over the built-in table
with_packs replaces a built-in biome in place and adds new ones after the
built-in ids, which never move; registry_order gives vanilla's network
order (Identifier: path, then namespace, as RegistryDataLoader sorts).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:59 -05:00
ThatCakeIsAPieandClaude Opus 5.5 83b958a824 docs/world-height.md, a 2096-high fixture pack, and the tall parity run
tools/fixtures/tall_datapacks/horizon_tall_test is Horizon's own pack
(MIT): an Overworld dimension type of min_y -64, height 2096 (To The
Max's, the highest top vanilla allows) and noise settings that raise
terrain to about y 1900. VanillaChunkGenerationOracle builds each level
at its dimension type's range instead of a fixed -64..320, so
HORIZON_PARITY_DATAPACKS=tools/fixtures/tall_datapacks
tools/test_datapack_worldgen_parity.py compares Horizon's tall generation
with vanilla's: 16 chunks over two seeds, terrain and decorated, the same.

docs/world-height.md describes the model (one LevelShape per level, from
its dimension type, read everywhere), the network path, the checks and
what is left; world-generation.md and the README say packs' dimension
types now apply.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:58 -05:00
ThatCakeIsAPieandClaude Opus 5.5 148d592567 Uniform sections are shared, and the sky-source scan skips them
Sections of one block, one biome and one light value are one allocation
per kind, shared by every column (new chunks, lighting, saved light);
edits copy them, as Arc::make_mut already does. A tall level is mostly
such sections: air with full sky light above the terrain. The relighter's lowest_sky_source passes
over a uniform section that light goes through instead of testing each
of its 16 blocks.

Memory only: every value read is the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:58 -05:00
ThatCakeIsAPieandClaude Opus 5.5 0a72ffbf5c Each level's shape from the world's dimension types, local and remote
Opening a world reads its levels' shapes from its dimension types, the
enabled data packs' over vanilla's (world_loading::level_shapes), and the
game keeps them (Game.level_shapes): each level's World, SaveDir,
streaming and flat terrain get its shape, and the teleport and portal
searches read a level's logical height from them. A host encodes each
level at its shape (HubConfig.levels) and sends the data of a dimension
type the packs changed even to a client that knows the vanilla pack
(NetConfig.levels, SyncData::registry_packets), so the integrated server
of single player and LAN guests see the same heights. A remote client
takes each level's shape and its chunks' column from the server's
dimension type, which now carries its logical height; the vanilla End's
fallback has sky light, as 26.3's the_end.json does.

A world with To The Max's dimension type is 2032 blocks high from here.
Vanilla worlds read vanilla's types: nothing changes there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:58 -05:00
ThatCakeIsAPieandClaude Opus 5.5 7a082c2176 Gameplay reads each level's bounds from its world
About ninety places took "where the level starts and ends" from the fixed
column: void damage, build limits, spawn and surface searches, falling
blocks, wind charges, commands, mob goals, navigation and paths, raids,
villagers' POI scans, fluid scans and the portal search. Those that used
the column for every dimension read the world's (World::bottom_y, top_y),
which is -64..320 in all three vanilla levels; those that chose per
dimension read the level's shape (LevelAccess from world.shape, Dim's
bounds from Dim::shape). Each level's World now holds its shape where it
is made. chunk::every_section replaces the per-file [true; SECTIONS]
arrays of the fluid scans.

Every value read is the one in use today.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:57 -05:00
ThatCakeIsAPieandClaude Opus 5.5 72317bb0c6 Worldgen: a level's build range from its dimension type, a data pack's over vanilla's
A pack's dimension_type/<id>.json joins the overrides (DIMENSION_TYPES).
LevelHeight is a dimension type's build range, logical height and
skylight, checked as its codec checks them; WorldgenOverrides::level_height
gives a vanilla level's, from the type its level stem names. The
generator takes the level's range from the same data instead of the
fixed table (dimension_type_height goes), so a pack's taller dimension
type no longer clamps the noise, and datapack::worldgen_overrides no
longer reports dimension types as left out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:57 -05:00
ThatCakeIsAPie 8e445b4c3d Merge the data pack worldgen series (feat/datapack-worldgen-4) under the level-shape series 2026-10-28 18:12:57 -05:00
ThatCakeIsAPieandClaude Opus 5.5 c4e54ad254 Section caches, generation glue, distant terrain, minimap and flat worlds read the column
The random-tick section cache keeps a section list as long as the chunk's
and counts its Y from the chunk's min_y. A generated chunk becomes a
column of its level's (OverworldGenerator::level gives the build range,
ColumnLayout::of_level its column), copying the whole level range, as
does a failed one. Distant terrain from proto chunks spans the level's
range, the minimap and saved-chunk far tiles scan from the chunk's own
bottom, and flat worlds lay their layers from the Overworld's minimum up
to its height (FlatTerrain::from_settings takes the level's shape). A
test ticks the sections of a 131-section chunk near its top.

Vanilla's levels keep their column and ranges, so nothing changes there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:56 -05:00
Shreyas 2ac85c50eb lan: register default size 1 for AbstractCubeMob metadata 2026-10-29 00:11:11 +05:30
Shreyas 43f2497f1f Sulfur cubes: synchronize size metadata and adult egg spawns
Include sulfur cubes in AbstractCubeMob size metadata serialization and
remote data handling so adult cubes retain their size across the network.
Adult sulfur cubes spawned from spawn eggs now correctly render and act
as adults rather than defaulting to size 1 babies.

Vanilla reference: AbstractCubeMob.ID_SIZE, SulfurCube.setSpawnSize.

Fixes #3151
2026-10-28 17:13:45 +05:30
Ludovic MarieandClaude Opus 5.5 f5f5532c19 Merge main into fix/goat-horns
Kept the goat horn use next to main's bundle use in use_item.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:41:36 +01:00
Kacpherek cb5743dcc8 Format
cargo fmt.
2026-10-27 22:36:44 +01:00
Kacpherek eea99bb586 Fix clippy
Return the arrow hit result directly.
2026-10-27 22:36:18 +01:00
Kacpherek 295871fcab Turn dirt to mud with a water bottle
Right-clicking a water bottle on mud-convertible blocks started a drink, only dispensers swapped. Vanilla (PotionItem.useOn) pours it out onto any side but the underside and sets mud.
2026-10-27 22:26:57 +01:00
Kacpherek ae43424882 Place lily pads and frogspawn on the water aimed at
The aim ray skips water, so these items could not be placed on the surface. Vanilla (PlaceOnWaterBlockItem.use) uses a source-only fluid ray and places on the position above the block it meets.
2026-10-27 22:26:13 +01:00
Kacpherek d5ae9945fa Honour can_break and can_place_on in adventure mode
Adventure players could never place or break. Vanilla (Player.blockActionRestricted, ItemStack.useOn) allows it when the held item's can_break matches the block, or can_place_on matches the clicked one.
2026-10-27 22:25:25 +01:00
Kacpherek c20247d708 Hurt mobs walking on magma blocks
Only the player took hot floor damage. Vanilla (MagmaBlock.stepOn) deals 1 hot_floor damage to any living entity on the block that is not sneaking.
2026-10-27 22:22:51 +01:00
Kacpherek 08947cd922 Burn mobs standing in a lit campfire
Only the player took campfire damage. Vanilla (CampfireBlock.entityInside) hurts any living entity in a lit campfire for 1, or 2 for a soul campfire.
2026-10-27 22:22:36 +01:00
Kacpherek 0e18c68b4d Hurt mobs with the lightning_bolt damage type
A strike hurt mobs as generic damage, which bypasses armour and wolf armour and loses the is_lightning loot condition (a struck turtle dropped no bowl). Vanilla (Entity.thunderHit) uses lightning_bolt.
2026-10-27 22:21:37 +01:00
Kacpherek 1b0b0a8149 Hurt crowded mobs with cramming damage
The cramming roll was drawn but nothing hurt the mob. Vanilla (LivingEntity.pushEntities) deals 6 cramming damage, 1 in 4 ticks, above max_entity_cramming - 1 pushers who are not riding.
2026-10-27 22:21:18 +01:00
Kacpherek ac22c4db04 Apply tipped and spectral arrow effects to the mobs they hit
Arrows built from a bow or crossbow carried no effect and mobs never got one. Vanilla (Arrow, SpectralArrow doPostHurtEffects) applies the pickup item's potion contents at its duration scale, or Glowing for 200 ticks.
2026-10-27 22:19:06 +01:00
Kacpherek e204d3c028 Drink a potion's custom effects at its duration scale
A stack's potion_contents lost custom_effects and custom_color on load, and drinking used scale 1. Vanilla (PotionContents.onConsume) applies the potion and custom effects at potion_duration_scale.
2026-10-27 22:18:00 +01:00
Ludovic MarieandClaude Opus 5.5 3246732087 Read the inline instrument through horizon_client_core::scoreboard
#2215 moved items.rs into horizon-items, where scoreboard is not in the
crate root.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:13:32 +01:00
Ludovic MarieandClaude Opus 5.5 c215a48c91 Merge main into fix/goat-horns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:11:21 +01:00
Ludovic MarieandClaude Opus 5.5 6aabf3f618 Merge main into fix/goat-horns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:43 +01:00
Ludovic Marie 36ba5c65a6 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/item_tooltip.rs
2026-10-27 13:44:46 +01:00
levzzz a3489d33b4 Merge remote-tracking branch 'upstream/main' into fix/root-vehicle-restore 2026-10-27 10:53:42 +05:00
levzzz 9c16d40db1 Merge remote-tracking branch 'upstream/main' into fix/root-vehicle-restore 2026-10-27 04:54:43 +05:00
Claude Code 730184c64f Riding a horse survives a save and a reopen
The player's file now names the vehicle it rides, the way vanilla's
ServerPlayer.saveParentVehicle does, and the ride comes back on the next
login in both single player's modes.

- The player compound holds a "RootVehicle" compound whose "Attach" is the
  UUID of the vehicle at the root of the rider chain (Entity.getRootVehicle).
  Horizon keeps the vehicle in the world, so the file names it instead of
  holding a second copy of it: the horse and its equipment stay in the
  chunk's entity file, and nothing is duplicated.
- The single player's own player writes the vehicle it rides, and a server's
  guests write theirs; on the next login the vehicle is looked up by its
  UUID among the entities in the world, and the player rides it again.
- A guest whose file names a vehicle gets back on it once the vehicle is
  loaded, as ServerPlayer.loadAndSpawnParentVehicle does.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:40:43 +05:00
Kacpherek 75267d2fc6 Read inline goat horn instruments as well as registry ids 2026-10-26 21:43:33 +01:00
Kacpherek 9b88df32d3 Make goat horns play their sound and show the horn pose
A goat horn did nothing when used. It now plays its instrument at the
player, is held up for the instrument's duration, goes on cooldown, posts
the instrument_play game event and counts as used. Other players hear it,
see the arm raised to the head, and the tooltip names the call.
2026-10-26 19:54:15 +01:00
395 changed files with 26554 additions and 3754 deletions

No files matched your search

+3 -3
View File
@@ -233,9 +233,9 @@ jobs:
# before the Nightly-only switch keep (they don't read Nightlies).
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
Generated
+27
View File
@@ -561,6 +561,12 @@ dependencies = [
"typenum",
]
[[package]]
name = "cty"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35"
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -1412,6 +1418,8 @@ name = "horizon-threads"
version = "0.1.0"
dependencies = [
"libc",
"libmimalloc-sys",
"mimalloc",
"windows-sys 0.61.2",
]
@@ -1787,6 +1795,16 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
"cty",
]
[[package]]
name = "libredox"
version = "0.1.25"
@@ -1878,6 +1896,15 @@ dependencies = [
"libc",
]
[[package]]
name = "mimalloc"
version = "0.1.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
dependencies = [
"libmimalloc-sys",
]
[[package]]
name = "minisign-verify"
version = "0.3.0"
+2 -2
View File
@@ -47,7 +47,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation, taller levels included (vanilla's biomes for now).
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** content mods add blocks, items and data, and native mods change the game's own Rust code, built by the launcher on your copy, with no Rust to install. Horizon's own (Zoom, FPS Counter, Keystrokes, Minimap, Recipes and Coordinates HUD) are native mods. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
@@ -166,7 +166,7 @@ lists every method of vanilla 26.3 next to the Rust functions that port it, with
### Known issues
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; item tooltips lack attribute lines.
## Licence
+4 -4
View File
@@ -76,9 +76,9 @@ Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://
<details><summary>Technical</summary>
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. [docs/native-mods.md](docs/native-mods.md).
More: [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
More: [#3351](https://github.com/horizon-rust/horizon/issues/3351) · [#3353](https://github.com/horizon-rust/horizon/issues/3353) · [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
</details>
@@ -490,7 +490,7 @@ Small mods written as scripts, in the style of KubeJS, with no compiler.
<details><summary>Technical</summary>
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is removed. A script layer over the native hook points is to be designed.
Waits for native mods to settle: a script layer over the native hook points is to be designed.
More: [docs/native-mods.md](docs/native-mods.md)
@@ -718,7 +718,7 @@ Versions of Horizon for Android, iOS and web browsers. Nothing official yet; con
<details><summary>Technical</summary>
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
</details>
+75 -6
View File
@@ -46,6 +46,7 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--server HOST:PORT default 127.0.0.1:25565
--bots N default 10
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
stand: stays on the ground where the server puts it (/tp), for a survival test.
One abusive client, to test a server's limits (run it beside a normal crowd):
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
flood-acks (--flood chunk batch acks asking for 64 chunks a tick, while exploring),
@@ -59,6 +60,9 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
into a writable book), flood-sign (four 384-character lines on the block
below), flood-recipe (recipe book placements)
--flood N packets a tick for the flood behaviours; default 100
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test);
the report gives each bot's last health
--attack-after S start attacking S seconds after joining; default 5
--no-read stop reading from the server once spawned (a client that lets the
server's replies pile up)
--join-every SECONDS default 1
@@ -67,12 +71,14 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--seed N default 12345
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
--brand B the client brand sent in configuration (minecraft:brand); default none
--report FILE a JSON summary at the end (- prints it after the per-second lines)
";
#[derive(Clone, Copy, Debug, PartialEq)]
enum Behaviour {
Idle,
Stand,
Walk,
Explore,
FloodMove,
@@ -91,6 +97,7 @@ impl Behaviour {
fn name(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Stand => "stand",
Self::Walk => "walk",
Self::Explore => "explore",
Self::FloodMove => "flood-move",
@@ -117,9 +124,14 @@ struct Options {
view_distance: i8,
seed: u64,
explore_speed: f64,
/// The player to attack, and how long after joining to start.
attack: Option<String>,
attack_after: f64,
flood: usize,
no_read: bool,
name: String,
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
report: Option<String>,
}
@@ -134,10 +146,13 @@ fn options() -> Result<Options, String> {
view_distance: 10,
seed: 12345,
explore_speed: 22.0,
attack: None,
attack_after: 5.0,
flood: 100,
no_read: false,
name: "Bot".into(),
report: None,
brand: None,
};
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
@@ -167,16 +182,20 @@ fn options() -> Result<Options, String> {
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
}
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--attack" => o.attack = Some(value),
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--brand" => o.brand = Some(value),
_ => return Err(format!("unknown option {arg}; --help lists them")),
}
}
const BEHAVIOURS: [&str; 14] = [
const BEHAVIOURS: [&str; 15] = [
"idle",
"walk",
"explore",
"mixed",
"stand",
"flood-move",
"flood-acks",
"flood-packets",
@@ -219,6 +238,11 @@ struct Bot {
joined_after_ms: AtomicU64,
/// The bot stopped reading (--no-read).
deaf: AtomicBool,
/// `--attack`: the target's UUID and, once AddEntity showed it, its entity id.
target: Mutex<(Option<u128>, Option<i32>)>,
attacks: AtomicU64,
/// The last SetHealth.
health: Mutex<Option<f32>>,
}
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
@@ -398,6 +422,19 @@ fn join(o: &Options, name: &str) -> Result<Connection, String> {
.encode(),
)
.map_err(err)?;
// ClientBrandRetriever: vanilla's client says "vanilla", Horizon's "horizon".
if let Some(brand) = &o.brand {
let mut w = Writer::new();
w.string(brand);
c.send(
&configuration::Serverbound::CustomPayload(configuration::CustomPayload {
channel: "minecraft:brand".into(),
data: w.data,
})
.encode(),
)
.map_err(err)?;
}
loop {
let (id, body) = c.recv().map_err(err)?;
let reply = match id {
@@ -425,8 +462,9 @@ fn join(o: &Options, name: &str) -> Result<Connection, String> {
payload: None,
})
}
configuration::Clientbound::Disconnect(_) => {
return Err("disconnected during configuration".into());
configuration::Clientbound::Disconnect(p) => {
let reason = horizon_protocol::text::plain(&p.reason, &Default::default());
return Err(format!("disconnected during configuration: {reason}"));
}
configuration::Clientbound::FinishConfiguration(_) => {
c.send(&configuration::Serverbound::FinishConfiguration(Empty).encode())
@@ -531,6 +569,21 @@ fn read(
}
Ok(())
}
cb::ADD_ENTITY => {
if let Ok(play::Clientbound::AddEntity(p)) = play::Clientbound::decode(id, &body) {
let mut target = lock(&bot.target);
if target.0 == Some(p.uuid) {
target.1 = Some(p.id);
}
}
Ok(())
}
cb::SET_HEALTH => {
if let Ok(play::Clientbound::SetHealth(p)) = play::Clientbound::decode(id, &body) {
*lock(&bot.health) = Some(p.health);
}
Ok(())
}
cb::CHUNK_BATCH_START => {
batch.started = Instant::now();
Ok(())
@@ -618,6 +671,7 @@ fn run_bot(
};
let (reader, writer) = connection.split();
let writer = Arc::new(Mutex::new(writer));
lock(&bot.target).0 = o.attack.as_deref().map(offline_uuid);
bot.online.store(true, Ordering::SeqCst);
{
let (writer, bot) = (writer.clone(), bot.clone());
@@ -665,6 +719,7 @@ fn run_bot(
let heading = random.unit() * std::f64::consts::TAU;
let mut target = pos;
let mut next = Instant::now();
let (joined, mut tick) = (Instant::now(), 0u64);
loop {
if lock(&until).is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) {
break;
@@ -675,7 +730,9 @@ fn run_bot(
}
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
// The abusive client doesn't wait for its chunk, as a real client must.
if behaviour == Behaviour::FloodMove {
if behaviour == Behaviour::Stand {
// Where the server put it, on the ground.
} else if behaviour == Behaviour::FloodMove {
// The last of these moves goes out below, with the tick's end.
pos[1] = cruise;
for _ in 1..o.flood.max(1) {
@@ -711,7 +768,7 @@ fn run_bot(
pos[1] = (pos[1] + 0.5).min(cruise);
} else {
match behaviour {
Behaviour::Idle => {}
Behaviour::Idle | Behaviour::Stand => {}
Behaviour::Walk => {
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
let distance = dx.hypot(dz);
@@ -776,12 +833,21 @@ fn run_bot(
send(body);
}
}
let target = lock(&bot.target).1;
if let Some(id) = target
&& joined.elapsed().as_secs_f64() >= o.attack_after
&& tick % 10 == 0
{
send(play::Serverbound::Attack(play::Attack { id }).encode());
bot.attacks.fetch_add(1, Ordering::Relaxed);
}
tick += 1;
let ok = send(
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
x: pos[0],
y: pos[1],
z: pos[2],
flags: 0,
flags: u8::from(behaviour == Behaviour::Stand),
})
.encode(),
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
@@ -805,6 +871,7 @@ fn run_bot(
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
match mode {
"idle" => Behaviour::Idle,
"stand" => Behaviour::Stand,
"walk" => Behaviour::Walk,
"explore" => Behaviour::Explore,
"flood-move" => Behaviour::FloodMove,
@@ -918,6 +985,8 @@ fn main() {
"pulled_back": b.pulled_back.load(Ordering::Relaxed),
"waited_ticks": b.waited.load(Ordering::Relaxed),
"error": *lock(&b.error),
"attacks": b.attacks.load(Ordering::Relaxed),
"health": *lock(&b.health),
})).collect::<Vec<_>>(),
});
if o.report.as_deref() == Some("-") {
+4 -1
View File
@@ -237,7 +237,7 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
_ => None,
};
}
// HoverEvent.ShowText and ShowItem.
// HoverEvent.ShowText, ShowItem and ShowEntity.
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
match e.get("action").and_then(Value::as_str) {
Some("show_text") => {
@@ -248,6 +248,9 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
Some("show_item") if e.get("id").is_some_and(Value::is_string) => {
style.hover_item = Some(Box::new(Value::Object(e.clone())));
}
Some("show_entity") if e.get("id").is_some_and(Value::is_string) => {
style.hover_entity = Some(Box::new(Value::Object(e.clone())));
}
_ => {}
}
}
@@ -28,6 +28,10 @@ pub struct Registries {
/// Game rule name -> integer-valued.
pub game_rules: Vec<(String, bool)>,
pub players: Vec<String>,
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
/// completions the server sent (ClientboundCustomChatCompletionsPacket),
/// each once.
pub custom_completions: std::collections::BTreeSet<String>,
/// Scoreboard objective names, team names and tracked score holders.
pub objectives: Vec<String>,
pub teams: Vec<String>,
@@ -87,7 +91,55 @@ pub struct RegistryIds {
pub tags: Vec<String>,
}
const EMPTY: &[String] = &[];
/// ClientboundCustomChatCompletionsPacket.Action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CustomCompletionAction {
Add,
Remove,
Set,
}
impl CustomCompletionAction {
/// `Action.BY_ID`: `ByIdMap.continuous` with `OutOfBoundsStrategy.ZERO`,
/// so an unknown id is `ADD`.
pub fn from_id(id: i32) -> Self {
match id {
1 => Self::Remove,
2 => Self::Set,
_ => Self::Add,
}
}
}
/// `ClientSuggestionProvider.modifyCustomCompletions`: the server adds,
/// removes or replaces the extra chat completions.
pub fn modify_custom_completions(
set: &mut std::collections::BTreeSet<String>,
action: CustomCompletionAction,
entries: &[String],
) {
match action {
CustomCompletionAction::Add => set.extend(entries.iter().cloned()),
CustomCompletionAction::Remove => {
for e in entries {
set.remove(e);
}
}
CustomCompletionAction::Set => {
set.clear();
set.extend(entries.iter().cloned());
}
}
}
impl Registries {
/// `ClientSuggestionProvider.getCustomTabSuggestions`: what chat (not a
/// command) completes, the online players' names with the server's custom
/// completions, each once.
pub fn custom_tab_suggestions(&self) -> std::collections::BTreeSet<&str> {
self.players
.iter()
.chain(&self.custom_completions)
.map(String::as_str)
.collect()
}
/// Fill what the game did not build itself from vanilla's exported
/// registries (`command-registries.json`, written by
/// `VanillaCommandParseOracle registries`): built-in registries such as
+10 -3
View File
@@ -96,6 +96,8 @@ pub struct Style {
pub hover: Option<Box<Text>>,
/// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object.
pub hover_item: Option<Box<Value>>,
/// `HoverEvent.ShowEntity`: the event's `{id, uuid, name?}` object.
pub hover_entity: Option<Box<Value>>,
/// `Style.insertion`: the text a Shift-click puts in the chat box.
pub insertion: Option<String>,
/// The font (`FontDescription.Resource`), a full id like
@@ -127,11 +129,12 @@ impl Style {
if self.click.is_none() {
self.click.clone_from(&base.click);
}
if self.hover.is_none() {
// One HoverEvent: its three kinds are inherited together, so a style
// with its own event never picks up another kind from `base`.
if self.hover.is_none() && self.hover_item.is_none() && self.hover_entity.is_none() {
self.hover.clone_from(&base.hover);
}
if self.hover_item.is_none() {
self.hover_item.clone_from(&base.hover_item);
self.hover_entity.clone_from(&base.hover_entity);
}
if self.insertion.is_none() {
self.insertion.clone_from(&base.insertion);
@@ -591,6 +594,10 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
let mut event = item.clone();
event.insert("action".into(), "show_item".into());
out.push(("hover_event".to_owned(), Value::Object(event)));
} else if let Some(Value::Object(entity)) = style.hover_entity.as_deref() {
let mut event = entity.clone();
event.insert("action".into(), "show_entity".into());
out.push(("hover_event".to_owned(), Value::Object(event)));
}
if let Some(insertion) = &style.insertion {
out.push(("insertion".to_owned(), insertion.clone().into()));
+11 -5
View File
@@ -192,8 +192,9 @@ impl Assets {
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load(root, self)
}
/// [`Assets::reload`] for a server, which draws nothing: the models and
/// the sprites' layers, without the textures' pixels.
/// [`Assets::reload`] for a server, which draws nothing: the item models
/// and the sprites' layers, without the block models or the textures'
/// pixels.
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load_with(root, self, false)
}
@@ -202,14 +203,19 @@ impl RenderAssets {
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
Self::load_with(root, assets, true)
}
fn load_with(root: &Path, assets: &Assets, pixels: bool) -> Result<Self> {
fn load_with(root: &Path, assets: &Assets, draws: bool) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
for name in MATERIALS {
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
let parsed = crate::model_library::ParsedModels::load(root);
let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?;
// A server draws no block: its models (about 24 MB baked) stay empty.
let models = if draws {
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
} else {
crate::models::PreparedModels::default()
};
let model_library = parsed.into_item_library();
// Fluids are drawn by the fluid renderer, not block models; breaking
// progress uses the destroy-stage overlays.
@@ -226,7 +232,7 @@ impl RenderAssets {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.save_disk_cache();
if pixels {
if draws {
sprites.finish();
} else {
sprites.finish_without_pixels();
+10
View File
@@ -165,6 +165,8 @@ pub struct Audio {
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
/// any), with or without an audio device, as `heard`.
pub stopped: std::collections::BTreeMap<String, u32>,
/// The volume sliders' `SoundPreviewHandler`.
preview: crate::sound_preview::SoundPreview,
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
use symphonia::core::{
@@ -634,6 +636,7 @@ impl Audio {
capture: None,
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
}
}
#[cfg(not(feature = "audio"))]
@@ -817,6 +820,13 @@ impl Audio {
false,
);
}
/// `SoundPreviewHandler.preview`: a category volume slider moved to
/// `volume` on the title screen's options.
pub fn preview(&mut self, source: usize, volume: f32) {
let mut preview = std::mem::take(&mut self.preview);
preview.preview(self, source, volume);
self.preview = preview;
}
/// `play` with the seed a sound packet carries.
pub fn play_seeded(
&mut self,
+6 -6
View File
@@ -52,20 +52,20 @@ impl AuthSession {
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
pub fn parse(text: &str) -> Result<Self, String> {
let v: serde_json::Value =
serde_json::from_str(text).map_err(|_| "the session file is not valid JSON")?;
serde_json::from_str(text).map_err(|_| "the session is not valid JSON")?;
let field = |k: &str| v.get(k).and_then(|s| s.as_str());
let name = field("name").ok_or("the session file has no name")?;
let name = field("name").ok_or("the session has no name")?;
if name.is_empty() || name.chars().count() > 16 {
return Err("the session file's name is not a valid player name".into());
return Err("the session's name is not a valid player name".into());
}
let uuid = field("uuid")
.and_then(parse_uuid)
.ok_or("the session file has no valid uuid")?;
.ok_or("the session has no valid uuid")?;
let access_token = field("access_token")
.filter(|t| !t.is_empty())
.ok_or("the session file has no access token")?;
.ok_or("the session has no access token")?;
if field("user_type").is_some_and(|t| t != "msa") {
return Err("the session file is not a Microsoft account session".into());
return Err("the session is not a Microsoft account session".into());
}
Ok(Self {
name: name.to_owned(),
+245 -55
View File
@@ -3,7 +3,12 @@
use crate::Result;
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
use std::{
collections::{BTreeMap, HashMap},
fs,
path::Path,
sync::Arc,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GrassModifier {
@@ -102,6 +107,9 @@ pub struct BiomeInfo {
/// The biome's own fog colour attribute; otherwise the dimension's applies.
pub fog: Option<u32>,
pub water_fog: u32,
/// `BiomeGenerationSettings.features`: the placed feature ids of each
/// `GenerationStep.Decoration` step, in order.
pub features: Vec<Vec<String>>,
}
impl BiomeInfo {
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
@@ -130,10 +138,27 @@ impl BiomeInfo {
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
}
}
/// The game's biomes, by id. The built-in (vanilla) biomes come first, in
/// file order, and keep their ids whatever a world's data packs do; a pack's
/// new biomes follow them (`with_packs`). Those are the client's own ids:
/// the network registry's are `registry_order`'s.
#[derive(Clone)]
pub struct Biomes {
pub list: Vec<BiomeInfo>,
pub index: HashMap<String, u16>,
info_noise: SimplexNoise,
colormaps: Arc<Colormaps>,
/// The built-in biomes, the first of `list` unless packs replaced them.
builtin: Arc<Vec<BiomeInfo>>,
/// The data packs' biome files (new biomes and replaced built-in ones),
/// for the tables that read more of a biome than `BiomeInfo` keeps.
packs: BTreeMap<String, Arc<Value>>,
}
/// The grass, foliage and dry foliage colormaps.
struct Colormaps {
grass: Vec<u32>,
foliage: Vec<u32>,
dry: Vec<u32>,
}
fn color(v: &Value) -> Option<u32> {
match v {
@@ -166,11 +191,90 @@ fn sample(map: &[u32], temperature: f32, downfall: f32, fallback: u32) -> u32 {
let index = (y << 8 | x) as usize;
map.get(index).copied().unwrap_or(fallback)
}
impl BiomeInfo {
/// A biome file's (`worldgen/biome/<name>.json`) values, the colours it
/// leaves out sampled from the colormaps.
fn from_json(name: String, v: &Value, maps: &Colormaps) -> Self {
let effects = &v["effects"];
let attributes = &v["attributes"];
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
let modifier = match effects["grass_color_modifier"].as_str() {
Some("dark_forest") => GrassModifier::DarkForest,
Some("swamp") => GrassModifier::Swamp,
_ => GrassModifier::None,
};
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
// A modifier object ({argument, modifier: overlay}) or the plain value;
// the overlay over the dimension's empty default is the biome's value.
let natural = if natural["argument"].is_object() {
&natural["argument"]
} else {
natural
};
let categories = &natural["spawns_by_category"];
for (category, entries) in categories.as_object().into_iter().flatten() {
let list = parse_entries(entries);
spawns.insert(category.clone(), list);
}
BiomeInfo {
spawns,
spawn_costs: parse_costs(&natural["spawn_costs"]),
creature_probability:
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
.unwrap_or(0.1) as f32,
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
.as_bool()
.unwrap_or(false),
temperature,
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
grass: color(&effects["grass_color"])
.unwrap_or_else(|| sample(&maps.grass, temperature, downfall, 0xFF00FF)),
modifier,
foliage: color(&effects["foliage_color"])
.unwrap_or_else(|| sample(&maps.foliage, temperature, downfall, 0x48B518)),
dry_foliage: color(&effects["dry_foliage_color"])
.unwrap_or_else(|| sample(&maps.dry, temperature, downfall, 0x5D4930)),
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
sky: color(&attributes["minecraft:visual/sky_color"]),
fog: color(&attributes["minecraft:visual/fog_color"]),
water_fog: color(&attributes["minecraft:visual/water_fog_color"]).unwrap_or(0x050533),
features: v["features"]
.as_array()
.into_iter()
.flatten()
.map(|step| {
step.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect()
})
.collect(),
name,
}
}
}
/// `Identifier.compareTo`: the path first, then the namespace.
pub fn identifier_order(a: &str, b: &str) -> std::cmp::Ordering {
fn split(id: &str) -> (&str, &str) {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
(path, ns)
}
split(a).cmp(&split(b))
}
impl Biomes {
pub fn load(root: &Path) -> Result<Self> {
let grass = colormap(root, "grass")?;
let foliage = colormap(root, "foliage")?;
let dry = colormap(root, "dry_foliage")?;
let maps = Colormaps {
grass: colormap(root, "grass")?,
foliage: colormap(root, "foliage")?,
dry: colormap(root, "dry_foliage")?,
};
let mut list = Vec::new();
let mut names: Vec<_> = fs::read_dir(root.join("data/minecraft/worldgen/biome"))?
.filter_map(|e| e.ok())
@@ -186,57 +290,7 @@ impl Biomes {
.ok_or("bad biome")?
);
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
let effects = &v["effects"];
let attributes = &v["attributes"];
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
let modifier = match effects["grass_color_modifier"].as_str() {
Some("dark_forest") => GrassModifier::DarkForest,
Some("swamp") => GrassModifier::Swamp,
_ => GrassModifier::None,
};
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
// A modifier object ({argument, modifier: overlay}) or the plain value;
// the overlay over the dimension's empty default is the biome's value.
let natural = if natural["argument"].is_object() {
&natural["argument"]
} else {
natural
};
let categories = &natural["spawns_by_category"];
for (category, entries) in categories.as_object().into_iter().flatten() {
let list = parse_entries(entries);
spawns.insert(category.clone(), list);
}
list.push(BiomeInfo {
spawns,
spawn_costs: parse_costs(&natural["spawn_costs"]),
creature_probability:
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
.unwrap_or(0.1) as f32,
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
.as_bool()
.unwrap_or(false),
temperature,
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
grass: color(&effects["grass_color"])
.unwrap_or_else(|| sample(&grass, temperature, downfall, 0xFF00FF)),
modifier,
foliage: color(&effects["foliage_color"])
.unwrap_or_else(|| sample(&foliage, temperature, downfall, 0x48B518)),
dry_foliage: color(&effects["dry_foliage_color"])
.unwrap_or_else(|| sample(&dry, temperature, downfall, 0x5D4930)),
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
sky: color(&attributes["minecraft:visual/sky_color"]),
fog: color(&attributes["minecraft:visual/fog_color"]),
water_fog: color(&attributes["minecraft:visual/water_fog_color"])
.unwrap_or(0x050533),
name,
});
list.push(BiomeInfo::from_json(name, &v, &maps));
}
let index = list
.iter()
@@ -244,11 +298,65 @@ impl Biomes {
.map(|(i, b)| (b.name.clone(), i as u16))
.collect();
Ok(Self {
builtin: Arc::new(list.clone()),
list,
index,
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
})
}
/// The built-in biomes with a world's data packs' biome files (id →
/// JSON) over them, as `RegistryDataLoader` builds the registry: a file
/// for a built-in biome replaces it, keeping its id, and new biomes join
/// after the built-in ones, in registry order. Starts from the built-in
/// table whatever packs `self` has.
pub fn with_packs<'a>(&self, files: impl IntoIterator<Item = (&'a str, &'a Value)>) -> Self {
let mut out = self.builtin_only();
let mut files: Vec<(&str, &Value)> = files.into_iter().collect();
files.sort_by(|a, b| identifier_order(a.0, b.0));
for (name, json) in files {
let info = BiomeInfo::from_json(name.to_owned(), json, &out.colormaps);
match out.index.get(name) {
Some(&id) => out.list[usize::from(id)] = info,
None if out.list.len() < usize::from(u16::MAX) => {
out.index.insert(name.to_owned(), out.list.len() as u16);
out.list.push(info);
}
None => continue,
}
out.packs.insert(name.to_owned(), Arc::new(json.clone()));
}
out
}
/// The built-in biomes alone: this table without what packs gave it.
pub fn builtin_only(&self) -> Self {
let mut out = self.clone();
if self.packs.is_empty() {
return out;
}
out.list = self.builtin.to_vec();
let builtin = out.list.len();
out.index.retain(|_, id| usize::from(*id) < builtin);
out.packs.clear();
out
}
/// The data pack file a world's biome `name` came from, if a pack added
/// or replaced it.
pub fn pack_file(&self, name: &str) -> Option<&Value> {
self.packs.get(name).map(|v| &**v)
}
/// Whether data packs added or replaced any biome.
pub fn has_packs(&self) -> bool {
!self.packs.is_empty()
}
/// The biome names in the network registry's order: `RegistryDataLoader`
/// sorts a registry's files by `Identifier` (path, then namespace).
pub fn registry_order(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.list.iter().map(|b| b.name.as_str()).collect();
names.sort_by(|a, b| identifier_order(a, b));
names
}
pub fn id(&self, name: &str) -> u16 {
self.index
.get(name)
@@ -344,6 +452,88 @@ pub fn blend_rgb(weights: &[(u16, f64)], mut value: impl FnMut(u16) -> [f32; 3])
result.unwrap_or([0.0; 3])
}
#[cfg(test)]
mod pack_tests {
use super::*;
use serde_json::json;
/// A built-in table of `names` (no colormaps: sampled colours fall back).
fn builtin(names: &[&str]) -> Biomes {
let maps = Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
};
let list: Vec<BiomeInfo> = names
.iter()
.map(|n| BiomeInfo::from_json((*n).to_owned(), &json!({}), &maps))
.collect();
Biomes {
builtin: Arc::new(list.clone()),
index: list
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i as u16))
.collect(),
list,
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
}
}
#[test]
fn registry_order_is_path_then_namespace() {
use std::cmp::Ordering::*;
assert_eq!(identifier_order("minecraft:a", "minecraft:b"), Less);
assert_eq!(identifier_order("z:a", "a:b"), Less);
assert_eq!(identifier_order("b:same", "a:same"), Greater);
assert_eq!(identifier_order("minecraft:a", "minecraft:a_b"), Less);
assert_eq!(identifier_order("minecraft:a-b", "minecraft:a"), Greater);
}
#[test]
fn packs_replace_in_place_and_add_after_the_builtin_biomes() {
let base = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
let red = json!({"temperature": 2.0, "effects": {"water_color": "#ff0000"}});
let hot = json!({"effects": {"grass_color": 0x123456}});
let files = [
("pack:zeta", &hot),
("minecraft:molten_peaks", &hot),
("minecraft:river", &red),
];
let world = base.with_packs(files);
// The built-in ids stay; the river takes the pack's values.
assert_eq!(world.id("minecraft:plains"), 1);
assert_eq!(world.id("minecraft:river"), 2);
assert_eq!(world.get(2).water, 0xFF0000);
assert_eq!(world.get(2).temperature, 2.0);
// New biomes follow, in registry order.
assert_eq!(world.id("minecraft:molten_peaks"), 3);
assert_eq!(world.id("pack:zeta"), 4);
assert_eq!(world.get(3).grass, 0x123456);
assert!(world.pack_file("minecraft:river").is_some());
assert!(world.pack_file("minecraft:plains").is_none());
// The network registry sorts every name by path, then namespace.
assert_eq!(
world.registry_order(),
[
"minecraft:meadow",
"minecraft:molten_peaks",
"minecraft:plains",
"minecraft:river",
"pack:zeta"
]
);
// Another world starts again from the built-in biomes.
let other = world.with_packs([]);
assert_eq!(other.list.len(), 3);
assert!(!other.contains("minecraft:molten_peaks"));
assert_eq!(other.get(2).water, 0x3F76E4);
assert!(!other.has_packs());
}
}
#[cfg(test)]
mod blend_tests {
use super::*;
@@ -1114,7 +1114,11 @@ impl BlockEntityRenderer {
pub fn renders(&self, state: u32) -> bool {
!matches!(self.kind(state), Kind::None)
}
/// ChestRenderer and BannerRenderer submit their animated models for crumbling.
/// `submitCrumblingOverlay` of ChestRenderer.submit, BannerRenderer.submit,
/// SkullBlockRenderer.submitSkull, ShulkerBoxRenderer.submit,
/// BellRenderer.submit, LecternRenderer.submit (the book) and
/// CopperGolemStatueBlockRenderer.submit: the model drawn again under the
/// destroy stage.
pub fn render_breaking(
&self,
scene: &Scene,
@@ -1123,7 +1127,16 @@ impl BlockEntityRenderer {
stage: [f32; 2],
out: &mut Vec<Vertex>,
) {
if !matches!(self.kind(state), Kind::Banner { .. } | Kind::Chest { .. }) {
if !matches!(
self.kind(state),
Kind::Banner { .. }
| Kind::Chest { .. }
| Kind::Skull { .. }
| Kind::Shulker { .. }
| Kind::Bell
| Kind::Lectern { .. }
| Kind::CopperGolem { .. }
) {
return;
}
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
@@ -36,6 +36,22 @@ pub fn potion_name_key(item: &str, potion: Option<&str>) -> String {
let suffix = potion.and_then(self::potion).map_or("empty", |p| p.name);
format!("item.minecraft.{prefix}.effect.{suffix}")
}
/// `PotionContents.getAllEffects`: the potion's effects, then the stack's
/// `custom_effects` (id, duration, amplifier). Unknown effect ids are dropped.
pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
let potion = stack.potion().and_then(self::potion);
let custom = stack.custom_potion_effects();
potion
.map_or(&[][..], |d| d.effects)
.iter()
.copied()
.chain(
custom
.iter()
.filter_map(|(id, duration, amp)| Some((effect(id)?.id, *duration, *amp))),
)
.collect()
}
/// Roman amplifier suffix (`potion.potency.N`) and duration lines of a potion.
pub fn tooltip(
potion: Option<&str>,
+121 -10
View File
@@ -21,6 +21,12 @@ const COMMAND_HISTORY_FILE: &str = "command_history.txt";
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
/// GuiMessageTag.systemSinglePlayer's indicator colour.
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
/// A player message's signature (MessageSignature), which names it to a
/// server's deletion.
pub type Signature = horizon_protocol::secure_chat::Signature;
/// How long a message shows before a deletion replaces it
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
const DELETION_DELAY: u64 = 60;
/// `ChatVisiblity`: the Chat option's three values.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -165,6 +171,8 @@ struct Message {
content: Text,
indicator: Option<u32>,
source: ChatSource,
/// `GuiMessage.signature`: a signed player message's, for deletion.
signature: Option<Box<Signature>>,
}
/// A wrapped line (`GuiMessage.Line`).
#[derive(Clone, Debug)]
@@ -205,6 +213,9 @@ pub struct Chat {
pub received: u64,
/// The Chat option's `visibleMessageFilter` (`set_abilities`).
abilities: ChatAbilities,
/// `ChatComponent.messageDeletionQueue`: deletions of messages still
/// too new to replace, with the tick from which they may be.
deletions: Vec<(Box<Signature>, u64)>,
}
impl Chat {
pub fn lines_per_page(&self, focused: bool) -> usize {
@@ -227,6 +238,38 @@ impl Chat {
) {
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
}
/// `add` of a player message with its signature, which a server's
/// deletion names it by (`GuiMessage.signature`).
pub fn add_signed(
&mut self,
atlas: &HudAtlas,
content: Text,
signature: Option<Signature>,
ticks: u64,
focused: bool,
) {
self.add_signed_message(&atlas.font, content, signature, ticks, focused);
}
fn add_signed_message(
&mut self,
font: &crate::font::Font,
content: Text,
signature: Option<Signature>,
ticks: u64,
focused: bool,
) {
self.push_message(
font,
Message {
added: ticks,
content,
indicator: None,
source: ChatSource::Player,
signature: signature.map(Box::new),
},
focused,
);
}
fn add_message(
&mut self,
font: &crate::font::Font,
@@ -236,20 +279,28 @@ impl Chat {
ticks: u64,
focused: bool,
) {
if !self.abilities.shows(source) {
self.push_message(
font,
Message {
added: ticks,
content,
indicator,
source,
signature: None,
},
focused,
);
}
fn push_message(&mut self, font: &crate::font::Font, message: Message, focused: bool) {
if !self.abilities.shows(message.source) {
return;
}
// ChatComponent.logChatMessage: every message shown is in the log.
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {}", content.plain()));
let plain = message.content.plain();
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {plain}"));
if self.log {
println!("[CHAT] {}", content.plain());
println!("[CHAT] {plain}");
}
let message = Message {
added: ticks,
content,
indicator,
source,
};
self.add_lines(font, &message, focused);
self.messages.insert(0, message);
self.messages.truncate(100);
@@ -319,8 +370,66 @@ impl Chat {
pub fn clear(&mut self) {
self.messages.clear();
self.lines.clear();
self.deletions.clear();
self.scroll = 0;
}
/// `ChatComponent.deleteMessage`: the server deleted a message. One
/// shown for 60 ticks becomes the grey "deleted" marker now; a newer
/// one waits until then (`messageDeletionQueue`).
pub fn delete_message(&mut self, atlas: &HudAtlas, signature: &Signature, ticks: u64) {
self.delete_message_with(&atlas.font, signature, ticks);
}
fn delete_message_with(&mut self, font: &crate::font::Font, signature: &Signature, ticks: u64) {
if let Some(delayed) = self.delete_or_delay(font, signature, ticks) {
self.deletions.push((Box::new(*signature), delayed));
}
}
/// `ChatComponent.tick` (`processMessageDeletionQueue`): the delayed
/// deletions whose message has shown long enough.
pub fn tick(&mut self, atlas: &HudAtlas, ticks: u64) {
self.process_deletions(&atlas.font, ticks);
}
fn process_deletions(&mut self, font: &crate::font::Font, ticks: u64) {
let mut deletions = std::mem::take(&mut self.deletions);
deletions.retain(|(signature, after)| {
ticks < *after || self.delete_or_delay(font, signature, ticks).is_some()
});
self.deletions = deletions;
}
/// `ChatComponent.deleteMessageOrDelay`: the newest message with this
/// signature, replaced by `createDeletedMarker` once 60 ticks have
/// passed since it was added; otherwise the tick to retry from.
fn delete_or_delay(
&mut self,
font: &crate::font::Font,
signature: &Signature,
ticks: u64,
) -> Option<u64> {
let message = self
.messages
.iter_mut()
.find(|m| m.signature.as_deref() == Some(signature))?;
let deletable = message.added + DELETION_DELAY;
if ticks < deletable {
return Some(deletable);
}
*message = Message {
added: message.added,
content: Self::deleted_marker(),
indicator: Some(SYSTEM_INDICATOR),
source: ChatSource::Server,
signature: None,
};
// refreshTrimmedMessages.
self.rewrap(font);
None
}
/// `ChatComponent.DELETED_CHAT_MESSAGE`: grey, italic.
fn deleted_marker() -> Text {
Text::translate("chat.deleted_marker", &[])
.with_color(text::GRAY)
.italic()
}
/// `new CommandHistory(gameFolder)`: the commands kept from earlier runs
/// start the chat's history (`ChatComponent`'s constructor).
pub fn load_history(&mut self, game_folder: &std::path::Path) {
@@ -929,7 +1038,9 @@ impl ChatScreen {
} else if !command.chars().all(char::is_whitespace) {
let partial = &command[..cursor_b];
let mut builder = SuggestionsBuilder::new(partial, last_word_index(partial));
builder.suggest_matching(c.registries.players.iter().map(String::as_str));
// ClientSuggestionProvider.getCustomTabSuggestions: the players
// and the server's custom chat completions.
builder.suggest_matching(c.registries.custom_tab_suggestions());
let (range, list) = merge_suggestions(partial, builder.out);
self.awaiting = None;
self.pending = Some((range.0, list));
@@ -244,6 +244,58 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
assert_eq!(screen.value(), "/coords");
}
#[test]
fn chat_completes_the_servers_custom_completions_with_the_players() {
// CommandSuggestions.updateCommandInfo completes chat from
// ClientSuggestionProvider.getCustomTabSuggestions: the online players
// and what ClientboundCustomChatCompletionsPacket added.
use crate::command::{CustomCompletionAction as Action, modify_custom_completions};
let font = font();
let m = Measure(&font);
let mut registries = Registries {
players: vec!["Steve".into()],
..Registries::default()
};
let entries = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let set = &mut registries.custom_completions;
modify_custom_completions(set, Action::from_id(0), &entries(&["Alex", "#trade"]));
modify_custom_completions(set, Action::Add, &entries(&["Notch", "Steve"]));
modify_custom_completions(set, Action::Remove, &entries(&["Alex"]));
// An id past the last action is ADD (OutOfBoundsStrategy.ZERO).
modify_custom_completions(set, Action::from_id(7), &entries(&["Zuri"]));
assert_eq!(
registries
.custom_tab_suggestions()
.into_iter()
.collect::<Vec<_>>(),
["#trade", "Notch", "Steve", "Zuri"]
);
let completed = |registries: &Registries, line: &str| {
let dispatcher = crate::command::vanilla::game_dispatcher();
let c = Commands {
dispatcher,
registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
screen.type_text(&m, &c, line);
// Chat shows no list by itself: Tab shows it, Tab again uses it.
for _ in 0..2 {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
}
screen.value().to_owned()
};
assert_eq!(completed(&registries, "hi no"), "hi Notch");
assert_eq!(completed(&registries, "sell #tr"), "sell #trade");
assert_eq!(completed(&registries, "hi st"), "hi Steve");
assert_eq!(completed(&registries, "hi al"), "hi al");
// SET replaces them all; the players stay.
let set = &mut registries.custom_completions;
modify_custom_completions(set, Action::Set, &entries(&["Alex"]));
assert_eq!(completed(&registries, "hi no"), "hi no");
assert_eq!(completed(&registries, "hi al"), "hi Alex");
assert_eq!(completed(&registries, "hi st"), "hi Steve");
}
#[test]
fn ask_server_nodes_wait_for_the_servers_completions() {
// ClientSuggestionProvider.customSuggestion: an `ask_server` node at the
@@ -467,3 +519,62 @@ fn a_guests_line_follows_the_player_message_permission() {
);
}
}
/// `ChatComponent.deleteMessageOrDelay`: a deleted message shows for 60
/// ticks from when it was added, then becomes the grey italic
/// `chat.deleted_marker` line with the system bar; the other lines stay.
#[test]
fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
let font = font();
let mut chat = Chat::default();
let secret = [7; 256];
chat.add_signed_message(
&font,
Text::literal("<Steve> secret"),
Some(secret),
100,
false,
);
chat.add_signed_message(
&font,
Text::literal("<Alex> hi"),
Some([8; 256]),
110,
false,
);
let lines =
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
let marker = Chat::deleted_marker().plain();
chat.delete_message_with(&font, &secret, 130);
assert_eq!(
lines(&chat),
["<Alex> hi", "<Steve> secret"],
"too new: delayed"
);
chat.process_deletions(&font, 159);
assert_eq!(lines(&chat), ["<Alex> hi", "<Steve> secret"]);
chat.process_deletions(&font, 160);
assert_eq!(lines(&chat), ["<Alex> hi".to_owned(), marker.clone()]);
assert!(chat.deletions.is_empty());
let line = &chat.lines[1];
assert_eq!(line.indicator, Some(SYSTEM_INDICATOR));
assert_eq!(line.added, 100, "the marker keeps the message's time");
assert!(
line.content
.spans
.iter()
.all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic())
);
assert_eq!(
chat.messages[1].content.translation_key(),
Some("chat.deleted_marker")
);
// A message shown 60 ticks already is replaced at once; an unknown or
// already deleted signature changes nothing.
chat.delete_message_with(&font, &[8; 256], 170);
assert_eq!(lines(&chat), [marker.clone(), marker.clone()]);
chat.delete_message_with(&font, &secret, 200);
chat.delete_message_with(&font, &[9; 256], 200);
assert_eq!(lines(&chat), [marker.clone(), marker]);
assert!(chat.deletions.is_empty());
}
+99 -8
View File
@@ -82,13 +82,45 @@ impl LevelShape {
/// vanilla Overworld's `-64..320` so that vanilla's Nether and End keep
/// the 24 sections they have always had.
pub fn column(self) -> ColumnLayout {
let low = self.min_y.min(MIN_Y);
let high = self.max_y().max(MIN_Y + HEIGHT);
ColumnLayout {
min_y: low,
sections: ((high - low) / 16) as usize,
ColumnLayout::of_level(self.min_y, self.height)
}
}
/// The shapes of a world's three levels, from its dimension types.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LevelShapes {
pub overworld: LevelShape,
pub nether: LevelShape,
pub end: LevelShape,
}
impl LevelShapes {
pub const VANILLA: Self = Self {
overworld: LevelShape::OVERWORLD,
nether: LevelShape::NETHER,
end: LevelShape::END,
};
/// The shape of the level `id` (`minecraft:overworld`,
/// `minecraft:the_nether`, `minecraft:the_end`); others get the
/// Overworld's.
pub fn get(&self, id: &str) -> LevelShape {
match id {
"minecraft:the_nether" => self.nether,
"minecraft:the_end" => self.end,
_ => self.overworld,
}
}
pub fn get_mut(&mut self, id: &str) -> &mut LevelShape {
match id {
"minecraft:the_nether" => &mut self.nether,
"minecraft:the_end" => &mut self.end,
_ => &mut self.overworld,
}
}
}
impl Default for LevelShapes {
fn default() -> Self {
Self::VANILLA
}
}
/// The span of block Y a chunk holds: from `min_y`, `sections` sections up
@@ -103,6 +135,16 @@ impl ColumnLayout {
min_y: MIN_Y,
sections: SECTIONS,
};
/// The column of a level from `min_y`, `height` high
/// (`LevelShape::column`).
pub fn of_level(min_y: i32, height: i32) -> Self {
let low = min_y.min(MIN_Y);
let high = (min_y + height).max(MIN_Y + HEIGHT);
Self {
min_y: low,
sections: ((high - low) / 16) as usize,
}
}
/// `DimensionType.MIN_Y`, `MAX_Y`: 12 bits of `BlockPos` Y, less 32.
pub const LOWEST_Y: i32 = -2032;
pub const HIGHEST_Y: i32 = 2031;
@@ -155,6 +197,13 @@ impl ColumnLayout {
self.min_y + index as i32 * 16
}
}
/// `true` for each section of `layout`: what callers that keep no
/// per-section record (of fluids, say) pass as "every section may have it".
pub fn every_section(layout: ColumnLayout) -> &'static [bool] {
static ALL: [bool; crate::section_mask::SectionMask::CAPACITY] =
[true; crate::section_mask::SectionMask::CAPACITY];
&ALL[..layout.sections()]
}
impl Default for ColumnLayout {
fn default() -> Self {
Self::VANILLA
@@ -194,6 +243,47 @@ pub fn free_columns<T: Send + 'static>(columns: T, count: usize) {
pub fn set_block_states(states: usize) {
let bits = horizon_core::paletted::ceil_log2(states).max(16);
BLOCK_BITS.store(bits, std::sync::atomic::Ordering::Relaxed);
forget_shared();
}
/// One allocation per kind of uniform section (a single block, a single
/// biome and one light value), shared by every column that has it, keyed by
/// (block, biome, light). A tall column is mostly such sections: air with
/// full sky light above the terrain. Edits copy them (`Arc::make_mut`).
type SharedSections = std::collections::HashMap<(u32, u32, u8), Arc<Section>>;
static SHARED: std::sync::Mutex<Option<SharedSections>> = std::sync::Mutex::new(None);
/// Replaces the uniform sections of `sections` with the shared ones.
pub fn share_uniform(sections: &mut [Arc<Section>]) {
let key = |s: &Section| -> Option<(u32, u32, u8)> {
let Light::Uniform(light) = s.light else {
return None;
};
if s.blocks.bits() != 0 || s.biomes.bits() != 0 {
return None;
}
Some((s.blocks.uniform()?, s.biomes.uniform()?, light))
};
let Ok(mut shared) = SHARED.lock() else {
return;
};
let shared = shared.get_or_insert_with(Default::default);
for section in sections {
let Some(k) = key(section) else {
continue;
};
let one = shared.entry(k).or_insert_with(|| section.clone());
if !Arc::ptr_eq(one, section) {
*section = one.clone();
}
}
}
/// Forgets the shared sections, when the palette changes.
fn forget_shared() {
if let Ok(mut shared) = SHARED.lock() {
*shared = None;
}
}
/// Packed light per block: sky light in the high nibble, block light in the low one.
#[derive(Clone)]
@@ -325,13 +415,14 @@ impl Chunk {
Self::with_layout(x, z, ColumnLayout::VANILLA)
}
pub fn with_layout(x: i32, z: i32, layout: ColumnLayout) -> Self {
let mut one = [Arc::new(Section::default())];
share_uniform(&mut one);
let [empty] = one;
Self {
x,
z,
min_y: layout.min_y(),
sections: (0..layout.sections())
.map(|_| Arc::new(Section::default()))
.collect(),
sections: (0..layout.sections()).map(|_| empty.clone()).collect(),
surface: OnceLock::new(),
motion_blocking: OnceLock::new(),
lit: false,
@@ -305,7 +305,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<bool> {
let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4))
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
+47 -14
View File
@@ -296,8 +296,9 @@ fn memory() -> String {
}
}
/// The report's details: Horizon's and the machine's, then what the game set as it ran.
pub fn system_details() -> Vec<(String, String)> {
/// The report's details: Horizon's and the machine's, then what the game set as it ran. A dedicated
/// server renders nothing: it leaves out the render settings it read from options.txt.
pub fn system_details(side: &str) -> Vec<(String, String)> {
let mut out = vec![
("Horizon Version".to_owned(), version()),
(
@@ -308,18 +309,30 @@ pub fn system_details() -> Vec<(String, String)> {
("CPU".to_owned(), cpu()),
("Memory".to_owned(), memory()),
];
out.extend(details().into_iter().map(|(k, v)| (k.to_owned(), v)));
out.extend(
details()
.into_iter()
.filter(|(k, _)| side != "server" || *k != "Render Settings")
.map(|(k, v)| (k.to_owned(), v)),
);
out
}
fn thread_name() -> String {
/// The thread's name as vanilla's reports say it: the main thread is the client's "Render thread"
/// and the dedicated server's "Server thread".
fn thread_name(side: &str) -> String {
let current = std::thread::current();
match current.name() {
Some("main") | None if side == "server" => "Server thread".into(),
Some("main") | None => "Render thread".into(),
Some(name) => name.into(),
}
}
/// What starts the stderr line naming a saved report, the path follows: the Launch API's
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
/// Writes the report and tells where it is; the log gets the error too.
fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
if REPORTED.swap(true, Ordering::SeqCst) {
@@ -343,7 +356,7 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
crate::telemetry::keep_crash(home, &report);
match report.save(home) {
Ok(path) => {
eprintln!("Horizon: crash report saved to {}", path.display());
eprintln!("{SAVED_LINE}{}", path.display());
Some(path)
}
Err(e) => {
@@ -365,9 +378,9 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
description: "Watching Server".into(),
error: format!("{message} (Watching Server)"),
stack: String::new(),
thread: thread_name(),
thread: thread_name(side),
side,
details: system_details(),
details: system_details(side),
log: crate::logging::recent_lines(),
},
)
@@ -476,9 +489,9 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
&std::backtrace::Backtrace::force_capture().to_string(),
true,
),
thread: thread_name(),
thread: thread_name(side),
side,
details: system_details(),
details: system_details(side),
log: crate::logging::recent_lines(),
}
}
@@ -499,7 +512,7 @@ pub fn install(home: PathBuf, side: &'static str) {
} else {
log::error!(
"Caught previously unhandled exception in thread {}: {}",
thread_name(),
thread_name(side),
panic_message(info)
);
}
@@ -548,9 +561,9 @@ pub fn report_error(
description: DebugCrash::MESSAGE.into(),
error: DebugCrash::MESSAGE.into(),
stack: debug.stack.clone(),
thread: thread_name(),
thread: thread_name(side),
side,
details: system_details(),
details: system_details(side),
log: crate::logging::recent_lines(),
},
);
@@ -568,9 +581,9 @@ pub fn report_error(
description: "Fatal error".into(),
error: message,
stack: String::new(),
thread: thread_name(),
thread: thread_name(side),
side,
details: system_details(),
details: system_details(side),
log: crate::logging::recent_lines(),
},
)
@@ -668,6 +681,26 @@ mod tests {
));
}
#[test]
fn a_dedicated_servers_report_names_its_server_thread_and_no_render_settings() {
let unnamed = |side: &'static str| {
std::thread::spawn(move || thread_name(side))
.join()
.unwrap()
};
assert_eq!(unnamed("server"), "Server thread");
assert_eq!(unnamed("client"), "Render thread");
set_detail("Render Settings", "render distance 12");
let has = |side| {
system_details(side)
.iter()
.any(|(k, _)| k == "Render Settings")
};
assert!(has("client"));
assert!(!has("server"));
clear_detail("Render Settings");
}
#[test]
fn stacks_start_at_the_panicking_frame() {
let bt = format!(
+7 -9
View File
@@ -636,11 +636,6 @@ pub fn worldgen_overrides(
let read = || String::from_utf8(pack.read(&file)?).ok();
match out.add_file(&pack.id, &file, read) {
PackFile::Read | PackFile::Other => {}
PackFile::DimensionType { id, min_y, height } => warnings.push(format!(
"{}: dimension type {id} (min_y {min_y}, height {height}) is not used: \
the dimension keeps its vanilla build height",
pack.id
)),
PackFile::NotRead(kind) => {
*not_read.entry(format!("{}: {kind}", pack.id)).or_default() += 1
}
@@ -713,10 +708,13 @@ mod tests {
);
let tag_packs: Vec<&str> = o.tags("biome_tags").map(|(_, p, _)| p).collect();
assert_eq!(tag_packs, ["file/b", "file/a"]);
assert_eq!(warnings.len(), 3, "{warnings:?}");
assert!(warnings[0].contains("height 2096"));
assert!(warnings[1].contains("minecraft:new_peaks"));
assert!(warnings[2].contains("worldgen/world_preset"));
assert_eq!(warnings.len(), 2, "{warnings:?}");
assert!(warnings[0].contains("minecraft:new_peaks"));
assert!(warnings[1].contains("worldgen/world_preset"));
assert_eq!(
o.level_height("minecraft:overworld").map(|h| h.height),
Some(2096)
);
let _ = std::fs::remove_dir_all(&dir);
}
use horizon_world::nbt::parse_snbt;
+29 -10
View File
@@ -148,6 +148,15 @@ pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
_ => None,
}
}
/// `MobEffectInstance.withScaledDuration`: max(floor(d * scale), 1), leaving
/// an infinite (-1) or zero duration alone.
pub fn scaled_duration(duration: i32, scale: f32) -> i32 {
if duration == -1 || duration == 0 {
duration
} else {
((duration as f32 * scale).floor() as i32).max(1)
}
}
/// What an omen's `applyEffectTick` decided.
#[derive(Clone, Debug, PartialEq)]
pub enum OmenTick {
@@ -308,19 +317,29 @@ impl PlayerEffects {
pub fn potion_effects(
potion: Option<&str>,
scale: f32,
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
let effects = potion
.and_then(potions::potion)
.map_or(&[][..], |d| d.effects);
Self::contents_effects(effects, scale)
}
/// `PotionContents.forEachEffect`'s effects, as `potion_effects`.
pub fn contents_effects(
effects: &[(&'static str, i32, i32)],
scale: f32,
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
let mut lasting = Vec::new();
let mut now = Vec::new();
if let Some(def) = potion.and_then(potions::potion) {
for (id, duration, amp) in def.effects {
let id = potions::effect(id).map_or("speed", |e| e.id);
if potions::effect(id).is_some_and(|e| e.instant) {
now.extend(instant(id, *amp, 1.0));
} else {
// withScaledDuration: max(floor(d * scale), 1).
let d = ((*duration as f32) * scale).floor() as i32;
lasting.push(EffectInstance::new(id, d.max(1), *amp));
}
for (id, duration, amp) in effects {
let id = potions::effect(id).map_or("speed", |e| e.id);
if potions::effect(id).is_some_and(|e| e.instant) {
now.extend(instant(id, *amp, 1.0));
} else {
lasting.push(EffectInstance::new(
id,
scaled_duration(*duration, scale),
*amp,
));
}
}
(lasting, now)
+8 -4
View File
@@ -311,10 +311,14 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
fog.cloud_end = fog.environmental_end;
}
FogType::PowderSnow => {
fog.environmental_start = 0.0;
fog.environmental_end = 2.0;
fog.sky_end = 2.0;
fog.cloud_end = 2.0;
// PowderedSnowFogEnvironment.setupFog.
(fog.environmental_start, fog.environmental_end) = if input.spectator {
(-8.0, rd * 0.5)
} else {
(0.0, 2.0)
};
fog.sky_end = fog.environmental_end;
fog.cloud_end = fog.environmental_end;
}
_ if input.blindness.is_some() => {
let (duration, infinite) = input.blindness.unwrap_or((0, true));
+205 -89
View File
@@ -590,6 +590,10 @@ pub struct Renderer {
readback: readback::Readback,
pub info: wgpu::AdapterInfo,
pub suspended: bool,
/// Consecutive frames whose surface acquisition failed validation. The
/// first gets the swapchain a fresh configuration; later ones reattach
/// the window, as a lost surface does.
acquire_validation_failures: u32,
pub drawn_sections: usize,
/// Last frame's section draws (face ranges and translucent sections),
/// and the draw calls the backend issued for them.
@@ -792,9 +796,14 @@ fn mip_chain(
/// pass is also what passes each draw's first instance to shaders reading
/// `instance_index`, and DirectX 12 is the fallback, where working matters
/// more than CPU time.
/// `allow_nonconformant` offers the adapters wgpu hides for reporting no Vulkan
/// conformance version: Mesa's hasvk, which drives Ivy Bridge and Haswell,
/// reports 0.0.0.0 while it works. `open_device` asks for them only on a second
/// try at a backend that had no adapter at all.
fn instance_descriptor(
backends: wgpu::Backends,
window: Option<&Arc<Window>>,
allow_nonconformant: bool,
) -> wgpu::InstanceDescriptor {
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
if let Some(window) = window {
@@ -807,7 +816,13 @@ fn instance_descriptor(
.flags
.remove(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL);
}
descriptor.with_env()
let mut descriptor = descriptor.with_env();
if allow_nonconformant {
descriptor
.flags
.insert(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER);
}
descriptor
}
/// The window's display connection, so the OpenGL backend creates its EGL
/// display from the X11 or Wayland connection the window lives on. Without
@@ -946,96 +961,147 @@ async fn open_device(
if attempt > 0 {
log::warn!("Horizon renderer: trying {name} ({})", failures.join("; "));
}
let instance = wgpu::Instance::new(instance_descriptor(backends, Some(window)));
let surface = if offscreen {
None
} else {
match instance.create_surface(window.clone()) {
Ok(surface) => Some(surface),
Err(e) => {
failures.push(format!("{name} surface: {e}"));
continue;
}
}
};
let adapter = match instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: surface.as_ref(),
force_fallback_adapter: false,
..Default::default()
})
.await
{
Ok(adapter) => adapter,
Err(e) => {
failures.push(format!("{name}: {e}"));
continue;
}
};
describe_adapter(&adapter);
let info = adapter.get_info();
let limits = adapter.limits();
if let Err(e) = check_layers(&limits, layers) {
failures.push(format!("{name}: {e}"));
continue;
}
// Mappable primary buffers: section pages that no longer fit in
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
// stand-in neither clamps nor drops NaN.
let features = timing::GpuTimer::features(&adapter)
| cull::Cull::features(&adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
let driver = format!("{} {}", info.driver, info.driver_info);
startup_step(format_args!(
"requesting the device (driver {}) with features [{features}] and the adapter's \
limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)",
match driver.trim() {
"" => "unnamed",
driver => driver,
},
limits.max_buffer_size,
limits.max_storage_buffer_binding_size,
limits.max_texture_array_layers,
));
let desc = wgpu::DeviceDescriptor {
label: Some("Horizon device"),
required_features: features,
required_limits: limits,
..Default::default()
};
#[cfg(not(target_os = "macos"))]
let opened = match info.backend {
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
_ => None,
};
#[cfg(target_os = "macos")]
let opened = None;
let opened = match opened {
Some(opened) => opened,
None => adapter.request_device(&desc).await,
};
match opened {
Ok((device, queue)) => {
return Ok(Opened {
instance,
surface,
adapter,
device,
queue,
});
}
Err(e) => failures.push(format!("{name} device: {e}")),
match open_with_retry(backends, window, offscreen, layers).await {
Ok(opened) => return Ok(opened),
Err(e) => failures.push(e),
}
}
Err(format!("{NO_GPU_DRIVER} ({})", failures.join("; ")).into())
}
/// Why a backend gave no device: no adapter at all, which a second try with the
/// adapters wgpu hides can still find, or a failure after an adapter.
enum NoDevice {
NoAdapter(String),
Failed(String),
}
/// Opens a device on one backend, and on a backend whose adapters wgpu all hid
/// for reporting no Vulkan conformance version tries it again with them shown:
/// Mesa's hasvk drives Ivy Bridge and Haswell, works, and reports 0.0.0.0, so
/// without this the player is told they have no graphics driver while Vulkan
/// runs (a `gl not compatible with provided surface` fallback, or nothing, is
/// all they get). Returns why the backend gave no device.
async fn open_with_retry(
backends: wgpu::Backends,
window: &Arc<Window>,
offscreen: bool,
layers: u32,
) -> std::result::Result<Opened, String> {
match open_backend(backends, window, offscreen, false, layers).await {
Err(NoDevice::NoAdapter(hidden)) => {
log::warn!(
"Horizon renderer: {} has no conformant adapter ({hidden}); asking again for the \
ones wgpu hides as non-conformant",
backends_name(backends)
);
match open_backend(backends, window, offscreen, true, layers).await {
Ok(opened) => {
log::warn!(
"Horizon renderer: using {} although its driver reports no Vulkan \
conformance version",
opened.adapter.get_info().name
);
Ok(opened)
}
Err(NoDevice::NoAdapter(e)) | Err(NoDevice::Failed(e)) => Err(e),
}
}
Err(NoDevice::Failed(e)) => Err(e),
Ok(opened) => Ok(opened),
}
}
/// Opens a device on `backends` alone: its surface, adapter and device, or what
/// stopped it. `allow_nonconformant` is `open_with_retry`'s second try.
async fn open_backend(
backends: wgpu::Backends,
window: &Arc<Window>,
offscreen: bool,
allow_nonconformant: bool,
layers: u32,
) -> std::result::Result<Opened, NoDevice> {
let name = backends_name(backends);
let instance = wgpu::Instance::new(instance_descriptor(
backends,
Some(window),
allow_nonconformant,
));
let surface = if offscreen {
None
} else {
match instance.create_surface(window.clone()) {
Ok(surface) => Some(surface),
Err(e) => return Err(NoDevice::Failed(format!("{name} surface: {e}"))),
}
};
let adapter = match instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: surface.as_ref(),
force_fallback_adapter: false,
..Default::default()
})
.await
{
Ok(adapter) => adapter,
Err(e) => return Err(NoDevice::NoAdapter(format!("{name}: {e}"))),
};
describe_adapter(&adapter);
let info = adapter.get_info();
let limits = adapter.limits();
if let Err(e) = check_layers(&limits, layers) {
return Err(NoDevice::Failed(format!("{name}: {e}")));
}
// Mappable primary buffers: section pages that no longer fit in
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
// stand-in neither clamps nor drops NaN.
let features = timing::GpuTimer::features(&adapter)
| cull::Cull::features(&adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
let driver = format!("{} {}", info.driver, info.driver_info);
startup_step(format_args!(
"requesting the device (driver {}) with features [{features}] and the adapter's \
limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)",
match driver.trim() {
"" => "unnamed",
driver => driver,
},
limits.max_buffer_size,
limits.max_storage_buffer_binding_size,
limits.max_texture_array_layers,
));
let desc = wgpu::DeviceDescriptor {
label: Some("Horizon device"),
required_features: features,
required_limits: limits,
..Default::default()
};
#[cfg(not(target_os = "macos"))]
let opened = match info.backend {
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
_ => None,
};
#[cfg(target_os = "macos")]
let opened = None;
let opened = match opened {
Some(opened) => opened,
None => adapter.request_device(&desc).await,
};
match opened {
Ok((device, queue)) => Ok(Opened {
instance,
surface,
adapter,
device,
queue,
}),
Err(e) => Err(NoDevice::Failed(format!("{name} device: {e}"))),
}
}
/// What to tell a player whose system offers no usable graphics adapter.
/// Vulkan is loaded at run time (the game links no graphics library), so a
/// missing driver shows here rather than as a missing-DLL dialog.
@@ -2530,10 +2596,19 @@ impl Renderer {
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &TERRAIN_ATTRIBUTES,
})];
let info = adapter.get_info();
let min_sections = if flat::is_broken_intel_vulkan(&info)
|| std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
{
0
} else {
flat::MIN_SECTIONS
};
flat::Flat::new(
&device,
&bindings,
arena.page_vertices(),
min_sections,
|layout, label, fragment, blend| {
pipeline_in(
layout,
@@ -2764,6 +2839,7 @@ impl Renderer {
readback: readback::Readback::default(),
info,
suspended: !drawable_size(size.width, size.height, max_side),
acquire_validation_failures: 0,
drawn_sections: 0,
section_draws: 0,
section_draw_calls: 0,
@@ -3765,9 +3841,32 @@ impl Renderer {
return Ok(false);
}
Some(wgpu::CurrentSurfaceTexture::Validation) => {
return Err("surface acquisition failed validation".into());
// Acquire caught a validation error: the swapchain cannot be
// used as configured (wgpu reports a configuration raced with
// a submission this way). Give it a fresh configuration like
// an Outdated one, and once that has not helped, reattach the
// window like a Lost one — a fullscreen loading screen then
// skips frames instead of failing every one of them.
if self.acquire_validation_failures == 0 {
if let Some(surface) = &self.surface {
Self::configure(
&self.device,
surface,
&self.surface_config(),
self.hdr.as_ref(),
);
}
} else {
self.recreate_surface()?;
}
self.acquire_validation_failures =
self.acquire_validation_failures.saturating_add(1);
return Ok(false);
}
};
if surface_frame.is_some() {
self.acquire_validation_failures = 0;
}
let size = [self.config.width, self.config.height];
let target_texture = match (&surface_frame, &mut self.hdr) {
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
@@ -5947,6 +6046,23 @@ mod tests {
assert_eq!(order(api, "macos"), [Backends::default()]);
}
}
/// The second try at a backend shows the adapters wgpu hides for reporting
/// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and
/// Haswell, reports 0.0.0.0 and works).
#[test]
fn the_second_try_at_a_backend_shows_nonconformant_adapters() {
let shown = |allow| {
super::instance_descriptor(wgpu::Backends::VULKAN, None, allow)
.flags
.contains(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER)
};
assert!(shown(true));
// The first try keeps wgpu's own filtering, unless `with_env` turns the
// flag on from the environment (WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER).
if std::env::var_os("WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER").is_none() {
assert!(!shown(false));
}
}
/// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend
/// writes it for FXC (shader model 5.1), entry point by entry point.
#[test]
@@ -6102,7 +6218,7 @@ mod tests {
#[test]
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
let validated = |backends| {
super::instance_descriptor(backends, None)
super::instance_descriptor(backends, None, false)
.flags
.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)
};
@@ -147,7 +147,7 @@ impl SpriteAnimator {
group,
});
}
let Some(pipeline) = pipeline else {
let Some((pipeline, layout)) = pipeline else {
let groups = keys
.iter()
.zip(&counts)
@@ -193,7 +193,6 @@ impl SpriteAnimator {
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let layout = pipeline.get_bind_group_layout(0);
let groups = keys
.iter()
.zip(&counts)
@@ -364,11 +363,15 @@ impl SpriteAnimator {
}
}
/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL
/// driver can list compute shaders among its extensions while wgpu writes
/// the GLSL version of its core version, which has none (Mesa's
/// `MESA_GL_VERSION_OVERRIDE`, older drivers).
fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
/// `animate.wgsl`'s pipeline and the bind group layout of its group 0,
/// unless the device fails to make the pipeline: an OpenGL driver can list
/// compute shaders among its extensions while wgpu writes the GLSL version
/// of its core version, which has none (Mesa's `MESA_GL_VERSION_OVERRIDE`,
/// older drivers). A device lost between the scopes' pushes answers pipeline
/// creation with a silent placeholder and reports its invalidity only when
/// the layout is asked for — inside the scopes that error is caught, outside
/// them it is uncaptured and panics (#3866).
fn pipeline(device: &wgpu::Device) -> Option<(wgpu::ComputePipeline, wgpu::BindGroupLayout)> {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -383,9 +386,10 @@ fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
compilation_options: Default::default(),
cache: None,
});
let layout = pipeline.get_bind_group_layout(0);
let internal = pollster::block_on(internal.pop());
match internal.or(pollster::block_on(validation.pop())) {
None => Some(pipeline),
None => Some((pipeline, layout)),
Some(e) => {
log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})");
None
+17 -7
View File
@@ -115,7 +115,7 @@ pub struct Flat {
/// (9,300 draws) the CPU saves 1.3 ms. The bar was 4,096 draws when they
/// were 2.2 to 2.5 per section; a section's draw count now depends on the
/// sides it shows the eye (`mesher::Sides`), so it counts sections.
const MIN_SECTIONS: usize = 1600;
pub const MIN_SECTIONS: usize = 1600;
/// Whether flat draws apply: the backend emulates multi-draw, and vertex
/// shaders can read storage buffers large enough for an index page.
@@ -126,7 +126,20 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
Ok("1") => return supported(adapter, device),
_ => {}
}
adapter.get_info().backend == wgpu::Backend::Metal && supported(adapter, device)
let info = adapter.get_info();
(info.backend == wgpu::Backend::Metal || is_broken_intel_vulkan(&info))
&& supported(adapter, device)
}
/// Intel Gen11 (Ice Lake) ANV Vulkan driver has broken `first_instance` for instance-step
/// vertex attributes (#3529), causing terrain sections to be drawn with another section's origin
/// and flicker.
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Vulkan
&& info.vendor == 0x8086
&& (info.name.contains("ICL")
|| info.name.contains("Ice Lake")
|| (info.device >= 0x8a50 && info.device <= 0x8a71))
}
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
@@ -254,6 +267,7 @@ impl Flat {
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
page_vertices: u64,
min_sections: usize,
pipeline: impl Fn(
&wgpu::PipelineLayout,
&str,
@@ -357,11 +371,7 @@ impl Flat {
phase1_calls: 0,
solid_calls: 0,
shadow_calls: [0; 2],
min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
0
} else {
MIN_SECTIONS
},
min_sections,
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
.max(1),
}
+108 -5
View File
@@ -10,19 +10,36 @@ const CHUNK: u64 = 1 << 20;
pub struct Uploads {
belt: wgpu::util::StagingBelt,
chunk: u64,
}
impl Uploads {
pub fn new(device: &wgpu::Device) -> Self {
Self::with_chunk(device, CHUNK)
}
/// A belt whose staging buffers hold `chunk` bytes (larger writes get
/// a buffer of their own).
/// A belt whose staging buffers hold `chunk` bytes (a larger write gets
/// a buffer of its own, rounded up to a power of two).
pub fn with_chunk(device: &wgpu::Device, chunk: u64) -> Self {
Self {
belt: wgpu::util::StagingBelt::new(device.clone(), chunk),
chunk,
}
}
/// A mapped staging slice of at least `size` bytes. The belt gives a
/// write larger than its chunk a buffer of exactly that size, keeps it for
/// good and only reuses it for writes that fit: an upload that grows a
/// little every frame (the entity batch while mobs appear) took a new
/// buffer each frame until the game ran out of memory (#2826). Rounded up
/// to a power of two, the large buffers it keeps are reused, one per
/// doubling.
fn allocate(&mut self, size: NonZeroU64, alignment: NonZeroU64) -> wgpu::BufferSlice<'_> {
let size = if size.get() > self.chunk {
size.checked_next_power_of_two().unwrap_or(size)
} else {
size
};
self.belt.allocate(size, alignment)
}
/// Copy tightly packed RGBA8 `pixels` (`width` by `height`) into one mip
/// level of one array layer, padding rows to the copy alignment.
pub fn texture(
@@ -41,7 +58,7 @@ impl Uploads {
};
let alignment = NonZeroU64::new(u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT))
.expect("non-zero alignment");
let slice = self.belt.allocate(size, alignment);
let slice = self.allocate(size, alignment);
{
let mut view = slice
.get_mapped_range_mut()
@@ -80,8 +97,20 @@ impl Uploads {
let Some(size) = NonZeroU64::new(bytes.len() as u64) else {
return;
};
self.belt
.write_buffer(encoder, target, offset, size)
// The belt's own `write_buffer` checks, kept now that a large write's
// slice is rounded up past them.
assert!(
offset.is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT)
&& size.get().is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT),
"buffer upload at {offset} of {size} bytes must be 4-byte aligned"
);
let alignment = NonZeroU64::new(wgpu::COPY_BUFFER_ALIGNMENT).expect("non-zero alignment");
let slice = self.allocate(size, alignment).slice(..size.get());
encoder.copy_buffer_to_buffer(slice.buffer(), slice.offset(), target, offset, size.get());
slice
.get_mapped_range_mut()
.expect("staging belt slices are mapped")
.slice(..)
.copy_from_slice(bytes);
}
/// Close this frame's staging buffers: they come back for reuse once the
@@ -217,6 +246,80 @@ mod tests {
}
}
/// Staging buffers the belt holds, once every submitted frame is done
/// with them. The belt only reports its buffers through `Debug`.
fn kept_buffers(device: &wgpu::Device, uploads: &mut Uploads) -> usize {
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
// Finishing an empty frame takes back every recalled buffer.
uploads.finish(&device.create_command_encoder(&Default::default()));
let belt = format!("{:?}", uploads.belt);
let count = |field: &str| -> usize {
let at = belt.find(&format!("{field}: ")).expect(&belt) + field.len() + 2;
let digits = belt[at..].split(|c: char| !c.is_ascii_digit()).next();
digits.and_then(|d| d.parse().ok()).expect(&belt)
};
count("active_chunks") + count("closed_chunks") + count("free_chunks")
}
/// #2826: a write larger than the chunk that grows a little every frame
/// (the entity batch while mobs appear) got a new exact-size buffer each
/// frame, all of them kept, until the game ran out of memory.
#[test]
fn growing_large_writes_reuse_the_belt_buffers() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
const CHUNK: u64 = 1 << 16;
const TARGET: usize = 4 * CHUNK as usize;
let target = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: TARGET as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: TARGET as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut uploads = Uploads::with_chunk(&device, CHUNK);
let mut expected = vec![0u8; TARGET];
let frames = 64;
for frame in 0..frames {
let mut encoder = device.create_command_encoder(&Default::default());
// One small write, then the large one, 1.5 to 3.5 chunks long.
uploads.buffer(&mut encoder, &target, TARGET as u64 - 4, &[9; 4]);
expected[TARGET - 4..].fill(9);
let len = (3 * CHUNK as usize / 2 + frame * 2048).next_multiple_of(4);
let bytes: Vec<u8> = (0..len).map(|i| (i + frame) as u8).collect();
uploads.buffer(&mut encoder, &target, 0, &bytes);
expected[..len].copy_from_slice(&bytes);
encoder.copy_buffer_to_buffer(&target, 0, &readback, 0, TARGET as u64);
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
readback
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
// Only the written bytes land: the rest of the target is untouched.
assert!(
*readback.slice(..).get_mapped_range().unwrap() == expected[..],
"frame {frame}"
);
readback.unmap();
}
// A chunk for the small writes and a buffer per doubling of the large
// one (2 and 4 chunks), maybe one more where a small write took the
// start of a buffer the large one needed whole: not one per frame.
let kept = kept_buffers(&device, &mut uploads);
assert!(
kept <= 4,
"the belt kept {kept} buffers over {frames} frames"
);
}
#[test]
fn belt_buffer_writes_land_in_order_at_their_offsets() {
let Some((device, queue)) = device() else {
@@ -131,8 +131,20 @@ impl Style {
value: f32,
hover: bool,
active: bool,
dragging: bool,
) {
ui::slider(batch, atlas, x, y, w, label, value, hover && active, active);
ui::slider(
batch,
atlas,
x,
y,
w,
label,
value,
hover && active,
active,
dragging && active,
);
}
/// The cost tag and the description beside a control, wrapped to two lines.
pub fn description(
@@ -855,6 +855,7 @@ impl GraphicsUi {
value,
hover || self.dragging == Some(item),
live,
self.dragging == Some(item),
),
None => STYLE.button(
batch,
+104 -41
View File
@@ -168,6 +168,31 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
}
out
}
/// The `animation` section of a texture's `.mcmeta`.
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
let meta: serde_json::Value = serde_json::from_str(mcmeta?).ok()?;
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
}
/// An animated strip's first frame: the region the mcmeta's frame size picks
/// (`AnimationMetadataSection.frameSize`), which is the top-left square unless
/// it names other dimensions or the frames run along a row.
fn first_frame(
(w, h, pixels): (u32, u32, Vec<u8>),
animation: Option<&AnimationMetadata>,
) -> (u32, u32, Vec<u8>) {
let [fw, fh] = animation.map_or([w.min(h) as i32; 2], |meta| {
meta.frame_size(w as i32, h as i32)
});
match (u32::try_from(fw), u32::try_from(fh)) {
(Ok(fw), Ok(fh))
if fw > 0 && fh > 0 && w % fw == 0 && h % fh == 0 && (fw, fh) != (w, h) =>
{
let side = fw.max(fh);
(side, side, strip_frame(&pixels, w, 0, 0, fw, fh))
}
_ => (w, h, pixels),
}
}
/// `image` inside a one-texel border copied from its opposite edges, the
/// texels repeat addressing reads past each edge.
fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
@@ -184,8 +209,9 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
}
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
/// files already packed: generated ones (`paletted_permutations` such as the
/// armour trim sprites, `unstitch` regions) and files listed under another id.
fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
/// armour trim sprites, `unstitch` regions) and files listed under another id,
/// such as other namespaces' item textures; and the animated ones' names.
fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<String>) {
use crate::atlas_sources::{self, SpriteOrigin};
let have: std::collections::HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
let textures = atlas_sources::list_textures(root);
@@ -229,29 +255,45 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
};
let first = |(name, origins): &(String, Vec<(usize, SpriteOrigin)>)| {
origins.iter().find_map(|(order, origin)| {
let mut animated = false;
let image = match origin {
SpriteOrigin::File(texture) => {
let path = texture.strip_prefix("minecraft:").unwrap_or(texture);
load(texture, "textures").map(|(w, h, p)| {
// Animated strips keep their first frame, like the packed files.
if h > w && (path.starts_with("block/") || path.starts_with("item/")) {
(w, w, p[..(w * w * 4) as usize].to_vec())
// Its path under `textures/` in any namespace (a server
// pack's `lobby:item/...` as well as vanilla's).
let path = texture.split_once(':').map_or(texture.as_str(), |(_, p)| p);
let file = atlas_sources::texture_path(root, texture, "textures");
decode(&file).ok().map(|image| {
// Animated strips keep their first frame and animate,
// like the packed files.
if path.starts_with("block/") || path.starts_with("item/") {
let mcmeta = fs::read_to_string(file.with_extension("png.mcmeta")).ok();
let animation = animation_of(mcmeta.as_deref());
animated = animation.is_some();
first_frame(image, animation.as_ref())
} else {
(w, h, p)
image
}
})
}
generated => atlas_sources::generate(generated, &load),
};
image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels)))
image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels), animated))
})
};
let mut out: Vec<_> = crate::par::par_map(&names, first)
.into_iter()
.flatten()
.collect();
out.sort_unstable_by_key(|(order, _)| *order);
out.into_iter().map(|(_, image)| image).collect()
out.sort_unstable_by_key(|(order, ..)| *order);
let animated = out
.iter()
.filter(|(.., animated)| *animated)
.map(|(_, image, _)| image.0.clone())
.collect();
(
out.into_iter().map(|(_, image, _)| image).collect(),
animated,
)
}
impl HudAtlas {
/// Keep a ready profile skin when another source only has its default.
@@ -425,37 +467,15 @@ impl HudAtlas {
.with_extension("")
.to_string_lossy()
.replace('\\', "/");
let (mut w, mut h, pixels) = decode(path).ok()?;
let image = decode(path).ok()?;
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
let animation = mcmeta
.as_deref()
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
.and_then(|meta| {
meta.get("animation")
.and_then(|section| AnimationMetadata::from_json(&section.to_string()).ok())
});
// An animated strip keeps its first frame: the region the mcmeta's
// frame size picks (`AnimationMetadataSection.frameSize`), which is
// the top-left square unless it names other dimensions or the
// frames run along a row.
let animation = animation_of(mcmeta.as_deref());
// An animated strip keeps its first frame.
let frames = name.starts_with("block/") || name.starts_with("item/");
let pixels = if frames {
let stride = w;
let [fw, fh] = animation.as_ref().map_or([w.min(h) as i32; 2], |meta| {
meta.frame_size(w as i32, h as i32)
});
match (u32::try_from(fw), u32::try_from(fh)) {
(Ok(fw), Ok(fh))
if fw > 0 && fh > 0 && w % fw == 0 && h % fh == 0 && (fw, fh) != (w, h) =>
{
let side = fw.max(fh);
(w, h) = (side, side);
strip_frame(&pixels, stride, 0, 0, fw, fh)
}
_ => pixels,
}
let (w, h, pixels) = if frames {
first_frame(image, animation.as_ref())
} else {
pixels
image
};
// The default texture's size, when a pack's differs from it.
let gui_size = default_textures
@@ -507,7 +527,9 @@ impl HudAtlas {
images.push(image);
}
if full {
images.extend(atlas_sprites(root, &images));
let (more, more_animated) = atlas_sprites(root, &images);
images.extend(more);
animated.extend(more_animated);
}
// The title screen's "JAVA EDITION" banner reads "RUST EDITION".
if let Some(edition) = images
@@ -579,7 +601,15 @@ impl HudAtlas {
let Some(rect) = sprites.get(&name).copied() else {
continue;
};
let layer = loaded.insert(root, &format!("minecraft:{name}"))?;
// Another namespace's sprite is named with it (`lobby:item/...`).
let id = if name.contains(':') {
name.clone()
} else {
format!("minecraft:{name}")
};
let Ok(layer) = loaded.insert(root, &id) else {
continue;
};
let Some(animation) = loaded.animations.iter().find(|a| a.layer == layer).cloned()
else {
continue;
@@ -1890,6 +1920,39 @@ mod tests {
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 255, 0, 255]);
}
#[test]
fn another_namespaces_animated_item_texture_packs_its_first_frame_and_animates() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-ns-anim-{}", std::process::id()));
let atlases = root.join("assets/minecraft/atlases");
let item = root.join("assets/lobby/textures/item/npc");
fs::create_dir_all(&atlases).unwrap();
fs::create_dir_all(&item).unwrap();
fs::write(
atlases.join("items.json"),
r#"{"sources": [{"type": "directory", "source": "item", "prefix": "item/"}]}"#,
)
.unwrap();
// A server pack's NPC skin: two 16x16 frames, green then red.
write_strip(
&item.join("skin.png"),
16,
32,
&[[0, 255, 0, 255], [255, 0, 0, 255]],
Some(r#"{"animation": {"frametime": 3}}"#),
);
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["lobby:item/npc/skin"];
// One frame, not the whole strip, which a model's UVs would span.
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
assert_eq!(texel_at(&atlas, rect, 0, 15), [0, 255, 0, 255]);
let (_, animation) = atlas
.sprite_animations
.iter()
.find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
.expect("the namespaced sprite animates");
assert_eq!(animation.frame_count(), 2);
}
/// `count` images of `w x h`; layout reads only their sizes.
fn sized(count: usize, w: u32, h: u32) -> Vec<PackedImage> {
(0..count)
+1
View File
@@ -71,6 +71,7 @@ pub mod signs;
pub mod skins;
pub mod sound_engine;
pub mod sound_events;
pub mod sound_preview;
pub mod spans;
pub mod split_layout;
mod sprite_cache;
+115 -2
View File
@@ -467,8 +467,15 @@ fn pack_lighting(
/// edits and immutable snapshots retained by other workers.
pub fn apply_lighting(chunk: &mut Chunk, lights: Vec<Light>) {
for (section, light) in chunk.sections.iter_mut().zip(lights) {
// A shared section already lit so stays as it is.
if let (Light::Uniform(a), Light::Uniform(b)) = (&section.light, &light)
&& a == b
{
continue;
}
Arc::make_mut(section).light = light;
}
crate::chunk::share_uniform(&mut chunk.sections);
}
/// `apply_lighting` for a chunk lit before: writes only the sections whose
@@ -535,6 +542,7 @@ pub fn light_from_layers<L: AsRef<[u8]>>(
section.light = Light::Full(data);
section.compact();
}
crate::chunk::share_uniform(&mut chunk.sections);
chunk.lit = true;
chunk.light_loaded = true;
}
@@ -658,6 +666,12 @@ impl<'w> Region<'w> {
let slot = self.slot(x, y, z)?;
Some(self.slots[slot].section.get(index(x, y, z)))
}
/// The one state of the section holding `y`, when it has a single one.
fn uniform_state(&mut self, x: i32, y: i32, z: i32) -> Option<u32> {
let slot = self.slot(x, y, z)?;
let section = &self.slots[slot].section;
(section.blocks.bits() == 0).then(|| section.get(0))
}
/// Packed light (sky << 4 | block), None outside loaded, lit chunks.
fn light(&mut self, x: i32, y: i32, z: i32) -> Option<u8> {
let slot = self.slot(x, y, z)?;
@@ -826,6 +840,15 @@ fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -
return min_y;
};
while y > min_y {
// A section of one state that lets light through itself (the air of
// a tall column) holds no source edge: go to its bottom block.
if let Some(state) = world.uniform_state(x, y - 1, z)
&& state == top
&& !tables.edge_occluded(top as usize, top as usize)
{
y = ((y - 1 - min_y) & !15) + min_y;
continue;
}
let Some(bottom) = world.state(x, y - 1, z) else {
break;
};
@@ -856,6 +879,23 @@ pub fn relight(
relight_region(&mut region, tables, sky_light, positions, force, changed);
region.finish();
}
/// The columns `changed` touched that border a loaded column not lit yet
/// (`unlit`). `relight` reads and writes only lit columns, so an edit's
/// darkening can take away light such a neighbour sends in and never put it
/// back, and nothing relights the column when that neighbour's own light
/// lands: an air block at 0 beside one at 6 (#2786). Vanilla's light engine
/// keeps one light storage for every chunk, so its edits and its chunk
/// lighting see each other. These columns need a full light job, which
/// lights them from the blocks of their whole 3x3, that neighbour's included.
pub fn beside_unlit(changed: &LightChanges, unlit: impl Fn(i32, i32) -> bool) -> Vec<(i32, i32)> {
let mut columns: Vec<(i32, i32)> = changed.sections().map(|(x, _, z)| (x, z)).collect();
columns.sort_unstable();
columns.dedup();
columns.retain(|&(x, z)| {
(-1..=1).any(|dz| (-1..=1).any(|dx| (dx, dz) != (0, 0) && unlit(x + dx, z + dz)))
});
columns
}
fn relight_region(
world: &mut Region,
tables: &LightTables,
@@ -1109,6 +1149,70 @@ mod tests {
// Without the shape test the room would be lit (slabs have no dampening).
assert_eq!(sky(&w, 8, 2, 8), 0, "the full bottom face stops sky light");
}
/// #2786: (1, 0) is loaded but its own light job hasn't landed; (0, 0)
/// was lit from the 3x3's blocks, its torch's light and the far torch's
/// in (1, 0) both. Taking its torch away darkens everything the torch
/// lit, the far torch's light at the border too, and `relight` cannot
/// bring that back from a column it doesn't read. Columns like that are
/// lit again whole; without it the border stays at 0 for good once
/// (1, 0)'s light lands beside it.
#[test]
fn an_edit_beside_a_column_not_lit_yet_lights_the_column_again() {
let tables = tables();
let mut w: HashMap<(i32, i32), Arc<Chunk>> = HashMap::new();
for z in -1..=1 {
for x in -1..=1 {
let mut c = Chunk::empty(x, z);
for y in MIN_Y..0 {
for i in 0..256 {
c.set(i & 15, y, i >> 4, STONE);
}
}
if (x, z) == (0, 0) {
c.set(14, 0, 8, TORCH);
} else if (x, z) == (1, 0) {
c.set(6, 0, 8, TORCH);
}
w.insert((x, z), Arc::new(c));
}
}
let light = |w: &HashMap<(i32, i32), Arc<Chunk>>, (x, z): (i32, i32)| {
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
w.get(&(x + dx as i32 - 1, z + dz as i32 - 1)).map(|c| &**c)
})
});
light_chunk(&region, &tables, true)
};
let keys: Vec<_> = w.keys().copied().collect();
for key in &keys {
let lights = light(&w, *key);
let c = Arc::make_mut(w.get_mut(key).unwrap());
apply_lighting(c, lights);
c.lit = *key != (1, 0);
}
// The far torch at x 22 gives 7 at x 15; (0, 0)'s own torch gives 13.
assert_eq!(block(&w, 15, 0, 8), 13);
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(14, 0, 8, AIR);
let mut changed = LightChanges::default();
relight(&mut w, &tables, true, &[(14, 0, 8)], false, &mut changed);
let again = beside_unlit(&changed, |x, z| w.get(&(x, z)).is_some_and(|c| !c.lit));
for &key in &again {
let lights = light(&w, key);
let c = Arc::make_mut(w.get_mut(&key).unwrap());
apply_relighting(c, lights, &mut LightChanges::default());
}
// (1, 0)'s own job lands.
let lights = light(&w, (1, 0));
let c = Arc::make_mut(w.get_mut(&(1, 0)).unwrap());
apply_lighting(c, lights);
c.lit = true;
assert_eq!(block(&w, 16, 0, 8), 8);
assert_eq!(block(&w, 15, 0, 8), 7, "air beside 8 across the border");
assert_eq!(block(&w, 14, 0, 8), 6);
assert!(again.contains(&(0, 0)), "{again:?}");
}
#[test]
fn relight_keeps_unchanged_and_unlit_sections() {
let mut w = world(|_| {});
@@ -1271,6 +1375,14 @@ mod tests {
assert_eq!(chunk.light(8, 1601, 8) >> 4, 15);
assert_eq!(chunk.light(8, 1599, 8) >> 4, 0, "under the roof");
assert_eq!(chunk.light(8, 2031, 8), 0xF0);
// The empty sections above are one shared allocation.
let top = chunk.sections.len() - 1;
assert!(Arc::ptr_eq(&chunk.sections[top], &chunk.sections[top - 10]));
let other = Chunk::with_layout(1, 0, layout);
apply_lighting(&mut other.clone(), vec![Light::Uniform(0xF0); 131]);
let mut lit = other.clone();
apply_lighting(&mut lit, vec![Light::Uniform(0xF0); 131]);
assert!(Arc::ptr_eq(&chunk.sections[top], &lit.sections[top]));
}
#[test]
fn a_tall_column_relights_as_its_full_light_and_keys_its_sections() {
@@ -1545,9 +1657,10 @@ mod tests {
.iter()
.all(|v| matches!(v, Light::Uniform(n) if *n == if sky { 0xF0 } else { 0 }))
);
let section = Arc::as_ptr(&chunk.sections[0]);
apply_lighting(&mut chunk, lights);
assert_eq!(section, Arc::as_ptr(&chunk.sections[0]));
// Uniform sections are one shared allocation (`share_uniform`).
let first = &chunk.sections[0];
assert!(chunk.sections.iter().all(|s| Arc::ptr_eq(s, first)));
}
}
+194 -12
View File
@@ -186,8 +186,15 @@ impl Column {
self.push(span);
}
}
/// `kinds`: `state_kinds`, per block state.
fn from_chunk(chunk: &Chunk, kinds: &[u8], x: i32, z: i32) -> Self {
/// `kinds`: `state_kinds`, per block state; `water`: `water_source(kinds)`,
/// what blocks holding water (kelp, seagrass, waterlogged ones) are drawn as.
fn from_chunk(
chunk: &Chunk,
kinds: &[u8],
water: Option<BlockStateId>,
x: i32,
z: i32,
) -> Self {
let kind = |state: BlockStateId| kinds.get(state.0 as usize).copied().unwrap_or(0);
let face_light = |y: i32| {
let mut sky = 0;
@@ -212,7 +219,7 @@ impl Column {
};
let mut column = Self::default();
for (sy, section) in chunk.sections.iter().enumerate() {
let min = MIN_Y + sy as i32 * 16;
let min = chunk.min_y() + sy as i32 * 16;
let uniform = section.blocks.bits() == 0
&& section.biomes.bits() == 0
&& matches!(section.light, crate::chunk::Light::Uniform(_));
@@ -222,11 +229,17 @@ impl Column {
let step = if uniform { 16 } else { 1 };
for offset in (0..16).step_by(step) {
let y = min + offset;
let state = BlockStateId(chunk.get(x, y, z));
let kind = kind(state);
let mut state = BlockStateId(chunk.get(x, y, z));
let mut kind = kind(state);
if kind & KEPT == 0 {
continue;
}
if kind & WATERY != 0
&& let Some(water) = water
{
state = water;
kind = kinds[water.0 as usize];
}
let light = face_light(y);
column.push(Span {
min: y as i16,
@@ -256,7 +269,7 @@ impl Column {
&& let Some((height, state)) = water
{
column.surface = Some(Surface {
height: MIN_Y,
height: chunk.min_y(),
state: BlockStateId(
kinds.iter().position(|&kind| kind & AIR != 0).unwrap_or(0) as u32
),
@@ -269,15 +282,28 @@ impl Column {
}
/// A chunk's 256 columns (`Column::from_chunk`), on the far-terrain worker.
fn summarize(chunk: &Chunk, kinds: &[u8]) -> Arc<[Column; 256]> {
let water = water_source(kinds);
Arc::new(std::array::from_fn(|i| {
Column::from_chunk(chunk, kinds, (i & 15) as i32, (i >> 4) as i32)
Column::from_chunk(chunk, kinds, water, (i & 15) as i32, (i >> 4) as i32)
}))
}
/// `state_kinds` bits: kept in columns, a fluid, the air state; the light
/// emission is in the high nibble.
/// The water source state: the first plain kept fluid without light
/// (`minecraft:water` comes before lava, and its level 0 first).
fn water_source(kinds: &[u8]) -> Option<BlockStateId> {
kinds
.iter()
.position(|&kind| kind == KEPT | FLUID)
.map(|i| BlockStateId(i as u32))
}
/// `state_kinds` bits: kept in columns, a fluid, the air state, a block
/// holding water; the light emission is in the high nibble.
const KEPT: u8 = 1;
const FLUID: u8 = 2;
const AIR: u8 = 4;
/// A block holding water that draws as none (kelp, seagrass, bubble columns,
/// waterlogged blocks that aren't kept): far terrain draws water there, or
/// kelp reaching the surface leaves holes in the sea.
const WATERY: u8 = 8;
/// What `Column::from_chunk` needs of each block state, read from the facts
/// once: matching their names for every block made observing a chunk slow.
fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
@@ -296,14 +322,29 @@ fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|| f.full_collision
|| f.name.ends_with("_leaves")
|| chorus
|| fluid);
(u8::from(kept) * KEPT)
| (u8::from(fluid) * FLUID)
|| fluid
|| nearly_full(&f.name));
let watery = !kept && !facts.is_air(state) && f.fluid == crate::blocks::Fluid::Water;
(u8::from(kept || watery) * KEPT)
| (u8::from(fluid || watery) * FLUID)
| (u8::from(watery) * WATERY)
| (u8::from(state == facts.air) * AIR)
| (f.emission.min(15) << 4)
})
.collect()
}
/// Blocks that fill their cell but neither draw solid nor collide as a full
/// block: far terrain draws them as blocks, or patches of powder snow leave
/// holes in mountains (and paths and fields dips).
fn nearly_full(name: &str) -> bool {
matches!(
name,
"minecraft:powder_snow"
| "minecraft:dirt_path"
| "minecraft:farmland"
| "minecraft:honey_block"
) || name.ends_with("_stairs")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TileKey {
pub x: i32,
@@ -1085,6 +1126,16 @@ impl Lod {
})
.copied()
.collect();
// Retiring fine columns are empty stand-ins, excluded from `active`
// while their normal meshes still cover the gap. Request their detail
// explicitly: neither the active-tile requests nor sampling an already
// ready stand-in can fill it, so its GPU sections would never retire.
keys.extend(retiring_detail(
&self.plan,
&self.retiring,
&self.saved,
&self.detail_masks,
));
keys.sort_unstable_by_key(|key| (key.distance(camera), *key));
let mut chunks = FxHashSet::default();
for key in keys {
@@ -1589,11 +1640,84 @@ impl Drop for Lod {
}
}
}
/// Fine retiring stand-ins need actual column detail before `Plan::replaces`
/// can release their normal meshes. The caller keeps the usual 32-column
/// request budget and waits for the replacement mesh before changing cutout.
fn retiring_detail<'a>(
plan: &'a Plan,
retiring: &'a FxHashSet<(i32, i32)>,
saved: &'a Saved,
masks: &'a FxHashMap<TileKey, [u64; 4]>,
) -> impl Iterator<Item = TileKey> + 'a {
retiring.iter().filter_map(move |&(x, z)| {
let key = TileKey { x, z, level: 0 };
(plan.nodes.contains(&key)
&& !saved.contains_key(&(x, z))
&& masks.get(&key) != Some(&[u64::MAX; 4]))
.then_some(key)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesher::TerrainVertex;
#[test]
fn retiring_fine_stand_ins_request_detail_before_their_gpu_meshes_can_retire() {
let column = (12, 0);
let fine = TileKey {
x: 12,
z: 0,
level: 0,
};
let mut plan = Plan::default();
plan.nodes.insert(fine);
let ready: FxHashMap<TileKey, ()> = [(fine, ())].into_iter().collect();
let retiring = [column].into_iter().collect();
let saved = Saved::default();
let mut masks = FxHashMap::default();
// A covered stand-in is ready, but empty: ordinary sampling skips it,
// and selection leaves it out of `active`. It cannot yet replace the
// old full-detail column, even though the near world has unloaded it.
assert!(!plan.replaces(column, &ready, |key| masks.get(&key)
== Some(&[u64::MAX; 4])));
assert_eq!(
retiring_detail(&plan, &retiring, &saved, &masks).collect::<Vec<_>>(),
[fine]
);
// The existing RequestDetail -> apply_fine path marks complete detail;
// only then may selection retire it and publish the new mesh cutout.
masks.insert(fine, [u64::MAX; 4]);
assert!(
retiring_detail(&plan, &retiring, &saved, &masks)
.next()
.is_none()
);
assert!(plan.replaces(column, &ready, |key| masks.get(&key)
== Some(&[u64::MAX; 4])));
}
#[test]
fn retiring_detail_requests_only_planned_columns_still_missing_detail() {
let fine = |x| TileKey { x, z: 0, level: 0 };
let mut plan = Plan::default();
plan.nodes.extend((0..4).map(fine));
plan.nodes.insert(fine(5));
let retiring = (0..5).map(|x| (x, 0)).collect();
let saved = [((1, 0), Arc::new(std::array::from_fn(|_| Column::default())))]
.into_iter()
.collect();
let masks = [
(fine(2), [u64::MAX; 4]),
(fine(3), [u64::MAX, u64::MAX, u64::MAX, u64::MAX - 1]),
]
.into_iter()
.collect();
let mut requested: Vec<_> = retiring_detail(&plan, &retiring, &saved, &masks).collect();
requested.sort_unstable();
// Saved or complete detail needs no request; an unplanned retiree and
// a planned column that is not retiring must not consume the budget.
assert_eq!(requested, [fine(0), fine(3)]);
}
#[test]
fn reduced_far_detail_only_merges_tiles_away_from_the_near_terrain() {
let leaves = |plan: &Plan| {
plan.nodes
@@ -1840,6 +1964,40 @@ mod tests {
}
}
#[test]
fn nearly_full_blocks_are_kept() {
assert!(nearly_full("minecraft:powder_snow"));
assert!(nearly_full("minecraft:dirt_path"));
assert!(nearly_full("minecraft:oak_stairs"));
assert!(!nearly_full("minecraft:oak_fence"));
assert!(!nearly_full("minecraft:wheat"));
}
#[test]
fn kelp_to_the_surface_is_drawn_as_water() {
// 0 air, 1 stone, 2 water, 3 kelp (holds water, draws as none).
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | WATERY];
let mut chunk = Chunk::empty(0, 0);
for x in [3, 4] {
for y in MIN_Y..40 {
chunk.set(x, y, 3, 1);
}
}
for y in 40..63 {
chunk.set(3, y, 3, 3);
chunk.set(4, y, 3, 2);
}
let water = water_source(&kinds);
assert_eq!(water, Some(BlockStateId(2)));
let kelp = Column::from_chunk(&chunk, &kinds, water, 3, 3);
let sea = Column::from_chunk(&chunk, &kinds, water, 4, 3);
assert_eq!(kelp.spans, sea.spans);
assert_eq!(
kelp.spans.last().map(|s| (s.min, s.max, s.state)),
Some((40, 63, BlockStateId(2)))
);
assert_eq!(kelp.surface.unwrap().water, Some((63, BlockStateId(2))));
assert_eq!(kelp.surface.unwrap().height, 40);
}
#[test]
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
let span = |min, max, state| Span {
min,
@@ -2035,6 +2193,24 @@ mod tests {
chunk.set(8, 71, 8, chorus.0);
chunk.set(8, 72, 8, chorus.0);
chunk.set(8, 73, 8, flower.0);
// Kelp to the surface is water; powder snow and a waterlogged stair
// are kept as themselves.
let state = |name: &str| assets.registry.default_state(name).unwrap();
let (kelp, powder_snow) = (state("minecraft:kelp"), state("minecraft:powder_snow"));
let stair = facts
.states
.iter()
.position(|f| &*f.name == "minecraft:oak_stairs" && f.waterlogged)
.map(|i| BlockStateId(i as u32))
.unwrap();
chunk.set(2, 54, 2, stone.0);
for y in 55..64 {
chunk.set(2, y, 2, kelp.0);
}
chunk.set(6, 90, 6, stone.0);
chunk.set(6, 91, 6, powder_snow.0);
chunk.set(10, 54, 10, stone.0);
chunk.set(10, 55, 10, stair.0);
lod.observe(Arc::new(chunk.clone()), &facts);
receive_observed(&mut lod);
let observed = &lod.saved[&(-1, -1)];
@@ -2052,6 +2228,12 @@ mod tests {
.collect::<Vec<_>>(),
vec![(70, 71, end_stone), (71, 73, chorus), (73, 74, flower)]
);
let kelp_column = &observed[2 * 16 + 2];
assert_eq!(kelp_column.surface.unwrap().height, 55);
assert_eq!(kelp_column.surface.unwrap().water, Some((64, water)));
assert_eq!(observed[6 * 16 + 6].surface.unwrap().state, powder_snow);
assert_eq!(observed[6 * 16 + 6].surface.unwrap().height, 92);
assert_eq!(observed[10 * 16 + 10].surface.unwrap().state, stair);
let fine = TileKey {
x: -1,
+13 -2
View File
@@ -45,9 +45,20 @@ pub fn data_dir() -> PathBuf {
std::env::temp_dir()
}
/// Where the launcher runs the game and keeps its saves: `HORIZON_HOME`, else `horizon` in the
/// platform data folder (launcher/src/backend.rs `game_home`).
/// `--home DIR`, set once at start; it wins over `HORIZON_HOME`.
static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// Makes `game_home` return `home` for the rest of the run (`--home`).
pub fn set_game_home(home: PathBuf) {
let _ = HOME.set(home);
}
/// Where the launcher runs the game and keeps its saves: `--home`, else `HORIZON_HOME`, else
/// `horizon` in the platform data folder (launcher/src/backend.rs `game_home`).
pub fn game_home() -> PathBuf {
if let Some(home) = HOME.get() {
return home.clone();
}
match std::env::var_os("HORIZON_HOME").filter(|h| !h.is_empty()) {
Some(h) => PathBuf::from(h),
None => data_dir().join("horizon"),
+8 -7
View File
@@ -8,7 +8,7 @@
//! changed.
use crate::{
blocks::{BlockFacts, Fluid, MAP_COLORS},
chunk::{Chunk, MIN_Y, SECTIONS},
chunk::Chunk,
fast_hash::FxHashMap,
world::World,
};
@@ -113,7 +113,8 @@ fn scan_column(
return (color | WALL, head as i16, 0);
}
}
while y > MIN_Y {
let bottom = chunk.min_y();
while y > bottom {
let (mut color, flags) = palette.get(chunk.get(x, y, z));
if color == 0 {
y -= 1;
@@ -123,7 +124,7 @@ fn scan_column(
let fluid = flags & FLUID;
if fluid != 0 {
let mut below = y - 1;
while below > MIN_Y && palette.get(chunk.get(x, below, z)).1 & FLUID != 0 {
while below > bottom && palette.get(chunk.get(x, below, z)).1 & FLUID != 0 {
depth = depth.saturating_add(1);
below -= 1;
}
@@ -134,11 +135,11 @@ fn scan_column(
}
return (color, y as i16, depth);
}
(0, MIN_Y as i16, 0)
(0, bottom as i16, 0)
}
/// The highest block of a chunk worth scanning from.
fn top_of(chunk: &Chunk, air: u32, layer: Layer) -> i32 {
let surface = (0..SECTIONS)
let surface = (0..chunk.sections.len())
.rev()
.find(|&s| {
chunk
@@ -146,7 +147,7 @@ fn top_of(chunk: &Chunk, air: u32, layer: Layer) -> i32 {
.get(s)
.is_some_and(|sec| !sec.is_uniform(air))
})
.map_or(MIN_Y, |s| MIN_Y + s as i32 * 16 + 15);
.map_or(chunk.min_y(), |s| chunk.min_y() + s as i32 * 16 + 15);
match layer {
Layer::Surface => surface,
Layer::Below(head) => surface.min(head),
@@ -156,7 +157,7 @@ fn scan_tile(chunk: &Chunk, palette: &Palette, air: u32, layer: Layer, revision:
let top = top_of(chunk, air, layer);
let mut tile = Tile {
color: [0; 256],
height: [MIN_Y as i16; 256],
height: [chunk.min_y() as i16; 256],
depth: [0; 256],
revision,
layer,
@@ -45,6 +45,7 @@ pub struct ModelInstance {
pub layer: u32,
pub shade: f32,
}
#[derive(Default)]
pub struct PreparedModels {
pub catalog: StateModels,
pub placements: Vec<(BlockPos, BlockStateId)>,
@@ -42,6 +42,9 @@ pub struct BakedModel {
pub cube: Option<[super::cube::CubeStyle; 6]>,
}
type VariantKey = (String, [u8; 3], bool);
/// Empty by default: no state has a model (a dedicated server, which draws
/// nothing).
#[derive(Default)]
pub struct StateModels {
/// Each definition's choices, a variant standing as its index in `baked`.
choices: Vec<CompiledChoices<u32>>,
+116 -8
View File
@@ -141,9 +141,7 @@ impl Opt {
| Opt::ChunkFade
| Opt::ImprovedTransparency
| Opt::ChunkBuilder
// Saved, but the creative tabs, the quit keys and the mouse
// don't read them yet.
| Opt::OperatorItemsTab
// Saved, but the quit keys and the mouse don't read them yet.
| Opt::QuitShortcuts
| Opt::CtrlClickEmulatesRightClick
)
@@ -952,6 +950,24 @@ pub fn set_slider(opt: Opt, s: &mut Settings, v: f32) {
apply_preset(s);
}
}
/// A slider pressed or dragged to `v`: apply it live, and tell the game when
/// a category volume changed (`Options.createSoundSliderOptionInstance`'s
/// callback, which `OptionInstance.set` runs only for a new value).
fn drag_slider(opt: Opt, s: &mut Settings, v: f32, events: &mut Vec<Event>) {
let volume = |s: &Settings| match opt {
Opt::Volume(0) => Some(s.master_volume),
Opt::Volume(i) => Some(s.volumes[i]),
_ => None,
};
let before = volume(s);
set_slider(opt, s, v);
events.push(Event::SliderDrag);
if let (Opt::Volume(source), Some(after)) = (opt, volume(s))
&& before != Some(after)
{
events.push(Event::Volume(source, after));
}
}
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set (texture
/// filtering and anisotropy have no option here yet).
pub fn apply_preset(s: &mut Settings) {
@@ -1106,6 +1122,9 @@ pub enum Event {
Changed,
/// A slider is being dragged: apply settings live but defer saving.
SliderDrag,
/// A category volume slider (`SoundSource` index) moved to a new value,
/// after its `SliderDrag` (the sound slider's `onValueUpdate`).
Volume(usize, f32),
/// The language changed (reload translations).
Language(String),
/// The pack selection changed (reload resources).
@@ -2532,6 +2551,7 @@ impl OptionsUi {
shown,
hover || dragging,
true,
dragging,
);
}
WKind::Text {
@@ -2747,8 +2767,7 @@ impl OptionsUi {
{
let v = ui::slider_value(wd.x, wd.w, x);
self.slider_value = Some((opt, v, Instant::now()));
set_slider(opt, s, v);
events.push(Event::SliderDrag);
drag_slider(opt, s, v, &mut events);
}
return events;
}
@@ -2861,8 +2880,7 @@ impl OptionsUi {
self.dragging = Some(opt);
let v = ui::slider_value(wd.x, wd.w, x);
self.slider_value = Some((opt, v, Instant::now()));
set_slider(opt, s, v);
events.push(Event::SliderDrag);
drag_slider(opt, s, v, &mut events);
}
WKind::LanguageRow { code, .. } => {
if self.language_choice.as_deref() == Some(code.as_str()) {
@@ -3415,6 +3433,16 @@ mod tests {
assert!(loaded.toggle_attack && loaded.toggle_use);
}
#[test]
fn operator_items_tab_option_can_be_switched() {
// The creative screen reads it (CreativeModeInventoryScreen.hasPermissions).
assert!(Opt::OperatorItemsTab.available());
let mut s = Settings::default();
assert!(!s.operator_items_tab);
Opt::OperatorItemsTab.cycle(&mut s, false, [854, 480]);
assert!(s.operator_items_tab);
}
#[test]
fn render_distance_slider_round_trips_through_extended_options_range() {
let mut s = Settings::default();
@@ -3645,6 +3673,57 @@ mod tests {
assert_eq!(ui.page(), Page::NativeMod(0));
}
#[test]
fn a_native_mods_key_shows_and_keeps_the_key_pressed() {
// #3539: Zoom's key set to R showed another key.
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
let size = (480.0, 240.0);
s.mods.installed.push(crate::mod_list::Installed {
id: "zoom".into(),
title: "Zoom 2.0.0".into(),
description: String::new(),
problem: None,
dev: false,
native: Some(crate::mod_list::NativePage {
keys: vec![["zoom".into(), "Zoom key".into(), "key.keyboard.z".into()]],
buttons: Vec::new(),
}),
});
ui.show(Page::NativeMod(0));
let key_label = |ui: &mut OptionsUi, s: &Settings| {
let (widgets, ..) = ui.layout(s, size.0, size.1);
widgets.into_iter().find_map(|w| match w.kind {
WKind::Button {
label,
act: Act::NativeKey(0, 0),
..
} => Some((label.plain(), w.x, w.y)),
_ => None,
})
};
let (label, x, y) = key_label(&mut ui, &s).expect("the key's row");
assert_eq!(label, "Z");
ui.click(
&mut s,
&atlas,
x + 1.0,
y + 1.0,
size,
[480, 240],
false,
false,
);
assert_eq!(
ui.key(&mut s, &atlas, winit::keyboard::KeyCode::KeyR, Some("r")),
vec![Event::Changed]
);
assert_eq!(key_label(&mut ui, &s).unwrap().0, "R");
let saved = crate::mod_list::ModList::parse(&s.mods.serialize());
assert_eq!(saved.keys_of("zoom"), vec![("zoom", "key.keyboard.r")]);
}
#[test]
fn option_tooltip_keys_follow_value_dependent_descriptions() {
let mut s = Settings::default();
@@ -4093,7 +4172,7 @@ mod tests {
for cx in [-100.0, 250.0, 1000.0] {
assert_eq!(
ui.click(&mut s, &atlas, cx, -100.0, size, [854, 480], true, false),
vec![Event::SliderDrag]
vec![Event::SliderDrag, Event::Volume(0, s.master_volume)]
);
}
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
@@ -4103,6 +4182,35 @@ mod tests {
);
}
#[test]
fn a_moved_volume_slider_reports_its_category_and_value() {
let atlas = atlas();
let size = (480.0, 320.0);
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Sound);
let hostile = SOUND_SOURCES.iter().position(|n| *n == "hostile").unwrap();
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let slider = widgets
.iter()
.find(|wd| matches!(wd.kind, WKind::Slider { opt: Opt::Volume(i), .. } if i == hostile))
.unwrap();
let y = slider.y + 1.0;
let x = slider.x + 4.0 + (slider.w - 8.0) * 0.5;
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
let volume = s.volumes[hostile];
assert!((volume - 0.5).abs() < 1e-3, "{volume}");
assert_eq!(
events,
vec![Event::SliderDrag, Event::Volume(hostile, volume)]
);
// OptionInstance.set: an unchanged value runs no callback.
assert_eq!(
ui.click(&mut s, &atlas, x, -50.0, size, [854, 480], true, false),
vec![Event::SliderDrag]
);
}
#[test]
fn classic_sliders_follow_the_pointer_and_snap_on_release() {
let atlas = atlas();
+21 -1
View File
@@ -278,7 +278,8 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
Source::Dir(d) => fs::read(d.join("pack.mcmeta")).ok()?,
Source::Zip(z) => Zip::open(z).ok()?.read("pack.mcmeta").ok()?,
};
serde_json::from_slice(&bytes).ok()
// Gson reads past a UTF-8 byte order mark; serde_json rejects it.
serde_json::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes)).ok()
}
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
pack_for(id, title, source, RESOURCE_FORMAT)
@@ -938,6 +939,25 @@ impl SelectionModel {
mod tests {
use super::*;
#[test]
fn mcmeta_with_utf8_bom_is_read() {
let dir = std::env::temp_dir().join(format!("horizon-bom-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let mut meta = b"\xEF\xBB\xBF".to_vec();
meta.extend_from_slice(br#"{"pack":{"description":"d","pack_format":97}}"#);
fs::write(dir.join("pack.mcmeta"), &meta).unwrap();
assert!(read_meta(&Source::Dir(dir.clone())).is_some());
// Without a BOM the same JSON still reads; other leading bytes still fail.
fs::write(dir.join("pack.mcmeta"), &meta[3..]).unwrap();
assert!(read_meta(&Source::Dir(dir.clone())).is_some());
fs::write(
dir.join("pack.mcmeta"),
[b"\xEF\xBB".as_slice(), &meta[3..]].concat(),
)
.unwrap();
assert!(read_meta(&Source::Dir(dir.clone())).is_none());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn compatibility_ranges() {
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
assert!(compatible(&v(
+25 -1
View File
@@ -1042,6 +1042,8 @@ pub struct ParticleEngine {
level_random: LegacyRandom,
/// `SpellParticle.RANDOM`.
spell_random: LegacyRandom,
/// The local player's eye while it looks through a spyglass in first person.
scope_eye: Option<[f64; 3]>,
/// Seeds for each particle's own random (`RandomSource.create()`).
seeds: LegacyRandom,
/// When set, particles ask the level for other particles here instead
@@ -1080,6 +1082,7 @@ impl ParticleEngine {
random: LegacyRandom::new(0x7061_7274),
level_random: LegacyRandom::new(0x6c65_7665),
spell_random: LegacyRandom::new(0x7370_656c),
scope_eye: None,
seeds: LegacyRandom::new(0x7365_6564),
requested: None,
names: Vec::new(),
@@ -1437,6 +1440,16 @@ impl ParticleEngine {
}
}
}
/// Set each frame to the local player's eye while it scopes in first person.
pub fn set_scope_eye(&mut self, eye: Option<[f64; 3]>) {
self.scope_eye = eye;
}
/// `SpellParticle.isCloseToScopingPlayer`: within 3 blocks of that eye.
fn close_to_scoping_player(&self, pos: [f64; 3]) -> bool {
self.scope_eye.is_some_and(|eye| {
(eye[0] - pos[0]).powi(2) + (eye[1] - pos[1]).powi(2) + (eye[2] - pos[2]).powi(2) <= 9.0
})
}
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
pub fn send_particles(
&mut self,
@@ -2636,6 +2649,12 @@ impl ParticleEngine {
q.size *= 0.75;
q.lifetime = (8.0 / (f64::from(r.next_float()) * 0.8 + 0.2)) as i32;
q.has_physics = false;
// SpellParticle's constructor: setAlpha(0.0F), which also sets originalAlpha.
// MobEffectProvider sets the option's alpha after it, so an entity
// effect starts visible and the first tick hides it.
if self.close_to_scoping_player(q.position) {
q.rgba[3] = 0.0;
}
match ty {
T::Witch => {
let b = self.random.next_float() * 0.5 + 0.35;
@@ -3743,7 +3762,12 @@ impl ParticleEngine {
Behaviour::Spell { original_alpha } => {
p.base_tick(level);
self.sprite_from_age(p);
p.rgba[3] += (original_alpha - p.rgba[3]) * 0.05;
// SpellParticle.tick: alpha 0 while close, else eased back to originalAlpha.
if self.close_to_scoping_player(p.position) {
p.rgba[3] = 0.0;
} else {
p.rgba[3] += (original_alpha - p.rgba[3]) * 0.05;
}
}
Behaviour::Cloud => {
p.base_tick(level);
@@ -50,6 +50,9 @@ pub fn sound_source(event: &str) -> usize {
index("ambient")
} else if e.starts_with("music_disc.") {
index("record")
} else if e.starts_with("item.goat_horn.") {
// InstrumentItem.play: SoundSource.RECORDS.
index("record")
} else if e.starts_with("music.") {
index("music")
} else if e.starts_with("weather.") || e.starts_with("entity.lightning_bolt") {
@@ -0,0 +1,179 @@
//! `SoundPreviewHandler`: the sound a category volume slider plays while it
//! moves on the title screen's options (`Options.createSoundSliderOptionInstance`
//! calls `preview` when there is no level).
use crate::settings::SOUND_SOURCES;
use crate::sound_engine::{InstanceId, SoundEngine, SoundInstance, Sounds};
/// The sound `SoundPreviewHandler.preview` plays for a `SoundSource` index,
/// or `None` (`SoundEvents.EMPTY`) for master, music and voice.
pub fn preview_event(source: usize) -> Option<&'static str> {
Some(match *SOUND_SOURCES.get(source)? {
"record" => "minecraft:block.note_block.guitar",
"weather" => "minecraft:entity.lightning_bolt.thunder",
"block" => "minecraft:block.grass.place",
"hostile" => "minecraft:entity.zombie.ambient",
// SoundEvents.COW_SOUNDS.get(CowSoundVariants.SoundSet.CLASSIC).ambientSound()
"neutral" => "minecraft:entity.cow.ambient",
"player" => "minecraft:entity.generic.eat",
"ambient" => "minecraft:ambient.cave",
"ui" => "minecraft:ui.button.click",
_ => return None,
})
}
/// `SoundPreviewHandler`'s `activePreview` and `previousCategory`.
#[derive(Debug, Default)]
pub struct SoundPreview {
active: Option<InstanceId>,
previous: Option<usize>,
}
impl SoundPreview {
/// `SoundPreviewHandler.preview(soundManager, category, volume)`: stop the
/// preview of another category, then, unless the last preview still
/// plays, play this category's sound (`SimpleSoundInstance.forUI` at
/// pitch 1 and the slider's volume).
pub fn preview(&mut self, sounds: &mut impl Sounds, source: usize, volume: f32) {
self.stop_other_category_preview(sounds, source);
if !self.can_play_sound(sounds) {
return;
}
let Some(event) = preview_event(source) else {
return;
};
if let Some(old) = self.active.take() {
sounds.release(old);
}
let instance = SoundInstance::for_ui(event, 1.0, volume, SoundEngine::unseeded());
self.active = Some(sounds.play_instance(instance, true).0);
}
/// The preview playing now, if any.
pub fn active(&self) -> Option<InstanceId> {
self.active
}
/// `stopOtherCategoryPreview`.
fn stop_other_category_preview(&mut self, sounds: &mut impl Sounds, source: usize) {
if self.previous != Some(source) {
self.previous = Some(source);
if let Some(active) = self.active {
sounds.stop_instance(active);
}
}
}
/// `canPlaySound`: no preview yet, or the last one has finished.
fn can_play_sound(&self, sounds: &mut impl Sounds) -> bool {
self.active.is_none_or(|active| !sounds.is_active(active))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sound_engine::{Bound, TickContext, UI, test_backend::Null};
use crate::sound_events::SoundRegistry;
fn engine() -> SoundEngine {
let mut registry = SoundRegistry::default();
registry.add_file(
"minecraft",
br#"{"entity.zombie.ambient": {"sounds": ["mob/zombie/say1"]},
"entity.cow.ambient": {"sounds": ["mob/cow/say1"]},
"ui.button.click": {"sounds": ["random/click"]}}"#,
&|_, _| true,
);
SoundEngine::new(registry)
}
fn source(name: &str) -> usize {
SOUND_SOURCES.iter().position(|s| *s == name).unwrap()
}
#[test]
fn each_category_previews_its_vanilla_sound() {
let expected = [
("master", None),
("music", None),
("record", Some("minecraft:block.note_block.guitar")),
("weather", Some("minecraft:entity.lightning_bolt.thunder")),
("block", Some("minecraft:block.grass.place")),
("hostile", Some("minecraft:entity.zombie.ambient")),
("neutral", Some("minecraft:entity.cow.ambient")),
("player", Some("minecraft:entity.generic.eat")),
("ambient", Some("minecraft:ambient.cave")),
("voice", None),
("ui", Some("minecraft:ui.button.click")),
];
for (name, event) in expected {
assert_eq!(preview_event(source(name)), event, "{name}");
}
}
fn slide(
preview: &mut SoundPreview,
engine: &mut SoundEngine,
backend: &mut Null,
name: &str,
volume: f32,
) {
preview.preview(&mut Bound { engine, backend }, source(name), volume);
}
/// Ticks enough for the engine to drop finished or stopped channels.
fn settle(engine: &mut SoundEngine, backend: &mut Null) {
for _ in 0..40 {
engine.tick(false, &TickContext::default(), backend);
}
}
#[test]
fn a_moving_slider_plays_one_preview_at_a_time_and_switching_stops_it() {
let (mut engine, mut backend) = (engine(), Null::default());
let mut preview = SoundPreview::default();
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.4);
let first = preview
.active()
.expect("the hostile slider plays a preview");
let instance = engine.instance(first).unwrap();
assert_eq!(instance.identifier, "minecraft:entity.zombie.ambient");
assert_eq!(instance.source, UI);
assert_eq!((instance.volume, instance.pitch), (0.4, 1.0));
assert!(instance.relative);
assert!(engine.is_active(first));
// canPlaySound: still playing, so dragging further does not restart it.
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6);
assert_eq!(preview.active(), Some(first));
assert_eq!(engine.active_instances(), vec![first]);
// Once it has finished, the next move plays it again.
for stopped in backend.stopped.values_mut() {
*stopped = true;
}
settle(&mut engine, &mut backend);
assert!(!engine.is_active(first));
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6);
let second = preview.active().unwrap();
assert_ne!(second, first);
assert!(engine.is_active(second));
// Another category stops the playing preview (SoundManager.stop); the
// engine lets its channel go on the next ticks, then that category's
// own sound plays.
slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0);
assert!(backend.stopped.values().all(|stopped| *stopped));
settle(&mut engine, &mut backend);
assert!(engine.active_instances().is_empty());
slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0);
let third = preview.active().unwrap();
assert_eq!(
engine.instance(third).unwrap().identifier,
"minecraft:entity.cow.ambient"
);
// Master has no sound but still stops the other category's preview.
slide(&mut preview, &mut engine, &mut backend, "master", 1.0);
settle(&mut engine, &mut backend);
assert!(engine.active_instances().is_empty());
slide(&mut preview, &mut engine, &mut backend, "master", 1.0);
assert!(engine.active_instances().is_empty());
}
}
@@ -27,6 +27,10 @@ pub struct Span {
pub fn enable(on: bool) {
ENABLED.store(on, Ordering::Relaxed);
}
/// Whether spans are being recorded.
pub fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// Run `f`, recording its duration under `name` when spans are enabled.
#[inline]
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
@@ -3,6 +3,7 @@
//! hands immutable `Arc` snapshots to workers; results carry a sequence number
//! so stale work is dropped.
use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::section_mask::SectionMask;
use crate::{
chunk::Chunk,
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
@@ -81,6 +82,12 @@ pub trait ChunkGenerator: Send + Sync + 'static {
fn light_view(&self, _proto: &Proto) -> Option<Chunk> {
None
}
/// A decorated proto chunk's column sections (`Chunk::sections`
/// indexes) holding a block other than air: what its neighbours' light
/// layers count once it is at INITIALIZE_LIGHT.
fn non_air_sections(&self, _proto: &Proto) -> Option<SectionMask> {
None
}
fn saved_proto(&self, _proto: &Proto) -> Option<crate::chunk::SavedProto> {
None
}
@@ -275,6 +282,7 @@ impl Mailbox {
pub struct Streaming {
shared: Arc<Shared>,
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
threads: Vec<thread::JoinHandle<()>>,
order: u64,
/// Ticks since the columns and protos that fell out of range were swept.
@@ -653,9 +661,14 @@ impl Streaming {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.light_view(p))
};
let non_air_sections = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.non_air_sections(p))
};
Self {
shared,
light_view,
non_air_sections,
threads,
order: 0,
scan_age: 0,
@@ -956,6 +969,15 @@ impl Streaming {
pub fn is_lit(&self, x: i32, z: i32) -> bool {
self.lit.contains(&(x, z))
}
/// The non-air sections of a column that is a decorated proto chunk
/// here (vanilla's INITIALIZE_LIGHT and on), if it is one: a level
/// chunk's light layers count them (#4294).
pub fn proto_sections(&self, x: i32, z: i32) -> Option<SectionMask> {
if !self.decorated.contains(&(x, z)) {
return None;
}
(self.non_air_sections)(self.protos.get(&(x, z))?.as_ref()?)
}
/// `ChunkMap.getLatestStatus` for the loading screen's chunk map: the
/// furthest step this column has reached, `None` before it is asked for.
pub fn latest_status(
@@ -1475,6 +1497,7 @@ impl Streaming {
&mut changed,
);
Self::mark_light_changes(world, &changed);
Self::relight_beside_unlit(world, &changed);
if !self.light_snapshots.is_empty() {
for p in positions {
self.light_edit_sequence += 1;
@@ -1504,6 +1527,7 @@ impl Streaming {
&mut changed,
);
Self::mark_light_changes(world, &changed);
Self::relight_beside_unlit(world, &changed);
}
let oldest = self.light_snapshots.values().copied().min();
match oldest {
@@ -1511,6 +1535,14 @@ impl Streaming {
None => self.light_log.clear(),
}
}
/// A column whose light an edit changed beside a column not lit yet gets
/// a full light job ([`crate::light::beside_unlit`]).
fn relight_beside_unlit(world: &mut World, changed: &LightChanges) {
let columns = crate::light::beside_unlit(changed, |x, z| {
world.chunks.get(&(x, z)).is_some_and(|c| !c.lit)
});
world.dirty_light.extend(columns);
}
/// Sections whose meshes sample a changed light value (faces and corners
/// read light from neighbouring blocks).
fn mark_light_changes(world: &mut World, changed: &LightChanges) {
+29 -10
View File
@@ -390,6 +390,7 @@ pub fn slider(
value: f32,
hover: bool,
active: bool,
dragging: bool,
) {
let kind = crate::widgets::Kind::Slider {
value,
@@ -401,17 +402,8 @@ pub fn slider(
// A controller's D-pad can focus it (Horizon's controller support).
crate::widgets::target_as([x, y, w, 20.0], kind);
}
let track = if hover && active {
"gui/sprites/widget/slider_highlighted"
} else {
"gui/sprites/widget/slider"
};
let (track, handle) = slider_sprites(hover, active, dragging);
nine_slice(batch, atlas, track, x, y, w, 20.0, u32::MAX);
let handle = if hover && active {
"gui/sprites/widget/slider_handle_highlighted"
} else {
"gui/sprites/widget/slider_handle"
};
nine_slice(
batch,
atlas,
@@ -429,6 +421,21 @@ pub fn slider(
};
centered_text(batch, atlas, label, x + 2.0, y + 6.0, w - 4.0, color);
}
/// Track and handle sprites: the track outline shows only while the slider is
/// dragged, hovering highlights just the handle.
fn slider_sprites(hover: bool, active: bool, dragging: bool) -> (&'static str, &'static str) {
let track = if dragging {
"gui/sprites/widget/slider_highlighted"
} else {
"gui/sprites/widget/slider"
};
let handle = if hover && active {
"gui/sprites/widget/slider_handle_highlighted"
} else {
"gui/sprites/widget/slider_handle"
};
(track, handle)
}
/// Slider value for a click at `cx` (AbstractSliderButton.setValueFromMouse).
pub fn slider_value(x: f32, w: f32, cx: f32) -> f32 {
((cx - (x + 4.0)) / (w - 8.0)).clamp(0.0, 1.0)
@@ -1344,6 +1351,18 @@ mod tests {
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
}
#[test]
fn slider_track_is_outlined_only_while_dragged() {
let (track, handle) = slider_sprites(true, true, false);
assert_eq!(track, "gui/sprites/widget/slider");
assert_eq!(handle, "gui/sprites/widget/slider_handle_highlighted");
let (track, handle) = slider_sprites(true, true, true);
assert_eq!(track, "gui/sprites/widget/slider_highlighted");
assert_eq!(handle, "gui/sprites/widget/slider_handle_highlighted");
let (track, handle) = slider_sprites(false, true, false);
assert_eq!(track, "gui/sprites/widget/slider");
assert_eq!(handle, "gui/sprites/widget/slider_handle");
}
#[test]
fn separator_tiles_sample_both_rows_and_clip_the_last_column() {
let mut batch = HudBatch::new([640, 480]);
tile(
@@ -262,6 +262,14 @@ impl World {
pub fn layout(&self) -> ColumnLayout {
self.shape.column()
}
/// The lowest block Y the level's chunks hold.
pub fn bottom_y(&self) -> i32 {
self.layout().min_y()
}
/// One above the highest block Y the level's chunks hold.
pub fn top_y(&self) -> i32 {
self.layout().max_y()
}
/// Resident storage write; marks the touched sections (and neighbours on
/// section borders) for remeshing and the surrounding chunks for relighting.
/// Neighbour updates, drops and block entities belong to the gameplay layer.
+1 -1
View File
@@ -65,7 +65,7 @@ unicode-bidi = "0.3.18"
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
rustix = { version = "1", default-features = false, features = ["std", "time"] }
rustix = { version = "1", default-features = false, features = ["std", "time", "process"] }
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
@@ -153,6 +153,9 @@ pub fn main() -> Result<()> {
bits(position),
bits(velocity)
)),
ArrowEvent::PotionPuff { id, color } => {
events.push(format!("potion-puff {id} {color:08x}"))
}
ArrowEvent::Sound {
sound,
volume,
@@ -1,9 +1,10 @@
//! The creative inventory, for tools/test_creative_parity.py: loads an
//! exported `creative-inventory.json` into `horizon_client::creative::Creative`
//! and prints the tabs it shows (canonical stacks) and the search tab's
//! results for every query of the vanilla oracle's output.
//! results for every query of the vanilla oracle's output; with `permissions`,
//! as an operator with the Operator Items Tab option on.
//!
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]
use horizon_client::{
creative::{Creative, Names},
item_data::canon_stack,
@@ -13,11 +14,13 @@ use std::{fs, path::Path};
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let usage = "usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT";
let usage =
"usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]";
let names = Names::load(Path::new(args.get(1).ok_or(usage)?));
let creative = Creative::from_json(&serde_json::from_slice(&fs::read(
let mut creative = Creative::from_json(&serde_json::from_slice(&fs::read(
args.get(2).ok_or(usage)?,
)?)?)?;
creative.set_permissions(args.get(5).is_some_and(|a| a == "permissions"), &names);
let vanilla: Value = serde_json::from_slice(&fs::read(args.get(3).ok_or(usage)?)?)?;
let tabs: Vec<Value> = creative
.tabs
@@ -4,7 +4,12 @@
//!
//! usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
mobs::{Mob, MobTypes, Species, cube, drops, gear, raider, render::DYE_NAMES, sulfur_cube},
assets::Assets,
blocks::BlockFacts,
items::Items,
mobs::{
Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES, sulfur_cube,
},
random_sequences::RandomSequences,
};
use horizon_core::{geometry::Vec3, random::LegacyRandom};
@@ -65,6 +70,13 @@ pub fn main() -> horizon_client::Result<()> {
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let types = MobTypes::load(root)?;
let loot = LootData::load(root, &PackOverlay::NONE)?;
// Block loot for an enderman's carried block (BlockState.getDrops).
let assets = Assets::load(root)?;
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let block_items = Items::load(root, registry, &facts, &PackOverlay::NONE)?;
// The stand-in level's random (RandomSource.create(0)), never reset.
let mut level_random = LegacyRandom::new(0);
let mut out = String::new();
for raw in input.lines() {
if raw.trim().is_empty() || raw.starts_with('#') {
@@ -121,6 +133,13 @@ pub fn main() -> horizon_client::Result<()> {
m.extra.color = DYE_NAMES.iter().position(|d| d == color).unwrap_or(0) as u8;
}
m.extra.sheared = get("sheared", "0") == "1";
if let Some(block) = c.get("carried") {
m.extra.carried = Some(
registry
.default_state(block)
.ok_or_else(|| format!("unknown block {block}"))?,
);
}
if get("leader", "0") == "1" {
raider::state_mut(&mut m).patrol_leader = true;
}
@@ -272,6 +291,23 @@ pub fn main() -> horizon_client::Result<()> {
for d in items {
let _ = match d {
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
drops::Drop::CarriedBlock(state) => {
let stacks = enderman::carried_block_drops(
&block_items,
registry,
state,
&mut level_random,
Some((&mut sequences, world_seed)),
);
for s in stacks {
let _ = write!(out, "drop {} {}", s.item, s.count);
if s.damage != 0 {
let _ = write!(out, " damage={}", s.damage);
}
let _ = writeln!(out);
}
Ok(())
}
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
};
}
@@ -99,6 +99,7 @@ mod toggle_keys_probe;
mod tooltip_display_fixture;
mod trading_fixture;
mod translucency_fixture;
mod trident_probe;
mod weather_fixture;
mod world_border_render_fixture;
mod world_options_fixture;
@@ -191,6 +192,7 @@ probes! {
text_display_fixture,
text_display_render_fixture,
trading_fixture,
trident_probe,
translucency_fixture,
weather_fixture,
world_border_render_fixture,
@@ -76,6 +76,7 @@ pub fn main() -> Result<()> {
raining_at: &|_| false,
height_no_leaves: &|_| 0,
firefly_bush_sounds: false,
creaking_active: false,
sky_darken: 0,
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
model_offset: &|_, _, _| None,
@@ -0,0 +1,130 @@
//! Thrown tridents over the client's resident scene (`arrows::clip_block_stop`),
//! for tools/test_trident_parity.py: `AbstractArrow.tick`'s block step and
//! `AbstractArrow.onHitBlock`, one line of state per fixture.
#![forbid(unsafe_code)]
use horizon_client::{
Result, arrows, assets::Assets, blocks::BlockFacts, collisions, world::World,
};
use horizon_core::geometry::Vec3;
use serde_json::Value;
use std::{
fs,
io::{BufRead, Write},
path::PathBuf,
};
fn number(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn vector(v: &Value) -> Vec3 {
Vec3::new(number(&v[0]), number(&v[1]), number(&v[2]))
}
fn bits(v: Vec3) -> String {
format!(
"{:016x},{:016x},{:016x}",
v.x.to_bits(),
v.y.to_bits(),
v.z.to_bits()
)
}
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().skip(1).collect();
if args.len() != 3 {
return Err("usage: trident_probe RESOURCES FIXTURE_DIRECTORY --describe|REFERENCE".into());
}
let root = PathBuf::from(&args[0]);
let dir = PathBuf::from(&args[1]);
let assets = Assets::load(&root)?;
let render = assets.reload(&root)?;
let mut scene = World::test_scene(&assets, &render, 1)?;
fs::write(
dir.join("scene.json"),
serde_json::to_vec(
&serde_json::json!({"radius":1,"resident_chunks":scene.chunks.len(),"model_gallery":render.model_gallery}),
)?,
)?;
if args[2] == "--describe" {
return Ok(());
}
let binding = collisions::load(&root, assets.registry)?;
let facts = BlockFacts::load(&root, assets.registry)?;
let side = &args[2];
let mut out = std::io::BufWriter::new(fs::File::create(dir.join(format!("rust-{side}.txt")))?);
for line in std::io::BufReader::new(fs::File::open(dir.join("inputs.txt"))?).lines() {
let f: Value = serde_json::from_str(&line?)?;
let mut position = vector(&f["position"]);
let mut velocity = vector(&f["velocity"]);
let mut in_ground = false;
let mut shake = 0u32;
for op in f["ops"].as_array().unwrap() {
match op["mode"].as_str().unwrap() {
"block" => {
let p = &op["position"];
let mut state = assets
.registry
.default_state(op["name"].as_str().unwrap())
.ok_or("missing fixture block")?;
if let Some(properties) = op["properties"].as_object() {
let mut definition = assets.registry.state(state).unwrap().clone();
for (name, value) in properties {
definition
.properties
.insert(name.clone(), value.as_str().unwrap().into());
}
state = assets
.registry
.id(&definition)
.ok_or("missing fixture state")?;
}
scene.set(
p[0].as_i64().unwrap() as i32,
p[1].as_i64().unwrap() as i32,
p[2].as_i64().unwrap() as i32,
state,
)?;
}
// Ops only the vanilla oracle needs.
"level_seed" | "owner" | "pickup" | "touch" | "target" | "velocity" | "ignite" => {}
"tick" => {
// AbstractArrow.tick: shakeTime down, then the block step.
shake = shake.saturating_sub(1);
if in_ground {
continue;
}
if let Some((stop, _)) =
arrows::clip_block_stop(&scene, &facts, &binding, position, velocity)
{
// onHitBlock: pulled back, stopped, stuck, shaking 7 ticks.
position = stop;
velocity = Vec3::ZERO;
in_ground = true;
shake = 7;
} else {
// A MISS moves the whole movement, then air drag and gravity.
position = Vec3::new(
position.x + velocity.x,
position.y + velocity.y,
position.z + velocity.z,
);
velocity = Vec3::new(
velocity.x * f64::from(0.99f32),
velocity.y * f64::from(0.99f32) - 0.05,
velocity.z * f64::from(0.99f32),
);
}
}
other => return Err(format!("unknown operation {other}").into()),
}
}
out.write_all(
format!(
"trident {} {} {in_ground} {shake}\n",
bits(position),
bits(velocity)
)
.as_bytes(),
)?;
}
Ok(())
}
+40
View File
@@ -101,6 +101,8 @@ enum Animate {
DeadBush,
/// `ShortDryGrassBlock` and `TallDryGrassBlock`.
DryGrass,
/// A creaking heart that is not uprooted (`CreakingHeartBlock.animateTick`).
CreakingHeart,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -236,6 +238,9 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"firefly_bush" => Animate::FireflyBush,
"open_eyeblossom" => Animate::OpenEyeblossom,
"pale_hanging_moss" => Animate::PaleHangingMoss,
"creaking_heart" if prop("creaking_heart_state").as_deref() != Some("uprooted") => {
Animate::CreakingHeart
}
"dead_bush" => Animate::DeadBush,
"short_dry_grass" | "tall_dry_grass" => Animate::DryGrass,
"sculk_sensor" | "calibrated_sculk_sensor"
@@ -442,6 +447,8 @@ pub struct AnimateContext<'a> {
pub height_no_leaves: &'a dyn Fn([i32; 3]) -> i32,
/// `EnvironmentAttributes.FIREFLY_BUSH_SOUNDS` (overworld day timeline).
pub firefly_bush_sounds: bool,
/// `EnvironmentAttributes.CREAKING_ACTIVE` (overworld day timeline).
pub creaking_active: bool,
/// `Level.getSkyDarken`, for `getMaxLocalRawBrightness`.
pub sky_darken: i32,
/// Creative main-hand marker block and a resolver for each placed state's sprite.
@@ -1259,6 +1266,39 @@ fn block_animate_tick(
}
}
}
Animate::CreakingHeart => {
// CreakingHeartBlock.animateTick: the idle creak is drawn only while
// the attribute is on, then needs pale oak logs on all six sides
// (`CreakingHeartBlock.isSurroundedByLogs`).
if ctx.creaking_active
&& next_int(engine.random(), 16) == 0
&& [
[-1, 0, 0],
[1, 0, 0],
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
]
.iter()
.all(|d| {
let p = [pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]];
tables
.pale_oak_logs
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
})
{
sound(
engine,
"minecraft:block.creaking_heart.idle",
[fx, fy, fz],
1.0,
1.0,
);
}
}
Animate::DeadBush => {
// AmbientDesertBlockSoundsPlayer.playAmbientDeadBushSounds.
if next_int(engine.random(), 130) == 0 {
+90 -4
View File
@@ -94,6 +94,13 @@ pub enum ArrowEvent {
position: Vec3,
velocity: Vec3,
},
/// `Level.broadcastEntityEvent(arrow, 0)`: a tipped arrow stuck for 600
/// ticks. The clients burst its particles (`Arrow.handleEntityEvent`);
/// the arrow itself is a plain one from now on.
PotionPuff {
id: u32,
color: u32,
},
Sound {
sound: ArrowSound,
volume: f32,
@@ -186,6 +193,8 @@ pub struct ArrowState {
/// The arrow's own random (`Entity.random`).
pub random: LegacyRandom,
pub events: Vec<ArrowEvent>,
/// The speed (`deltaMovement.length()`) it hit a block at, before it stuck.
pub hit_speed: f64,
}
impl fmt::Debug for ArrowState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@@ -230,8 +239,15 @@ impl ArrowState {
removed: false,
random,
events: Vec::new(),
hit_speed: 0.0,
}
}
/// `Arrow.getColor` of the server's own arrow: the colour of the potion on
/// its pickup item, None for a plain arrow (`PotionContents.EMPTY`).
pub fn potion_color(&self) -> Option<u32> {
let potion = self.pickup_item.potion()?;
Some(crate::brewing::potion_color(Some(potion)))
}
/// The `AbstractArrow` constructor's `pickupItemStack`: the drawn ammo
/// (one item). Ammo drawn without being used up (`INTANGIBLE_PROJECTILE`)
/// makes the arrow CREATIVE_ONLY; vanilla takes the component off the
@@ -375,6 +391,61 @@ impl ArrowScene<'_, '_> {
true
}
}
/// `AbstractArrow.tick`'s block phase for a thrown trident: the zero-width
/// `level().clip(new ClipContext(position(), position().add(movement),
/// ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this))`, then
/// `AbstractArrow.onHitBlock`'s `position().subtract(signum * 0.05)`. Returns
/// the position to stop at (pull-back included) and the block that was hit.
pub fn clip_block_stop(
world: &World,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
from: Vec3,
movement: Vec3,
) -> Option<(Vec3, BlockPos)> {
let to = Vec3::new(
from.x + movement.x,
from.y + movement.y,
from.z + movement.z,
);
let mut scene = SceneCollisions {
world,
binding: collision,
subject: None,
};
let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| {
let state = world.block(p.x, p.y, p.z);
if facts.is_air(state) {
return None;
}
let shape = collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?;
shape.clip(from, to, p)
})
.ok()
.flatten()?;
Some((back_off(hit.location, movement), hit.block))
}
/// `AbstractArrow.onHitBlock`'s back-off: vanilla scales `(double) 0.05f` by
/// `Math.signum` per axis, whose value at zero is zero (not Rust's -1.0).
pub fn back_off(location: Vec3, movement: Vec3) -> Vec3 {
fn signum(v: f64) -> f64 {
if v > 0.0 {
1.0
} else if v < 0.0 {
-1.0
} else {
0.0
}
}
let offset = f64::from(0.05f32);
Vec3::new(
location.x - signum(movement.x) * offset,
location.y - signum(movement.y) * offset,
location.z - signum(movement.z) * offset,
)
}
fn bounds(at: Vec3) -> Aabb {
// EntityDimensions.makeBoundingBox: half the float width.
let w = f64::from(WIDTH / 2.0);
@@ -576,6 +647,19 @@ impl Ticker<'_, '_, '_, '_> {
let at = self.position();
self.apply_effects_from_blocks(at, at);
}
// Arrow.tick on the server: a tipped arrow stuck for 600 ticks sends
// entity event 0 (the clients' burst of particles in its colour,
// `handleEntityEvent`) and turns into a plain arrow. The flight
// and ground particles are the clients' own (`Level.isClientSide`).
if self.st.in_ground_time >= 600
&& let Some(color) = self.st.potion_color()
{
self.st.events.push(ArrowEvent::PotionPuff {
id: self.p.id,
color,
});
self.st.pickup_item = crate::items::ItemStack::new("minecraft:arrow", 1);
}
return Hit::None;
}
self.st.in_ground_time = 0;
@@ -664,7 +748,7 @@ impl Ticker<'_, '_, '_, '_> {
if !self.st.first_tick && self.st.fluids.is_in_fluid(&Tags, LAVA) {
self.st.fall_distance *= 0.5;
}
if self.position().y < f64::from(crate::chunk::MIN_Y - 64) {
if self.position().y < f64::from(self.scene.world.bottom_y() - 64) {
self.st.removed = true;
}
self.st.first_tick = false;
@@ -848,6 +932,8 @@ impl Ticker<'_, '_, '_, '_> {
/// it moved on, stop, and stick.
fn on_hit_block(&mut self, pos: BlockPos, state: BlockStateId) {
self.st.last_state = Some(state);
// BlockState.onProjectileHit still sees the deltaMovement it hit at.
self.st.hit_speed = self.p.velocity.length();
// Projectile.onHitBlock: BlockState.onProjectileHit.
if self.st.on_fire()
&& let Some(reaction) = self.block_reaction(state)
@@ -1159,10 +1245,10 @@ impl FluidInteractionWorld for Ticker<'_, '_, '_, '_> {
Ok(self.scene.world.chunks.contains_key(&(x, z)).then_some(()))
}
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
let layout = self.scene.world.layout();
Ok(FluidSections {
minimum_section_y: crate::chunk::MIN_Y >> 4,
has_fluid: &SECTIONS,
minimum_section_y: layout.min_y() >> 4,
has_fluid: crate::chunk::every_section(layout),
})
}
fn bounding_box_min_y(&self) -> f64 {
+239 -55
View File
@@ -24,6 +24,31 @@ pub struct Tab {
/// tab is selected (`set_saved_hotbars`).
pub hotbar: bool,
}
impl Tab {
/// The tab without its stacks.
fn header(&self) -> Self {
Self {
id: self.id.clone(),
title: self.title.clone(),
icon: self.icon.clone(),
items: Vec::new(),
top: self.top,
column: self.column,
search: self.search,
inventory: self.inventory,
hotbar: self.hotbar,
}
}
}
/// A tab as exported, built with permissions: `operator` marks the stacks a
/// build without them leaves out (the export's `permissions_only`).
#[derive(Clone, Debug)]
struct Exported {
tab: Tab,
/// `CreativeModeTab.Type.CATEGORY`: shown only with a stack (`shouldDisplay`).
category: bool,
operator: Vec<bool>,
}
/// What the client's creative search trees (SessionSearchTrees) index for one
/// stack of the search tab: its creative tooltip lines and its item tags.
#[derive(Clone, Debug, Default)]
@@ -37,6 +62,8 @@ struct SearchEntry {
tags: Vec<(String, String)>,
}
pub struct Creative {
/// The tabs shown (`CreativeModeTabs.tabs()`), with the stacks the current
/// permissions put in them.
pub tabs: Vec<Tab>,
pub selected: usize,
/// Scroll position 0..1 (scrollOffs).
@@ -48,6 +75,13 @@ pub struct Creative {
/// The search tab's stacks, in its display order.
search_items: Vec<ItemStack>,
search_index: Vec<SearchEntry>,
/// Every exported tab, and the search tab's stacks with their entries and
/// whether only permissions list them: `rebuild` keeps what is shown.
exported: Vec<Exported>,
exported_search: Vec<(ItemStack, SearchEntry, bool)>,
/// The `hasPermissions` the tabs were last built with
/// (`CreativeModeTabs.tryRebuildTabContents`).
permissions: bool,
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
/// search changes (selectTab, refreshSearchResults), not on every frame.
shown: Mutex<Option<Shown>>,
@@ -202,14 +236,22 @@ impl Creative {
}
pub fn from_json(raw: &Value) -> Result<Self> {
let mut tabs = Vec::new();
let mut search_items = Vec::new();
let mut search_index = Vec::new();
let mut search = Vec::new();
for t in raw["tabs"].as_array().into_iter().flatten() {
// The stacks only a build with permissions lists, by index.
let only: Vec<u64> = t["permissions_only"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_u64)
.collect();
let mut items = Vec::new();
for v in t["items"].as_array().into_iter().flatten() {
let mut operator = Vec::new();
for (i, v) in t["items"].as_array().into_iter().flatten().enumerate() {
let snbt = v.as_str().unwrap_or("-");
if let Some(stack) = crate::item_data::parse_stack(snbt)? {
items.push(stack);
operator.push(only.contains(&(i as u64)));
}
}
let kind = t["type"].as_str().unwrap_or("");
@@ -223,34 +265,35 @@ impl Creative {
};
for (i, stack) in items.iter().enumerate() {
let (namespace, path) = split_id(&java_lower(&stack.item));
search_index.push(SearchEntry {
let entry = SearchEntry {
namespace,
path,
lines: strings(&t["lines"][i]),
tags: strings(&t["tags"][i]).iter().map(|t| split_id(t)).collect(),
});
};
search.push((stack.clone(), entry, operator[i]));
}
search_items = items.clone();
}
if t["visible"] == false {
continue;
}
let icon = t["icon"]
.as_str()
.and_then(|s| crate::item_data::parse_stack(s).ok().flatten());
tabs.push(Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: kind == "HOTBAR",
tabs.push(Exported {
tab: Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: kind == "HOTBAR",
},
category: kind == "CATEGORY",
operator,
});
}
Ok(Self::new(tabs, search_items, search_index))
Ok(Self::new(tabs, search))
}
/// The old export: item ids per tab, no components, tooltips or tags.
fn from_legacy(raw: &Value) -> Result<Self> {
@@ -272,34 +315,39 @@ impl Creative {
}
}
let kind = t["type"].as_str().unwrap_or("");
tabs.push(Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: false,
tabs.push(Exported {
operator: vec![false; items.len()],
tab: Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: false,
},
category: false,
});
}
let search_index = search_items
.iter()
let search = search_items
.into_iter()
.map(|s| {
let (namespace, path) = split_id(&java_lower(&s.item));
SearchEntry {
let entry = SearchEntry {
namespace,
path,
..Default::default()
}
};
(s, entry, false)
})
.collect();
Ok(Self::new(tabs, search_items, search_index))
Ok(Self::new(tabs, search))
}
fn new(tabs: Vec<Tab>, search_items: Vec<ItemStack>, search_index: Vec<SearchEntry>) -> Self {
Self {
tabs,
fn new(exported: Vec<Exported>, exported_search: Vec<(ItemStack, SearchEntry, bool)>) -> Self {
let mut creative = Self {
tabs: Vec::new(),
selected: 0,
scroll: 0.0,
scrolling: false,
@@ -310,10 +358,81 @@ impl Creative {
search.text_color = u32::MAX;
search
},
search_items,
search_index,
search_items: Vec::new(),
search_index: Vec::new(),
exported,
exported_search,
permissions: false,
shown: Mutex::new(None),
};
creative.rebuild();
creative
}
/// `CreativeModeTabs.buildAllTabContents` with `permissions`: the operator
/// tab's display items generator lists its stacks only with them
/// (`hasPermissions`), and the search tab with it; a category tab is
/// shown with a stack (`CreativeModeTab.shouldDisplay`).
fn rebuild(&mut self) {
let permissions = self.permissions;
let keep = |operator: bool| permissions || !operator;
self.tabs = self
.exported
.iter()
.filter_map(|e| {
let items: Vec<ItemStack> = (e.tab.items.iter().zip(&e.operator))
.filter(|(_, o)| keep(**o))
.map(|(s, _)| s.clone())
.collect();
(!e.category || !items.is_empty()).then(|| Tab {
items,
..e.tab.header()
})
})
.collect();
(self.search_items, self.search_index) = self
.exported_search
.iter()
.filter(|(_, _, o)| keep(*o))
.map(|(s, e, _)| (s.clone(), e.clone()))
.unzip();
self.forget_items();
}
/// `CreativeModeInventoryScreen.tryRefreshInvalidatedTabs`, which its
/// constructor and `containerTick` run with `hasPermissions`
/// (`Player.canUseGameMasterBlocks` and the Operator Items Tab option):
/// when that changed, the tabs are built again. A selected tab left
/// without stacks (the operator tab) gives way to the default tab; any
/// other keeps its first row (`refreshCurrentTabContents`). True when the
/// tabs were rebuilt.
pub fn set_permissions(&mut self, permissions: bool, names: &Names) -> bool {
if permissions == self.permissions {
return false;
}
let selected = self.tab().id.clone();
let row = self.first_row(names);
self.permissions = permissions;
self.rebuild();
match self.tabs.iter().position(|t| t.id == selected) {
Some(tab) => {
self.selected = tab;
// ItemPickerMenu.getScrollForRowIndex.
let hidden = self.hidden_rows(names);
self.scroll = if hidden > 0 {
(row as f32 / hidden as f32).clamp(0.0, 1.0)
} else {
0.0
};
}
None => {
// CreativeModeTabs.getDefaultTab.
let default = self
.tabs
.iter()
.position(|t| t.id == "minecraft:building_blocks");
self.select(default.unwrap_or(0));
}
}
true
}
/// Where a stack comes in the creative tabs (the search tab lists them
/// all, in tab order): the same stack's place, else its item's first.
@@ -327,20 +446,7 @@ impl Creative {
}
/// The tabs without their stacks: what the screen draws of them each frame.
pub fn tab_headers(&self) -> Vec<Tab> {
self.tabs
.iter()
.map(|t| Tab {
id: t.id.clone(),
title: t.title.clone(),
icon: t.icon.clone(),
items: Vec::new(),
top: t.top,
column: t.column,
search: t.search,
inventory: t.inventory,
hotbar: t.hotbar,
})
.collect()
self.tabs.iter().map(Tab::header).collect()
}
pub fn tab(&self) -> &Tab {
&self.tabs[self.selected.min(self.tabs.len() - 1)]
@@ -601,7 +707,7 @@ impl Creative {
pub fn tab_position(tab: &Tab) -> (f32, f32) {
let mut x = tab.column as f32 * 27.0;
if tab.column >= 5 {
// Right-aligned tabs (search, inventory).
// Right-aligned tabs (search, operator, inventory).
x = Self::WIDTH - 27.0 * (7 - tab.column) as f32 + 1.0;
}
let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 };
@@ -1083,6 +1189,84 @@ mod tests {
assert_eq!(c.search.value, "");
}
/// An export built with permissions: the operator tab's stacks, and their
/// place at the end of the search tab, only with them.
fn with_operator_tab() -> Creative {
let stack = |id: &str| format!("{{count:1,id:\"minecraft:{id}\"}}");
let raw = serde_json::json!({"schema": 3, "tabs": [
{"id": "minecraft:building_blocks", "type": "CATEGORY", "row": "TOP", "column": 0, "title": "Building Blocks",
"icon": stack("bricks"), "visible": true, "items": [stack("stone")]},
{"id": "minecraft:op_blocks", "type": "CATEGORY", "row": "BOTTOM", "column": 5, "title": "Operator Utilities",
"icon": stack("command_block"), "visible": false,
"items": [stack("command_block"), stack("barrier")], "permissions_only": [0, 1]},
{"id": "minecraft:search", "type": "SEARCH", "row": "TOP", "column": 6, "title": "Search Items",
"icon": stack("compass"), "visible": true,
"items": [stack("stone"), stack("command_block"), stack("barrier")],
"lines": [["Stone"], ["Command Block"], ["Barrier"]], "tags": [[], [], []],
"permissions_only": [1, 2]},
{"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory",
"icon": stack("chest"), "visible": true, "items": []}
]});
Creative::from_json(&raw).unwrap()
}
fn tab_ids(c: &Creative) -> Vec<&str> {
c.tabs.iter().map(|t| t.id.as_str()).collect()
}
#[test]
fn operator_stacks_stay_out_without_permissions() {
let c = with_operator_tab();
let names = Names::default();
assert_eq!(
tab_ids(&c),
[
"minecraft:building_blocks",
"minecraft:search",
"minecraft:inventory"
]
);
assert_eq!(c.search_items().len(), 1);
assert!(c.search_results("command", &names).is_empty());
assert_eq!(c.search_results("", &names), [0]);
}
#[test]
fn operator_tab_comes_and_goes_with_permissions() {
let mut c = with_operator_tab();
let names = Names::default();
assert!(!c.set_permissions(false, &names));
assert!(c.set_permissions(true, &names));
assert!(!c.set_permissions(true, &names));
assert_eq!(
tab_ids(&c),
[
"minecraft:building_blocks",
"minecraft:op_blocks",
"minecraft:search",
"minecraft:inventory"
]
);
let op: Vec<&str> = c.tabs[1].items.iter().map(|s| s.item.as_ref()).collect();
assert_eq!(op, ["minecraft:command_block", "minecraft:barrier"]);
assert_eq!(c.search_results("command", &names), [1]);
assert_eq!(c.search_results("", &names), [0, 1, 2]);
// The search tab keeps its place across the rebuild.
c.select(2);
c.search.set_value("block");
assert!(c.set_permissions(false, &names));
assert!(c.tab().search);
assert_eq!(c.search.value, "block");
assert!(c.items(&names).is_empty());
// The selected operator tab is left empty: the default tab instead.
c.set_permissions(true, &names);
c.select(1);
assert_eq!(c.items(&names).len(), 2);
c.set_permissions(false, &names);
assert_eq!(c.tab().id, "minecraft:building_blocks");
assert_eq!(c.items(&names)[0].item.as_ref(), "minecraft:stone");
}
#[test]
fn saved_hotbars_tab_shows_its_rows_and_locks_the_hint() {
let mut c = Creative::from_json(&serde_json::json!({"schema": 2, "tabs": [
+99
View File
@@ -4,6 +4,7 @@
//! (the entity goes through on the next tick), and
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
use crate::nether_portal::{ENTITY_DELAY, PortalState};
use crate::projectiles::{Projectile, ProjectileKind};
use horizon_core::{geometry::Vec3, position::BlockPos};
/// The End gateway travel of one entity.
@@ -65,6 +66,35 @@ pub fn swept_gateway(
found
}
/// The End gateway a thrown ender pearl passed through on its last move, from
/// where it started the tick to where it is (`Entity.checkInsideBlocks` from
/// `ThrowableProjectile.tick`'s `applyEffectsFromBlocks`). Other projectiles
/// are not looked at.
pub fn pearl_gateway(
pearl: &Projectile,
is_gateway: impl FnMut(BlockPos) -> bool,
) -> Option<BlockPos> {
if !matches!(pearl.kind, ProjectileKind::EnderPearl) {
return None;
}
let size = pearl.kind.size() as f32;
swept_gateway(pearl.previous, pearl.position, size, size, is_gateway)
}
/// A thrown ender pearl at its gateway's exit `at`
/// (`EndGatewayBlock.getPortalDestination` for a `ThrownEnderpearl`: a
/// `TeleportTransition` with no motion, rotation 0 and nothing relative).
/// It falls from there and takes its owner where it lands.
pub fn place_pearl(pearl: &mut Projectile, at: Vec3) {
pearl.position = at;
pearl.previous = at;
pearl.velocity = Vec3::ZERO;
pearl.yaw = 0.0;
pearl.pitch = 0.0;
// A server tells its clients of the jump at once.
pearl.needs_sync = true;
}
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
/// lands at a gateway's exit.
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
@@ -150,6 +180,75 @@ mod tests {
assert_eq!(traveller.travel(false), None);
}
/// A world with no blocks in the way and nothing to hit: the gateway
/// block has no collision shape, so a projectile flies into it.
struct Open;
impl crate::projectiles::ProjectileWorld for Open {
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
None
}
fn in_water(&self, _: horizon_core::geometry::Aabb) -> bool {
false
}
fn targets(&self) -> &[(crate::mobs::Who, horizon_core::geometry::Aabb)] {
&[]
}
}
/// The ticks a projectile flies, thrown east at the gateway, until its
/// move goes through one (`None` if it never does in 5 ticks).
fn thrown_at_gateway(kind: ProjectileKind) -> (Projectile, Option<BlockPos>) {
let mut shot = Projectile::new(kind, at(699.5, 100.3, 0.5), None);
shot.velocity = at(1.5, 0.0, 0.0);
for _ in 0..5 {
shot.tick(&Open);
if let Some(g) = pearl_gateway(&shot, is_gateway) {
return (shot, Some(g));
}
}
(shot, None)
}
#[test]
fn a_thrown_pearl_goes_through_the_gateway_and_falls_from_its_exit() {
let (mut pearl, found) = thrown_at_gateway(ProjectileKind::EnderPearl);
let gateway = BlockPos::new(702, 100, 0);
// ThrownEnderpearl in the gateway block: EndGatewayBlock.entityInside
// takes it in, and it goes through on the next tick.
assert_eq!(found, Some(gateway));
let mut traveller = Traveller::default();
traveller.enter(gateway);
assert_eq!(traveller.travel(true), Some(gateway));
// getPortalDestination: at the exit, no motion and rotation 0.
let exit = bottom_center([8, 100, 0]);
place_pearl(&mut pearl, exit);
assert_eq!(pearl.position, exit);
assert_eq!(pearl.previous, exit);
assert_eq!(pearl.velocity, Vec3::ZERO);
assert_eq!((pearl.yaw, pearl.pitch), (0.0, 0.0));
// It falls straight down from the exit (ThrowableProjectile gravity,
// then inertia), where it lands and takes its owner along.
pearl.tick(&Open);
assert_eq!((pearl.position.x, pearl.position.z), (8.5, 0.5));
assert!((pearl.position.y - (100.0 - 0.03 * 0.99)).abs() < 1e-9);
// The pearl is out of the gateway: it does not go back at once.
assert_eq!(traveller.travel(true), None);
assert_eq!(traveller.cooldown(), ENTITY_DELAY - 1);
}
#[test]
fn a_pearl_flying_past_the_gateway_stays_out_of_it() {
// Thrown one block higher, over the gateway block.
let mut pearl = Projectile::new(ProjectileKind::EnderPearl, at(699.5, 101.3, 0.5), None);
pearl.velocity = at(1.5, 0.0, 0.0);
for _ in 0..5 {
pearl.tick(&Open);
assert_eq!(pearl_gateway(&pearl, is_gateway), None);
}
// Only the pearl travels: a snowball on the same path is not taken in.
assert_eq!(thrown_at_gateway(ProjectileKind::Snowball).1, None);
}
#[test]
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
+135 -1
View File
@@ -1,6 +1,7 @@
//! Non-player entities simulated on the client world. Dropped items follow
//! `ItemEntity.tick`: gravity 0.04, 0.98 drag (block friction on the ground),
//! a ground bounce of -0.5, water buoyancy, pickup delay and 6000-tick despawn.
//! a ground bounce of -0.5, water buoyancy, pickup delay, 6000-tick despawn,
//! and `HoneyBlock.entityInside` when falling against a honey block's side.
use crate::{
blocks::{BlockFacts, Fluid},
collisions::SceneCollisions,
@@ -237,6 +238,8 @@ impl ItemEntity {
if moved.y != wanted.y {
self.velocity.y = 0.0;
}
// Entity.move -> applyEffectsFromBlocks: HoneyBlock.entityInside.
self.apply_honey_slide(world, facts);
let mut friction = 0.98;
if self.on_ground {
let below = world.block(
@@ -260,6 +263,51 @@ impl ItemEntity {
self.update_fluids(world, fluids);
self.age < 6000 && self.position.y > -128.0
}
/// `HoneyBlock.entityInside` for the honey blocks this item's box is in
/// (`Entity.checkInsideBlocks`, deflated by `1.0E-5`). Slide sounds and
/// entity event 53 are skipped (`doesEntityDoHoneyBlockSlideEffects` is
/// false for an item).
fn apply_honey_slide(&mut self, world: &World, facts: &BlockFacts) {
let b = self.bounds().deflate(Vec3::new(1e-5, 1e-5, 1e-5));
let (lo, hi) = (b.min_position(), b.max_position());
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
for y in lo.y.floor() as i32..=hi.y.floor() as i32 {
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
if &*facts.get(world.block(x, y, z)).name == "minecraft:honey_block" {
self.honey_block_inside([x, y, z]);
}
}
}
}
}
/// `HoneyBlock.isSlidingDown` then `doSlideMovement`. `getOldDeltaY` /
/// `getNewDeltaY` wrap the stored velocity the same way they do for every
/// entity (`vy / 0.98F + 0.08`).
pub(crate) fn honey_block_inside(&mut self, pos: [i32; 3]) {
let old_delta_y = |dy: f64| dy / f64::from(0.98_f32) + 0.08;
let new_delta_y = |dy: f64| (dy - 0.08) * f64::from(0.98_f32);
let [x, y, z] = pos.map(f64::from);
if self.on_ground
|| self.position.y > y + 0.9375 - 1.0e-7
|| old_delta_y(self.velocity.y) >= -0.08
{
return;
}
let dx = (x + 0.5 - self.position.x).abs();
let dz = (z + 0.5 - self.position.z).abs();
let overlap = 0.4375 + Self::WIDTH / 2.0;
if dx + 1.0e-7 <= overlap && dz + 1.0e-7 <= overlap {
return;
}
let v = self.velocity;
let old = old_delta_y(v.y);
self.velocity = if old < -0.13 {
let factor = -0.05 / old;
Vec3::new(v.x * factor, new_delta_y(-0.05), v.z * factor)
} else {
Vec3::new(v.x, new_delta_y(-0.05), v.z)
};
}
/// `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 {
@@ -1272,3 +1320,89 @@ mod merge_tests {
assert_eq!(items[1].stack.count, 2);
}
}
#[cfg(test)]
mod honey_slide_tests {
use super::*;
fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
let at = Vec3::new(x, y, z);
ItemEntity {
id: 1,
thrower: None,
target: None,
stack: ItemStack {
item: std::sync::Arc::from("minecraft:stone"),
count: 1,
damage: 0,
extra: None,
},
health: 5,
fire_ticks: 0,
position: at,
previous: at,
velocity: Vec3::new(vx, vy, vz),
on_ground: false,
age: 10,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 10,
}
}
fn old_delta_y(dy: f64) -> f64 {
dy / f64::from(0.98_f32) + 0.08
}
fn new_delta_y(dy: f64) -> f64 {
(dy - 0.08) * f64::from(0.98_f32)
}
#[test]
fn a_falling_item_beside_honey_slides_down() {
// Item width 0.25: overlap 0.5625. Honey at (0, 10, 0); centre 0.5.
// x = -0.0625 is against the west face (inset 1/16).
let mut item = falling_item(-0.0625, 10.4, 0.5, 0.12, -0.20, 0.04);
assert!(old_delta_y(-0.20) < -0.08);
assert!(old_delta_y(-0.20) >= -0.13);
item.honey_block_inside([0, 10, 0]);
assert_eq!(item.velocity.x, 0.12);
assert_eq!(item.velocity.z, 0.04);
assert_eq!(item.velocity.y, new_delta_y(-0.05));
}
#[test]
fn a_fast_fall_scales_the_horizontal_speed() {
let mut item = falling_item(-0.0625, 10.4, 0.5, 0.2, -0.30, -0.1);
let old = old_delta_y(-0.30);
assert!(old < -0.13);
item.honey_block_inside([0, 10, 0]);
let factor = -0.05 / old;
assert_eq!(item.velocity.x, 0.2 * factor);
assert_eq!(item.velocity.z, -0.1 * factor);
assert_eq!(item.velocity.y, new_delta_y(-0.05));
}
#[test]
fn an_item_on_the_ground_or_on_top_does_not_slide() {
let mut grounded = falling_item(-0.0625, 10.4, 0.5, 0.0, -0.20, 0.0);
grounded.on_ground = true;
grounded.honey_block_inside([0, 10, 0]);
assert_eq!(grounded.velocity.y, -0.20);
let mut on_top = falling_item(0.5, 10.9375, 0.5, 0.0, -0.20, 0.0);
on_top.honey_block_inside([0, 10, 0]);
assert_eq!(on_top.velocity.y, -0.20, "standing on the honey");
}
#[test]
fn a_centred_or_slow_item_does_not_slide() {
let mut centred = falling_item(0.5, 10.4, 0.5, 0.0, -0.20, 0.0);
centred.honey_block_inside([0, 10, 0]);
assert_eq!(centred.velocity.y, -0.20, "inside, not on a side");
let mut slow = falling_item(-0.0625, 10.4, 0.5, 0.0, -0.04, 0.0);
assert!(old_delta_y(-0.04) >= -0.08);
slow.honey_block_inside([0, 10, 0]);
assert_eq!(slow.velocity.y, -0.04);
}
}
+3 -3
View File
@@ -83,10 +83,10 @@ impl FluidInteractionWorld for ItemFluidWorld<'_> {
Ok(self.world.chunks.contains_key(&(x, z)).then_some(()))
}
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
let layout = self.world.layout();
Ok(FluidSections {
minimum_section_y: crate::chunk::MIN_Y >> 4,
has_fluid: &SECTIONS,
minimum_section_y: layout.min_y() >> 4,
has_fluid: crate::chunk::every_section(layout),
})
}
fn bounding_box_min_y(&self) -> f64 {
+16
View File
@@ -1986,6 +1986,8 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32,
},
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
"minecraft:wind_charge" => ProjectileKind::WindCharge,
"minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge,
_ => return None,
};
let owner = uuid_from_tag(tag.get("Owner"));
@@ -3419,6 +3421,20 @@ mod fireball_nbt_tests {
(p.kind, p.acceleration),
(ProjectileKind::LargeFireball { power: 1 }, 0.1)
);
// `/summon` and a saved world give back both wind charges.
for (id, kind) in [
("minecraft:wind_charge", ProjectileKind::WindCharge),
(
"minecraft:breeze_wind_charge",
ProjectileKind::BreezeWindCharge,
),
] {
let tag = Tag::compound([
("id", Tag::String(id.into())),
("Pos", doubles(&[0.0, 0.0, 0.0])),
]);
assert_eq!(fireball_from_nbt(&tag, None).unwrap().kind, kind);
}
}
}
+33
View File
@@ -28,6 +28,12 @@ pub enum ItemPose {
Bow {
ticks_used: f32,
},
/// A brush in use (`ItemUseAnimation.BRUSH`): the ticks left of the use
/// and the partial tick.
Brush {
remaining: f32,
partial: f32,
},
/// A spear being charged (`ItemUseAnimation.SPEAR`): the time held and
/// the ticks since its last hit's feedback.
Spear {
@@ -78,6 +84,9 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
if matches!(pose, ItemPose::Using) {
return m;
}
if let ItemPose::Brush { remaining, partial } = pose {
return m * brush_transform(left, remaining, partial);
}
// The spear's own place in the hand, without the equip height.
if let ItemPose::Spear {
kinetic,
@@ -177,6 +186,30 @@ impl MapHold {
}
}
/// `FirstPersonHandsAndItemsRenderer.applyBrushTransform`: the brush in use,
/// swept back and forth by the ticks left of its use (`remaining`).
fn brush_transform(left: bool, remaining: f32, partial: f32) -> glam::Mat4 {
use glam::{Mat4, Vec3};
use horizon_core::geometry::trig::Mth;
let ticks = remaining % 10.0;
let delta = ticks - partial + 1.0;
let scaled = 1.0 - delta / 10.0;
let swipe = -15.0 + 75.0 * (scaled * 2.0 * std::f32::consts::PI).mcos();
let sweep = Mat4::from_rotation_x(swipe.to_radians());
if left {
Mat4::from_translation(Vec3::new(0.1, 0.83, 0.35))
* Mat4::from_rotation_x((-80.0f32).to_radians())
* Mat4::from_rotation_y((-90.0f32).to_radians())
* sweep
* Mat4::from_translation(Vec3::new(-0.3, 0.22, 0.35))
} else {
Mat4::from_translation(Vec3::new(-0.25, 0.22, 0.35))
* Mat4::from_rotation_x((-80.0f32).to_radians())
* Mat4::from_rotation_y(90.0f32.to_radians())
* sweep
}
}
/// `renderPlayerArm` up to `renderPlayerHand`: the pose of the bare `left`
/// or right arm.
pub fn player_arm_pose(left: bool, inverse_height: f32, attack: f32) -> glam::Mat4 {
+210 -15
View File
@@ -1,21 +1,80 @@
//! FlatLevelSource: superflat worlds (`minecraft:flat` generator).
use crate::{chunk::Chunk, streaming::ChunkGenerator};
use horizon_world::{nbt::Tag, registry::BlockRegistry};
use crate::{
chunk::{Chunk, LevelShape},
streaming::ChunkGenerator,
};
use horizon_world::{
nbt::Tag,
registry::{BlockRegistry, BlockStateId},
worldgen::feature::{BasicFeature, FeatureBlocks, FeatureWorld},
};
/// The layers of a flat generator, bottom up from the world's minimum Y.
pub struct FlatTerrain {
/// The Overworld's shape: the layers start at its minimum Y and stop at
/// its top.
shape: LevelShape,
layers: Vec<u32>,
biome: u16,
surface: Option<crate::lod::Surface>,
/// The blocks of `minecraft:void_start_platform`, when the adjusted
/// generation settings place it (the Void preset).
void_platform: Option<FeatureBlocks>,
}
/// `GenerationStep.Decoration` ordinals `adjustGenerationSettings` leaves out.
const LAKES: usize = 1;
const UNDERGROUND_STRUCTURES: usize = 3;
const SURFACE_STRUCTURES: usize = 4;
/// FlatLevelGeneratorSettings.adjustGenerationSettings for the world's own
/// biome: its placed features, by step, when `features` is on and the world
/// isn't only air (`voidGen`) or its biome is the_void. The structure steps
/// are left out, and the lakes step when `lakes` puts the lava lakes there.
fn decoration_features(
biome_features: &[Vec<String>],
features: bool,
lakes: bool,
void_gen: bool,
the_void: bool,
) -> impl Iterator<Item = &str> {
let decorate = (!void_gen || the_void) && features;
biome_features
.iter()
.enumerate()
.filter(move |&(step, _)| {
decorate
&& step != UNDERGROUND_STRUCTURES
&& step != SURFACE_STRUCTURES
&& !(lakes && step == LAKES)
})
.flat_map(|(_, step)| step.iter().map(String::as_str))
}
/// The proto chunk as the world a feature writes in.
struct ChunkWorld<'a>(&'a mut Chunk);
impl FeatureWorld for ChunkWorld<'_> {
type Error = std::convert::Infallible;
fn block(&mut self, [x, y, z]: [i32; 3]) -> Result<BlockStateId, Self::Error> {
Ok(self.0.block(x, y, z))
}
fn set_block(
&mut self,
[x, y, z]: [i32; 3],
state: BlockStateId,
_: i32,
) -> Result<bool, Self::Error> {
self.0.set(x, y, z, state.0);
Ok(true)
}
}
impl FlatTerrain {
/// The overworld generator of `world_gen_settings` (`data.dimensions`), if it
/// is flat. Its `features` and `lakes` decorate the world; a world without
/// them (the default) gets only its layers, as FlatLevelSource places them.
/// is flat. Its layers, as FlatLevelSource places them, and of the features
/// its settings decorate the world with, `void_start_platform`; the other
/// features and the lakes are not placed yet.
pub fn from_settings(
settings: &Tag,
registry: &BlockRegistry,
biomes: &crate::biome::Biomes,
shape: LevelShape,
) -> Option<Self> {
let generator = settings
.get("dimensions")?
@@ -36,14 +95,32 @@ impl FlatTerrain {
}
}
let biome = biomes.id(&biome);
// FlatLevelGeneratorSettings.updateLayers: every layer is Blocks.AIR.
let void_gen = layers.iter().all(|&id| {
registry
.state(BlockStateId(id))
.is_some_and(|s| s.name == "minecraft:air")
});
let flag = |key| crate::save::number(s.get(key)).is_some_and(|v| v != 0);
let info = biomes.get(biome);
let void_platform = if decoration_features(
&info.features,
flag("features"),
flag("lakes"),
void_gen,
info.name == "minecraft:the_void",
)
.any(|f| f == "minecraft:void_start_platform")
{
FeatureBlocks::from_registry(registry)
.inspect_err(|e| log::warn!("Horizon: no void_start_platform: {e}"))
.ok()
} else {
None
};
let mut water = None;
let mut surface = None;
for (i, &id) in layers
.iter()
.take(crate::chunk::HEIGHT as usize)
.enumerate()
.rev()
{
for (i, &id) in layers.iter().take(shape.height as usize).enumerate().rev() {
let state = horizon_world::registry::BlockStateId(id);
let name = registry.state(state)?.name.as_str();
if matches!(
@@ -52,7 +129,7 @@ impl FlatTerrain {
) {
continue;
}
let height = crate::chunk::MIN_Y + i as i32 + 1;
let height = shape.min_y + i as i32 + 1;
if matches!(name, "minecraft:water" | "minecraft:lava") {
water.get_or_insert((height, state));
} else {
@@ -66,14 +143,16 @@ impl FlatTerrain {
}
}
Some(Self {
shape,
layers,
biome,
surface,
void_platform,
})
}
/// FlatLevelSource.getSpawnHeight: the top of the layers.
pub fn spawn_height(&self) -> i32 {
crate::chunk::MIN_Y + self.layers.len().min(crate::chunk::HEIGHT as usize) as i32
self.shape.min_y + self.layers.len().min(self.shape.height as usize) as i32
}
}
/// DimensionType.Y_SIZE: the most layers FlatLevelGeneratorSettings takes.
@@ -190,23 +269,39 @@ impl ChunkGenerator for FlatTerrain {
self.surface
}
fn proto(&self, _: &mut (), cx: i32, cz: i32) -> crate::streaming::Proto {
let mut chunk = Chunk::empty(cx, cz);
let mut chunk = Chunk::with_layout(cx, cz, self.shape.column());
for s in &mut chunk.sections {
std::sync::Arc::make_mut(s).biomes = horizon_core::paletted::PalettedContainer::new(
crate::chunk::BIOME_STRATEGY,
u32::from(self.biome),
);
}
for (i, &block) in self.layers.iter().enumerate().take(384) {
let height = self.shape.height as usize;
for (i, &block) in self.layers.iter().enumerate().take(height) {
if block == 0 {
continue;
}
for z in 0..16 {
for x in 0..16 {
chunk.set(x, -64 + i as i32, z, block);
chunk.set(x, self.shape.min_y + i as i32, z, block);
}
}
}
// ChunkGenerator.applyBiomeDecoration at the chunk's section origin.
// VoidStartPlatformFeature writes only inside its own chunk and draws
// no random numbers, so placing it here gives the FEATURES result.
if let Some(blocks) = self.void_platform {
let origin = [cx * 16, self.shape.min_y, cz * 16];
let mut random = horizon_core::random::LegacyRandom::new(0);
if let Err(e) = BasicFeature::VoidStartPlatform.place(
&mut ChunkWorld(&mut chunk),
&mut random,
origin,
blocks,
) {
log::warn!("Horizon: placing void_start_platform failed: {e}");
}
}
for s in &mut chunk.sections {
std::sync::Arc::make_mut(s).compact();
}
@@ -233,6 +328,106 @@ mod tests {
)
}
/// A world_gen_settings with a flat overworld of `layers` (block, height).
fn flat_world(biome: &str, features: bool, layers: &[(&str, i32)]) -> Tag {
let layers = layers
.iter()
.map(|&(block, height)| {
Tag::compound([
("block", Tag::String(block.into())),
("height", Tag::Int(height)),
])
})
.collect();
let settings = Tag::compound([
("biome", Tag::String(biome.into())),
("features", Tag::Byte(i8::from(features))),
("lakes", Tag::Byte(0)),
("layers", Tag::List(layers)),
]);
let generator = Tag::compound([
("type", Tag::String("minecraft:flat".into())),
("settings", settings),
]);
Tag::compound([(
"dimensions",
Tag::compound([(
"minecraft:overworld",
Tag::compound([("generator", generator)]),
)]),
)])
}
#[test]
fn the_void_preset_places_the_start_platform() {
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 registry = assets.registry;
let biomes = crate::biome::Biomes::load(&root).unwrap();
let terrain = |biome, features, layers: &[(&str, i32)]| {
FlatTerrain::from_settings(
&flat_world(biome, features, layers),
registry,
&biomes,
LevelShape::OVERWORLD,
)
.unwrap()
};
let block = |terrain: &FlatTerrain, [x, y, z]: [i32; 3]| {
let chunk = terrain.finish(terrain.proto(&mut (), x >> 4, z >> 4));
registry.state(chunk.block(x, y, z)).unwrap().name.clone()
};
// The Void preset (flat_level_generator_preset/the_void): the_void's
// only feature, VoidStartPlatformFeature, at the minimum Y + 3: a
// 33×33 stone square around 8, 8 with cobblestone at its centre, in
// the chunks within one of chunk 0, 0.
let void = terrain("minecraft:the_void", true, &[("minecraft:air", 1)]);
for (pos, expected) in [
([8, -61, 8], "minecraft:cobblestone"),
([0, -61, 0], "minecraft:stone"),
([-8, -61, -8], "minecraft:stone"),
([24, -61, 24], "minecraft:stone"),
([24, -61, -8], "minecraft:stone"),
([-9, -61, 8], "minecraft:air"),
([25, -61, 8], "minecraft:air"),
([8, -60, 8], "minecraft:air"),
([8, -62, 8], "minecraft:air"),
([40, -61, 8], "minecraft:air"),
] {
assert_eq!(block(&void, pos), expected, "{pos:?}");
}
// The decoration is off without `features`.
let plain = terrain("minecraft:the_void", false, &[("minecraft:air", 1)]);
assert_eq!(block(&plain, [8, -61, 8]), "minecraft:air");
// the_void over solid layers still gets its platform.
let floor = terrain("minecraft:the_void", true, &[("minecraft:bedrock", 1)]);
assert_eq!(block(&floor, [8, -64, 8]), "minecraft:bedrock");
assert_eq!(block(&floor, [8, -61, 8]), "minecraft:cobblestone");
}
#[test]
fn adjusted_generation_settings_keep_the_biome_features_outside_structures() {
let steps: Vec<Vec<String>> = (0..11).map(|step| vec![format!("f{step}")]).collect();
let kept = |features, lakes, void_gen, the_void| {
decoration_features(&steps, features, lakes, void_gen, the_void)
.map(str::to_owned)
.collect::<Vec<_>>()
};
let all_but_structures = ["f0", "f1", "f2", "f5", "f6", "f7", "f8", "f9", "f10"];
assert_eq!(kept(true, false, false, false), all_but_structures);
// The lava lakes take the LAKES step's place.
assert_eq!(kept(true, true, false, false)[1..], all_but_structures[2..]);
// An all-air world is decorated only in the_void.
assert!(kept(true, false, true, false).is_empty());
assert_eq!(kept(true, false, true, true), all_but_structures);
assert!(kept(false, false, false, true).is_empty());
}
#[test]
fn generator_settings_read_as_flat_level_generator_settings() {
// server.properties' default: the preset's own layers, and the codec's error.
File diff suppressed because it is too large. Load diff
+15 -3
View File
@@ -7,7 +7,6 @@ use super::{Game, Screen};
use horizon_client::{
Result,
block_entity_anim::Ticker,
chunk::{MIN_Y, SECTIONS},
items::ItemStack,
mobs::{
Species,
@@ -549,7 +548,7 @@ impl Game<'_> {
selected,
} => json!({
"mode": "select-world",
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1 })).collect::<Vec<_>>(),
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1, "corrupted": w.corrupted })).collect::<Vec<_>>(),
"selected": selected,
}),
Mode::Create {
@@ -603,6 +602,17 @@ impl Game<'_> {
Mode::BackupBeforeUpgrade { world, .. } => {
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
}
Mode::Recover { world, issues } => {
let issue = |i: &Option<horizon_client::save::DatIssue>| match i {
None => json!(null),
Some(horizon_client::save::DatIssue::Missing) => json!("missing file"),
Some(horizon_client::save::DatIssue::Unreadable(why)) => json!(why),
};
json!({ "mode": "recover-world", "folder": world.folder, "level_dat": issue(&issues[0]), "level_dat_old": issue(&issues[1]) })
}
Mode::Recovered { world, success } => {
json!({ "mode": "recovery-done", "folder": world.folder, "success": success })
}
Mode::Error { message, .. } => {
json!({ "mode": "world-operation-error", "message": message })
}
@@ -1088,7 +1098,9 @@ impl Game<'_> {
.world
.chunks
.get(&(x >> 4, z >> 4))
.filter(|_| (MIN_Y..MIN_Y + SECTIONS as i32 * 16).contains(&y))
.filter(|_| {
(self.world.bottom_y()..self.world.top_y()).contains(&y)
})
.map(|c| c.light(x & 15, y, z & 15));
let digit = |v: u8| char::from_digit(u32::from(v), 16).unwrap_or('.');
sky.push(packed.map_or('.', |v| digit(v >> 4)));
+22 -2
View File
@@ -47,14 +47,34 @@ impl Game<'_> {
.items()
.get(&held)
.map_or(64, |i| i.max_stack as i32);
// GoToWantedItem / wantsToPickUp: the same item within 32.
// GoToWantedItem / wantsToPickUp: the same item within 32 that the
// allay sees (NearestItemSensor's hasLineOfSight, eye to eye).
let wanted = if griefing && carried < max && self.mobs[i].extra.ints[3] <= 0 {
let w = self.mob_world_base(&[]);
let eye = horizon_core::geometry::Vec3::new(pos.x, self.mobs[i].eye_y(), pos.z);
// The sensor looks in the allay's box grown by (32, 16, 32).
let b = self.mobs[i].bounds();
let (low, high) = (b.min_position().y - 16.0, b.max_position().y + 16.0);
self.dropped
.iter()
.enumerate()
.filter(|(_, e)| e.stack.item == held && e.pickup_delay == 0)
.map(|(j, e)| (j, e.position.distance_squared(pos)))
.filter(|(_, d)| *d <= 32.0 * 32.0)
.filter(|(_, d)| *d < 32.0 * 32.0)
.filter(|(j, _)| {
let y = self.dropped[*j].position.y;
y <= high && y + 0.25 >= low
})
.filter(|(j, _)| {
// ItemEntity's eye height is 0.2125.
let e = &self.dropped[*j];
let at = horizon_core::geometry::Vec3::new(
e.position.x,
e.position.y + 0.2125,
e.position.z,
);
w.line_of_sight(eye, at)
})
.min_by(|a, b| a.1.total_cmp(&b.1))
} else {
None
+146 -54
View File
@@ -39,8 +39,8 @@ pub(super) struct ArmorStandAssets {
pub layers: HashMap<(&'static str, bool), Part>,
/// The player's armour models by slot (helmet, chestplate, leggings, boots).
pub player: HashMap<&'static str, Part>,
/// asset id -> layer kind (humanoid / humanoid_leggings) -> (texture, undyed colour).
pub equipment: HashMap<String, HashMap<String, (String, Option<u32>)>>,
/// asset id -> layer kind (humanoid / humanoid_leggings) -> each layer's (texture, undyed colour), in order.
pub equipment: HashMap<String, HashMap<String, Vec<(String, Option<u32>)>>>,
/// asset id -> `trim_overrides`.
pub trim_overrides: HashMap<String, Vec<TrimOverride>>,
pub trims: TrimRegistry,
@@ -89,18 +89,24 @@ impl ArmorStandAssets {
let Ok(v) = v else { continue };
let mut kinds = HashMap::new();
for kind in ["humanoid", "humanoid_leggings", "wings"] {
if let Some(layer) = v["layers"][kind].get(0) {
let tex = layer["texture"]
.as_str()
.unwrap_or(name)
.trim_start_matches("minecraft:");
let undyed = layer["dyeable"]["color_when_undyed"]
.as_i64()
.map(|c| c as u32 & 0xFF_FFFF);
kinds.insert(
kind.to_owned(),
(format!("entity/equipment/{kind}/{tex}"), undyed),
);
// Every layer of the kind: leather's overlay is layer 1.
let layers: Vec<(String, Option<u32>)> = v["layers"][kind]
.as_array()
.into_iter()
.flatten()
.map(|layer| {
let tex = layer["texture"]
.as_str()
.unwrap_or(name)
.trim_start_matches("minecraft:");
let undyed = layer["dyeable"]["color_when_undyed"]
.as_i64()
.map(|c| c as u32 & 0xFF_FFFF);
(format!("entity/equipment/{kind}/{tex}"), undyed)
})
.collect();
if !layers.is_empty() {
kinds.insert(kind.to_owned(), layers);
}
}
trim_overrides.insert(format!("minecraft:{name}"), TrimOverride::list(&v));
@@ -195,19 +201,14 @@ impl Game<'_> {
} else {
"humanoid"
};
let Some((texture, undyed)) = assets.equipment.get(&asset).and_then(|k| k.get(kind))
else {
let Some(layers) = assets.equipment.get(&asset).and_then(|k| k.get(kind)) else {
continue;
};
let (Some(r), Some(part)) = (
self.gui.atlas.sprites.get(texture.as_str()),
assets.player.get(layer),
) else {
let Some(part) = assets.player.get(layer) else {
continue;
};
let color = layer_color(*undyed, stack);
// EquipmentLayerRenderer.renderLayers: NO_OVERLAY, the armour
// glint on the layer and its trim when `stack.hasFoil()`.
// glint on the first layer and on the trim when `stack.hasFoil()`.
let item_glint = self
.res
.items()
@@ -220,8 +221,20 @@ impl Game<'_> {
} else {
0
};
out.push((part, [r.u0, r.v0, r.u1, r.v1], color, overlay));
// EquipmentLayerRenderer: the trim layer over the armour.
// Every layer of the asset, the leather overlay too, then the
// trim layer over the armour.
for (i, (texture, undyed)) in layers.iter().enumerate() {
let Some(r) = self.gui.atlas.sprites.get(texture.as_str()) else {
continue;
};
let color = layer_color(*undyed, stack);
out.push((
part,
[r.u0, r.v0, r.u1, r.v1],
color,
if i == 0 { overlay } else { 0 },
));
}
if let Some(r) = stack
.and_then(|s| assets.trim_texture(&asset, kind, s))
.and_then(|t| self.gui.atlas.sprites.get(&t.sprite()))
@@ -337,7 +350,11 @@ impl Game<'_> {
/// `ArmorStandItem.useOn` up to its `FAIL`s, which a client checks too:
/// not on a bottom face, and nothing in the new stand's box. The block it
/// stands in and the stand.
pub(super) fn armor_stand_spawn(&self, hit: &super::Hit) -> Option<([i32; 3], ArmorStand)> {
pub(super) fn armor_stand_spawn(
&self,
hit: &super::Hit,
stack: &ItemStack,
) -> Option<([i32; 3], ArmorStand)> {
if hit.face == [0, -1, 0] {
return None;
}
@@ -358,7 +375,11 @@ impl Game<'_> {
);
let yaw =
(((self.yaw - 180.0 + 180.0).rem_euclid(360.0) - 180.0 + 22.5) / 45.0).floor() * 45.0;
let stand = ArmorStand::new(self.next_entity + 1, foot, yaw);
let mut stand = ArmorStand::new(self.next_entity + 1, foot, yaw);
// Entity.applyImplicitComponents: a name from an anvil names the stand.
if let Some(name) = stack.custom_name() {
stand.custom_name = Some(horizon_client::text::Text::literal(name));
}
let b = stand.bounds();
// Touching the floor is not a collision.
let (lo, hi) = (b.min_position(), b.max_position());
@@ -386,7 +407,7 @@ impl Game<'_> {
if &*stack.item != "minecraft:armor_stand" {
return false;
}
let Some((pos, stand)) = self.armor_stand_spawn(hit) else {
let Some((pos, stand)) = self.armor_stand_spawn(hit, stack) else {
return false;
};
let foot = stand.position;
@@ -559,6 +580,37 @@ impl Game<'_> {
}
hurt
}
/// `ArmorStand.showBreakingParticles`: ten oak plank crumbs from the box,
/// spread by a quarter of its width and height, sent like `sendParticles`.
fn show_breaking_particles(&mut self, at: [f64; 3], width: f64, height: f64) {
let Some(state) = self
.pack
.assets
.registry
.default_state("minecraft:oak_planks")
else {
return;
};
let block = [
at[0].floor() as i32,
at[1].floor() as i32,
at[2].floor() as i32,
];
let Some((sprite, tint)) = self.particle_sprite(state, block) else {
return;
};
let options = horizon_client::particles::ParticleOptions::block(
horizon_client::particles::ParticleType::Block,
sprite,
tint,
);
// Entity.getY(2/3): the box's height is scaled by 2/3 from the feet.
let centre = [at[0], at[1] + height * (2.0 / 3.0), at[2]];
let spread = [width / 4.0, height / 4.0, width / 4.0];
self.particles
.send_particles(&options, centre, spread, 0.05, 10);
self.lan_level_particles(&options, centre, spread, 0.05, 10, Some(state));
}
/// `ArmorStand.hurtServer` for a player's attack (also a sweep's): a
/// creative player breaks it, otherwise the first hit wobbles it and a
/// second within five ticks breaks it.
@@ -567,9 +619,11 @@ impl Game<'_> {
let at = self.armor_stands[i].position;
let at = [at.x, at.y, at.z];
if self.creative {
let (width, height) = stand_box(&self.armor_stands[i]);
self.armor_stands.remove(i);
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.show_breaking_particles(at, f64::from(width), f64::from(height));
return true;
}
if time - self.armor_stands[i].last_hit > 5 {
@@ -581,8 +635,10 @@ impl Game<'_> {
}
// brokenByPlayer: the stand item, then brokenByAnything: the equipment.
let a = self.armor_stands.remove(i);
let (width, height) = stand_box(&a);
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.show_breaking_particles(at, f64::from(width), f64::from(height));
let center = [at[0], at[1] + if a.small { 0.4875 } else { 0.9875 }, at[2]];
let stand = ItemStack {
item: Arc::from("minecraft:armor_stand"),
@@ -635,17 +691,23 @@ impl Game<'_> {
}
}
for i in dead.into_iter().rev() {
let a = self.armor_stands.remove(i);
let at = [a.position.x, a.position.y, a.position.z];
let (pos, drops) = burnt_armor_stand_equipment(a);
for stack in drops {
self.pop_resource(pos, stack);
}
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
self.break_armor_stand_by_anything(i);
}
}
/// `ArmorStand.brokenByAnything`, then `kill`, as burning out or a blast
/// breaks a stand: its equipment pops above its block, the stand item does
/// not drop.
pub(super) fn break_armor_stand_by_anything(&mut self, i: usize) {
let a = self.armor_stands.remove(i);
let at = [a.position.x, a.position.y, a.position.z];
let (pos, drops) = broken_armor_stand_equipment(a);
for stack in drops {
self.pop_resource(pos, stack);
}
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
pub(super) fn armor_stand_geometry(
&self,
@@ -770,16 +832,12 @@ impl Game<'_> {
} else {
"humanoid"
};
let Some((texture, undyed)) =
assets.equipment.get(&asset).and_then(|k| k.get(kind))
else {
let Some(layers) = assets.equipment.get(&asset).and_then(|k| k.get(kind)) else {
continue;
};
let (Some(uv), Some(part)) = (uv_of(texture), assets.layers.get(&(layer, a.small)))
else {
let Some(part) = assets.layers.get(&(layer, a.small)) else {
continue;
};
let color = layer_color(*undyed, Some(stack));
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
// when `stack.hasFoil()`, as on a living entity.
let item_glint = self
@@ -797,16 +855,24 @@ impl Game<'_> {
// ArmorStandArmorModel.setupAnim: the same pose as the stand.
let mut layer_poses = Poses::new(part);
apply_pose(&mut layer_poses, &a.pose);
horizon_client::mobs::render::draw(
part,
base,
&layer_poses,
uv,
color,
packed,
overlay,
out,
);
// EquipmentLayerRenderer: every layer of the asset, the leather overlay
// too; the shader glint goes on the first layer only (renderShaderGlint).
for (i, (texture, undyed)) in layers.iter().enumerate() {
let Some(uv) = uv_of(texture) else {
continue;
};
let color = layer_color(*undyed, Some(stack));
horizon_client::mobs::render::draw(
part,
base,
&layer_poses,
uv,
color,
packed,
if i == 0 { overlay } else { 0 },
out,
);
}
// EquipmentLayerRenderer: the trim layer over the armour.
if let Some(trim) = assets
.trim_texture(&asset, kind, stack)
@@ -1041,6 +1107,14 @@ impl Game<'_> {
}
}
}
/// `ArmorStand.getBbWidth` and `getBbHeight`: a marker has no box, a small stand is half size.
fn stand_box(a: &ArmorStand) -> (f32, f32) {
match (a.marker, a.small) {
(true, _) => (0.0, 0.0),
(false, true) => (0.25, 0.9875),
(false, false) => (0.5, 1.975),
}
}
/// `AbstractSkullBlock.getType` of a skull item.
pub(super) fn skull_kind(item: &str) -> Option<&'static str> {
Some(match item {
@@ -1055,7 +1129,15 @@ pub(super) fn skull_kind(item: &str) -> Option<&'static str> {
})
}
fn burnt_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
/// `ArmorStand.ignoreExplosion` (an invisible stand), and `hurtServer`
/// refusing an invisible or marker stand: such a stand outlasts any blast.
pub(super) fn spared_by_blast(a: &ArmorStand) -> bool {
a.invisible || a.marker
}
/// `ArmorStand.brokenByAnything`: the equipment, less what curse of vanishing
/// keeps (`PREVENT_EQUIPMENT_DROP`), popped at `blockPosition().above()`.
fn broken_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
let pos = [
a.position.x.floor() as i32,
a.position.y.floor() as i32 + 1,
@@ -1073,6 +1155,16 @@ fn burnt_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
mod burn_drop_tests {
use super::*;
#[test]
fn only_visible_non_marker_stands_are_broken_by_blasts() {
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 64.0, 0.5), 0.0);
assert!(!spared_by_blast(&stand));
stand.invisible = true;
assert!(spared_by_blast(&stand));
stand.invisible = false;
stand.marker = true;
assert!(spared_by_blast(&stand));
}
#[test]
fn burnt_equipment_drops_above_the_block_and_vanishing_items_do_not_drop() {
let mut stand = ArmorStand::new(1, Vec3::new(-0.1, 4.8, 0.0), 0.0);
let kept = ItemStack::new("minecraft:diamond_boots", 1);
@@ -1083,7 +1175,7 @@ mod burn_drop_tests {
.push(("minecraft:vanishing_curse".into(), 1));
stand.equipment[0] = Some(kept.clone());
stand.equipment[1] = Some(vanished);
let (pos, drops) = burnt_armor_stand_equipment(stand);
let (pos, drops) = broken_armor_stand_equipment(stand);
assert_eq!(pos, [-1, 5, 0]);
assert_eq!(drops, vec![kept]);
}
+2 -2
View File
@@ -177,7 +177,7 @@ impl Game<'_> {
}
}
if b.last_check_y >= surface {
b.last_check_y = super::MIN_Y - 1;
b.last_check_y = self.world.bottom_y() - 1;
b.sections = std::mem::take(&mut b.checking);
let (was, is) = (previous > 0, b.levels > 0);
if !was && is {
@@ -231,7 +231,7 @@ impl Game<'_> {
let mut levels = 0;
for step in 1..=4 {
let ly = y - step;
if ly < super::MIN_Y {
if ly < self.world.bottom_y() {
break;
}
let ok = (x - step..=x + step).all(|lx| {
@@ -461,7 +461,7 @@ impl Game<'_> {
if is_conduit(section.get(i)) {
found.push([
cx * 16 + (i & 15) as i32,
horizon_client::chunk::MIN_Y + si as i32 * 16 + (i >> 8) as i32,
self.world.bottom_y() + si as i32 * 16 + (i >> 8) as i32,
cz * 16 + ((i >> 4) & 15) as i32,
]);
}
+65 -5
View File
@@ -8,7 +8,8 @@ use horizon_client::{
items::ItemStack, save,
};
use horizon_world::{
nbt::{Compound, Tag},
level::classes::Class,
nbt::{Compound, JavaString, Tag},
registry::BlockStateId,
};
@@ -42,10 +43,32 @@ impl Game<'_> {
let key = (pos[0], pos[1], pos[2]);
let state = self.world.block(pos[0], pos[1], pos[2]);
let facts = self.res.facts().get(state);
if &*facts.item == "minecraft:air" {
// PistonHeadBlock.getCloneItemStack: the piston the head belongs to.
let head = self
.pack
.assets
.registry
.state(state)
.filter(|s| s.name.as_str() == "minecraft:piston_head")
.map(|s| match s.properties.get("type") {
Some(t) if t == "sticky" => "minecraft:sticky_piston",
_ => "minecraft:piston",
});
let item = head.unwrap_or(&*facts.item);
if item == "minecraft:air" {
return None;
}
let mut stack = ItemStack::new(&facts.item, 1);
// FlowerPotBlock.getCloneItemStack: a potted plant picks as that plant
// (`new ItemStack(potted)`), an empty pot as the pot itself.
let behaviours = self.res.behaviours();
let plant = behaviours.data(state);
let plant_item = plant
.is(Class::FlowerPot)
.then_some(plant.link)
.flatten()
.map(|block| behaviours.block_data(block).item.clone())
.filter(|item| &**item != "minecraft:air");
let mut stack = ItemStack::new(plant_item.as_deref().unwrap_or(item), 1);
let mut patch = Compound::new();
// BeehiveBlock.getCloneItemStack: the honey level as the item's block state.
if include_data
@@ -62,16 +85,53 @@ impl Game<'_> {
Tag::compound([("honey_level", Tag::String(level.as_str().into()))]),
);
}
// CopperGolemStatueBlock.getCloneItemStack: the statue's pose as the item's
// block_state (`CopperGolemStatueBlockEntity.getItem`), data or not.
if let Some(pose) = self
.pack
.assets
.registry
.state(state)
.filter(|s| s.name.ends_with("copper_golem_statue"))
.and_then(|s| s.properties.get("copper_golem_pose"))
{
patch.insert(
"minecraft:block_state".into(),
Tag::compound([("copper_golem_pose", Tag::String(pose.as_str().into()))]),
);
}
// LightBlock.getCloneItemStack, setLightOnStack: the light's level as
// the item's block state, whether or not the block data is picked.
if let Some(level) = self
.pack
.assets
.registry
.state(state)
.filter(|s| s.name.as_str() == "minecraft:light")
.and_then(|s| s.properties.get("level"))
{
patch.insert(
"minecraft:block_state".into(),
Tag::compound([("level", Tag::String(level.as_str().into()))]),
);
}
// AbstractBannerBlock.getCloneItemStack: the banner's own item
// (BannerBlockEntity.getItem), data or not.
let banner = matches!(
self.world.block_entities.get(&key),
Some(BlockEntity::Banner(_))
);
if (include_data || banner)
// DecoratedPotBlock.getCloneItemStack: createDecoratedPotInstance, the
// sherds only; the pot's item comes with include_data.
let pot = &*facts.name == "minecraft:decorated_pot";
if (include_data || banner || pot)
&& let Some(saved) = self.saved_block_entity(key)
{
patch.extend(block_entity_item::pick(&saved));
let mut picked = block_entity_item::pick(&saved);
if pot && !include_data {
picked.retain(|k, _| *k == JavaString::from("minecraft:pot_decorations"));
}
patch.extend(picked);
}
if !patch.is_empty() {
let mut all = components(&stack);
+125 -24
View File
@@ -12,6 +12,7 @@ use horizon_client::{
screen_draw::Measure,
text::{ClickEvent, Style, Text},
};
use horizon_world::registry::{BlockRegistry, BlockStateId};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
@@ -162,6 +163,61 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
ids
}
/// Every block's properties and their values, as `/setblock` completes them.
type BlockProperties = BTreeMap<String, Vec<(String, Vec<String>)>>;
static BLOCK_PROPERTIES: Mutex<Vec<(usize, Arc<BlockProperties>)>> = Mutex::new(Vec::new());
/// `BlockProperties` of a block registry, listed once: a registry never
/// changes once built, and walking its tens of thousands of states took
/// about 30 ms of every world opening.
fn block_properties(registry: &'static BlockRegistry) -> Arc<BlockProperties> {
let key = std::ptr::from_ref(registry) as usize;
let mut cache = BLOCK_PROPERTIES.lock().unwrap_or_else(|e| e.into_inner());
if let Some((_, properties)) = cache.iter().find(|(cached, _)| *cached == key) {
return properties.clone();
}
let mut blocks: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
for id in 0..registry.len() {
let Some(state) = registry.state(BlockStateId(id as u32)) else {
continue;
};
let props = blocks.entry(state.name.clone()).or_default();
for (k, v) in &state.properties {
let values = props.entry(k.clone()).or_default();
if !values.contains(v) {
values.push(v.clone());
}
}
}
let properties: Arc<BlockProperties> = Arc::new(
blocks
.into_iter()
.map(|(k, v)| (k, v.into_iter().collect()))
.collect(),
);
cache.push((key, properties.clone()));
properties
}
static COMMAND_EXPORTS: Mutex<Vec<(PathBuf, Option<Arc<serde_json::Value>>)>> =
Mutex::new(Vec::new());
/// A folder's `command-registries.json` (vanilla's built-in registries),
/// read once like the resource ids: parsing its 1.6 MB again took about
/// 15 ms of every world opening.
fn command_export(dir: &Path) -> Option<Arc<serde_json::Value>> {
let mut cache = COMMAND_EXPORTS.lock().unwrap_or_else(|e| e.into_inner());
if let Some((_, export)) = cache.iter().find(|(cached, _)| cached == dir) {
return export.clone();
}
let export = std::fs::read(dir.join("command-registries.json"))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.map(Arc::new);
cache.push((dir.to_owned(), export.clone()));
export
}
/// The `minecraft:` ids of the `extension` files under `root`, sorted.
fn ids_under(root: &Path, extension: &str) -> Vec<String> {
ids_in(root, extension, "minecraft")
@@ -306,25 +362,7 @@ impl Game<'_> {
.map(|k| horizon_client::command::args::normalize(k))
.collect();
reg.block_tags.sort();
let registry = self.pack.assets.registry;
let mut blocks: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
for id in 0..registry.len() {
let Some(state) = registry.state(horizon_world::registry::BlockStateId(id as u32))
else {
continue;
};
let props = blocks.entry(state.name.clone()).or_default();
for (k, v) in &state.properties {
let values = props.entry(k.clone()).or_default();
if !values.contains(v) {
values.push(v.clone());
}
}
}
reg.blocks = blocks
.into_iter()
.map(|(k, v)| (k, v.into_iter().collect()))
.collect();
reg.blocks = BlockProperties::clone(&block_properties(self.pack.assets.registry));
let mut summonable: Vec<String> = self
.res
.mob_types()
@@ -438,10 +476,8 @@ impl Game<'_> {
}
// Vanilla's built-in registries (mob effects, components, dialogs...).
for dir in [&self.options.resources, &self.ui.vanilla_root] {
if let Ok(bytes) = std::fs::read(dir.join("command-registries.json"))
&& let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes)
{
self.ui.registries.fill_from_export(&v);
if let Some(export) = command_export(dir) {
self.ui.registries.fill_from_export(&export);
break;
}
}
@@ -474,6 +510,7 @@ impl Game<'_> {
if let Some(names) = self.remote_online_names() {
self.ui.registries.players = names;
}
self.ui.registries.custom_completions = self.remote_custom_completions();
}
/// `ChatComponent.createScreen`: T or / opens the chat with the draft kept
/// last time, when this method can restore it (a command draft only for
@@ -978,6 +1015,41 @@ impl Game<'_> {
.chat
.draw(batch, &self.gui.atlas, self.ticks, focused);
}
/// `HoverEvent.EntityTooltipInfo.getTooltipLines`: the name when the
/// event has one, the entity type and the UUID.
fn entity_tooltip_lines(entity: &serde_json::Value) -> Vec<(Text, u32)> {
let mut lines = Vec::new();
if let Some(name) = entity.get("name") {
let name = horizon_client::component::to_text(name, &mut |s| Text::literal(s));
lines.push((name, u32::MAX));
}
let id = entity
.get("id")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let kind = Text::translate(&format!("entity.{}", id.replace(':', ".")), &[]);
lines.push((
Text::translate("gui.entity_tooltip.type", &[kind]),
u32::MAX,
));
if let Some(uuid) = entity.get("uuid").and_then(Self::entity_uuid) {
lines.push((Text::literal(uuid), u32::MAX));
}
lines
}
/// The UUID of a ShowEntity event: its string, or the int array
/// `UUIDUtil.CODEC` writes (most significant word first).
fn entity_uuid(v: &serde_json::Value) -> Option<String> {
if let Some(s) = v.as_str() {
return Some(s.to_owned());
}
let words = v.as_array().filter(|w| w.len() == 4)?;
let mut bits = 0u128;
for w in words {
bits = (bits << 32) | u128::from(w.as_i64()? as u32);
}
Some(horizon_protocol::crypto::uuid_string(bits))
}
pub(super) fn draw_chat_screen(&mut self, batch: &mut HudBatch) {
let Some(mut screen) = self.ui.chat_screen.take() else {
return;
@@ -1028,6 +1100,13 @@ impl Game<'_> {
let (cx, cy) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
let wrap = (batch.width as i32 / 2).max(200);
if let Some(style) = self.ui.chat.style_at(&self.gui.atlas.font, height, cx, cy) {
// DrawingFocusedGraphicsAccess.handleMessage: the pointing hand over
// insertion text, which ChatScreen only asks for while Shift is held.
if style.insertion.is_some() && self.has_shift_down() {
horizon_client::widgets::request_cursor(
horizon_client::widgets::Cursor::PointingHand,
);
}
if let Some(hover) = style.hover {
let lines = self.wrapped_rows(&hover, wrap);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
@@ -1038,6 +1117,14 @@ impl Game<'_> {
.filter(|s| self.items().get(&s.item).is_some())
{
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
} else if let Some(entity) = style
.hover_entity
.as_deref()
.filter(|_| self.settings.file.bool("advancedItemTooltips"))
{
// ChatScreen: a ShowEntity tooltip only with advanced tooltips on.
let lines = Self::entity_tooltip_lines(entity);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
} else if self.ui.chat.indicator_at(height, cx, cy) {
// GuiMessageTag.systemSinglePlayer's text, split at 210.
@@ -1204,7 +1291,7 @@ impl Game<'_> {
let Some(p) = self.player.as_ref() else {
return;
};
let below_world = p.position.y < f64::from(horizon_client::chunk::MIN_Y - 64);
let below_world = p.position.y < f64::from(self.world.bottom_y() - 64);
let name = self.player_display_text();
let message = match cause {
Some(key) => Text::translate(&key, &[name]),
@@ -1344,4 +1431,18 @@ mod tests {
assert!(Arc::ptr_eq(&first, &resource_ids(&root)));
let _ = std::fs::remove_dir_all(&root);
}
/// The export is read once per folder; a folder without one stays without.
#[test]
fn command_exports_are_read_once_per_folder() {
let dir = std::env::temp_dir().join(format!("horizon-export-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("command-registries.json"), r#"{"effects":["a"]}"#).unwrap();
let first = command_export(&dir).unwrap();
assert_eq!(first["effects"][0], "a");
std::fs::write(dir.join("command-registries.json"), "{}").unwrap();
assert!(Arc::ptr_eq(&first, &command_export(&dir).unwrap()));
assert!(command_export(&dir.join("missing")).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
}
@@ -144,7 +144,7 @@ impl Game<'_> {
.filter_map(|e| effect_particle(e.id, e.ambient))
.collect();
let ambient = effects.iter().filter(|e| e.visible).all(|e| e.ambient);
let invisible = effects.iter().any(|e| e.id == "invisibility");
let invisible = self.own_invisible();
let size = (
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
+136 -1
View File
@@ -1,8 +1,9 @@
//! The player's `CombatTracker`: the damage taken lately, who dealt it and
//! where the player fell from, turned into the vanilla death message.
use super::{Game, chat::PLAYER_NAME};
use super::{Game, chat::PLAYER_NAME, commands::Target};
use horizon_client::{
health::HealthEvent,
mobs::{Mob, Species},
motion::MotionEvent,
particles::{ParticleOptions, ParticleType},
text::Text,
@@ -72,6 +73,10 @@ pub(super) struct CombatTracker {
/// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list,
/// but the mobs they killed still ask for their loot.
pub(super) charged_exploders: Vec<u32>,
/// The mobs whose blast went off this tick and that left the mob list
/// (`MobEvent::ExplosionSource`): their names, for the death messages of
/// the mobs the blast killed.
pub(super) exploded: Vec<(u32, Text)>,
/// The player's fall distance before this tick's movement (as of the
/// end of the last one).
fall_before: f64,
@@ -391,4 +396,134 @@ impl Game<'_> {
// template that names a killer then shows as written).
Text::translate(&key, &[me])
}
/// `TamableAnimal.die`: with `showDeathMessages`, a tame wolf, cat,
/// parrot or nautilus's owner, when online in any dimension
/// (`EntityReference.getEntity` looks through every level), is sent how it
/// died. `killer` is the player who last hurt it (`None` inside for the
/// host), from `last_hurt_by_player`.
pub(super) fn tell_owner_pet_died(&mut self, m: &Mob, killer: Option<Option<u32>>) {
let tamable = matches!(
m.species(),
Species::Wolf
| Species::Cat
| Species::Parrot
| Species::Nautilus
| Species::ZombieNautilus
);
let Some(owner) = m.extra.owner.filter(|_| tamable && m.extra.tame) else {
return;
};
if !self.ui.rules.bool("show_death_messages") {
return;
}
let host = self.player.is_some()
&& self.options.dedicated.is_none()
&& horizon_client::mobs::tame::owned_by(
Some(owner),
self.level.player_uuid.unwrap_or_default(),
);
let uuid = horizon_client::lan::playerdata::ints_uuid(owner);
let guest = !host
&& self
.lan
.as_ref()
.is_some_and(|l| l.hub.guests.iter().any(|g| g.placed && g.uuid == uuid));
if !host && !guest {
return;
}
let message = self.mob_death_message(m, killer);
if host {
self.system_message(message);
} else if let Some(lan) = &self.lan {
let content = horizon_client::lan::data::component_nbt(&message.component());
lan.hub.system_message_to_player(uuid, content);
}
}
/// `CombatTracker.getDeathMessage` for a mob, from the hits it recorded
/// (`Mob::combat`), told the way the player's own tracker tells them.
pub(super) fn mob_death_message(&mut self, m: &Mob, killer: Option<Option<u32>>) -> Text {
use horizon_client::mobs::{DamageKind, Who};
let me = mob_name(m);
// The player behind a `Who::Player` hit: the last one to hurt it, or
// the one in place (a server with nobody acting has none).
let player = match killer {
Some(Some(conn)) => Some((u32::MAX - 1 - conn, self.target_name(Target::Guest(conn)))),
Some(None) => Some((u32::MAX, self.target_name(Target::Player))),
None if self.player.is_some() || self.lan_acting.is_some() => {
Some((u32::MAX, self.target_name(Target::Player)))
}
None => None,
};
// The cause's display name: a mob that is gone (a creeper that blew
// up) is still named by its blast.
let name = |g: &Self, who: Who| match who {
Who::Player => player.clone(),
Who::Mob(id) => g
.mob_display_name(id)
.or_else(|| {
g.combat
.exploded
.iter()
.find(|(e, _)| *e == id)
.map(|(_, n)| n.clone())
})
.map(|n| (id, n)),
};
let entries = m
.combat
.entries
.iter()
.map(|e| {
let attacker = e.source.attacker.and_then(|w| name(self, w));
// AbstractArrow.onHitEntity: an arrow nobody shot is its own
// cause.
let direct = (attacker.is_none() && e.source.kind == DamageKind::Arrow)
.then(|| Text::translate("entity.minecraft.arrow", &[]));
Entry {
// DamageSources.explosion: player_explosion whenever
// something caused the blast (the only kind that asks).
damage_type: super::creatures::damage_type(
e.source.kind,
e.source.attacker.is_some(),
)
.to_owned(),
damage: e.damage,
fall_location: e.in_water.then_some("water"),
fall_distance: e.fall_distance,
attacker,
direct,
}
})
.collect();
// LivingEntity.getKillCredit: the player who hurt it lately, else the
// last mob to hurt it within 100 ticks.
let credit = if m.ai.last_hurt_by_player > 0 && player.is_some() {
player.clone()
} else {
m.ai.last_hurt_by
.filter(|_| m.age as i32 - m.ai.last_hurt_by_time <= 100)
.and_then(|w| name(self, w))
};
let now = self.world.game_time;
let mut tracker = CombatTracker {
entries,
last_damage: now,
in_combat: m.combat.in_combat,
last_hurt_by: credit.map(|c| (c, now)),
..CombatTracker::default()
};
std::mem::swap(&mut self.combat, &mut tracker);
let text = self.combat_death_message_for(me);
std::mem::swap(&mut self.combat, &mut tracker);
text
}
}
/// `Entity.getDisplayName` of a mob: its custom name or its type's name.
fn mob_name(m: &Mob) -> Text {
match (&m.custom_name_text, &m.custom_name) {
(Some(text), _) => text.clone(),
(None, Some(name)) => Text::literal(name.clone()),
(None, None) => Text::translate(&format!("entity.{}", m.kind.id.replace(':', ".")), &[]),
}
}
@@ -30,7 +30,7 @@ type Pos = (i32, i32, i32);
/// The open `CommandBlockEditScreen`.
pub(super) struct CommandBlockScreen {
pub(super) pos: Pos,
pos: Pos,
/// The command box with its `CommandSuggestions`.
command: ChatScreen,
/// When the command box was focused, for its cursor's blink.
@@ -112,6 +112,7 @@ impl CommandBlockScreen {
self.previous.set_value(&shown);
}
}
fn state_of(registry: &BlockRegistry, state: BlockStateId) -> Option<&BlockState> {
registry.state(state)
}
@@ -240,21 +241,21 @@ impl Game<'_> {
let at = BlockPos::new(pos.0, pos.1, pos.2);
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
}
/// `CommandBlockEditScreen.updateGui`: the server's block-entity data
/// arrived while the screen is open; refresh the fields from the entity.
/// The mode and conditional live in the block state here (vanilla's
/// `CommandBlockEntity.Mode`).
pub(super) fn update_command_block_screen(&mut self) {
let Some(screen_pos) = self.command_block_screen.as_ref().map(|s| s.pos) else {
/// `ClientPacketListener.handleBlockEntityData` then
/// `CommandBlockEditScreen.updateGui`: new data for the command block the
/// edit screen shows refreshes its fields. The mode and conditional live in
/// the block state here (vanilla's `CommandBlockEntity.Mode`).
pub(super) fn command_block_data_arrived(&mut self, pos: Pos) {
if self.command_block_screen.as_ref().map(|s| s.pos) != Some(pos) {
return;
};
let state = self.block_state_at(screen_pos).map(|s| {
}
let state = self.block_state_at(pos).map(|s| {
(
Mode::of_block(&s.name).unwrap_or(Mode::Redstone),
conditional(s),
)
});
let block = self.command_block(screen_pos).cloned();
let block = self.command_block(pos).cloned();
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote),
@@ -825,18 +826,6 @@ impl Game<'_> {
}),
);
}
/// `ClientPacketListener.handleBlockEntityData`: new data for the command
/// block the edit screen shows refills it (`CommandBlockEditScreen.updateGui`).
pub(super) fn command_block_data_arrived(&mut self, pos: Pos) {
let open = self.screen == Screen::CommandBlock
&& self
.command_block_screen
.as_ref()
.is_some_and(|s| s.pos == pos);
if open {
self.open_command_block(pos);
}
}
/// What `handleSetCommandBlock` does once the player may edit the block.
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
let pos = s.pos;
+98 -8
View File
@@ -28,6 +28,7 @@ use horizon_client::{
text::{self, Style, Text},
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_world::registry::BlockStateId;
pub(super) type R<T> = Result<T, CommandError>;
@@ -87,6 +88,30 @@ fn java_float(v: f64) -> String {
fn format_double(v: f64) -> String {
format!("{v:.6}")
}
/// `EntitySelectorParser.createRotationPredicate`: the wrapped angle lies in
/// the wrapped range, which runs through 180 when its minimum is above its
/// maximum (defaults 0 and 359).
fn rotation_in((lo, hi): (Option<f64>, Option<f64>), angle: f32) -> bool {
let min = wrap_degrees(lo.unwrap_or(0.0) as f32);
let max = wrap_degrees(hi.unwrap_or(359.0) as f32);
let angle = wrap_degrees(angle);
if min > max {
angle >= min || angle <= max
} else {
angle >= min && angle <= max
}
}
/// `Mth.wrapDegrees`.
fn wrap_degrees(angle: f32) -> f32 {
let wrapped = angle % 360.0;
if wrapped >= 180.0 {
wrapped - 360.0
} else if wrapped < -180.0 {
wrapped + 360.0
} else {
wrapped
}
}
impl Game<'_> {
/// Commands.performCommand for a line without its slash.
@@ -301,9 +326,7 @@ impl Game<'_> {
if dimension != self.dim || !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) {
return Err(err("argument.pos.unloaded", &[]));
}
if p[1] < horizon_client::chunk::MIN_Y
|| p[1] >= horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT
{
if p[1] < self.world.bottom_y() || p[1] >= self.world.top_y() {
return Err(err("argument.pos.outofworld", &[]));
}
Ok(p)
@@ -655,6 +678,18 @@ impl Game<'_> {
{
return None;
}
// x_rotation is the pitch, y_rotation the yaw (getXRot, getYRot).
let [yaw, pitch] = self.target_rotation(t);
if let Some(range) = s.x_rotation
&& !rotation_in(range, pitch)
{
return None;
}
if let Some(range) = s.y_rotation
&& !rotation_in(range, yaw)
{
return None;
}
if s.delta.iter().any(Option::is_some) {
let d = s.delta.map(|v| v.unwrap_or(0.0));
for i in 0..3 {
@@ -689,7 +724,14 @@ impl Game<'_> {
.iter()
// The kinds /data can write (entity_nbt).
.filter(|p| {
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
p.is_arrow()
|| matches!(
p.kind,
K::LargeFireball { .. }
| K::SmallFireball
| K::WindCharge
| K::BreezeWindCharge
)
})
.map(|p| Target::Projectile(p.id)),
);
@@ -719,6 +761,27 @@ impl Game<'_> {
});
dims
}
/// `Level.getEntity`: the entity of the level in place with a network
/// id, players aside.
pub(in crate::game) fn level_entity(&self, id: i32) -> Option<Target> {
let id = u32::try_from(id).ok()?;
[
Target::Mob(id),
Target::Item(id),
Target::Orb(id),
Target::Projectile(id),
Target::Stand(id),
Target::Frame(id),
Target::Painting(id),
Target::Boat(id),
Target::Cart(id),
Target::Other(id),
Target::Crystal(id),
Target::Dragon(id),
]
.into_iter()
.find(|t| self.level_holds(*t))
}
/// Whether the level in place holds the entity a target names.
fn level_holds(&self, t: Target) -> bool {
match t {
@@ -1034,6 +1097,11 @@ impl Game<'_> {
let custom = s.data().and_then(|data| data.custom_name.as_ref());
let hover = if let Some(c) = custom {
horizon_client::component::name_text(c)
} else if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
// `ItemStack.getItemName`: the `item_name` component wins over the
// item's own name (`Item.getName`), so a datapack's or a server's
// renaming shows here as it does on the tooltip's first line.
horizon_client::component::name_text(name)
} else if let Some(title) = s
.data()
.and_then(|d| d.written.as_ref())
@@ -1418,7 +1486,10 @@ impl Game<'_> {
let targets = if id.starts_with("teleport.targets") {
self.entities(ctx, "targets", false)?
} else {
vec![self.implied_player()]
vec![
self.source_entity()
.ok_or_else(|| err("permissions.requires.entity", &[]))?,
]
};
if id.ends_with("destination") {
let dest = self.entities(ctx, "destination", false)?[0];
@@ -1731,6 +1802,16 @@ impl Game<'_> {
}
Target::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
// LivingEntity.kill: a generic_kill hit of Float.MAX_VALUE,
// which its death message tells.
if m.alive() {
m.record_damage(
horizon_client::mobs::Damage::of(
horizon_client::mobs::DamageKind::GenericKill,
),
f32::MAX,
);
}
m.health = 0.0;
if m.death_time == 0 {
m.death_time = 1;
@@ -2617,6 +2698,15 @@ impl Game<'_> {
// Peaceful removes hostile mobs.
self.mobs.retain(|m| !m.hostile());
}
// MinecraftServer.setDifficulty: every player, guests included, is
// sent the new difficulty.
if let Some(host) = self.lan.as_ref() {
host.hub
.broadcast(&C::ChangeDifficulty(play::ChangeDifficulty {
difficulty: i32::from(i),
locked: self.level.difficulty_locked,
}));
}
self.feedback(Text::translate("commands.difficulty.success", &[name(i)]));
Ok(0)
}
@@ -2966,7 +3056,7 @@ impl Game<'_> {
if !section.blocks.maybe_has(&mut wanted_state) {
continue;
}
let base_y = horizon_client::chunk::MIN_Y + 16 * i as i32;
let base_y = self.world.bottom_y() + 16 * i as i32;
for j in 0..4096 {
let state = section.get(j);
let Some(kind) = cache.get(&state).copied().flatten() else {
@@ -3028,8 +3118,8 @@ impl Game<'_> {
radius: 6400,
horizontal: 32,
vertical: 64,
min_y: horizon_client::chunk::MIN_Y,
max_y: horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT - 1,
min_y: self.world.bottom_y(),
max_y: self.world.top_y() - 1,
};
let found = self
.end
@@ -318,6 +318,9 @@ impl Game<'_> {
self.take_loot_table(key, &tag, &mut new);
self.world.block_entities.insert(key, new);
self.update_spawner_display(key);
// BlockDataAccessor.setData: setChanged and sendBlockUpdated(3),
// even when the block state itself stays the same.
self.mark_block_entity_updated(key);
let state = self.world.block(p[0], p[1], p[2]);
// Redraw what shows the block entity (signs, banners).
let _ = self.world.set(p[0], p[1], p[2], state);
@@ -785,8 +785,8 @@ impl Game<'_> {
}
/// The highest block of a heightmap type at a column (`Level.getHeight`).
fn heightmap_at(&self, kind: &str, x: i32, z: i32) -> i32 {
let top = horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT;
for y in (horizon_client::chunk::MIN_Y..top).rev() {
let top = self.world.top_y();
for y in (self.world.bottom_y()..top).rev() {
let state = self.world.block(x, y, z);
if state == self.world.air {
continue;
@@ -809,7 +809,7 @@ impl Game<'_> {
return y + 1;
}
}
horizon_client::chunk::MIN_Y
self.world.bottom_y()
}
/// A `store` modifier: a new callback in the source's chain.
fn store_source(&mut self, c: &Context, s: Source) -> R<Source> {
@@ -310,12 +310,19 @@ impl Game<'_> {
None => (0, None),
};
let count = Self::int(ctx, "count").unwrap_or(1).max(1) as u32;
Ok(Some(ItemStack {
let stack = ItemStack {
item: id.as_str().into(),
count,
damage,
extra,
}))
};
// ItemInput.createItemStack: a count above the stack's max (its
// max_stack_size component, else the item's) is refused.
let max_stack = self.stack_max_size(&stack);
if count > max_stack {
return Err(err("arguments.item.overstacked", &[&id, &max_stack]));
}
Ok(Some(stack))
}
/// One item modifier (a typed object, a list or a datapack reference)
/// applied to a stack; `None` when the stack ends up empty.
@@ -1,7 +1,6 @@
//! `/forceload`, `/fillbiome`, `/worldborder` and `/loot`.
use super::{Arg, Context, Game, R, Target, err, items::Owner};
use horizon_client::{
chunk::{HEIGHT, MIN_Y},
command::CommandError,
items::ItemStack,
scoreboard_cmd::{Holder, Tracker},
@@ -213,7 +212,8 @@ impl Game<'_> {
if usize::from(current) == target {
continue;
}
let qy = ((y - MIN_Y) >> 2).clamp(0, HEIGHT / 4 - 1);
let qy = ((y - self.world.bottom_y()) >> 2)
.clamp(0, self.world.layout().height() / 4 - 1);
if let Some(chunk) = self.world.chunks.get_mut(&key) {
let chunk = std::sync::Arc::make_mut(chunk);
chunk.set_biome_cell((x & 15) >> 2, qy, (z & 15) >> 2, target as u16);
@@ -228,10 +228,20 @@ impl Game<'_> {
}
for key in &touched {
self.world.modified.insert(*key);
for sy in 0..horizon_client::chunk::SECTIONS as i32 {
for sy in 0..self.world.layout().sections() as i32 {
self.world.dirty_sections.insert((key.0, sy, key.1));
}
}
// ChunkMap.resendBiomesForChunks: the players who see the changed
// columns are sent their new biomes, in the order the columns were
// gathered (z, then x).
if let Some(host) = self.lan.as_ref() {
let columns: Vec<(i32, i32)> = ((lo[2] >> 4)..=(hi[2] >> 4))
.flat_map(|cz| ((lo[0] >> 4)..=(hi[0] >> 4)).map(move |cx| (cx, cz)))
.filter(|key| touched.contains(key))
.collect();
host.hub.resend_biomes(&self.world, &columns);
}
self.feedback(Text::translate(
"commands.fillbiome.success.count",
&[
@@ -113,7 +113,8 @@ impl Game<'_> {
"explosion" | "player_explosion" | "bad_respawn_point" => DamageKind::Explosion,
"fall" | "ender_pearl" | "fly_into_wall" => DamageKind::Fall,
"on_fire" => DamageKind::OnFire,
"in_fire" | "hot_floor" => DamageKind::InFire,
"in_fire" => DamageKind::InFire,
"hot_floor" => DamageKind::HotFloor,
"campfire" => DamageKind::Campfire,
"lava" => DamageKind::Lava,
"drown" => DamageKind::Drown,
@@ -202,10 +203,15 @@ impl Game<'_> {
attacker,
from,
};
self.mobs
let hurt = self
.mobs
.iter_mut()
.find(|m| m.id == id)
.is_some_and(|m| m.damage(amount, damage))
.is_some_and(|m| m.damage(amount, damage));
if hurt {
self.resolve_player_responsible(id, damage);
}
hurt
}
_ => false,
};
@@ -1278,12 +1284,12 @@ impl Game<'_> {
let respect = matches!(ctx.get("respectTeams"), Some(Arg::Bool(true)));
let max_y = match ctx.get("maxHeight") {
Some(Arg::Int(h)) => *h,
_ => horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT,
_ => self.world.top_y(),
};
if max_y < horizon_client::chunk::MIN_Y {
if max_y < self.world.bottom_y() {
return Err(err(
"commands.spreadplayers.failed.invalid.height",
&[&max_y, &horizon_client::chunk::MIN_Y],
&[&max_y, &self.world.bottom_y()],
));
}
let targets = self.entities(ctx, "targets", false)?;
@@ -1484,7 +1490,7 @@ impl Game<'_> {
let mut air2 = air(y);
y -= 1;
let mut air1 = air(y);
while y > horizon_client::chunk::MIN_Y {
while y > self.world.bottom_y() {
y -= 1;
let current = air(y);
if !current && air1 && air2 {
+3 -3
View File
@@ -128,7 +128,7 @@ impl Game<'_> {
if !self.world.loaded(pos.x, pos.z) {
return false;
}
while pos.y > self.dim.min_y() {
while pos.y > self.world.shape.min_y {
pos.y -= 1;
let state = self.world.block(pos.x, pos.y, pos.z);
if self.state_in_tag("minecraft:entities_can_teleport_to", state) {
@@ -296,10 +296,10 @@ impl TeleportConsumeWorld for Teleporter<'_, '_> {
&mut self.0.world.random
}
fn min_y(&mut self, level: Dim) -> Result<i32, Infallible> {
Ok(level.min_y())
Ok(self.0.level_shapes.get(level.id()).min_y)
}
fn logical_height(&mut self, level: Dim) -> Result<i32, Infallible> {
Ok(level.logical_height())
Ok(self.0.level_shapes.get(level.id()).logical_height)
}
fn is_passenger(&mut self) -> Result<bool, Infallible> {
Ok(self.0.riding.is_some())
@@ -36,6 +36,8 @@ impl Game<'_> {
self.world.chest_openers.remove(&key);
}
self.block_entity_event(chest, 1, count.max(0));
// The guests get the same `ClientboundBlockEventPacket` (the lid).
self.lan_block_event(chest, 1, count.max(0));
}
/// A copper golem took from or filled a chest slot.
pub(super) fn golem_chest_slot(
@@ -100,6 +102,7 @@ impl Game<'_> {
o.0 = count;
}
self.block_entity_event(p, 1, count);
self.lan_block_event(p, 1, count);
}
}
/// `ChestBlockEntity.playSound` for a chest the recheck closed.
+15 -7
View File
@@ -92,11 +92,14 @@ impl Game<'_> {
.unwrap_or_default()
}
/// Runs `f` on the heart at `key` with the game's mobs and level random,
/// then applies what the heart reported.
/// then applies what the heart reported. `others`: whether `f` reads the
/// other mobs through the mob world (without, it holds none: building them
/// costs a pass over every mob).
fn with_heart(
&mut self,
key: Pos,
heart: &mut CreakingHeart,
others: bool,
f: impl FnOnce(
&mut CreakingHeart,
&horizon_client::mobs::MobWorld,
@@ -112,7 +115,11 @@ impl Game<'_> {
let spawning = self.spawning_monsters();
let creaking = self.res.mob_types().get("minecraft:creaking").cloned();
let player_uuid = self.level.player_uuid;
let snapshot: Vec<_> = mobs.iter().map(|m| seen(m, player_uuid)).collect();
let snapshot: Vec<_> = if others {
mobs.iter().map(|m| seen(m, player_uuid)).collect()
} else {
Vec::new()
};
let mut events = Vec::new();
let added = {
let w = self.mob_world_base(&snapshot);
@@ -168,7 +175,7 @@ impl Game<'_> {
else {
return;
};
let _ = self.with_heart(key, &mut heart, |h, w, lr, host, out| {
let _ = self.with_heart(key, &mut heart, true, |h, w, lr, host, out| {
creaking_heart::creaking_hurt(h, home, w, lr, host, out);
});
self.world
@@ -232,7 +239,7 @@ impl Game<'_> {
else {
return;
};
let _ = self.with_heart(key, &mut heart, |h, _, _, host, out| {
let _ = self.with_heart(key, &mut heart, false, |h, _, _, host, out| {
creaking_heart::remove_protector(h, host, true, out);
});
let state = self.world.block(key.0, key.1, key.2);
@@ -270,7 +277,7 @@ impl Game<'_> {
if &*self.res.facts().get(state).name != "minecraft:creaking_heart" {
// The block is gone: preRemoveSideEffects.
self.world.block_entities.forget(&key);
self.with_heart(key, &mut heart, |h, _, _, host, out| {
self.with_heart(key, &mut heart, false, |h, _, _, host, out| {
creaking_heart::remove_protector(h, host, false, out);
})?;
return Ok(());
@@ -278,7 +285,8 @@ impl Game<'_> {
// getTicker: none once uprooted.
if self.heart_state_name(state) != "uprooted" {
let pos = [key.0, key.1, key.2];
self.with_heart(key, &mut heart, |h, w, lr, host, out| {
let others = creaking_heart::tick_updates(&heart);
self.with_heart(key, &mut heart, others, |h, w, lr, host, out| {
creaking_heart::server_tick(h, pos, w, lr, host, out);
})?;
}
@@ -342,7 +350,7 @@ impl Game<'_> {
let (lx, lz, ly) = ((i & 15) as i32, ((i >> 4) & 15) as i32, (i >> 8) as i32);
let pos = (
key.0 * 16 + lx,
horizon_client::chunk::MIN_Y + si as i32 * 16 + ly,
self.world.bottom_y() + si as i32 * 16 + ly,
key.1 * 16 + lz,
);
self.world
+504 -44
View File
@@ -21,7 +21,7 @@ use horizon_client::{
text::Text,
};
use horizon_core::{
geometry::{Aabb, Direction, Vec3, traverse_blocks},
geometry::{Aabb, Direction, Vec3, traverse_blocks, trig::Mth},
position::BlockPos,
random::RandomSource,
};
@@ -57,8 +57,9 @@ pub(super) struct Creatures {
/// (game/lan_pvp.rs).
pub sweeps: Vec<super::lan_pvp::Sweep>,
/// Explosion packets from a guest's turn, waiting for it to end
/// (centre, small, radius, blocks hit; game/lan_particles.rs).
pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>,
/// (centre, small, radius, blocks hit, the players it pushed;
/// game/lan_particles.rs).
pub lan_explosions: Vec<([f64; 3], bool, f32, usize, Vec<(u32, [f64; 3])>)>,
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
pub elder_apparition: i32,
/// The wandering trader spawner's clock.
@@ -70,11 +71,13 @@ pub(super) struct Creatures {
/// Particles from a guest's turn, waiting for it to end
/// (`lan_send_particles`: options, centre, spread, speed, count).
pub lan_particles: Vec<(
&'static str,
horizon_client::particles::ParticleOptions,
Option<horizon_world::registry::BlockStateId>,
[f64; 3],
[f64; 3],
f64,
u32,
i32,
)>,
}
pub(super) struct ProjWorld<'a, 'w> {
@@ -107,12 +110,10 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
hit.direction.normal(),
))
}
fn in_water(&self, at: Vec3) -> bool {
self.w.fluid(
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
) == Fluid::Water
/// Entity.updateFluidInteraction over the projectile's box, not the
/// block at its feet (#3072).
fn in_water(&self, bounds: Aabb) -> bool {
self.w.fluids(bounds, bounds.max_position().y).in_water
}
fn targets(&self) -> &[(Who, Aabb)] {
&self.targets
@@ -130,21 +131,35 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
/// `AbstractWindCharge` is pickable, so a thrown pearl's
/// `ProjectileUtil.getEntityHitResult` can strike it (`ThrownEnderpearl.onHit`).
const WIND_CHARGE_HIT: u32 = super::dragon::SPECIAL - 1;
/// `WIND_CHARGE_IDS + j` is `projectiles[j]` during `tick_projectiles`, below
/// the dragon's reserved ids.
const WIND_CHARGE_IDS: u32 = super::dragon::SPECIAL - 0x10000;
fn wind_charge_hitboxes(projectiles: &[Projectile], skip: usize) -> Vec<(Who, Aabb)> {
/// The index of the wind charge a `wind_charge_hitboxes` id stands for.
fn wind_charge_hit(id: u32) -> Option<usize> {
(WIND_CHARGE_IDS..super::dragon::SPECIAL)
.contains(&id)
.then(|| (id - WIND_CHARGE_IDS) as usize)
}
fn wind_charge_hitboxes(
projectiles: &[Projectile],
alive: &[bool],
skip: usize,
) -> Vec<(Who, Aabb)> {
projectiles
.iter()
.enumerate()
.filter(|(j, q)| {
*j != skip
&& alive[*j]
&& !q.stuck
&& matches!(
q.kind,
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
)
})
.map(|(_, q)| (Who::Mob(WIND_CHARGE_HIT), q.aabb()))
.map(|(j, q)| (Who::Mob(WIND_CHARGE_IDS + j as u32), q.aabb()))
.collect()
}
@@ -354,6 +369,41 @@ impl Game<'_> {
}
Some(id)
}
/// `Mob.convertTo(type, ConversionParams.single(...))` for a mob already
/// removed: a new mob of `kind`, made without spawn finalization, takes
/// its place with what `conversion::single` carries over, and the old
/// mob's riders move onto it. A zombie that comes out of it rolls
/// `Zombie.handleAttributes(special, CONVERSION)` (the
/// `postConversionStep` of its `ConversionTracker`).
pub(super) fn convert_to(
&mut self,
from: &Mob,
kind: &str,
params: horizon_client::mobs::conversion::Params,
) -> Option<u32> {
let t = self.res.mob_types().get(kind).cloned()?;
self.next_entity += 1;
let mut mob = Mob::new(self.next_entity, t, from.position, from.yaw);
horizon_client::mobs::conversion::single(from, &mut mob, params);
if matches!(from.species(), Species::Zombie | Species::Husk) {
let at = [from.position.x, from.position.y, from.position.z];
let special = self.local_difficulty(at).special();
horizon_client::mobs::finalize::zombie_attributes(
&mut mob,
special,
horizon_client::mobs::finalize::Reason::Conversion,
);
}
let id = mob.id;
// ConversionType.SINGLE: the passengers get off and board the new mob.
for rider in self.mobs.iter_mut() {
if rider.extra.mount == Some(from.id) {
rider.extra.mount = Some(id);
}
}
self.mobs.push(mob);
Some(id)
}
/// The `summon` command: spawn a mob in front of the player or at a position.
pub(super) fn summon(&mut self, kind: &str, at: Option<[f64; 3]>, baby: bool) -> Result<u32> {
let at = match at {
@@ -454,13 +504,13 @@ impl Game<'_> {
/// Highest motion-blocking (no leaves) block + 1 (Heightmap MOTION_BLOCKING_NO_LEAVES).
pub(super) fn motion_top(&self, x: i32, z: i32) -> i32 {
let top = if self.dim == Dim::Nether { 127 } else { 319 };
for y in (horizon_client::chunk::MIN_Y..=top).rev() {
for y in (self.world.bottom_y()..=top).rev() {
let f = self.res.facts().get(self.world.block(x, y, z));
if (f.full_collision || f.fluid != Fluid::None) && !f.name.ends_with("leaves") {
return y + 1;
}
}
horizon_client::chunk::MIN_Y
self.world.bottom_y()
}
pub(super) fn mob_world_base<'s>(&'s self, others: &'s [Seen]) -> MobWorld<'s> {
let (rain, thunder) = self.world.weather.levels(1.0);
@@ -589,8 +639,10 @@ impl Game<'_> {
self.died.insert(m.id);
self.mob_died(&m);
}
// Their victims have been looted: no charged creeper is waiting any more.
// Their victims have been looted and mourned: no charged creeper is
// waiting any more.
self.combat.charged_exploders.clear();
self.combat.exploded.clear();
let uuid = self.level.player_uuid;
let mut snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
let mut events = Vec::new();
@@ -800,6 +852,7 @@ impl Game<'_> {
self.tick_sniffer_effects();
self.piglin_pickups();
self.sulfur_cube_pickups();
self.sulfur_cube_power_priming();
// Hurt and death sounds of damage dealt outside the mob ticks.
let mut queued = Vec::new();
for m in &mut self.mobs {
@@ -833,6 +886,8 @@ impl Game<'_> {
self.set_bed_occupied(bed, false);
}
let killer = self.last_hurt_by_player.remove(&m.id);
// TamableAnimal.die, before the rest of LivingEntity.die.
self.tell_owner_pet_died(m, killer);
self.love_cause.remove(&m.id);
let by_player = killer.is_some() || m.ai.last_hurt_by_player > 0;
let mut dying = m.clone();
@@ -883,8 +938,12 @@ impl Game<'_> {
}
}
self.score_entity_removed(m.id);
self.raider_died(m, by_player);
self.villager_murdered(m, by_player, killer.flatten());
// Raider.die's hero and Villager.tellWitnessesThatIWasMurdered go to
// the causing entity, not the credited player: a tame wolf's kill is
// neither its owner's heroism nor a murder by them.
let slain_by_player = by_player && !killed_by_mob(m);
self.raider_died(m, slain_by_player);
self.villager_murdered(m, slain_by_player, killer.flatten());
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
self.drop_death_loot(&mut dying, by_player, consumed, pos);
self.create_wither_rose(m);
@@ -919,7 +978,12 @@ impl Game<'_> {
/// The killer's side of a mob's death: `ServerPlayer.awardKillScore`'s
/// statistics and scores, and the `player_killed_entity` trigger.
fn credit_kill(&mut self, m: &Mob) {
self.stats.add("killed", &m.kind.id, 1);
// Player.killedEntity: the killed statistic goes to the causing
// entity, so a kill by a mob (a tame wolf credited to its owner)
// leaves it out; ServerPlayer.awardKillScore counts the rest.
if !killed_by_mob(m) {
self.stats.add("killed", &m.kind.id, 1);
}
self.stats.custom("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(m.id));
let view = self.mob_entity(m);
@@ -1056,6 +1120,20 @@ impl Game<'_> {
// the orbs' merge ids and block ticks read next).
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
}
drops::Drop::CarriedBlock(state) => {
// Enderman.dropCustomDeathLoot: spawnAtLocation for each
// of the carried block's silk-touch drops.
let stacks = horizon_client::mobs::enderman::carried_block_drops(
world_items!(self),
self.pack.assets.registry,
state,
&mut self.world.random,
Some((&mut self.rand_sequences, self.level.seed)),
);
for item in stacks {
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
}
}
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
drops::Drop::Orb(_) => {}
}
@@ -1088,6 +1166,9 @@ impl Game<'_> {
}
}
}
if hurt {
self.resolve_player_responsible(target, source);
}
if infested {
self.mob_infested(target);
}
@@ -1110,6 +1191,49 @@ impl Game<'_> {
}
hurt
}
/// `LivingEntity.resolvePlayerResponsibleForDamage` after `source` hurt a
/// mob: a tame wolf's hit is its owner's (`setLastHurtByPlayer(uuid, 100)`),
/// so the kill drops experience and player-only loot and the owner, when
/// in the world, gets the credit (`getKillCredit`); a tame wolf without an
/// owner clears it. A player's own hits are recorded where they land.
pub(super) fn resolve_player_responsible(&mut self, victim: u32, source: Damage) {
let Some(Who::Mob(attacker)) = source.attacker else {
return;
};
let Some(owner) = self
.mobs
.iter()
.find(|m| m.id == attacker && m.species() == Species::Wolf && m.extra.tame)
.map(|m| m.extra.owner)
else {
return;
};
let player = owner.and_then(|uuid| self.player_with_uuid(uuid));
let Some(m) = self.mobs.iter_mut().find(|m| m.id == victim) else {
return;
};
m.ai.last_hurt_by_player = if owner.is_some() { 100 } else { 0 };
match player {
Some(who) => self.last_hurt_by_player.insert(victim, who),
None => self.last_hurt_by_player.remove(&victim),
};
}
/// The player in the world with `uuid` (`EntityReference.getPlayer`), as
/// `last_hurt_by_player` keeps them: None for the player in place, else the
/// guest's connection.
fn player_with_uuid(&self, uuid: [i32; 4]) -> Option<Option<u32>> {
if self.player.is_some()
&& horizon_client::mobs::tame::owned_by(Some(uuid), self.acting_uuid())
{
return Some(self.lan_acting);
}
let uuid = horizon_client::lan::playerdata::ints_uuid(uuid);
self.lan
.iter()
.flat_map(|h| h.hub.guests.iter())
.find(|g| g.placed && g.uuid == uuid)
.map(|g| Some(g.conn))
}
/// `InfestedMobEffect.onMobHurt` for a mob: silverfish from half its height up.
pub(super) fn mob_infested(&mut self, mob: u32) {
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
@@ -1205,7 +1329,7 @@ impl Game<'_> {
let options = horizon_client::particles::ParticleOptions::simple(ty);
self.particles
.send_particles(&options, at, spread, speed, count);
self.lan_level_particles(&options, at, spread, speed, count);
self.lan_level_particles(&options, at, spread, speed, count, None);
}
}
MobEvent::Sound(name, at, volume, pitch)
@@ -1319,6 +1443,7 @@ impl Game<'_> {
self.combat.explosion_source = Some(mob);
// The creeper causes the blast's damage, but it is out of
// the mob list already: its name comes with the event.
self.combat.exploded.push((mob, name.clone()));
self.note_named_attacker("minecraft:explosion", mob, name);
if charged {
self.combat.charged_exploders.push(mob);
@@ -1361,6 +1486,31 @@ impl Game<'_> {
} => {
self.mob_hits_mob(target, amount, source);
}
MobEvent::CubeHurtMob {
cube,
target,
amount,
source,
} => {
// AbstractCubeMob.dealDamage: the attack sound (and its
// two pitch draws, made even for a silent cube, whose
// Entity.playSound is then skipped) only follows a hit
// that landed.
if self.mob_hits_mob(target, amount, source)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == cube)
{
let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0;
let at = m.center();
if !m.silent {
self.handle_mob_events(vec![MobEvent::Sound(
"entity.slime.attack",
at,
1.0,
pitch,
)])?;
}
}
}
MobEvent::SonicBoom {
target,
attacker,
@@ -1503,6 +1653,9 @@ impl Game<'_> {
self.apply_setup(id, &setup);
}
}
MobEvent::Convert { from, kind, params } => {
self.convert_to(&from, kind, params);
}
MobEvent::DropItem { item, count, at } => self.spawn_item(
ItemStack {
item,
@@ -1850,6 +2003,11 @@ impl Game<'_> {
}
}
MobEvent::PandaSneezed { mob } => self.panda_sneezed(mob),
MobEvent::FallToPassengers {
mob,
distance,
modifier,
} => self.fall_to_passengers(mob, distance, modifier),
MobEvent::ShedScute { mob, at, pitch } => {
// The mob already checked `shouldDropLoot` (a baby, `mob_drops`).
let table = "minecraft:gameplay/armadillo_shed";
@@ -2083,7 +2241,9 @@ impl Game<'_> {
}
return;
}
let coin = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
// (Mooshroom draws its own coin in getOffspringVariant.)
let coin = p.species() != Species::Mooshroom
&& self.world.random.next_int_bound(2).unwrap_or(0) == 0;
// Axolotl.getBreedOffspring: useRareVariant (one in 1200) makes it blue,
// else either parent's variant. The draws come from the mother's random.
let axolotl = if p.species() == Species::Axolotl {
@@ -2102,6 +2262,24 @@ impl Game<'_> {
} else {
None
};
// MushroomCow.getOffspringVariant, from the mother's random: the other
// colour 1 time in 1024 when both parents share one, otherwise either
// parent's colour by a coin flip.
let mooshroom = if p.species() == Species::Mooshroom {
let mate = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
let own = p.extra.variant;
self.mobs.iter_mut().find(|m| m.id == parent).map(|m| {
if own == mate && m.random.next_int_bound(1024).unwrap_or(1) == 0 {
if own == 1 { 0 } else { 1 }
} else if m.random.next_boolean() {
own
} else {
mate
}
})
} else {
None
};
// A horse and a donkey have a mule.
let kind = match (p.species(), q.as_ref().map(|q| q.species())) {
(Species::Horse, Some(Species::Donkey)) | (Species::Donkey, Some(Species::Horse)) => {
@@ -2113,6 +2291,8 @@ impl Game<'_> {
&& let Some(child) = self.mobs.iter_mut().find(|m| m.id == id)
{
child.extra.age = -24000;
// No Mob.finalizeSpawn either: no follow range bonus, right-handed.
spawning::bred_offspring(child);
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
// the default (`classic`) sound variant.
child.extra.sound_variant = None;
@@ -2247,6 +2427,9 @@ impl Game<'_> {
{
child.extra.variant = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
}
if let Some(variant) = mooshroom {
child.extra.variant = variant;
}
// Sheep.getBreedOffspring: the lamb's wool mixes both parents' dyes.
if p.species() == Species::Sheep {
let other = q.as_ref().unwrap_or(&p);
@@ -2645,7 +2828,7 @@ impl Game<'_> {
let mut keep_flags = vec![true; projectiles.len()];
for i in 0..projectiles.len() {
let winds = if matches!(projectiles[i].kind, ProjectileKind::EnderPearl) {
wind_charge_hitboxes(&projectiles, i)
wind_charge_hitboxes(&projectiles, &keep_flags, i)
} else {
Vec::new()
};
@@ -2754,6 +2937,16 @@ impl Game<'_> {
hits.push((p.clone(), hit));
}
keep_flags[i] = keep;
// Projectile.onHit: the wind charge it hit turns before its own tick.
if let Hit::Entity {
who: Who::Mob(id), ..
} = hit
&& let Some(j) = wind_charge_hit(id)
{
let (owner, guest) = (p.owner, p.guest);
let look = self.owner_look(owner, guest);
super::deflect::aim_deflect(&mut projectiles[j], look, owner, guest);
}
}
let mut idx = 0;
projectiles.retain(|_| {
@@ -2863,11 +3056,29 @@ impl Game<'_> {
}
}
}
/// `onProjectileHit` of chorus flowers and decorated pots: an impact
/// projectile (`#impact_projectiles`) breaks the block when
/// `Projectile.mayInteract` and `Projectile.mayBreak` allow it, which takes
/// the `projectiles_can_break_blocks` rule and a shooter that may break
/// blocks (`canBreakBlockInAdventureMode`: never a mob, never an
/// `AmethystBlock.onProjectileHit`: a projectile that strikes an amethyst
/// block rings `block.amethyst_block.chime` there, pitched by the level random.
fn amethyst_projectile_hit(&mut self, pos: [i32; 3]) {
let state = self.world.block(pos[0], pos[1], pos[2]);
if &*self.res.facts().get(state).name != "minecraft:amethyst_block" {
return;
}
let pitch = 0.5 + self.world.random.next_float() * 1.2;
self.level_events
.push(horizon_world::level::LevelEvent::Sound {
pos: BlockPos::new(pos[0], pos[1], pos[2]),
sound: "minecraft:block.amethyst_block.chime".into(),
volume: 1.0,
pitch,
});
let _ = self.process_level_events();
}
/// `onProjectileHit` of chorus flowers, decorated pots and speleothems
/// (pointed dripstone, sulfur spikes): an impact projectile
/// (`#impact_projectiles`) breaks the block when `Projectile.mayInteract`
/// and `Projectile.mayBreak` allow it, which takes the
/// `projectiles_can_break_blocks` rule and a shooter that may break blocks
/// (`canBreakBlockInAdventureMode`: never a mob, never an
/// adventure player). A pot is cracked first (flags 260), so it shatters.
fn projectile_breaks_block(&mut self, p: &Projectile, pos: [i32; 3]) {
use horizon_client::command::args::GameMode;
@@ -2895,6 +3106,11 @@ impl Game<'_> {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
match &*self.res.facts().get(state).name {
"minecraft:chorus_flower" => {}
// SpeleothemBlock.onProjectileHit (pointed dripstone and sulfur spikes): a
// thrown trident faster than 0.6 breaks it.
"minecraft:pointed_dripstone" | "minecraft:sulfur_spike"
if matches!(p.kind, ProjectileKind::Trident)
&& p.arrow.as_ref().is_some_and(|a| a.hit_speed > 0.6) => {}
"minecraft:decorated_pot" => {
let registry = &self.pack.assets.registry;
let Some(mut pot) = registry.state(state).cloned() else {
@@ -2929,6 +3145,11 @@ impl Game<'_> {
[position.x, position.y, position.z],
[velocity.x, velocity.y, velocity.z],
),
// Arrow.tick: `broadcastEntityEvent(this, 0)`. The window and the
// guests burst the particles themselves (`remote_arrow_puff`).
ArrowEvent::PotionPuff { id, .. } => {
self.lan_entity_event(id as i32, 0, [at.x, at.y, at.z]);
}
ArrowEvent::Sound {
sound,
volume,
@@ -3074,6 +3295,7 @@ impl Game<'_> {
&& face != [0; 3]
{
self.bell_projectile_hit(p, pos, at, face);
self.amethyst_projectile_hit(pos);
self.projectile_breaks_block(p, pos);
}
self.combat.projectile_owner = match p.owner {
@@ -3229,7 +3451,7 @@ impl Game<'_> {
}
// AbstractArrow.onHitBlock plays its sound itself (ArrowEvent).
(ProjectileKind::Trident, Hit::Block { .. }) => {}
(ProjectileKind::Arrow { effect, .. }, Hit::Entity { who, .. }) => {
(ProjectileKind::Arrow { .. }, Hit::Entity { who, .. }) => {
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
let player_fire = self.player.as_ref().map(|pl| pl.fire_ticks);
@@ -3242,17 +3464,11 @@ impl Game<'_> {
}])?;
}
let hurt = match who {
Who::Player => {
let hurt = self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:arrow",
);
if hurt && let Some((e, t)) = effect {
self.give_player_effect(e, *t, 0);
}
hurt
}
Who::Player => self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:arrow",
),
Who::Mob(id) => {
let hurt = self
.mobs
@@ -3371,6 +3587,9 @@ impl Game<'_> {
velocity: [p.velocity.x * k, 0.1, p.velocity.z * k],
}])?;
}
if hurt {
self.arrow_post_hurt_effects(p, who);
}
match (hurt, who) {
(true, _) => self.finish_arrow_hit(arrow, EntityHit::Hurt),
// What the player took no damage from (a raised shield)
@@ -4696,8 +4915,14 @@ impl Game<'_> {
f64::from(p[1]),
f64::from(p[2]) + 0.5,
];
if self.spawn_mob_spawned(&kind, at).is_none() {
let Some(mob_id) = self.spawn_mob_spawned(&kind, at) else {
return Ok(false);
};
// Entity.applyImplicitComponents: a name from an anvil names the mob.
if let Some(name) = stack.custom_name()
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id)
{
m.custom_name = Some(name.to_owned());
}
self.swing_arm();
if !self.creative
@@ -4801,6 +5026,11 @@ impl Game<'_> {
if mob.invisible && outline.is_none() {
continue;
}
// LevelRenderer: the entity the view looks out of is not drawn
// in first person.
if self.hides_as_camera(mob.id) {
continue;
}
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the view scale. RemotePlayer's box counts
@@ -4972,10 +5202,28 @@ impl Game<'_> {
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z(a.pitch.to_radians());
// ArrowRenderer: `state.shake = shakeTime - partialTicks`; ArrowModel.setupAnim
// rolls the root while the shake timer runs.
let shake = a
.arrow
.as_ref()
.map_or(0.0, |s| s.shake as f32 - alpha as f32);
let roll = if shake > 0.0 {
-(shake * 3.0).msin() * shake * (std::f32::consts::PI / 180.0)
} else {
0.0
};
let pose_for = |part: &horizon_client::mobs::Part| {
let mut pose = part.pose;
if std::ptr::eq(part, model) {
pose[5] += roll;
}
pose
};
horizon_client::mobs::render_model(
model,
m,
&|part| part.pose,
&pose_for,
[r.u0, r.v0, r.u1, r.v1],
packed,
0,
@@ -5123,12 +5371,12 @@ impl Game<'_> {
let Some(r) = atlas.sprites.get(name).copied() else {
continue;
};
let s = 0.25 * a.sprite_scale();
for (k, uv) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
let lift = is_item_billboard(&a.kind);
let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift);
for (v, uv) in quad
.iter()
.zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]])
{
let v = c + right * k.0 * s + up * k.1 * s;
out.push(Vertex {
normal: [0; 4],
pos: v.to_array(),
@@ -5158,6 +5406,36 @@ fn thrown_item_axes(
let orientation = super::camera_rotation(camera_type, yaw, pitch);
(orientation * glam::Vec3::X, orientation * glam::Vec3::Y)
}
/// The four corners of the item billboard, in the order its UVs are laid out.
/// `ThrownItemRenderer` only scales and billboards it; the lift is
/// `item/generated`'s ground display transform, `translation [0, 2, 0]` of 16
/// units = 0.125 blocks, which is `0.125 * scale` == `0.5 * s`.
fn thrown_item_quad(
at: glam::Vec3,
right: glam::Vec3,
up: glam::Vec3,
scale: f32,
lift: bool,
) -> [glam::Vec3; 4] {
let s = 0.25 * scale;
let rise = if lift { 0.5 * s } else { 0.0 };
let centre = at + up * rise;
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
.map(|k| centre + right * k.0 * s + up * k.1 * s)
}
/// Whether vanilla draws the kind as `ThrownItemRenderer`'s item billboard, the
/// only ones that take that lift. Llama spit shares this quad but
/// `LlamaSpitRenderer` is a model, with its own offset.
fn is_item_billboard(kind: &ProjectileKind) -> bool {
matches!(
kind,
ProjectileKind::Snowball
| ProjectileKind::Egg
| ProjectileKind::EnderPearl
| ProjectileKind::SmallFireball
| ProjectileKind::LargeFireball { .. }
)
}
/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water
/// bucket picks up.
fn bucketable(species: Species) -> Option<(&'static str, &'static str)> {
@@ -5221,6 +5499,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::Spit => "minecraft:spit",
K::Stalagmite => "minecraft:stalagmite",
K::Starve => "minecraft:starve",
K::HotFloor => "minecraft:hot_floor",
K::SulfurCubeHot => "minecraft:sulfur_cube_hot",
K::WindCharge => "minecraft:wind_charge",
K::Wither => "minecraft:wither",
@@ -5319,6 +5598,55 @@ impl Game<'_> {
mod tests {
use super::super::perspective::CameraType;
use super::thrown_item_axes;
use super::thrown_item_quad;
use super::{ProjectileKind, is_item_billboard};
/// The component-wise lowest and highest corner of the quad.
fn bounds(corners: &[glam::Vec3; 4]) -> (glam::Vec3, glam::Vec3) {
(
corners.iter().copied().reduce(glam::Vec3::min).unwrap(),
corners.iter().copied().reduce(glam::Vec3::max).unwrap(),
)
}
#[test]
fn thrown_item_billboard_lifts_by_the_generated_ground_transform() {
// The kinds vanilla draws with `ThrownItemRenderer`: the only lifted ones.
assert!(is_item_billboard(&ProjectileKind::Snowball));
assert!(is_item_billboard(&ProjectileKind::Egg));
assert!(is_item_billboard(&ProjectileKind::EnderPearl));
assert!(is_item_billboard(&ProjectileKind::SmallFireball));
let large = ProjectileKind::LargeFireball { power: 1 };
assert!(is_item_billboard(&large));
// A projectile at y = 100 with right = +X, up = +Y: the sprite sits
// 0.125 blocks above the entity (2 of `item/generated`'s 16 units) and
// stays 0.5 blocks wide, so the quad's y runs 99.875 .. 100.375.
let at = glam::Vec3::Y * 100.0;
let (right, up) = (glam::Vec3::X, glam::Vec3::Y);
let lifted = is_item_billboard(&ProjectileKind::Snowball);
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted));
assert_eq!((lo.y, hi.y), (99.875, 100.375));
assert_eq!((lo.x, hi.x), (-0.25, 0.25));
// A small fireball's 0.75 scale: the same quad scaled down, lifted by
// 0.09375, half extent 0.1875.
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 0.75, lifted));
assert_eq!((lo.y, hi.y), (99.90625, 100.28125));
// A large fireball's 3.0: lift 0.375, half extent 0.75.
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 3.0, lifted));
assert_eq!((lo.y, hi.y), (99.625, 101.125));
// Llama spit shares the quad here but is a model in vanilla
// (`LlamaSpitRenderer`): no lift at all.
let lifted = is_item_billboard(&ProjectileKind::LlamaSpit);
assert!(!lifted);
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted));
assert_eq!((lo.y, hi.y), (99.75, 100.25));
// The rise follows the billboard's own up axis, not world up.
let (right, up) = (glam::Vec3::Z, glam::Vec3::X);
let lifted = is_item_billboard(&ProjectileKind::Egg);
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted));
assert_eq!((lo.x, hi.x), (-0.125, 0.375));
assert_eq!((lo.y, hi.y), (100.0, 100.0));
}
#[test]
fn thrown_item_billboard_does_not_flip_at_straight_up_or_down() {
@@ -5349,6 +5677,13 @@ mod tests {
}
}
/// Whether the killing blow's causing entity (`DamageSource.getEntity`) was a
/// mob, such as a tame wolf whose owner gets the kill credit.
fn killed_by_mob(m: &Mob) -> bool {
m.last_damage
.is_some_and(|(d, _)| matches!(d.attacker, Some(Who::Mob(_))))
}
/// `Animal.finalizeSpawnChildFromBreeding`'s player: the one who fed the
/// parent, or else the one who fed the partner (`getLoveCause`). `Some(None)`
/// is the host, `Some(Some(conn))` a guest. Both animals are spent.
@@ -5379,3 +5714,128 @@ mod breeding_tests {
assert_eq!(breeding_credit(&mut love, 1, 2), None);
}
}
#[cfg(test)]
mod pearl_tests {
use super::*;
/// A pearl's water test is `Entity.isInWater`: `wasTouchingWater` from the
/// last `updateFluidInteraction`, water whose surface reaches its 0.25
/// box (#3072), not the block at its feet.
#[test]
fn a_pearl_feels_the_water_its_box_touches() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-types.json").exists()
|| !root.join("movement-properties.nbt").exists()
{
return;
}
let assets = horizon_client::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let registry = assets.registry;
let facts = horizon_client::blocks::BlockFacts::load(&root, registry).unwrap();
let motion = horizon_client::motion::MotionResources::load(&root, &assets).unwrap();
let data = horizon_client::mobs::MobData::load(&root, &facts);
let biomes = horizon_client::biome::Biomes::load(&root).unwrap();
let items = horizon_client::items::Items::load(
&root,
registry,
&facts,
&horizon_world::loot::PackOverlay::NONE,
)
.unwrap();
let collision = horizon_client::collisions::load(&root, registry).unwrap();
let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
// A pool of still water one block deep at y = 4, from x = 20 on (clear of the
// scene's pyramid): its
// surface is at 4 + 8/9.
let water = registry.default_state("minecraft:water").unwrap();
for x in 20..=40 {
for z in 0..=2 {
world.set(x, 4, z, water).unwrap();
}
}
let lid_openers = std::collections::HashMap::new();
let w = MobWorld {
teams: Default::default(),
world: &world,
registry,
facts: &facts,
fluid_tables: &motion.fluids,
movement: &motion.blocks,
collision: &collision,
data: &data,
player: None,
nearest_player: None,
others: &[],
others_grid: None,
others_index: None,
daylight: true,
monsters_burn: false,
game_time: 1000,
day_time: 1000,
raining: false,
thundering: false,
nether: false,
block_tags: &items.block_tags,
biomes: &biomes,
mob_griefing: true,
mob_drops: true,
max_entity_cramming: 24,
difficulty: spawning::LocalDifficulty::new(2, 1000, 0, 0.0),
end: false,
world_seed: 0,
sea_level: 63,
items: Some(&items),
lid_openers: &lid_openers,
};
let pw = ProjWorld {
w: &w,
targets: Vec::new(),
};
let pearl = |x: f64, y: f64, velocity: Vec3| {
let mut p = Projectile::new(ProjectileKind::EnderPearl, Vec3::new(x, y, 1.5), None);
p.velocity = velocity;
p
};
// Skimming the pool: its feet are in the water block, its box above
// the surface. ThrowableProjectile.applyInertia keeps the air drag.
let mut p = pearl(25.5, 4.95, Vec3::new(0.3, 0.0, 0.0));
p.tick(&pw);
assert!(p.position.y > 4.9, "the pearl dipped: {p:?}");
p.tick(&pw);
assert!(
(p.velocity.x - 0.3 * 0.99 * 0.99).abs() < 1e-9,
"a pearl above the surface slowed as in water: {p:?}"
);
// At the pool's edge: its feet over the dry block at x = 19, a side
// of its box in the water at x = 20. The water drag after the first
// tick, which still reads `wasTouchingWater` as false.
let mut p = pearl(19.95, 4.3, Vec3::ZERO);
p.tick(&pw);
assert!(
(p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9,
"the first tick has the air drag: {p:?}"
);
p.tick(&pw);
assert!(
(p.velocity.y - (-0.03 * 0.99 - 0.03) * 0.8).abs() < 1e-9,
"a pearl whose box is in the water kept the air drag: {p:?}"
);
// Thrown under water, a pearl moves with the air drag on its first
// tick and the water drag after it.
let mut p = pearl(30.5, 4.2, Vec3::ZERO);
p.tick(&pw);
assert!(
(p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9,
"the first tick under water has the air drag: {p:?}"
);
p.tick(&pw);
assert!(
(p.velocity.y - (-0.03 * 0.99 - 0.03) * 0.8).abs() < 1e-9,
"the second tick under water has the water drag: {p:?}"
);
}
}
+203 -32
View File
@@ -14,6 +14,8 @@ use horizon_client::{
text::Text,
};
use horizon_core::geometry::{Axis, Vec3};
use horizon_protocol::packets::play::{BlockEntityTagQuery, EntityTagQuery, Serverbound};
use horizon_world::nbt::Tag;
use winit::keyboard::KeyCode;
/// `ChatFormatting.YELLOW` and `RED`.
@@ -65,6 +67,9 @@ impl Game<'_> {
/// own handling. Returns true when it is used up (a debug combination,
/// F3 itself, F3+Escape).
pub(super) fn debug_key(&mut self, key: KeyCode, action: KeyAction) -> bool {
if self.title.is_some() {
return false;
}
let Some(name) = keybinds::key_name(key) else {
return false;
};
@@ -217,7 +222,7 @@ impl Game<'_> {
self.debug_charts.log_packets([sent, received]);
}
if shown {
use horizon_protocol::packets::{play::Serverbound, status::Ping};
use horizon_protocol::packets::status::Ping;
let time = self.debug_millis();
self.send(Serverbound::PingRequest(Ping { time }));
}
@@ -273,8 +278,8 @@ impl Game<'_> {
/// entries, over everything the frame drew (menus too), at the debug GUI
/// scale. F1 hides it unless a screen is open.
pub(super) fn draw_debug_overlay(&self, batch: &mut HudBatch, size: [u32; 2]) {
let screen_open = self.title.is_some() || self.screen != Screen::Playing;
if !self.debug || (self.ui.hide_gui && !screen_open) {
let screen_open = self.screen != Screen::Playing;
if self.title.is_some() || !self.debug || (self.ui.hide_gui && !screen_open) {
return;
}
let framebuffer = size.map(|v| v as i32);
@@ -682,7 +687,7 @@ impl Game<'_> {
/// `GameModeCommand.setGameMode` with its feedback.
pub(super) fn change_game_mode(&mut self, mode: GameMode) {
if self.remote.is_some() {
use horizon_protocol::packets::play::{ChangeGameMode, Serverbound};
use horizon_protocol::packets::play::ChangeGameMode;
let mode = match mode {
GameMode::Survival => 0,
GameMode::Creative => 1,
@@ -698,10 +703,11 @@ impl Game<'_> {
}
}
/// `KeyboardHandler.copyRecreateCommand`: the targeted block or mob as a
/// `/setblock` or `/summon` command. In a single-player world the
/// server's data (no Shift) is the game's own.
/// `/setblock` or `/summon` command. The server's data (no Shift) comes
/// from the server the game is connected to, the integrated one included
/// (`DebugQueryHandler`); without one, the game's own data is the server's.
fn copy_recreate_command(&mut self, add_nbt: bool, from_server: bool) {
use horizon_client::java_text::format_fixed2 as f2;
let query = add_nbt && from_server && self.remote.is_some();
let side = if add_nbt && from_server {
"server"
} else {
@@ -710,45 +716,135 @@ impl Game<'_> {
if self.entity_targeted()
&& let Some((index, _)) = self.mob_hit()
{
let (id, pos, mob) = {
let (kind, position, id) = {
let m = &self.mobs[index];
(m.kind.id.clone(), m.position, m.id)
};
let mut command = format!("/summon {id} {} {} {}", f2(pos.x), f2(pos.y), f2(pos.z));
if add_nbt && let Some(mut tag) = self.entity_nbt(super::commands::Target::Mob(mob)) {
if let horizon_world::nbt::Tag::Compound(c) = &mut tag {
for key in ["id", "UUID", "Pos"] {
c.remove(&key.into());
}
if query {
// DebugQueryHandler.queryEntityTag: the answer copies it.
let reply = QueryReply::Entity { id, kind, position };
if let Some(transaction) = self.remote.as_mut().map(|r| r.debug_query.start(reply))
{
self.send(Serverbound::EntityTagQuery(EntityTagQuery {
transaction,
entity: id as i32,
}));
}
command.push(' ');
command.push_str(&horizon_client::snbt::pretty(&tag).plain());
return;
}
self.set_clipboard(&command);
// Entity.saveWithoutId.
let tag = if add_nbt {
self.entity_nbt(super::commands::Target::Mob(id))
.map(|mut tag| {
if let Tag::Compound(c) = &mut tag {
c.remove(&"id".into());
}
tag
})
} else {
None
};
self.copy_create_entity_command(&kind, position, tag);
self.debug_translated(&format!("debug.inspect.{side}.entity"));
return;
}
let Some(hit) = self.pick() else {
return;
};
let p = hit.pos;
let mut description = self.state_string(self.world.block(p[0], p[1], p[2]));
if add_nbt && let Some(e) = self.world.block_entities.get(&(p[0], p[1], p[2])) {
// BlockEntity.saveWithoutMetadata: no id or position.
let name = self.data_block_name(p);
let mut tag = horizon_client::save::encode_block_entity((p[0], p[1], p[2]), e, &name);
if let horizon_world::nbt::Tag::Compound(c) = &mut tag {
for key in ["id", "x", "y", "z", "keepPacked"] {
c.remove(&key.into());
}
let pos = hit.pos;
let state = self.state_string(self.world.block(pos[0], pos[1], pos[2]));
if query {
// DebugQueryHandler.queryBlockEntityTag: the answer copies it.
let reply = QueryReply::Block { pos, state };
if let Some(transaction) = self.remote.as_mut().map(|r| r.debug_query.start(reply)) {
self.send(Serverbound::BlockEntityTagQuery(BlockEntityTagQuery {
transaction,
pos,
}));
}
description.push_str(&horizon_client::snbt::to_snbt(&tag));
return;
}
let tag = if add_nbt {
self.block_entity_tag(pos)
} else {
None
};
self.copy_create_block_command(pos, &state, tag.as_ref());
self.debug_translated(&format!("debug.inspect.{side}.block"));
}
/// `BlockEntity.saveWithoutMetadata` of the block entity at `pos`, if
/// there is one: its data without the id or the position.
pub(super) fn block_entity_tag(&self, pos: [i32; 3]) -> Option<Tag> {
let e = self.world.block_entities.get(&(pos[0], pos[1], pos[2]))?;
let name = self.data_block_name(pos);
let mut tag = horizon_client::save::encode_block_entity((pos[0], pos[1], pos[2]), e, &name);
if let Tag::Compound(c) = &mut tag {
for key in ["id", "x", "y", "z", "keepPacked"] {
c.remove(&key.into());
}
}
Some(tag)
}
/// `ClientPacketListener.handleTagQueryPacket`: the server's answer to
/// the last debug query (`DebugQueryHandler.handleResponse`) completes
/// it; an answer to an older query is ignored.
pub(super) fn answer_tag_query(&mut self, transaction: i32, tag: Tag) {
let Some(reply) = self
.remote
.as_mut()
.and_then(|r| r.debug_query.answer(transaction))
else {
return;
};
// FriendlyByteBuf.readNbt: an End tag is no compound.
let tag = match tag {
Tag::End => None,
tag => Some(tag),
};
match reply {
QueryReply::Block { pos, state } => {
self.copy_create_block_command(pos, &state, tag.as_ref());
self.debug_translated("debug.inspect.server.block");
}
QueryReply::Entity { id, kind, position } => {
// Entity.position() when the answer comes: where it is now,
// else where it was last.
let position = self
.mobs
.iter()
.find(|m| m.id == id)
.map_or(position, |m| m.position);
self.copy_create_entity_command(&kind, position, tag);
self.debug_translated("debug.inspect.server.entity");
}
}
}
/// `KeyboardHandler.copyCreateBlockCommand`.
fn copy_create_block_command(&mut self, pos: [i32; 3], state: &str, tag: Option<&Tag>) {
let mut description = state.to_owned();
if let Some(tag) = tag {
description.push_str(&horizon_client::snbt::to_snbt(tag));
}
self.set_clipboard(&format!(
"/setblock {} {} {} {description}",
p[0], p[1], p[2]
pos[0], pos[1], pos[2]
));
self.debug_translated(&format!("debug.inspect.{side}.block"));
}
/// `KeyboardHandler.copyCreateEntityCommand`: the data without its UUID
/// or position.
fn copy_create_entity_command(&mut self, kind: &str, pos: Vec3, tag: Option<Tag>) {
use horizon_client::java_text::format_fixed2 as f2;
let mut command = format!("/summon {kind} {} {} {}", f2(pos.x), f2(pos.y), f2(pos.z));
if let Some(mut tag) = tag {
if let Tag::Compound(c) = &mut tag {
for key in ["UUID", "Pos"] {
c.remove(&key.into());
}
}
command.push(' ');
command.push_str(&horizon_client::snbt::pretty(&tag).plain());
}
self.set_clipboard(&command);
}
/// `KeyboardHandler.setClipboard`.
fn set_clipboard(&mut self, text: &str) {
@@ -766,8 +862,8 @@ impl Game<'_> {
p.position.y.floor() as i32,
p.position.z.floor() as i32,
];
let min_y = horizon_client::chunk::MIN_Y;
g.chunk_borders(block, min_y, min_y + horizon_client::chunk::HEIGHT - 1);
let min_y = self.world.bottom_y();
g.chunk_borders(block, min_y, min_y + self.world.layout().height() - 1);
}
if !self.show_hitboxes {
return g;
@@ -1010,3 +1106,78 @@ fn step_view(step: [i32; 3]) -> (f32, f32) {
(-x.atan2(z)).to_degrees() as f32,
)
}
/// `DebugQueryHandler`: the last debug NBT query (F3+I) sent to the server
/// and what its answer completes. Only the newest query is answered.
#[derive(Debug)]
pub(super) struct DebugQuery {
transaction: i32,
pending: Option<QueryReply>,
}
impl Default for DebugQuery {
fn default() -> Self {
Self {
transaction: -1,
pending: None,
}
}
}
/// What a debug query's answer is copied into: the block command
/// (`copyCreateBlockCommand`) or the entity's (`copyCreateEntityCommand`).
#[derive(Clone, Debug, PartialEq)]
pub(super) enum QueryReply {
Block {
pos: [i32; 3],
state: String,
},
Entity {
id: u32,
kind: String,
position: Vec3,
},
}
impl DebugQuery {
/// `DebugQueryHandler.startTransaction`: the next transaction id, which
/// replaces any query still waiting.
fn start(&mut self, reply: QueryReply) -> i32 {
self.pending = Some(reply);
self.transaction = self.transaction.wrapping_add(1);
self.transaction
}
/// `DebugQueryHandler.handleResponse`: what the answer to `transaction`
/// completes, once, when it answers the newest query.
fn answer(&mut self, transaction: i32) -> Option<QueryReply> {
if transaction == self.transaction {
self.pending.take()
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn block(x: i32) -> QueryReply {
QueryReply::Block {
pos: [x, 64, 0],
state: "minecraft:chest[facing=north,type=single,waterlogged=false]".into(),
}
}
#[test]
fn a_debug_query_is_answered_once_and_only_the_newest() {
let mut q = DebugQuery::default();
// The first transaction is 0 (the handler starts at -1).
assert_eq!(q.start(block(1)), 0);
assert_eq!(q.answer(0), Some(block(1)));
assert_eq!(q.answer(0), None);
// A second press before the first answer: the older answer is ignored
// and the newer one copies the newer target.
assert_eq!(q.start(block(2)), 1);
assert_eq!(q.start(block(3)), 2);
assert_eq!(q.answer(1), None);
assert_eq!(q.answer(2), Some(block(3)));
}
}
+62 -4
View File
@@ -34,6 +34,7 @@ use std::{
time::{Duration, Instant},
};
mod report;
mod status;
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
@@ -505,6 +506,11 @@ struct ServerState {
broadcast_console: bool,
/// `op-permission-level`: what `/op` grants (operatorUserPermissions).
op_level: i32,
/// `horizon-report`: the ticks being recorded.
report: Option<report::Recording>,
/// View and simulation distance, and the player limit, for the report.
view: (u32, u32),
max_players: usize,
}
/// The dedicated-only commands' replies vanilla sends with `broadcast`
@@ -733,6 +739,11 @@ impl Game<'_> {
resource_pack: props.resource_pack.clone(),
// DedicatedServer.serverLinks: `bug-report-link` as the only link
// (ServerLinks.KnownLinkType.BUG_REPORT is id 0).
levels: self.level_shapes,
horizon_clients_only: props
.raw
.get(horizon_client::lan::HORIZON_CLIENTS_ONLY)
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true")),
server_links: if props.bug_report_link.is_empty() {
Vec::new()
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
@@ -756,6 +767,7 @@ impl Game<'_> {
view_distance: props.view_distance.clamp(2, 32),
simulation_distance: props.simulation_distance.clamp(2, 32),
dimension: self.dim.id().to_owned(),
levels: self.level_shapes,
seed: self.level.seed,
max_players: props.max_players.max(0) as usize,
dedicated: true,
@@ -803,6 +815,12 @@ impl Game<'_> {
paused: false,
broadcast_console: props.broadcast_console_to_ops,
op_level: props.op_permission_level,
report: None,
view: (
props.view_distance.clamp(2, 32) as u32,
props.simulation_distance.clamp(2, 32) as u32,
),
max_players: props.max_players.max(0) as usize,
};
let pause_after = props.pause_when_empty_seconds * 20;
let idle_timeout = props.player_idle_timeout;
@@ -845,7 +863,17 @@ impl Game<'_> {
self.ticks = self.ticks.max(1);
}
// MinecraftServer.tickServer: while paused only the connections run.
self.tick_server_timed(paused)?;
match &mut state.report {
Some(recording) => self.tick_server_spans(paused, |wall, cpu, spans| {
recording.add(wall, cpu, spans)
})?,
None => self.tick_server_timed(paused)?,
}
if state.report.as_ref().is_some_and(report::Recording::done)
&& let Some(recording) = state.report.take()
{
self.finish_report(recording, state.view, state.max_players);
}
if idle_timeout > 0 {
self.kick_idle(idle_timeout);
}
@@ -1043,6 +1071,33 @@ impl Game<'_> {
self.console_reply(Text::translate("commands.stop.stopping", &[]));
state.stop = true;
}
// Horizon's own, on the console only: a performance report (report.rs).
"horizon-report" => {
let seconds = match rest.first().map(|s| s.parse::<u32>()) {
None => Ok(60),
Some(Ok(s)) if (1..=report::MAX_SECONDS).contains(&s) => Ok(s),
Some(_) => Err(()),
};
match (seconds, &state.report) {
(Err(()), _) => log(
"INFO",
&format!(
"horizon-report [SECONDS]: 1 to {} seconds, 60 by default",
report::MAX_SECONDS
),
),
(Ok(_), Some(_)) => {
log("INFO", "A performance report is already being recorded")
}
(Ok(seconds), None) => {
state.report = Some(report::Recording::start(seconds));
log(
"INFO",
&format!("Recording a performance report for {seconds} s (reports/)"),
);
}
}
}
"save-all" => {
self.console_reply(Text::translate("commands.save.saving", &[]));
match self.save_game() {
@@ -1501,10 +1556,13 @@ impl Game<'_> {
self.native_lifecycle.begin_close();
log("INFO", "Stopping server");
log("INFO", "Saving players");
if let Some(h) = self.lan.as_mut() {
for g in &h.hub.guests {
horizon_client::lan::playerdata::save(g, &self.world_root);
if let Some(host) = self.lan.take() {
for g in &host.hub.guests {
// The vehicle the guest rides, for the file that names it.
let vehicle = self.root_vehicle_uuid(g.vehicle);
horizon_client::lan::playerdata::save(g, &self.world_root, vehicle);
}
self.lan = Some(host);
}
if let Some(h) = self.lan.as_ref() {
for g in &h.hub.guests {
@@ -0,0 +1,333 @@
//! `horizon-report [SECONDS]` on the dedicated server's console (and `horizon-server report`): records every
//! tick's time and spans for a window (60 s unless told), then writes a performance report to share,
//! `reports/perf-<time>.json` and a short `.txt` beside it in the server's folder. It says where the ticks'
//! time went (the spans of `HORIZON_SPIKES`), shows the worst ticks with their spans, counts the entities by
//! type and the chunks of each level, and gives the memory, the CPU and the view settings. It holds no player
//! names, UUIDs or addresses, no seed and no world name.
use super::super::{Dim, Game};
use horizon_client::spans::{self, Span};
use serde_json::{Value, json};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
time::{Duration, Instant},
};
/// The longest window a report records.
pub(super) const MAX_SECONDS: u32 = 600;
/// The worst ticks a report lists with their spans.
const WORST: usize = 10;
struct Tick {
wall_ms: f32,
cpu_ms: Option<f32>,
spans: Vec<Span>,
}
/// A report being recorded.
pub(super) struct Recording {
seconds: u32,
started: Instant,
cpu_s: Option<f64>,
/// Whether spans were on before (`HORIZON_SPIKES`): they stay on after.
spans_were_on: bool,
ticks: Vec<Tick>,
}
impl Recording {
pub(super) fn start(seconds: u32) -> Self {
let spans_were_on = spans::enabled();
spans::enable(true);
Self {
seconds,
started: Instant::now(),
cpu_s: horizon_bench::cpu_seconds(std::process::id()),
spans_were_on,
ticks: Vec::with_capacity(seconds as usize * 20),
}
}
pub(super) fn add(&mut self, wall_ms: f32, cpu_ms: Option<f32>, spans: Vec<Span>) {
self.ticks.push(Tick {
wall_ms,
cpu_ms,
spans,
});
}
pub(super) fn done(&self) -> bool {
self.started.elapsed() >= Duration::from_secs(u64::from(self.seconds))
}
}
fn pct(sorted: &[f32], q: f64) -> f64 {
let i = ((sorted.len() as f64 * q) as usize).min(sorted.len().saturating_sub(1));
sorted.get(i).map_or(0.0, |&v| f64::from(v))
}
fn round(v: f64) -> f64 {
(v * 100.0).round() / 100.0
}
/// The tick times and spans of a recording, as the report's `ticks` and `spans`.
fn tick_summary(r: &Recording, window_s: f64) -> (Value, Value, Value) {
let mut walls: Vec<f32> = r.ticks.iter().map(|t| t.wall_ms).collect();
walls.sort_by(f32::total_cmp);
let n = r.ticks.len().max(1) as f64;
let mean = |f: &dyn Fn(&Tick) -> f64| r.ticks.iter().map(f).sum::<f64>() / n;
let ticks = json!({
"count": r.ticks.len(),
"tps": round(r.ticks.len() as f64 / window_s.max(1e-9)),
"mspt": {
"average": round(mean(&|t| f64::from(t.wall_ms))),
"p50": round(pct(&walls, 0.5)),
"p95": round(pct(&walls, 0.95)),
"p99": round(pct(&walls, 0.99)),
"max": round(walls.last().copied().map_or(0.0, f64::from)),
},
"cpu_ms_average": round(mean(&|t| f64::from(t.cpu_ms.unwrap_or(t.wall_ms)))),
"over_50_ms": walls.iter().filter(|&&w| w > 50.0).count(),
"over_100_ms": walls.iter().filter(|&&w| w > 100.0).count(),
});
let mut totals: BTreeMap<&str, (f64, f64, usize)> = BTreeMap::new();
for t in &r.ticks {
for s in &t.spans {
let e = totals.entry(s.name).or_default();
e.0 += f64::from(s.ms);
e.1 += f64::from(s.cpu_ms);
e.2 += 1;
}
}
let mut by_time: Vec<_> = totals.into_iter().collect();
by_time.sort_by(|a, b| b.1.0.total_cmp(&a.1.0));
let tick_ms = mean(&|t| f64::from(t.wall_ms)).max(1e-9);
let spans = Value::Array(
by_time
.iter()
.map(|(name, (ms, cpu, seen))| {
json!({
"name": name,
"ms_per_tick": round(ms / n),
"cpu_ms_per_tick": round(cpu / n),
"share_of_tick": round(ms / n / tick_ms),
"ticks_seen": seen,
})
})
.collect(),
);
let mut worst: Vec<&Tick> = r.ticks.iter().collect();
worst.sort_by(|a, b| b.wall_ms.total_cmp(&a.wall_ms));
let worst = Value::Array(
worst
.into_iter()
.take(WORST)
.map(|t| {
json!({
"ms": round(f64::from(t.wall_ms)),
"cpu_ms": t.cpu_ms.map(|c| round(f64::from(c))),
"spans": t.spans.iter().take(8).map(|s| json!([s.name, round(f64::from(s.ms))])).collect::<Vec<_>>(),
})
})
.collect(),
);
(ticks, spans, worst)
}
impl Game<'_> {
/// The level in place and the parked ones: their chunks and their entities by type.
fn report_levels(&self) -> Value {
let mut levels = Vec::new();
let here = |dim: Dim,
world: &horizon_client::world::World,
mobs: &[horizon_client::mobs::Mob]| {
let mut by_type: BTreeMap<&str, usize> = BTreeMap::new();
for m in mobs.iter().filter(|m| !m.removed) {
*by_type.entry(&m.kind.id).or_default() += 1;
}
let mut by_type: Vec<_> = by_type.into_iter().collect();
by_type.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
json!({
"dimension": dim.id(),
"chunks": world.chunks.len(),
"block_entities": world.block_entities.len(),
"mobs": mobs.iter().filter(|m| !m.removed).count(),
"mobs_by_type": by_type.into_iter().map(|(k, v)| json!([k, v])).collect::<Vec<_>>(),
})
};
let mut current = here(self.dim, &self.world, &self.mobs);
current["item_entities"] = json!(self.dropped.len());
current["experience_orbs"] = json!(self.orbs.len());
current["projectiles"] = json!(self.creatures.projectiles.len());
current["minecarts"] = json!(self.carts.len());
current["boats"] = json!(self.boats.len());
levels.push(current);
for dim in self.levels.parked_dims() {
if let (Some(world), Some(mobs)) =
(self.levels.parked_world(dim), self.levels.parked_mobs(dim))
{
levels.push(here(dim, world, mobs));
}
}
Value::Array(levels)
}
/// The report of a finished recording: written to `reports/` in the server's folder, its summary logged.
pub(super) fn finish_report(&mut self, r: Recording, view: (u32, u32), max_players: usize) {
if !r.spans_were_on {
spans::enable(false);
}
let window_s = r.started.elapsed().as_secs_f64();
let (ticks, spans, worst) = tick_summary(&r, window_s);
let cpu = horizon_bench::cpu_seconds(std::process::id())
.zip(r.cpu_s)
.map(|(end, start)| round((end - start) / window_s.max(1e-9)));
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
let report = json!({
"format": 1,
"horizon": horizon_client::crash::version(),
"minecraft": horizon_core::VANILLA_VERSION,
"window_s": round(window_s),
"ticks": ticks,
"spans": spans,
"worst_ticks": worst,
"players": players,
"levels": self.report_levels(),
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
"cpu_cores_used": cpu,
"config": {
"view_distance": view.0,
"simulation_distance": view.1,
"max_players": max_players,
},
});
let text = summary(&report);
// The machine's description runs commands: off the tick.
std::thread::spawn(move || {
let mut report = report;
let hw = horizon_bench::Hardware::detect();
report["machine"] = json!({
"os": hw.os, "arch": hw.arch, "cpu": hw.cpu, "cpu_threads": hw.cpu_threads, "ram_mb": hw.ram_mb,
});
match write_report(Path::new("reports"), &report, &text) {
Ok(path) => log::info!(
"Horizon: performance report written to {}\n{text}",
path.display()
),
Err(e) => log::error!("Horizon: could not write the performance report: {e}"),
}
});
}
}
/// A few lines for the console: the tick rate and times, the five costliest spans, the worst tick.
fn summary(r: &Value) -> String {
let t = &r["ticks"];
let m = &t["mspt"];
let mut out = format!(
"{} players, TPS {}, MSPT average {} ms, P50 {}, P95 {}, P99 {}, max {} ({} ticks over 50 ms, {} over 100 ms)\n",
r["players"],
t["tps"],
m["average"],
m["p50"],
m["p95"],
m["p99"],
m["max"],
t["over_50_ms"],
t["over_100_ms"],
);
let spans: Vec<String> = r["spans"]
.as_array()
.into_iter()
.flatten()
.take(5)
.map(|s| {
format!(
"{} {} ms",
s["name"].as_str().unwrap_or(""),
s["ms_per_tick"]
)
})
.collect();
out += &format!("per tick: {}\n", spans.join(", "));
if let Some(w) = r["worst_ticks"].get(0) {
out += &format!("worst tick {} ms: {}\n", w["ms"], w["spans"]);
}
let mobs: u64 = r["levels"]
.as_array()
.into_iter()
.flatten()
.filter_map(|l| l["mobs"].as_u64())
.sum();
let chunks: u64 = r["levels"]
.as_array()
.into_iter()
.flatten()
.filter_map(|l| l["chunks"].as_u64())
.sum();
out += &format!(
"{mobs} mobs, {chunks} chunks, {} MB resident, {} cores of CPU",
r["memory"]["rss_mb"], r["cpu_cores_used"]
);
out
}
fn write_report(dir: &Path, report: &Value, text: &str) -> std::io::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let stamp = chrono::Utc::now().format("%Y-%m-%d_%H-%M-%SZ");
let path = dir.join(format!("perf-{stamp}.json"));
std::fs::write(&path, serde_json::to_string_pretty(report)? + "\n")?;
std::fs::write(path.with_extension("txt"), format!("{text}\n"))?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn span(name: &'static str, ms: f32) -> Span {
Span {
name,
ms,
cpu_ms: ms,
}
}
#[test]
fn ticks_and_spans_are_summed_per_tick() {
let mut r = Recording {
seconds: 1,
started: Instant::now(),
cpu_s: None,
spans_were_on: true,
ticks: Vec::new(),
};
for i in 0..20 {
let wall = if i == 7 { 120.0 } else { 10.0 };
r.add(
wall,
Some(wall - 1.0),
vec![span("tick_mobs", wall / 2.0), span("tick_lan", 2.0)],
);
}
let (ticks, spans, worst) = tick_summary(&r, 1.0);
assert_eq!(ticks["count"], 20);
assert_eq!(ticks["tps"], 20.0);
assert_eq!(ticks["over_100_ms"], 1);
assert_eq!(ticks["mspt"]["max"], 120.0);
assert_eq!(spans[0]["name"], "tick_mobs");
assert_eq!(spans[0]["ms_per_tick"], 7.75);
assert_eq!(spans[1]["ms_per_tick"], 2.0);
assert_eq!(worst[0]["ms"], 120.0);
assert_eq!(worst[0]["spans"][0], json!(["tick_mobs", 60.0]));
let text = summary(
&json!({"ticks": ticks, "spans": spans, "worst_ticks": worst, "players": 3,
"levels": [{"mobs": 5, "chunks": 9}], "memory": {"rss_mb": 300}, "cpu_cores_used": 1.5}),
);
assert!(text.starts_with("3 players, TPS 20.0"), "{text}");
assert!(
text.contains("per tick: tick_mobs 7.75 ms, tick_lan 2.0 ms"),
"{text}"
);
assert!(
text.ends_with("5 mobs, 9 chunks, 300 MB resident, 1.5 cores of CPU"),
"{text}"
);
}
}
+73
View File
@@ -25,6 +25,26 @@ pub(super) fn redirectable(p: &Projectile) -> bool {
)
}
/// `Projectile.onRedirectProjectile`: another projectile hit `p`, which turns
/// with `deflect(AIM_DEFLECT, hitter's owner, hitter's owner, true, 1.0)`.
/// It flies along the owner's look (`Entity.getLookAngle`; unchanged when the
/// owner is gone), is that owner's now, and its acceleration power is back
/// to 0.1 (`AbstractHurtingProjectile.onDeflection` by an attack).
pub(super) fn aim_deflect(
p: &mut Projectile,
look: Option<Vec3>,
owner: Option<Who>,
guest: Option<u32>,
) {
if let Some(look) = look {
p.velocity = look;
p.needs_sync = true;
}
p.owner = owner;
p.guest = guest;
p.acceleration = 0.1;
}
/// Its box for the crosshair: the entity's (a fireball 1 x 1, a wind charge
/// 0.3125, from its feet up) grown by the pick radius.
fn pick_box(p: &Projectile) -> Aabb {
@@ -106,6 +126,27 @@ impl Game<'_> {
d2 < range * range
}
/// `Entity.getLookAngle` of a projectile's owner: the host, the guest who
/// threw it, or a mob; none when the owner is not here.
pub(super) fn owner_look(&self, owner: Option<Who>, guest: Option<u32>) -> Option<Vec3> {
let (pitch, yaw) = match (owner?, guest) {
(Who::Player, Some(conn)) => {
let lan = self.lan.as_ref()?;
let g = lan.hub.guests.iter().find(|g| g.conn == conn)?;
(g.pitch, g.yaw)
}
(Who::Player, None) => {
self.player.as_ref()?;
(self.pitch, self.yaw)
}
(Who::Mob(id), _) => {
let m = self.mobs.iter().find(|m| m.id == id)?;
(m.head_pitch, m.yaw)
}
};
Some(horizon_world::view_direction::view_vector(pitch, yaw))
}
/// `Projectile.deflect(AIM_DEFLECT, player, player, true)`.
pub(super) fn deflect(&mut self, i: usize) {
let dir = view_vector(self.yaw, self.pitch);
@@ -156,4 +197,36 @@ mod tests {
assert_eq!(b.min_position().x, -1.5);
assert_eq!(b.max_position().y, 66.0);
}
fn wind_charge() -> Projectile {
let mut p = Projectile::new(
ProjectileKind::WindCharge,
Vec3::new(0.0, 70.0, 0.0),
Some(Who::Mob(7)),
);
p.velocity = Vec3::new(0.0, 1.5, 0.0);
p
}
#[test]
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
let mut p = wind_charge();
let look = Vec3::new(0.0, -1.0, 0.0);
aim_deflect(&mut p, Some(look), Some(Who::Player), Some(3));
assert_eq!(p.velocity, look);
assert_eq!(p.owner, Some(Who::Player));
assert_eq!(p.guest, Some(3));
assert_eq!(p.acceleration, 0.1);
assert!(p.needs_sync);
}
#[test]
fn with_no_owner_here_it_keeps_its_movement_but_changes_hands() {
let mut p = wind_charge();
aim_deflect(&mut p, None, None, None);
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
assert_eq!(p.owner, None);
assert_eq!(p.acceleration, 0.1);
assert!(!p.needs_sync);
}
}
@@ -422,6 +422,7 @@ impl Game<'_> {
position,
hover || held,
true,
held,
);
}
Part::Item { body, rect } => {
@@ -186,6 +186,8 @@ impl Game<'_> {
Some("end") => self.dim = super::Dim::End,
_ => {}
}
// The level's build range follows its dimension, as `enter_dimension` sets it.
self.world.shape = self.dim.shape();
// The oracle level's clock stands at day time 6000.
self.world.day_time = 6000;
if let Some(rules) = case["rules"].as_object() {
+17 -5
View File
@@ -908,6 +908,12 @@ impl Game<'_> {
let Some(id) = self.spawn_mob_at(&kind, front, facing != Direction::Up) else {
return Ok((Some(stack), CLICK));
};
// Entity.applyImplicitComponents: a name from an anvil names the mob.
if let Some(name) = stack.custom_name()
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.custom_name = Some(name.to_owned());
}
self.mob_ambient(id)?;
// SpawnEggItemBehavior: the entity was placed by the dispenser.
self.post_block_game_event("minecraft:entity_place", [pos.0, pos.1, pos.2], None, None);
@@ -1051,8 +1057,12 @@ impl Game<'_> {
// EntityType.create rolls a yaw from the level random before the stack config turns it.
let _ = self.world.random.next_float();
self.next_entity += 1;
let stand =
let mut stand =
horizon_client::entities::ArmorStand::new(self.next_entity, foot, y_rot(facing));
// Entity.applyImplicitComponents: a name from an anvil names the stand.
if let Some(name) = stack.custom_name() {
stand.custom_name = Some(horizon_client::text::Text::literal(name));
}
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
self.armor_stands.push(stand);
(shrink(stack), CLICK)
@@ -1176,8 +1186,9 @@ impl Game<'_> {
let (content, sound, mob) =
horizon_client::items::bucket_contents(&item).expect("filled bucket dispatcher");
let target_pos = Self::pos_of(target);
let emptied = self
.with_level(|b, level, ticks| b.bucket_empty(level, ticks, target_pos, content, sound));
let emptied = self.with_level(|b, level, ticks| {
b.bucket_empty(level, ticks, target_pos, content, sound, false, false)
});
self.process_level_events()?;
if !emptied {
return Ok((self.dispense_default(pos, facing, stack), CLICK));
@@ -1235,7 +1246,8 @@ impl Game<'_> {
stack: ItemStack,
) -> Result<Dispensed> {
let target = Self::pos_of(offset(pos, facing));
let picked = self.with_level(|b, level, ticks| b.bucket_pickup(level, ticks, target));
let picked =
self.with_level(|b, level, ticks| b.bucket_pickup(level, ticks, target, false));
self.process_level_events()?;
match picked {
Some(filled) => {
@@ -1412,7 +1424,7 @@ impl Game<'_> {
) -> Result<Dispensed> {
let target = offset(pos, facing);
let free = self.res.facts().is_air(self.block_at(target))
&& target.1 >= horizon_client::chunk::MIN_Y + 2
&& target.1 >= self.world.bottom_y() + 2
&& self.ui.difficulty != 0
&& self.can_build_wither(target);
if free {
+1
View File
@@ -2887,6 +2887,7 @@ impl Game<'_> {
precise: false,
needs_sync: false,
leash: None,
spectator: false,
})
}
}
@@ -25,6 +25,12 @@ enum MendSlot {
Armor(usize),
}
/// `Player.getDestroySpeed` multiplies by `BLOCK_BREAK_SPEED`; a player
/// without the attribute keeps the vanilla default of 1.0.
fn block_break_speed_factor(value: Option<f64>) -> f32 {
value.map_or(1.0, |value| value as f32)
}
impl Game<'_> {
/// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste
/// and mining fatigue.
@@ -39,6 +45,15 @@ impl Game<'_> {
}
speed * self.potions.effects.mining_speed_factor()
}
/// `Player.getDestroySpeed`'s final `Attributes.BLOCK_BREAK_SPEED` factor:
/// 1.0 by default, so a missing player or attribute changes nothing.
pub(super) fn block_break_speed(&mut self) -> f32 {
block_break_speed_factor(
self.player
.as_mut()
.and_then(|p| p.attribute("minecraft:block_break_speed").ok()),
)
}
/// `Attributes.SUBMERGED_MINING_SPEED` (0.2, raised by aqua affinity).
pub(super) fn submerged_mining_speed(&self) -> f32 {
let mut total = 0.0;
@@ -1012,3 +1027,19 @@ fn location_requirements(req: &Value, on_ground: bool, riding: bool) -> bool {
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::block_break_speed_factor;
/// #4094: `BLOCK_BREAK_SPEED` is a multiplier on the destroy speed.
#[test]
fn block_break_speed_is_a_destroy_speed_multiplier() {
assert_eq!(block_break_speed_factor(None), 1.0);
assert_eq!(block_break_speed_factor(Some(1.0)), 1.0);
assert_eq!(block_break_speed_factor(Some(0.5)), 0.5);
assert_eq!(block_break_speed_factor(Some(2.0)), 2.0);
assert_eq!(8.0 * block_break_speed_factor(Some(0.5)), 4.0);
assert_eq!(8.0 * block_break_speed_factor(Some(2.0)), 16.0);
}
}
+157 -50
View File
@@ -10,8 +10,10 @@ use horizon_client::{
environment::Environment,
items::ItemStack,
mesher::Vertex,
mobs::Who,
motion::PlayerMotion,
particles::{ParticleOptions, ParticleType},
projectiles::{Projectile, ProjectileKind},
vanillagen::VanillaTerrain,
world_spawn::PlayerSpawnFinder,
};
@@ -29,6 +31,9 @@ fn doubles(values: [f64; 3]) -> Tag {
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
}
/// `TicketType.PORTAL`'s timeout, in ticks.
const PORTAL_TICKET: u32 = 300;
/// ServerLevel.END_SPAWN_POINT.
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
/// EnderDragonFight gateway ring radius and height.
@@ -47,18 +52,27 @@ pub(super) enum GatewayJob {
Known { exit: [i32; 3], exact: bool },
}
/// An entity travelling through an End gateway: its saved compound and the
/// job that finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
/// An entity travelling through an End gateway: the entity and the job that
/// finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
#[derive(Clone, Debug)]
struct GatewayArrival {
/// The entity's compound, as it waits for the exit's chunks.
tag: Tag,
/// The entity, as it waits for the exit's chunks.
traveller: Arriving,
/// The gateway's exit, or the search for one.
job: GatewayJob,
/// The spot to load before the job is resolved.
target: Vec3,
}
/// What waits at a gateway's exit.
#[derive(Clone, Debug)]
enum Arriving {
/// An item or a mob, as its saved compound.
Saved(Tag),
/// A thrown ender pearl (projectiles are not saved).
Pearl(Box<Projectile>),
}
impl GatewayJob {
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
fn for_gateway(
@@ -120,6 +134,10 @@ pub(super) struct EndState {
arrivals: Vec<GatewayArrival>,
/// The chunks kept loaded for those arrivals.
areas: Vec<(i32, i32)>,
/// `Entity.placePortalTicket` of the pearls that came out of a gateway:
/// the chunk each landed in, kept loaded for the ticks left of its
/// `TicketType.PORTAL`, so that it can come down and take its owner there.
tickets: Vec<((i32, i32), u32)>,
/// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure).
locator: std::sync::Mutex<Option<Arc<VanillaTerrain>>>,
/// Thrown eyes of ender in flight.
@@ -1462,13 +1480,17 @@ impl Game<'_> {
// ---- End gateway travel for the entities ----
/// `EndGatewayBlock.entityInside` for the items and mobs in the End: the
/// gateway blocks their box swept through. A gateway that is cooling down
/// (`TheEndGatewayBlockEntity.triggerCooldown`) takes nobody in.
/// `EndGatewayBlock.entityInside` for the items, mobs and thrown ender
/// pearls in the End: the gateway blocks their box swept through. A
/// gateway that is cooling down (`TheEndGatewayBlockEntity.triggerCooldown`)
/// takes nobody in.
pub(super) fn find_end_gateway_portals(&mut self) {
if self.remote.is_some() || self.dim != Dim::End {
return;
}
let is_gateway = |game: &Self, p: BlockPos| {
&*game.res.facts().get(game.world.block(p.x, p.y, p.z)).name == "minecraft:end_gateway"
};
let items: Vec<(u32, Vec3, f32, f32)> = self
.dropped
.iter()
@@ -1484,13 +1506,28 @@ impl Game<'_> {
for (id, to, width, height) in items.into_iter().chain(mobs) {
let from = self.portals.before.get(&id).copied().unwrap_or(to);
let found = horizon_client::end_gateway::swept_gateway(from, to, width, height, |p| {
&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name
== "minecraft:end_gateway"
is_gateway(self, p)
});
if let Some(pos) = found {
inside.push((id, pos));
}
}
// A pearl's move this tick, from where it started it.
let pearls: Vec<(u32, Option<Who>, Option<u32>, BlockPos)> = self
.creatures
.projectiles
.iter()
.filter_map(|p| {
let pos = horizon_client::end_gateway::pearl_gateway(p, |b| is_gateway(self, b))?;
Some((p.id, p.owner, p.guest, pos))
})
.collect();
for (id, owner, guest, pos) in pearls {
if owner == Some(Who::Player) {
self.pearl_owner_in_gateway(guest, pos);
}
inside.push((id, pos));
}
for (id, pos) in inside {
// One entity at a time: the gateway cools down for 40 ticks.
if !self.gateway_enter([pos.x, pos.y, pos.z]) {
@@ -1504,6 +1541,22 @@ impl Game<'_> {
}
}
/// `ThrownEnderpearl.onInsideBlock` in an End gateway: its owner's
/// `ServerPlayer.onInsideBlock`, the enter_block trigger, as if they had
/// walked in. A guest's pearl triggers theirs; one whose guest has left
/// has no owner.
fn pearl_owner_in_gateway(&mut self, guest: Option<u32>, pos: BlockPos) {
let state = self.world.block(pos.x, pos.y, pos.z);
let Some(conn) = guest else {
self.trigger_enter_block(state);
return;
};
if let Some(mut host) = self.lan.take() {
self.as_guest(&mut host, conn, |g| g.trigger_enter_block(state));
self.lan = Some(host);
}
}
/// `Entity.handlePortal` for the entities in an End gateway: they go
/// through to the gateway's exit, where they wait for its chunks.
pub(super) fn handle_end_gateway_portals(&mut self) {
@@ -1515,8 +1568,20 @@ impl Game<'_> {
.iter()
.map(|e| (e.id, true))
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
.chain(
self.creatures
.projectiles
.iter()
.filter(|p| matches!(p.kind, ProjectileKind::EnderPearl))
.map(|p| (p.id, true)),
)
.collect();
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
let mut present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
// A pearl waiting for its exit keeps its portal cooldown.
present.extend(self.end.arrivals.iter().filter_map(|a| match &a.traveller {
Arriving::Pearl(p) => Some(p.id),
Arriving::Saved(_) => None,
}));
self.end.gateways.retain(|id, _| present.contains(id));
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
for (id, allowed) in ids {
@@ -1573,11 +1638,28 @@ impl Game<'_> {
self.forget_entity(g);
}
tag
} else if let Some(i) = self.creatures.projectiles.iter().position(|p| p.id == id) {
let pearl = Box::new(self.creatures.projectiles.remove(i));
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
let arrival = GatewayArrival {
traveller: Arriving::Pearl(pearl),
job,
target,
};
// It is at the exit before it moves this tick, once that has loaded.
if let Some(waiting) = self.arrive_through_gateway(arrival) {
self.end.arrivals.push(waiting);
}
return;
} else {
return;
};
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
self.end.arrivals.push(GatewayArrival { tag, job, target });
self.end.arrivals.push(GatewayArrival {
traveller: Arriving::Saved(tag),
job,
target,
});
}
/// The entities that reached a gateway's exit: they land where the
@@ -1586,47 +1668,60 @@ impl Game<'_> {
if self.remote.is_some() || self.dim != Dim::End {
return Ok(());
}
let mut i = 0;
while i < self.end.arrivals.len() {
let (cx, cz) = (
(self.end.arrivals[i].target.x.floor() as i32) >> 4,
(self.end.arrivals[i].target.z.floor() as i32) >> 4,
);
if !self.world.chunks.contains_key(&(cx, cz)) {
i += 1;
continue;
}
let (job, target) = (self.end.arrivals[i].job, self.end.arrivals[i].target);
let at = match self.resolve_gateway(job, target) {
Ok(at) => at,
Err(e) => {
// The traveller waits for the exit: the failure is retried.
log::warn!("End gateway: {e}");
i += 1;
continue;
}
};
let mut tag = self.end.arrivals.remove(i).tag;
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
// setPortalCooldown before the teleport.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
}
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
match self
.world
.loaded_entities
.iter_mut()
.find(|(k, _)| *k == key)
{
Some((_, tags)) => tags.push(tag),
None => self.world.loaded_entities.push((key, vec![tag])),
for arrival in std::mem::take(&mut self.end.arrivals) {
if let Some(waiting) = self.arrive_through_gateway(arrival) {
self.end.arrivals.push(waiting);
}
}
Ok(())
}
/// `Entity.teleport` of a gateway traveller once its exit's chunk has
/// loaded: an item or a mob joins the chunk it lands in, a pearl flies
/// on from there. The traveller comes back while it still waits.
fn arrive_through_gateway(&mut self, arrival: GatewayArrival) -> Option<GatewayArrival> {
let chunk = |at: Vec3| ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
if !self.world.chunks.contains_key(&chunk(arrival.target)) {
return Some(arrival);
}
let at = match self.resolve_gateway(arrival.job, arrival.target) {
Ok(at) => at,
Err(e) => {
// The traveller waits for the exit: the failure is retried.
log::warn!("End gateway: {e}");
return Some(arrival);
}
};
let key = chunk(at);
match arrival.traveller {
Arriving::Saved(mut tag) => {
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
// setPortalCooldown before the teleport.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
}
match self
.world
.loaded_entities
.iter_mut()
.find(|(k, _)| *k == key)
{
Some((_, tags)) => tags.push(tag),
None => self.world.loaded_entities.push((key, vec![tag])),
}
}
Arriving::Pearl(mut pearl) => {
horizon_client::end_gateway::place_pearl(&mut pearl, at);
// ThrownEnderpearl.teleport: placePortalTicket where it came out.
self.end.tickets.retain(|(c, _)| *c != key);
self.end.tickets.push((key, PORTAL_TICKET));
self.add_projectile(*pearl);
}
}
None
}
/// The chunks the entities waiting to arrive need, kept loaded like the
/// areas a server keeps around its players.
pub(super) fn keep_gateway_destinations(&mut self) {
@@ -1634,12 +1729,24 @@ impl Game<'_> {
self.streaming
.extra
.retain(|&(c, r)| !(r == 1 && areas.contains(&c)));
// The pearls' portal tickets hold End chunks, and run out there
// (TicketType.PORTAL's timeout).
let in_end = self.dim == Dim::End;
if in_end {
self.end.tickets.retain_mut(|(_, ticks)| {
*ticks = ticks.saturating_sub(1);
*ticks > 0
});
}
let mut areas = Vec::new();
for a in &self.end.arrivals {
let c = (
let arrivals = self.end.arrivals.iter().map(|a| {
(
(a.target.x.floor() as i32) >> 4,
(a.target.z.floor() as i32) >> 4,
);
)
});
let tickets = self.end.tickets.iter().filter(|_| in_end);
for c in arrivals.chain(tickets.map(|&(c, _)| c)) {
if !areas.contains(&c) {
areas.push(c);
self.streaming.extra.push((c, 1));
@@ -1,6 +1,5 @@
//! Entity occlusion affects mesh submission, never entity ticking or interaction.
use super::{Game, Vertex};
use horizon_client::chunk::MIN_Y;
use horizon_client::{
blocks::{RenderShape, StateFacts},
entity_culling::Culler,
@@ -41,7 +40,7 @@ impl Game<'_> {
if !opaque(self.res.facts().get(state)) {
return false;
}
let Some(y) = pos[1].checked_sub(MIN_Y) else {
let Some(y) = pos[1].checked_sub(self.world.bottom_y()) else {
return false;
};
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
@@ -219,7 +219,7 @@ fn armor_equipment_loads_without_an_entity_model_document() {
assert!(assets.layers.is_empty());
assert_eq!(
assets.equipment["minecraft:leather"]["humanoid"],
("entity/equipment/humanoid/leather".into(), Some(0x123456))
vec![("entity/equipment/humanoid/leather".into(), Some(0x123456))]
);
assert!(!assets.equipment.contains_key("minecraft:invalid"));
}
+146 -19
View File
@@ -89,6 +89,29 @@ pub(super) enum BlockInteraction {
DestroyWithDecay,
}
/// `ServerExplosion.hurtEntities`: an entity is hurt when its position is
/// within the blast's diameter of the centre.
pub(super) fn blast_reaches(center: [f64; 3], diameter: f64, at: [f64; 3]) -> bool {
let d =
((at[0] - center[0]).powi(2) + (at[1] - center[1]).powi(2) + (at[2] - center[2]).powi(2))
.sqrt();
d / diameter <= 1.0
}
/// Whether a blast leaves item frames, paintings, cushions and armour stands
/// alone. Without `mobGriefing`, a blast that keeps blocks is ignored
/// (`ServerExplosion.shouldAffectBlocklikeEntities` through their
/// `ignoreExplosion`; no wind charge goes through `server_explode`), and one a
/// mob causes does no harm (`BlockAttachedEntity.hurtServer` and
/// `ArmorStand.hurtServer`: `source.getEntity() instanceof Mob`).
pub(super) fn blast_spares_decorations(
mob_griefing: bool,
interaction: BlockInteraction,
by_mob: bool,
) -> bool {
!mob_griefing && (interaction == BlockInteraction::Keep || by_mob)
}
thread_local! {
/// Client-side randomness (explosion sound pitch), separate from the level random.
static CLIENT_RANDOM: std::cell::RefCell<LegacyRandom> = std::cell::RefCell::new(LegacyRandom::new(0x5eed));
@@ -224,20 +247,24 @@ impl Game<'_> {
}
set.entries().map(|(k, _)| k.0).collect()
}
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts);
/// the player takes `damage_type`.
/// `ServerExplosion.hurtEntities` for the player: damage scaled by
/// distance and exposure, through a raised shield (`hurtServer` blocks
/// explosions like any attack from where the blast is), then the push
/// (less with explosion knockback resistance; none for spectators or a
/// creative player flying).
///
/// Returns the push their client adds from the explosion packet
/// (`hitPlayers`), unless a hurt marked their velocity: that goes to them
/// whole, this push in it, once their server copy has travelled
/// (Entity.markHurt, then ServerEntity.sendChanges), and replaces what the
/// packet would add.
pub(super) fn explosion_hurt_player(
&mut self,
center: [f64; 3],
radius: f32,
damage_type: &str,
exploding: Option<u32>,
) {
) -> Option<[f64; 3]> {
let diameter = f64::from(radius * 2.0);
// Blast protection: `EXPLOSION_KNOCKBACK_RESISTANCE` (0.15 per level and
// piece, so two Blast Protection IV pieces cancel it).
@@ -246,9 +273,7 @@ impl Game<'_> {
.as_mut()
.and_then(|p| p.attribute("minecraft:explosion_knockback_resistance").ok())
.unwrap_or(0.0);
let Some(p) = self.player.as_ref() else {
return;
};
let p = self.player.as_ref()?;
let feet = [p.position.x, p.position.y, p.position.z];
let dist = ((feet[0] - center[0]).powi(2)
+ (feet[1] - center[1]).powi(2)
@@ -256,7 +281,7 @@ impl Game<'_> {
.sqrt()
/ diameter;
if dist > 1.0 || p.health.dead_or_dying() {
return;
return None;
}
// ServerPlayer.onExplosionHit: the blast that hit the player is theirs.
self.adv.marks.blast = Some((feet, false));
@@ -291,6 +316,7 @@ impl Game<'_> {
let (creative, spectator) = (self.creative, self.spectator());
let flying = self.player.as_ref().is_some_and(|p| p.flying);
let mut outcome = horizon_client::health::BlockOutcome::default();
let mut pushed = None;
if let Some(p) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(damage_type)
{
@@ -317,27 +343,33 @@ impl Game<'_> {
}
}
if !spectator && !(creative && flying) {
let push = [dir[0] * power, dir[1] * power, dir[2] * power];
p.velocity = Vec3::new(
p.velocity.x + dir[0] * power,
p.velocity.y + dir[1] * power,
p.velocity.z + dir[2] * power,
p.velocity.x + push[0],
p.velocity.y + push[1],
p.velocity.z + push[2],
);
pushed = (!p.health.sync_velocity).then_some(push);
}
}
if raised {
self.after_block(outcome, amount, None, false);
}
pushed
}
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts);
/// the player takes `damage_type`. Returns the players whose clients add a
/// push from the explosion packet, and how much (`explosion_hurt_player`).
fn explosion_hurt_entities(
&mut self,
center: [f64; 3],
radius: f32,
damage_type: &str,
interaction: BlockInteraction,
) {
) -> Vec<(u32, [f64; 3])> {
if radius < 1.0e-5 {
return;
return Vec::new();
}
self.explosion_hits_special(center, radius);
let diameter = f64::from(radius * 2.0);
@@ -371,8 +403,10 @@ impl Game<'_> {
})
};
// The player, then every other player in the level (LAN guests),
// each swapped in as the player.
self.explosion_hurt_player(center, radius, damage_type, exploding);
// each swapped in as the player. In a guest's turn (their hit on an
// end crystal) the player is that guest.
let own = self.explosion_hurt_player(center, radius, damage_type, exploding);
let mut pushed: Vec<_> = self.lan_acting.zip(own).into_iter().collect();
if let Some(mut host) = self.lan.take() {
let guests: Vec<u32> = host
.hub
@@ -382,9 +416,12 @@ impl Game<'_> {
.map(|g| g.conn)
.collect();
for conn in guests {
self.guest_hit_by(&mut host, conn, |g| {
g.explosion_hurt_player(center, radius, damage_type, exploding);
let push = self.as_guest(&mut host, conn, |g| {
g.explosion_hurt_player(center, radius, damage_type, exploding)
});
if let Some(Some(push)) = push {
pushed.push((conn, push));
}
}
self.lan = Some(host);
}
@@ -446,7 +483,6 @@ impl Game<'_> {
);
}
self.mobs = mobs;
self.explosion_breaks_frames(center, diameter, interaction);
// Dropped items: 5 health, destroyed by the explosion damage (nether stars resist).
let mut items = std::mem::take(&mut self.dropped);
items.retain_mut(|item| {
@@ -525,6 +561,74 @@ impl Game<'_> {
c.velocity.z + dir[2] * power,
);
}
// Frames, paintings, cushions and armour stands last: vanilla lists
// the entities in reach before hurting any, so the items they drop
// here are neither destroyed nor pushed by the same blast.
self.explosion_breaks_frames(center, diameter, interaction);
self.explosion_breaks_decorations(center, diameter, interaction);
pushed
}
/// `blast_spares_decorations` for the blast going off now: the mob that
/// owns its projectile or explodes itself is the source's entity.
pub(super) fn blast_spares_decorations(&self, interaction: BlockInteraction) -> bool {
let by_mob = self
.combat
.explosion_owner
.or(self.combat.explosion_source)
.is_some();
blast_spares_decorations(self.ui.rules.bool("mob_griefing"), interaction, by_mob)
}
/// `ServerExplosion.hurtEntities` for paintings, cushions and armour
/// stands (item frames: `explosion_breaks_frames`). A painting or cushion
/// in reach is killed and dropped (`BlockAttachedEntity.hurtServer`,
/// `Painting.dropItem`, `Cushion.dropItem`). An armour stand that is
/// neither invisible nor a marker breaks (`ArmorStand.hurtServer` with
/// `IS_EXPLOSION`: `brokenByAnything`, then `kill`): its equipment drops,
/// the stand itself does not.
/// Not ported: `BlockAttachedEntity.ignoreExplosion` also spares paintings
/// and cushions when the exploding entity is in water.
fn explosion_breaks_decorations(
&mut self,
center: [f64; 3],
diameter: f64,
interaction: BlockInteraction,
) {
if self.blast_spares_decorations(interaction) {
return;
}
let mut i = 0;
while i < self.paintings.len() {
// HangingEntity.recalculateBoundingBox: a painting stands at the
// centre of its box.
let (lo, hi) = self.paintings[i].bounds();
let at = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
if blast_reaches(center, diameter, at) {
self.break_painting(i, false);
} else {
i += 1;
}
}
let mut i = 0;
while i < self.cushions.len() {
let p = self.cushions[i].position;
if blast_reaches(center, diameter, [p.x, p.y, p.z]) {
self.break_cushion(i, false);
} else {
i += 1;
}
}
let mut i = 0;
while i < self.armor_stands.len() {
let a = &self.armor_stands[i];
let p = a.position;
if !super::armor_stands::spared_by_blast(a)
&& blast_reaches(center, diameter, [p.x, p.y, p.z])
{
self.break_armor_stand_by_anything(i);
} else {
i += 1;
}
}
}
/// `ServerLevel.getDestroyType`: a drop decay rule picks between decaying
/// and plain destruction.
@@ -586,7 +690,7 @@ impl Game<'_> {
let ctx = horizon_client::sculk::Context::default();
self.post_game_event("minecraft:explode", center, ctx);
let mut targets = self.exploded_positions(center, radius, resists);
self.explosion_hurt_entities(center, radius, damage_type, interaction);
let pushed = self.explosion_hurt_entities(center, radius, damage_type, interaction);
if interaction != BlockInteraction::Keep {
// Util.shuffle(list, level.random)
for i in (2..=targets.len()).rev() {
@@ -635,7 +739,7 @@ impl Game<'_> {
.explosion(center, small, block_count, radius, &|b| {
facts.is_air(world.block(b[0], b[1], b[2]))
});
self.lan_explode(center, small, radius, block_count);
self.lan_explode(center, small, radius, block_count, pushed);
Ok(())
}
/// `BlockBehaviour.onExplosionHit` (TNT: `wasExploded` primes a short fuse).
@@ -758,6 +862,29 @@ fn add_or_append_stack(
mod tests {
use super::*;
#[test]
fn blasts_reach_entities_within_their_diameter() {
// A TNT blast: radius 4, so the entity pass reaches 8 blocks out.
let center = [0.5, 64.0, 0.5];
assert!(blast_reaches(center, 8.0, [8.5, 64.0, 0.5]));
assert!(blast_reaches(center, 8.0, [-3.5, 68.0, 4.5]));
assert!(!blast_reaches(center, 8.0, [8.6, 64.0, 0.5]));
}
#[test]
fn mob_griefing_off_spares_decorations_from_kept_and_mob_blasts() {
use BlockInteraction::{Destroy, DestroyWithDecay, Keep};
// mobGriefing on: every blast breaks them, a creeper's included.
assert!(!blast_spares_decorations(true, DestroyWithDecay, true));
assert!(!blast_spares_decorations(true, Keep, true));
// Off: TNT still breaks them (ExplosionInteraction.TNT destroys blocks).
assert!(!blast_spares_decorations(false, Destroy, false));
assert!(!blast_spares_decorations(false, DestroyWithDecay, false));
// Off: a blast that keeps blocks, or one a mob causes, does not.
assert!(blast_spares_decorations(false, Keep, false));
assert!(blast_spares_decorations(false, Destroy, true));
}
#[test]
fn block_pos_hash_matches_vec3i() {
// Vec3i.hashCode: (y + z * 31) * 31 + x
+27 -25
View File
@@ -21,6 +21,12 @@ use horizon_core::{
pub(super) const FIREWORK_EXPLODE_EVENT: i8 = 17;
/// What `ProjectileUtil.getHitResultOnMoveVector` found along a rocket's move.
enum RocketHit {
Entity(Who),
Block,
}
pub(super) struct Rocket {
/// The entity id a server tracks it by (0 until it is tracked).
pub id: u32,
@@ -43,8 +49,7 @@ pub(super) struct Rocket {
/// `DATA_ATTACHED_TO_TARGET`: the server entity the rocket follows;
/// boosting happens when it is the local player.
pub(super) attached_to: Option<u32>,
/// `DATA_SHOT_AT_ANGLE` (dispensers): constant velocity, exploding on
/// the first block it hits.
/// `DATA_SHOT_AT_ANGLE` (dispensers, crossbows): constant velocity.
pub(super) angled: bool,
/// `DATA_ID_FIREWORKS_ITEM`: the stack it was launched from.
pub(super) item: ItemStack,
@@ -118,9 +123,8 @@ impl Rocket {
}
}
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
/// up (an angled one keeps its motion) and rise, then move. True when a
/// block stopped it.
fn free_flight(&mut self, scene: &mut SceneCollisions) -> bool {
/// up (an angled one keeps its motion) and rise, then move.
fn free_flight(&mut self, scene: &mut SceneCollisions) {
let h = 0.125;
let bounds = Aabb::new(
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
@@ -132,11 +136,8 @@ impl Rocket {
);
let wanted = self.next_motion();
let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else {
return false;
return;
};
let blocked = (moved.x - wanted.x).abs() > 1e-7
|| (moved.y - wanted.y).abs() > 1e-7
|| (moved.z - wanted.z).abs() > 1e-7;
let horizontal = (moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
self.position = Vec3::new(
self.position.x + moved.x,
@@ -148,7 +149,6 @@ impl Rocket {
} else {
wanted
};
blocked
}
/// Position and motion (the dispenser parity fixture).
pub(super) fn motion(&self) -> (Vec3, Vec3) {
@@ -334,15 +334,14 @@ impl Game<'_> {
binding: self.collision,
subject: None,
};
if r.free_flight(&mut scene) && r.angled {
// hitTargetOrDeflectSelf: explode on the block.
r.life = r.lifetime;
}
r.free_flight(&mut scene);
}
// hitTargetOrDeflectSelf on an entity: a breeze turns it back
// (ProjectileDeflection.REVERSE), anything else sets it off.
// onHitBlock: only a rocket with explosions (hasExplosion) goes
// off on a block; a plain one flies on to its lifetime.
match hit {
Some(Who::Mob(id))
Some(RocketHit::Entity(Who::Mob(id)))
if self
.mobs
.iter()
@@ -350,8 +349,9 @@ impl Game<'_> {
{
r.velocity = r.velocity.scale(-0.5);
}
Some(_) => r.life = r.lifetime,
None => {}
Some(RocketHit::Entity(_)) => r.life = r.lifetime,
Some(RocketHit::Block) if !r.explosions.is_empty() => r.life = r.lifetime,
Some(RocketHit::Block) | None => {}
}
let at = [r.position.x, r.position.y, r.position.z];
if r.life == 0 {
@@ -510,25 +510,26 @@ impl Game<'_> {
targets.extend(self.lan_projectile_targets());
targets
}
/// `ProjectileUtil.getHitResultOnMoveVector`'s entity half: the nearest
/// target on the move, short of the block it clips, each box grown by
/// `computeMargin` (`tickCount` is the rocket's life plus one).
/// `ProjectileUtil.getHitResultOnMoveVector`: the nearest target on the
/// move, short of the block it clips (`ClipContext.Block.COLLIDER`), each
/// box grown by `computeMargin` (`tickCount` is the rocket's life plus
/// one); else that block.
fn rocket_struck(
&self,
targets: &[(Who, Aabb)],
r: &Rocket,
motion: Vec3,
skip: Option<Who>,
) -> Option<Who> {
) -> Option<RocketHit> {
let from = r.position;
let mut to = from.add_vector(motion);
let w = self.mob_world_base(&[]);
if let Some((at, ..)) = (super::creatures::ProjWorld {
let block = (super::creatures::ProjWorld {
w: &w,
targets: Vec::new(),
})
.clip(from, to)
{
.clip(from, to);
if let Some((at, ..)) = block {
to = at;
}
let margin = (f64::from(r.life - 1) / 20.0).clamp(0.0, 0.3);
@@ -542,7 +543,8 @@ impl Game<'_> {
Some((*who, from.distance_squared(at)))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(who, _)| who)
.map(|(who, _)| RocketHit::Entity(who))
.or(block.map(|_| RocketHit::Block))
}
/// FireworkRocketEntity.handleEntityEvent: use the synchronized item data.
pub(super) fn remote_firework_explosion(&mut self, id: i32) {
+9 -1
View File
@@ -476,7 +476,15 @@ impl Game<'_> {
self.particles
.send_particles(&options, at, spread, speed, count);
// The players a server serves are sent them too.
self.lan_send_particles(&options, at, spread, speed, count);
self.lan_send_particles(
self.dim.id(),
&options,
None,
at,
spread,
speed,
count as i32,
);
}
/// `FishingHook.tick`.
pub(super) fn tick_fishing(&mut self) {
+8
View File
@@ -79,6 +79,14 @@ impl Game<'_> {
.fov_player()
.map_or(1.0, |p| field_of_view_modifier(&p, first_person, effect));
let target = self.zoomed_fov_target(target);
// A server's spectator camera on a mob: its eye height. Its modifier
// stays 1 (another player's own modifier is not modelled here); the
// zoom extra still narrows it.
if let Some(m) = self.camera_mob() {
let eye = m.eye_height() as f32;
self.view.tick(Some(eye), self.zoomed_fov_target(1.0));
return;
}
let eye = self.player.as_ref().map(|p| {
if self.sleeping.is_some() {
0.2
+12 -8
View File
@@ -138,7 +138,15 @@ impl Game<'_> {
&& horizon_client::save::number(v.get("Invisible")).unwrap_or(0) != 0
})
});
let pos = std::array::from_fn(|i| hit.pos[i] + hit.face[i]);
let pos: [i32; 3] = std::array::from_fn(|i| hit.pos[i] + hit.face[i]);
// `ItemFrameItem.mayPlace`: `Level.isInsideBuildHeight(blockPos)`, so a frame is not
// placed on the top face of the highest block or under the lowest one.
if !(horizon_client::chunk::MIN_Y
..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT)
.contains(&pos[1])
{
return false;
}
let frame = ItemFrame {
id: self.next_entity + 1,
pos,
@@ -298,9 +306,8 @@ impl Game<'_> {
}
/// `ServerExplosion.hurtEntities` for item frames: a frame within the blast
/// is killed and dropped with its item (`BlockAttachedEntity.hurtServer`).
/// A fixed frame is not hurt by an explosion. Without `mobGriefing` a blast
/// that keeps blocks (`ServerExplosion.shouldAffectBlocklikeEntities` through
/// `BlockAttachedEntity.ignoreExplosion`) and a mob's blast spare frames.
/// A fixed frame is not hurt by an explosion, and `blast_spares_decorations`
/// keeps every frame without `mobGriefing`.
/// Not ported: `ignoreExplosion` also spares frames when the exploding
/// entity is in water.
pub(super) fn explosion_breaks_frames(
@@ -309,10 +316,7 @@ impl Game<'_> {
diameter: f64,
interaction: super::explosion::BlockInteraction,
) {
if !self.ui.rules.bool("mob_griefing")
&& (interaction == super::explosion::BlockInteraction::Keep
|| self.combat.explosion_source.is_some())
{
if self.blast_spares_decorations(interaction) {
return;
}
let mut i = 0;
@@ -73,6 +73,105 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// `ItemStack.canBreakBlockInAdventureMode` / `canPlaceOnBlockInAdventureMode`
/// for the main-hand stack: some predicate of its `can_break` or
/// `can_place_on` component (`AdventureModePredicate`: one block predicate
/// or a list) matches the block at `pos`. Predicates with `nbt` or
/// `components` need the block entity's data and never match.
fn adventure_predicate(&self, component: &str, pos: [i32; 3]) -> bool {
use horizon_world::nbt::Tag;
let Some(tag) = self
.held()
.and_then(|s| s.data())
.and_then(|d| d.other(component))
else {
return false;
};
let state = self.world.block(pos[0], pos[1], pos[2]);
let name = &self.res.facts().get(state).name;
let properties = self
.pack
.assets
.registry
.state(state)
.map(|s| &s.properties);
let matches = |p: &Tag| {
if p.get("nbt").is_some() || p.get("components").is_some() {
return false;
}
let block_ok = match p.get("blocks") {
None => true,
Some(Tag::String(id)) => self.block_in_holder_set(&id.to_string_lossy(), name),
Some(Tag::List(ids)) => ids.iter().any(|id| match id {
Tag::String(id) => self.block_in_holder_set(&id.to_string_lossy(), name),
_ => false,
}),
Some(_) => false,
};
// StatePropertiesPredicate: every property exact, or in a min/max range.
let state_ok = match p.get("state") {
None => true,
Some(Tag::Compound(want)) => want.iter().all(|(key, matcher)| {
let Some(have) = properties.and_then(|p| p.get(&key.to_string_lossy())) else {
return false;
};
match matcher {
Tag::String(value) => *have == value.to_string_lossy(),
Tag::Compound(_) => {
let bound = |k: &str| match matcher.get(k) {
Some(Tag::String(v)) => Some(v.to_string_lossy()),
_ => None,
};
let in_range = |have: &str, v: Option<String>, above: bool| {
v.is_none_or(|v| match (have.parse::<i64>(), v.parse::<i64>()) {
(Ok(h), Ok(b)) if above => h >= b,
(Ok(h), Ok(b)) => h <= b,
_ => false,
})
};
in_range(have, bound("min"), true)
&& in_range(have, bound("max"), false)
}
_ => false,
}
}),
Some(_) => false,
};
block_ok && state_ok
};
match tag {
Tag::List(list) => list.iter().any(matches),
single => matches(single),
}
}
/// A `HolderSet<Block>` entry: a block id or a `#tag`.
fn block_in_holder_set(&self, entry: &str, block: &str) -> bool {
let namespaced = |id: &str| {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
};
match entry.strip_prefix('#') {
Some(tag) => self.res.items().block_in_tag(&namespaced(tag), block),
None => namespaced(entry) == block,
}
}
/// `Player.blockActionRestricted` for the block at `pos`: spectators never
/// act on blocks, adventure players only with a main-hand item whose
/// `can_break` matches it.
pub(super) fn block_action_restricted(&self, pos: [i32; 3]) -> bool {
if self.spectator() {
return true;
}
!self.may_build() && !self.adventure_predicate("minecraft:can_break", pos)
}
/// `ItemStack.useOn` in adventure mode: the used item's `can_place_on`
/// matches the clicked block (`Player.mayUseItemAt`).
pub(super) fn can_place_on_in_adventure(&self, clicked: [i32; 3]) -> bool {
self.adventure_predicate("minecraft:can_place_on", clicked)
}
/// `ServerPlayer.setGameMode(SPECTATOR)`: `stopRiding`.
fn dismount_for_spectator(&mut self) {
let Some(id) = self.riding else {
Loaded 100 of 395 files, more files were not shown because too many files have changed in this diff. Show more