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
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
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
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 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
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
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
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
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
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
DUzzL fd21d6ee82 Cover no-BOM and partial-BOM pack.mcmeta (#4388) 2026-10-30 14:08:19 +01:00
DUzzL c274dad88d Read pack.mcmeta with a UTF-8 byte order mark (#4388) 2026-10-30 13:59:36 +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
DUzzL 8a196eeb77 Copper golems: send the chest lid block event to guests too 2026-10-30 13:20:50 +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
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
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
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
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
v3nn7andCodex 7090cd2d38 Project entity shadows onto partial block bounds
Co-Authored-By: Codex <[email protected]>
2026-10-30 11:20:04 +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
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
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
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
Ludovic Marie 37f449f91f Merge branch 'pt1-3490' into pt1-3491 2026-10-30 07:33:51 +01: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
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
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
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 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
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 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
septydev c449d797bd Fix account chip text overflow 2026-10-30 01:17:01 +02: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
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 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 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 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
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
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
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 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
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 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
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
234 changed files with 14189 additions and 1245 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
+1 -1
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).
+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())));
}
_ => {}
}
}
+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()));
+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>,
+118 -9
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) {
@@ -519,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());
}
+82 -3
View File
@@ -86,6 +86,43 @@ impl LevelShape {
}
}
/// 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
/// (`LevelShape::column`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -206,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)]
@@ -337,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,
+42 -13
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,13 +309,21 @@ 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(),
}
@@ -369,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(),
},
)
@@ -480,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(),
}
}
@@ -503,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)
);
}
@@ -552,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(),
},
);
@@ -572,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(),
},
)
@@ -672,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));
@@ -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)
+34 -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;
};
@@ -1352,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() {
@@ -1626,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)));
}
}
@@ -2551,6 +2551,7 @@ impl OptionsUi {
shown,
hover || dragging,
true,
dragging,
);
}
WKind::Text {
@@ -3672,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();
+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") {
@@ -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 {
+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(
+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,
+26
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,
@@ -235,6 +242,12 @@ impl ArrowState {
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
@@ -634,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;
+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);
}
}
+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 {
+194 -3
View File
@@ -3,7 +3,11 @@ use crate::{
chunk::{Chunk, LevelShape},
streaming::ChunkGenerator,
};
use horizon_world::{nbt::Tag, registry::BlockRegistry};
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 {
@@ -13,11 +17,59 @@ pub struct FlatTerrain {
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,
@@ -43,6 +95,29 @@ 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(shape.height as usize).enumerate().rev() {
@@ -72,6 +147,7 @@ impl FlatTerrain {
layers,
biome,
surface,
void_platform,
})
}
/// FlatLevelSource.getSpawnHeight: the top of the layers.
@@ -211,6 +287,21 @@ impl ChunkGenerator for FlatTerrain {
}
}
}
// 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();
}
@@ -237,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.
+204 -44
View File
@@ -159,15 +159,18 @@ mod music;
mod names;
mod native_hooks;
mod native_lifecycle;
mod native_menus;
mod native_panel;
mod native_panel_recipes;
mod native_recipes;
mod native_server;
mod native_shaders;
mod native_world_read;
mod natural_spawning;
mod nautili;
mod net_harness;
mod nether_portal;
mod no_resources;
mod ominous_item_spawner;
mod options;
mod packs;
@@ -766,6 +769,15 @@ struct GameLevel<'w> {
client_side: bool,
/// Only the integrated or dedicated server dispatches native observations.
native_authoritative: bool,
native_particles: &'w mut Vec<(
&'static str,
horizon_client::particles::ParticleOptions,
Option<BlockStateId>,
[f64; 3],
[f64; 3],
f64,
i32,
)>,
}
struct PlayerCollision {
y: f64,
@@ -830,6 +842,11 @@ impl GameLevel<'_> {
.map(|s| s.bounds()),
)
}
/// `LilyPadBlock.entityInside`: the boxes of the boats that can break a pad.
fn boat_trigger_boxes(&self) -> impl Iterator<Item = Aabb> + '_ {
let b = self.blockers;
b.boats.iter().map(boats::Boat::bounds)
}
/// The non-living entities that trigger plates and tripwires (boats,
/// crystals, primed TNT, falling blocks, item entities, experience orbs and
/// projectiles).
@@ -2029,6 +2046,9 @@ struct Game<'a> {
cart_models: minecarts::CartModels,
/// The boat the player rides.
riding: Option<u32>,
/// The vehicle the player's file names, waiting for it to be in the world
/// (`ServerPlayer.loadAndSpawnParentVehicle`).
pending_vehicle: Option<[i32; 4]>,
/// The ender inventory (27 slots) and the ender chest showing it.
ender_items: Vec<Option<ItemStack>>,
ender_open: Option<(i32, i32, i32)>,
@@ -2111,6 +2131,8 @@ struct Game<'a> {
lan: Option<lan::LanHost>,
/// Single player's server thread, which `remote` is connected to.
integrated: Option<integrated::Integrated>,
/// The shapes of the open world's levels, from its dimension types.
level_shapes: horizon_client::chunk::LevelShapes,
/// This game is that server thread's (game/integrated.rs).
serving: Option<integrated::Serving>,
/// The open world is shared behind an invite code (game/friends.rs).
@@ -2267,7 +2289,7 @@ impl Dim {
_ => Self::Overworld,
}
}
/// The level's shape, from vanilla's dimension types.
/// The level's shape in a world of vanilla's dimension types.
fn shape(self) -> horizon_client::save::LevelShape {
use horizon_client::save::LevelShape;
match self {
@@ -2276,18 +2298,6 @@ impl Dim {
Self::End => LevelShape::END,
}
}
/// `Level.getMinY`: the dimension type's minimum y.
fn min_y(self) -> i32 {
self.shape().min_y
}
/// `DimensionType.logicalHeight`.
fn logical_height(self) -> i32 {
self.shape().logical_height
}
/// `Level.getMaxY`: the dimension type's minimum y plus height, less one.
fn max_y(self) -> i32 {
self.shape().max_y() - 1
}
}
fn facing_offset(facing: &str) -> [i32; 3] {
match facing {
@@ -3300,9 +3310,13 @@ impl Game<'_> {
lid_openers: &self.lid_openers,
client_side: self.remote.is_some(),
native_authoritative: self.serving.is_some() || self.options.dedicated.is_some(),
native_particles: &mut self.creatures.lan_particles,
};
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
self.flush_spill();
if self.lan.is_some() {
self.flush_lan_particles();
}
result
}
/// Level.setBlock(pos, state, UPDATE_ALL) with neighbour/shape updates.
@@ -3561,6 +3575,21 @@ impl Game<'_> {
}
}
self.with_level(|b, level, ticks| {
// LilyPadBlock.entityInside: a boat inside a lily pad breaks it.
let mut boat_cells: Vec<BlockPos> = Vec::new();
for bounds in level.boat_trigger_boxes() {
let (lo, hi) = (bounds.min_position(), bounds.max_position());
for x in (lo.x + 1e-7).floor() as i32..=(hi.x - 1e-7).floor() as i32 {
for y in (lo.y + 1e-7).floor() as i32..=(hi.y - 1e-7).floor() as i32 {
for z in (lo.z + 1e-7).floor() as i32..=(hi.z - 1e-7).floor() as i32 {
boat_cells.push(BlockPos::new(x, y, z));
}
}
}
}
for pos in boat_cells {
b.boat_inside(level, ticks, pos);
}
// The mobs and the other entities (items, orbs, projectiles...) too.
let mut touched: Vec<BlockPos> = Vec::new();
for bounds in level.mob_trigger_boxes().chain(level.other_trigger_boxes()) {
@@ -4514,6 +4543,10 @@ impl Game<'_> {
if !self.debug_stick_can_destroy(pos)? {
return Ok(());
}
// ServerPlayerGameMode.destroyBlock.
if self.block_action_restricted(pos) {
return Ok(());
}
if !self
.res
.items()
@@ -4757,9 +4790,13 @@ impl Game<'_> {
return;
};
let group = horizon_client::cooldowns::group(stack, default.as_ref());
self.cooldowns.add(Arc::clone(&group), component.ticks());
self.add_cooldown(group, component.ticks());
}
/// `ItemCooldowns.addCooldown` (a guest also gets the packet).
pub(super) fn add_cooldown(&mut self, group: Arc<str>, ticks: i32) {
self.cooldowns.add(Arc::clone(&group), ticks);
if self.lan_acting.is_some() {
self.guest_cooldowns.push((group, component.ticks()));
self.guest_cooldowns.push((group, ticks));
}
}
/// One hand's turn in `startUseItem`: `useItemOn` the block aimed at,
@@ -4929,6 +4966,21 @@ impl Game<'_> {
{
return Ok(false);
}
// Native mods, where ItemStack.useOn and Item.use start: the block
// aimed at had its turn, and the item is off cooldown. A guest's
// UseItemOn reaches only useOn, its UseItem only use.
let on_block = matches!(self.guest_use, Some(GuestUse::OnBlock(_)));
let mut claimed = false;
if let Some(hit) = block_hit {
self.native_use_item_on(&stack, hit.pos, hit.face, &mut claimed);
}
if !claimed && !on_block {
self.native_use_item(&stack, &mut claimed);
}
if claimed {
self.swing_arm();
return Ok(true);
}
// ItemStack.useOn: after the block's own useItemOn/useWithoutItem
// passed. DebugStickItem.useOn cycles the selected property.
if let Some(hit) = block_hit
@@ -4952,7 +5004,6 @@ impl Game<'_> {
}
// A guest's UseItemOn stops at ItemStack.useOn: what Item.use does
// (throw, drink, eat, raise, equip) waits for its UseItem.
let on_block = matches!(self.guest_use, Some(GuestUse::OnBlock(_)));
let spawn_egg = stack.item.ends_with("_spawn_egg");
use horizon_client::first_person::UseResult;
let swings = self.swings_started;
@@ -4966,6 +5017,12 @@ impl Game<'_> {
UseResult::Fail => return Ok(true),
UseResult::Pass => {}
}
// PotionItem.useOn comes before PotionItem.use's drink.
if let Some(hit) = block_hit
&& self.use_water_bottle_on_block(hit.pos, hit.face, &stack)?
{
return Ok(true);
}
if (!on_block && self.use_potion_item(&stack)?)
|| ((block_hit.is_some() || !spawn_egg)
&& (!on_block || spawn_egg)
@@ -5008,6 +5065,10 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if !on_block && &*stack.item == "minecraft:goat_horn" {
self.use_instrument(&stack);
return Ok(true);
}
if !on_block && &*stack.item == "minecraft:bow" {
if self.using.is_none()
&& (self.creative || self.projectile_slot("minecraft:bow").is_some())
@@ -5039,13 +5100,29 @@ impl Game<'_> {
if self.use_ender_eye(&stack, block_hit.is_some())? {
return Ok(true);
}
let Some(hit) = block_hit else {
return Ok(false);
// PlaceOnWaterBlockItem.use: the lily pad and frogspawn aim at the block
// or water source the SOURCE_ONLY ray meets and are placed on the
// position above it.
let water_hit;
let hit = if matches!(&*stack.item, "minecraft:lily_pad" | "minecraft:frogspawn") {
let Some(h) = self.pick_fluid() else {
return Ok(false);
};
water_hit = Hit {
pos: [h.pos[0], h.pos[1] + 1, h.pos[2]],
..h
};
&water_hit
} else {
let Some(hit) = block_hit else {
return Ok(false);
};
hit
};
if info.block.is_none() {
return Ok(false);
}
if !self.may_build() {
if !self.may_build() && !self.can_place_on_in_adventure(hit.pos) {
// ItemStack.useOn passes in adventure mode; useItem eats.
return Ok(!on_block && self.start_eating(&info));
}
@@ -5156,6 +5233,34 @@ impl Game<'_> {
}
true
}
/// `InstrumentItem.use` for a goat horn: the player holds it up for the
/// instrument's `use_duration`, its sound plays at the player
/// (`SoundSource.RECORDS`, volume `range / 16`), `INSTRUMENT_PLAY` is
/// posted, the used statistic counts and the horn is on cooldown as long.
/// A horn whose instrument is unknown fails, which still ends the click.
fn use_instrument(&mut self, stack: &ItemStack) {
let Some(instrument) = self.res.items().instrument(stack) else {
return;
};
if self.using.is_none() {
self.using = Some((self.held_slot(), 0));
}
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio
.play(&instrument.sound, at, instrument.range / 16.0, 1.0);
self.post_entity_event(
"minecraft:instrument_play",
horizon_client::mobs::Who::Player,
);
if instrument.use_duration > 0.0 {
let group = horizon_client::cooldowns::group(stack, None);
self.add_cooldown(group, instrument.use_ticks());
}
self.stats.add("used", &stack.item, 1);
}
fn apply_consume_effect(&mut self, effect: &horizon_client::items::ConsumeEffect) {
use horizon_client::items::ConsumeEffect;
match effect {
@@ -5289,6 +5394,16 @@ impl Game<'_> {
}
return;
}
if let Some(duration) = self
.res
.items()
.instrument(&stack)
.map(|i| i.use_ticks().max(0) as u32)
{
// InstrumentItem.getUseDuration: held up that long, nothing more.
self.using = (ticks + 1 < duration).then_some((slot, ticks + 1));
return;
}
if &*stack.item == "minecraft:brush" {
self.tick_brush(slot, ticks);
return;
@@ -5400,8 +5515,9 @@ impl Game<'_> {
self.mining = None;
return Ok(());
};
// GameType.isBlockPlacingRestricted: adventure and spectator.
if !self.may_build()
// Player.blockActionRestricted: spectators, and adventure players
// without a matching can_break item.
if self.block_action_restricted(hit.pos)
|| !self
.res
.items()
@@ -5698,10 +5814,10 @@ impl Game<'_> {
self.ticks += 1;
self.track_experience_display();
// `/tick freeze` stops the world but not the player.
let running = self.clock.world_runs();
if self.serving.is_some() || self.options.dedicated.is_some() {
self.native_lifecycle_server_tick_start(running);
}
let running = match self.level_pass.running {
Some(running) => running,
None => self.clock.world_runs(),
};
// GameRenderer.tick: a wither's bar darkens the world, then fades;
// the activated item counts down.
self.boss_overlay.tick_darken(running);
@@ -6127,6 +6243,8 @@ impl Game<'_> {
horizon_client::spans::time("touch_arrows", || self.touch_arrows());
}
horizon_client::spans::time("spawn_loaded_entities", || self.spawn_loaded_entities());
// The ride the player's file names, once the vehicle is in the world.
self.restore_root_vehicle();
if running {
horizon_client::spans::time("tick_mobs", || self.tick_mobs())?;
horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers());
@@ -8782,6 +8900,15 @@ impl Game<'_> {
horizon_client::shadows::shadow(
&self.world,
self.res.facts(),
&|state| {
self.res
.collision()
.cached_shapes()
.cached_shape(state)
.ok()
.flatten()
.and_then(|shape| shape.bounds())
},
sprite,
camera,
pos,
@@ -8804,7 +8931,7 @@ impl Game<'_> {
texts: None,
partial: alpha as f32,
game_time: self.world.game_time,
max_y: self.dim.max_y(),
max_y: self.world.shape.max_y() - 1,
world: &self.world,
sprites: &self.gui.atlas.sprites,
nether: self.dim == Dim::Nether,
@@ -9239,7 +9366,7 @@ impl Game<'_> {
// the offset is the one from the player to the particle.
let origin = from.unwrap_or(at);
let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]];
self.lan_send_particles(&arc, at, spread, 0.0, 0);
self.lan_send_particles(self.dim.id(), &arc, None, at, spread, 0.0, 0);
}
/// `Player.attack`'s sounds when a vehicle takes the hit (it always does,
/// and can't be hit critically): knockback when sprinting at full
@@ -9469,11 +9596,13 @@ impl Game<'_> {
);
// The players a server serves are sent them too.
self.lan_send_particles(
self.dim.id(),
&indicator,
None,
[feet[0], feet[1] + height * 0.5, feet[2]],
[0.1, 0.0, 0.1],
0.2,
(actual * 0.5) as u32,
(actual * 0.5) as u32 as i32,
);
}
self.last_hurt_by_player
@@ -9742,6 +9871,7 @@ impl Game<'_> {
self.reset_tutorial();
let world_loading::PreparedWorld {
save_root,
shapes,
level,
world,
streaming,
@@ -9751,6 +9881,7 @@ impl Game<'_> {
locator,
} = prepared;
let dim = Dim::from_id(&level.dimension);
self.level_shapes = shapes;
self.world_root = save_root.clone();
horizon_client::crash::set_detail(
"World",
@@ -9833,6 +9964,8 @@ impl Game<'_> {
self.world = world;
self.inventory = inventory;
self.save = save;
// The ride the player's file names, once the vehicle is in the world.
self.pending_vehicle = level.root_vehicle;
self.level = level;
// ServerRecipeBook.loadUntrusted: recipes the manager does not have are dropped.
let index = &world_stations!(self).recipe_index;
@@ -9911,6 +10044,7 @@ impl Game<'_> {
&self.settings,
world_root,
dim,
self.level_shapes.get(dim.id()),
seed,
)?;
self.end.set_locator(locator);
@@ -9958,7 +10092,7 @@ impl Game<'_> {
let (streaming, save) =
self.make_streaming(&self.world_root.clone(), target, self.level.seed)?;
let mut world = World::new(self.res.facts().air, self.level.seed);
world.shape = target.shape();
world.shape = self.level_shapes.get(target.id());
world.fast_lava = target == Dim::Nether;
world.game_time = self.world.game_time;
world.day_time = self.world.day_time;
@@ -11464,6 +11598,11 @@ impl Game<'_> {
}
}
"minecraft:trident" if using => ItemPose::Trident { ticks_used },
// ItemUseAnimation.BRUSH: applyBrushTransform.
"minecraft:brush" if using => ItemPose::Brush {
remaining: owner.use_item_remaining_ticks as f32,
partial: a,
},
_ if using => {
let components = self
.pack
@@ -12271,6 +12410,8 @@ impl Game<'_> {
self.level.game_rules = self.ui.rules.by_name();
self.level.selected_slot = self.selected as i32;
let mode = self.mode_id();
// The vehicle the player rides (`ServerPlayer.saveParentVehicle`).
let vehicle = self.root_vehicle_uuid(self.riding);
let level = &mut self.level;
level.creative = self.creative;
level.game_mode = mode;
@@ -12287,6 +12428,7 @@ impl Game<'_> {
level.xp_level = self.experience.level;
level.xp_progress = self.experience.progress;
level.xp_total = self.experience.total;
level.root_vehicle = vehicle.or(self.pending_vehicle);
if let Some(p) = &self.player {
level.position = Some([p.position.x, p.position.y, p.position.z]);
level.health = p.health.health();
@@ -12646,11 +12788,17 @@ impl Game<'_> {
let mut outline_after_terrain = false;
// GameRenderer.shouldRenderBlockOutline: not with the HUD hidden (F1), and only for a
// player who may build. Adventure players only get one with a CAN_BREAK or
// CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the
// components nor opens menus for spectators, so those never show it. An open
// CAN_PLACE_ON item matching the block, and spectators with a menu block; Horizon
// does not open menus for spectators, so those never show it. An open
// screen does not hide it: vanilla checks the HUD and the game mode, not the screen.
let outline = if !self.ui.hide_gui && self.may_build() {
let adventure = self.mode == horizon_client::command::args::GameMode::Adventure;
let outline = if !self.ui.hide_gui && (self.may_build() || adventure) {
self.pick()
.filter(|h| {
self.may_build()
|| !self.block_action_restricted(h.pos)
|| self.can_place_on_in_adventure(h.pos)
})
.map(|h| {
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
let f = self.res.facts().get(state);
@@ -14978,6 +15126,7 @@ impl<'a> Game<'a> {
trim_slots: Vec::new(),
cart_models: prepared.cart_models,
riding: None,
pending_vehicle: None,
ender_items: vec![None; 27],
ender_open: None,
pending: Default::default(),
@@ -15025,6 +15174,7 @@ impl<'a> Game<'a> {
remote: None,
lan: None,
integrated: None,
level_shapes: Default::default(),
serving: None,
world_options: None,
options_to_world_options: false,
@@ -16510,9 +16660,11 @@ fn missing_resources(root: &std::path::Path) -> Option<String> {
let shown = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf());
Some(format!(
"Horizon's prepared game files are missing: no horizon-resources.json in {}\n\
Start Horizon from the Horizon launcher, which downloads and prepares them, \
or prepare them with: horizon-prepare --prepare (or pass --resources <folder>)",
shown.display()
Horizon is started from its launcher: download it at {} (Download). It needs your \
own Minecraft: Java Edition; the launcher signs you in and prepares the game files.\n\
Developers: horizon-prepare --prepare, or pass --resources <folder>",
shown.display(),
horizon_client::no_resources::DOWNLOAD_URL
))
}
@@ -16595,6 +16747,23 @@ pub fn run() -> Result<()> {
// 10 to 200 ms to get one back.
horizon_threads::favour_current();
}
if options.execution_role.is_client() {
horizon_threads::catch_stop_signals();
}
let started = Instant::now();
horizon_client::spans::enable(options.spikes.is_some());
let vanilla_root = options.resources.clone();
if let Some(error) = missing_resources(&vanilla_root) {
// The game build started by hand (the release's zip), not by the
// launcher: a window says where to get it. Scripts and servers get
// the error.
if !options.offscreen && !synchronous_startup(&options) {
log::error!("Horizon: {error}");
let settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
return no_resources::show(&vanilla_root, &settings);
}
return Err(error.into());
}
// Someone is playing: the anonymous ping behind the launcher's "N playing now", and the
// official server in their list (servers.dat in the game folder, the working directory). Not
// for a server, a benchmark, a run that quits by itself, a harness's fixture or a run that
@@ -16606,15 +16775,6 @@ pub fn run() -> Result<()> {
log::warn!("could not add the official server to servers.dat: {e}");
}
}
if options.execution_role.is_client() {
horizon_threads::catch_stop_signals();
}
let started = Instant::now();
horizon_client::spans::enable(options.spikes.is_some());
let vanilla_root = options.resources.clone();
if let Some(error) = missing_resources(&vanilla_root) {
return Err(error.into());
}
let early_settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
if options.dedicated.is_none() {
+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
+111 -43
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"),
@@ -776,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
@@ -803,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)
@@ -1047,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 {
+14 -2
View File
@@ -43,7 +43,19 @@ 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;
}
// FlowerPotBlock.getCloneItemStack: a potted plant picks as that plant
@@ -56,7 +68,7 @@ impl Game<'_> {
.flatten()
.map(|block| behaviours.block_data(block).item.clone())
.filter(|item| &**item != "minecraft:air");
let mut stack = ItemStack::new(plant_item.as_ref().unwrap_or(&facts.item), 1);
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
+123 -23
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;
}
}
@@ -979,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;
@@ -1029,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);
@@ -1039,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.
@@ -1345,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);
}
}
+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(':', ".")), &[]),
}
}
+60 -1
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.
@@ -653,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 {
@@ -1449,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];
@@ -1762,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;
@@ -2648,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)
}
@@ -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.
@@ -232,6 +232,16 @@ impl Game<'_> {
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,
+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.
+14 -6
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);
})?;
}
+103 -21
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> {
@@ -636,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();
@@ -847,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 {
@@ -880,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();
@@ -1321,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)
@@ -1435,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);
@@ -1994,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";
@@ -2227,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 {
@@ -2246,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)) => {
@@ -2393,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);
@@ -3019,6 +3056,23 @@ impl Game<'_> {
}
}
}
/// `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`
@@ -3091,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,
@@ -3236,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 {
@@ -3391,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);
@@ -3404,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
@@ -3533,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)
@@ -4858,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
@@ -5139,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,
@@ -5418,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",
+5 -2
View File
@@ -67,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;
};
@@ -275,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);
+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}"
);
}
}
@@ -422,6 +422,7 @@ impl Game<'_> {
position,
hover || held,
true,
held,
);
}
Part::Item { body, rect } => {
+11 -1
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)
@@ -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"));
}
+33 -18
View File
@@ -247,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).
@@ -269,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)
@@ -279,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));
@@ -314,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)
{
@@ -340,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);
@@ -394,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
@@ -405,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);
}
@@ -552,6 +566,7 @@ impl Game<'_> {
// 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.
@@ -675,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() {
@@ -724,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).
+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) {
+9 -1
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,
@@ -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 {
+76 -2
View File
@@ -93,7 +93,63 @@ pub(super) fn matches_for_swap(a: Option<&ItemStack>, b: Option<&ItemStack>) ->
}
}
/// `AttackRange.isInRange(attacker, box, buffer)` with the reach of
/// `attack_reach` (or `AttackRange.defaultFor`'s, from the entity interaction
/// range): the eye's distance to the box, inclusive.
fn in_attack_range(distance: f64, (min, max, margin): (f32, f32, f32), buffer: f64) -> bool {
distance >= f64::from(min - margin) - buffer && distance <= f64::from(max + margin) + buffer
}
impl Game<'_> {
/// `getAttributeValue` of an interaction range of the player in place (a
/// guest swapped in on a server): creative's modifiers are on it
/// (`update_player_attributes`), as are `/attribute`'s and the items'.
fn interaction_range(&mut self, attribute: &str, survival: f64, creative: f64) -> f64 {
let otherwise = if self.creative { creative } else { survival };
self.player
.as_mut()
.and_then(|p| p.attribute(attribute).ok())
.unwrap_or(otherwise)
}
/// ServerGamePacketListenerImpl.handleAttack's
/// `Player.isWithinAttackRange(mainHandItem, box, 3.0)` for the player in
/// place: the held item's `attack_range`, else `AttackRange.defaultFor`
/// (0 to the entity interaction range, no margin). Out of range, vanilla
/// drops the attack without a word.
pub(super) fn within_attack_range(&mut self, target: &Aabb) -> bool {
let Some(eye) = self.eye_position() else {
return false;
};
let range = self.held().and_then(|stack| {
self.pack
.item_renderer
.models
.components(&horizon_client::item_render::stack_view(stack))
.get("minecraft:attack_range")
.cloned()
});
let reach = match &range {
Some(r) => attack_reach(r, self.creative),
None => {
let range = self.interaction_range("minecraft:entity_interaction_range", 3.0, 5.0);
(0.0, range as f32, 0.0)
}
};
let distance = target.distance_squared_to_point(v3(eye)).sqrt();
in_attack_range(distance, reach, 3.0)
}
/// `Player.isWithinEntityInteractionRange(box, buffer)` for the player in
/// place (ServerGamePacketListenerImpl.handleInteract, with 3).
pub(super) fn within_entity_interaction_range(&mut self, target: &Aabb, buffer: f64) -> bool {
let Some(eye) = self.eye_position() else {
return false;
};
let range = self.interaction_range("minecraft:entity_interaction_range", 3.0, 5.0) + buffer;
target.distance_squared_to_point(v3(eye)) < range * range
}
/// A slot of the inventory (`OFFHAND_SLOT` for the off hand) was set to
/// a new stack: the hand holding it compares it as another `ItemStack`
/// (`shows_held_stack`) until the hand shows the held stack again.
@@ -616,8 +672,8 @@ impl HandWorld for GameHands<'_, '_> {
fn attack_entity(&mut self) -> bool {
self.game.attack()
}
fn block_action_restricted(&mut self, _: [i32; 3]) -> bool {
!self.game.may_build()
fn block_action_restricted(&mut self, pos: [i32; 3]) -> bool {
self.game.block_action_restricted(pos)
}
fn within_border(&mut self, _: [i32; 3]) -> bool {
true
@@ -717,6 +773,24 @@ mod tests {
assert_eq!(reach_pick(&boxes, eye, look, Vec3::ZERO, reach, wall), None);
}
#[test]
fn the_server_takes_attacks_within_vanilla_reach_only() {
// handleAttack: AttackRange.defaultFor (0 to the entity interaction
// range, no margin) plus 3, inclusive: 6 in survival, 8 in creative.
let survival = (0.0, 3.0, 0.0);
assert!(in_attack_range(6.0, survival, 3.0));
assert!(!in_attack_range(6.01, survival, 3.0));
assert!(!in_attack_range(50.0, survival, 3.0));
let creative = (0.0, 5.0, 0.0);
assert!(in_attack_range(8.0, creative, 3.0));
assert!(!in_attack_range(8.01, creative, 3.0));
// A spear's own range, with its margin, from the server's side too.
let spear = serde_json::json!({"min_reach": 2.0, "max_reach": 4.5, "hitbox_margin": 0.125});
let reach = attack_reach(&spear, false);
assert!(in_attack_range(7.625, reach, 3.0));
assert!(!in_attack_range(7.7, reach, 3.0));
}
fn stack(item: &str, count: u32, damage: u32) -> Option<ItemStack> {
Some(ItemStack {
item: item.into(),
@@ -84,7 +84,7 @@ impl Game<'_> {
&s,
self.pack.assets.registry,
self.res.biomes(),
super::Dim::Overworld.shape(),
self.level_shapes.overworld,
)
})
{
@@ -469,6 +469,7 @@ impl Game<'_> {
view_distance,
simulation_distance: self.settings.simulation_distance.max(2),
dimension: self.dim.id().to_owned(),
levels: self.level_shapes,
seed: self.level.seed,
max_players: MAX_PLAYERS,
// No player of its own: the hub runs without a host.
@@ -486,6 +487,7 @@ impl Game<'_> {
(uuid != owner && published.published.load(Ordering::Relaxed) == 0)
.then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
})),
levels: self.level_shapes,
// IntegratedServer.initServer: the MOTD names the owner and the world.
..NetConfig::lan(
0,
+107 -6
View File
@@ -93,6 +93,9 @@ pub(super) struct LanHost {
pub commands: Vec<super::lan_actions::GuestCommand>,
/// Mods' quiet commands waiting to run a few a tick (game/lan_quiet.rs).
pub quiet: std::collections::VecDeque<super::lan_quiet::QuietBatch>,
/// Native mods' commands and the level each needs, once asked
/// (game/native_server.rs).
pub native_commands: Option<HashMap<String, u8>>,
/// The entities the guests track, described again every tick
/// (`lan_entities`) over last tick's states.
states: lan::entity::States,
@@ -847,6 +850,14 @@ fn mob_meta(
}
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
}
// Bogged.DATA_SHEARED (the first entry after Mob's): the mushrooms
// on its head are gone.
"minecraft:bogged" => {
if e.sheared {
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
}
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two).
"minecraft:wandering_trader" => unhappy_counter(m, meta, defaults),
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
@@ -1147,12 +1158,14 @@ impl Game<'_> {
view_distance: self.settings.render_distance.clamp(2, 32),
simulation_distance: self.settings.render_distance.clamp(2, 32),
dimension: self.dim.id().to_owned(),
levels: self.level_shapes,
seed: self.level.seed,
max_players: MAX_PLAYERS,
dedicated: false,
integrated: false,
};
let net = NetConfig {
levels: self.level_shapes,
online: horizon_client::lan::OnlineMode::lan(),
..NetConfig::lan(settings.port, motd, MAX_PLAYERS)
};
@@ -1229,6 +1242,7 @@ impl Game<'_> {
border_sent: None,
commands: Vec::new(),
quiet: std::collections::VecDeque::new(),
native_commands: None,
states: Default::default(),
});
self.audio.capture = Some(Vec::new());
@@ -1283,15 +1297,53 @@ impl Game<'_> {
/// Save a guest when they leave or the world closes: an integrated
/// server's owner with the world (`save_game`), anyone else in their file.
pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) {
// The vehicle the guest rides, for the file that names it.
let vehicle = self.root_vehicle_uuid(guest.vehicle);
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
if guest.placed {
playerdata::store(guest, &mut self.level);
playerdata::store(guest, &mut self.level, vehicle);
}
} else {
playerdata::save(guest, &self.world_root);
playerdata::save(guest, &self.world_root, vehicle);
}
self.save_guest_records(host, guest);
}
/// `ServerPlayer.loadAndSpawnParentVehicle`: a guest whose file names the
/// vehicle it rode rides it again, once the vehicle is in the world.
fn restore_guest_vehicles(&mut self, host: &mut LanHost) {
let waiting: Vec<(usize, [i32; 4])> = host
.hub
.guests
.iter()
.enumerate()
.filter_map(|(i, g)| Some((i, g.saved_vehicle?)))
.filter(|(i, _)| {
let g = &host.hub.guests[*i];
let chunk = (
(g.position[0].floor() as i32).div_euclid(16),
(g.position[2].floor() as i32).div_euclid(16),
);
g.placed
&& g.vehicle.is_none()
&& g.dimension == self.dim.id()
&& g.sent_chunks.contains(&chunk)
})
.collect();
for (i, uuid) in waiting {
let raw = host.hub.guests[i].saved.clone();
let Some(id) = self.restore_saved_vehicle(uuid, raw.as_ref()) else {
continue;
};
let g = &mut host.hub.guests[i];
if let Some(horizon_world::nbt::Tag::Compound(p)) = &mut g.saved {
p.remove(&"RootVehicle".into());
}
g.vehicle = Some(id);
g.saved_vehicle = None;
// Pair the stationary restored entities before their passenger packet.
g.moved = true;
}
}
/// The file name of a player's advancements and statistics: an
/// integrated server's owner keeps single player's.
fn records_uuid(&self, host: &LanHost, uuid: u128) -> Option<[i32; 4]> {
@@ -1362,14 +1414,28 @@ impl Game<'_> {
}
/// The integrated server's owner into the world's player, for `Level::write`.
pub(super) fn store_owner(&mut self) {
let Some(uuid) = self
.lan
.as_ref()
.and_then(|h| h.owner.as_ref())
.map(|o| o.uuid)
else {
return;
};
let riding = self.lan.as_ref().and_then(|h| {
h.hub
.guests
.iter()
.find(|g| g.uuid == uuid && g.placed)
.and_then(|g| g.vehicle)
});
// The vehicle the owner rides, by the UUID it is saved with.
let vehicle = self.root_vehicle_uuid(riding);
let Some(host) = &self.lan else {
return;
};
let Some(uuid) = host.owner.as_ref().map(|o| o.uuid) else {
return;
};
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == uuid && g.placed) {
playerdata::store(g, &mut self.level);
playerdata::store(g, &mut self.level, vehicle);
self.save_guest_records(host, g);
}
}
@@ -1397,6 +1463,25 @@ impl Game<'_> {
host.settings.force_game_mode = force;
host.hub.config.guest_commands = effective;
host.hub.config.force_game_mode = force;
// MinecraftServer.enforceGameTypeForPlayers: the guests online
// take the default mode (`setGameMode` does nothing when it is
// already theirs). A world open to LAN is an IntegratedServer,
// whose `updateGameModeBasedOnPermission` leaves out the players
// who have game master permission; a dedicated server does not.
if force {
let mode = host.hub.config.default_game_mode;
let spares_operators = !host.hub.config.dedicated || host.hub.config.integrated;
let stale: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| g.game_mode != mode && !(spares_operators && g.permission >= 2))
.map(|g| g.conn)
.collect();
for conn in stale {
host.hub.set_game_mode(conn, mode);
}
}
host.hub.resend_commands();
}
}
@@ -1534,6 +1619,7 @@ impl Game<'_> {
};
// What the tick sends a guest goes to its connection at the end.
let holding = lan::Holding::start();
self.register_native_commands(&mut host);
// The host's own commands' (and command blocks') players moving
// to another level, and their broadcastToAdmins lines.
if !self.level_teleports.is_empty() {
@@ -1553,6 +1639,8 @@ impl Game<'_> {
m.rider = None;
}
}
// A guest whose file names the vehicle it rode rides it again.
self.restore_guest_vehicles(&mut host);
// A dedicated server runs the hub without a host player.
if state.is_some() || host.hub.config.dedicated {
let has_guests = !host.hub.guests.is_empty();
@@ -1763,6 +1851,14 @@ impl Game<'_> {
.take()
.expect("the hub is back after the quiet commands");
}
if !host.hub.native_menu_events.is_empty() {
self.lan = Some(host);
self.run_native_menu_events();
host = self
.lan
.take()
.expect("the hub is back after the menu events");
}
self.lan_flush_effects(&mut host);
if !paused {
self.tick_guests(&mut host);
@@ -1945,6 +2041,11 @@ impl Game<'_> {
if event == 21 && self.options.execution_role.is_client() {
self.start_guardian_sound(id as u32);
}
// Event 53: a mob sliding down a honey block sheds honey particles
// (HoneyBlock.showSlideParticles), drawn by the client that runs the world.
if event == 53 && self.options.execution_role.is_client() {
self.honey_slide_particles(at);
}
// Event 2: a mob's spear hit felt; the same window hears its own mob's hit.
if event == 2 && self.options.execution_role.is_client() {
self.remote_kinetic_hit(id);
+130 -7
View File
@@ -954,6 +954,8 @@ impl Game<'_> {
};
horizon_client::containers::display_name(custom(k), key)
}
// Opened by a mod (`native_open_menu`), never here.
Slots::Native => return false,
};
let Some(items) = host
.hub
@@ -1138,7 +1140,7 @@ impl Game<'_> {
let _ = g.break_block(pos);
return true;
}
if g.may_build() {
if !g.block_action_restricted(pos) {
let _ = g.attack_block(pos);
g.mining = Some(Mining {
pos,
@@ -1255,12 +1257,29 @@ impl Game<'_> {
}
S::UseItemOn(p) => {
host.hub.ack(conn, p.sequence);
// handleUseItemOn: within reach, and the hit point within the block.
// handleUseItemOn: within reach, then the hit point within
// a block of the block's centre (logged when it isn't).
if !host.hub.guests[gi].may_reach_block(p.pos) {
return;
}
let inside = p
.cursor
.iter()
.all(|&c| (f64::from(c) - 0.5).abs() < 1.0000001);
if !(inside && host.hub.guests[gi].may_reach_block(p.pos)) {
if !inside {
let d = horizon_client::java_text::double_to_string;
let at = |k: usize| d(f64::from(p.pos[k]) + f64::from(p.cursor[k]));
let line = format!(
"Rejecting UseItemOnPacket from {}: Location ({}, {}, {}) too far away from hit block BlockPos{{x={}, y={}, z={}}}.",
host.hub.guests[gi].name,
at(0),
at(1),
at(2),
p.pos[0],
p.pos[1],
p.pos[2]
);
self.lan_log_event(horizon_client::lan::hub::HostEvent::Warn(line));
return;
}
let face = direction(p.face);
@@ -1319,17 +1338,37 @@ impl Game<'_> {
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let g = &mut host.hub.guests[gi];
(g.yaw, g.pitch) = (p.yaw, p.pitch);
let swung = self.as_guest(host, conn, |g| {
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let used = self.as_guest(host, conn, |g| {
// A horn's sound, inline instruments included.
let horn = g.with_hand(hand, |g| {
let held = g.held()?;
g.res.items().instrument(held).map(|i| i.sound)
});
g.guest_use = Some(GuestUse::Item(hand));
let swings = g.swings_started;
let _ = g.use_item();
g.guest_use = None;
g.swings_started != swings
(g.swings_started != swings, horn)
});
let (swung, horn) = match used {
Some((swung, horn)) => (Some(swung), horn),
None => (None, None),
};
self.lan_swing_back(host, conn, swung);
self.guest_instrument_sound(host, conn, heard, horn);
}
S::Attack(p) => self.guest_attack(host, conn, p.id),
S::Interact(p) => {
// handleInteract: within the entity interaction range plus 3
// of the target's box, or nothing happens.
if let Some(target) = self.guest_target_box(host, gi, p.id)
&& self.as_guest(host, conn, |g| {
g.within_entity_interaction_range(&target, 3.0)
}) != Some(true)
{
return;
}
let before = host.hub.guests[gi].vehicle;
if self
.look_at_entity(host, gi, p.id, Some(p.location))
@@ -1917,6 +1956,42 @@ impl Game<'_> {
}
}
/// `Level.getEntityOrPart(id).getBoundingBox()` for a guest's attack or
/// interaction: the host, another player, a mob, a vehicle or object, a
/// dragon part or crystal, a projectile. None for an id the level doesn't
/// have (the handlers then find nothing either).
fn guest_target_box(&self, host: &LanHost, gi: usize, id: i32) -> Option<Aabb> {
let conn = host.hub.guests[gi].conn;
if id == host.host_entity {
return self.player.as_ref().map(|p| p.bounds());
}
if let Some(g) = host
.hub
.guests
.iter()
.find(|g| g.entity_id == id && g.conn != conn && host.hub.here(g))
{
return Some(g.bounds());
}
if let Some(m) = self.mobs.iter().find(|m| m.id as i32 == id) {
return Some(m.bounds());
}
if let Some((lo, hi, _)) = self.object_target(id) {
return Some(Aabb::new(
Vec3::new(lo[0], lo[1], lo[2]),
Vec3::new(hi[0], hi[1], hi[2]),
));
}
if let Some(b) = self.tracked_special_box(id) {
return Some(b);
}
self.creatures
.projectiles
.iter()
.find(|p| p.id != 0 && p.id as i32 == id)
.map(|p| p.bounds())
}
fn guest_attack(&mut self, host: &mut LanHost, conn: u32, id: i32) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
@@ -1924,6 +1999,13 @@ impl Game<'_> {
if host.hub.guests[gi].spectator() {
return;
}
// handleAttack: `isWithinAttackRange(mainHandItem, box, 3.0)` before
// anything else, players, mobs, objects and fireballs alike.
if let Some(target) = self.guest_target_box(host, gi, id)
&& self.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true)
{
return;
}
if let Some(victim) = Self::pvp_victim(host, conn, id) {
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
&& self.pvp_land(host, victim, &strike)
@@ -2014,11 +2096,26 @@ impl Game<'_> {
);
return;
}
// A native mod's command, for every player its level allows,
// whether the world allows commands or not (game/native_server.rs).
let integrated = host.hub.config.integrated;
if let Some(level) = Self::native_command_level(host, command.trim_start_matches('/'))
&& let Some(permission) = host.hub.guest(conn).map(|g| g.permission)
&& permission >= level
{
host.commands.push(GuestCommand {
conn,
command: command.trim_start_matches('/').to_owned(),
permission,
integrated,
quiet: false,
});
return;
}
// An integrated server runs every player's commands at their own
// permission level (`CommandSourceStack.permissions`); a LAN world
// follows its Allow Commands, and a dedicated server leaves it to
// the ops list (the guest's permission level).
let integrated = host.hub.config.integrated;
let allowed = self.options.dedicated.is_some() || self.allow_commands();
if !integrated && (!host.settings.guest_commands || !allowed) {
host.hub.send_to(
@@ -2072,7 +2169,9 @@ impl Game<'_> {
self.lan = Some(host);
self.chat_capture = Some(Vec::new());
let level = std::mem::replace(&mut self.ui.registries.permission, c.permission);
let ok = if c.quiet {
let ok = if self.perform_native_command(c.conn, c.permission, &c.command) {
true
} else if c.quiet {
self.run_command_quietly(&c.command)
} else {
self.run_command(&c.command)
@@ -3498,6 +3597,30 @@ impl Game<'_> {
}
}
/// `InstrumentItem.play`'s `level.playSound(player, ...)`: the others hear
/// a guest's horn now, the guest's own client played it as it used it.
fn guest_instrument_sound(
&mut self,
host: &mut LanHost,
conn: u32,
heard: usize,
horn: Option<String>,
) {
let (Some(horn), Some(capture)) = (horn, self.audio.capture.as_mut()) else {
return;
};
let mut i = heard.min(capture.len());
while i < capture.len() {
if capture[i].0 == horn {
let (name, at, volume, pitch) = capture.remove(i);
let source = horizon_client::settings::sound_source(&name) as i32;
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
} else {
i += 1;
}
}
}
/// Positional sounds and level events of the tick, for the guests.
pub(super) fn lan_broadcast_effects(&mut self, host: &mut LanHost) {
let sounds = self.audio.capture.take().unwrap_or_default();
+30 -2
View File
@@ -467,17 +467,44 @@ impl Game<'_> {
else {
return false;
};
// handleContainerClick: broadcastFullState only for a click on an old
// state, else only what the client got wrong. The whole menu after every
// click would undo the clicks it has predicted since.
let mut agreed = false;
// A table that is no longer valid takes no clicks (handleContainerClick).
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& !host.hub.guests[gi].spectator()
&& self.lan_table_valid(&host.hub.guests[gi], pos)
{
self.guest_menu_op(host, gi, &mut menu, |menu, env| {
let changed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
let before: Vec<_> = (0..menu.slot_count())
.map(|s| menu.slot(s).cloned())
.collect();
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
(0..menu.slot_count())
.filter(|&s| menu.slot(s) != before[s].as_ref())
.collect::<Vec<_>>()
});
agreed = p.state == state
&& host.hub.click_agreed(
gi,
p,
menu.slot_count(),
&|s| menu.slot(s).cloned(),
&changed,
);
if agreed {
// The inventory menu's sync isn't to send what the click settled.
let g = &mut host.hub.guests[gi];
for &s in changed.iter().filter(|&&s| s >= 10) {
g.menu_sent[s - 1] = menu.slot(s).cloned();
}
}
}
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu, pos));
self.lan_send_table_content(host, conn);
if !agreed {
self.lan_send_table_content(host, conn);
}
true
}
@@ -549,6 +576,7 @@ impl Game<'_> {
carried,
}),
);
host.hub.guests[gi].inventory_told();
}
/// `AbstractContainerMenu.removed` of a crafting menu: the closed window
+40 -20
View File
@@ -83,7 +83,8 @@ impl Game<'_> {
}
/// `ServerLevel.sendParticles` of a mob's particles: a packet to each
/// player within 32 blocks. The window draws its own copy.
/// player within 32 blocks. The window draws its own copy. `state` is the
/// block of a block particle.
pub(super) fn lan_level_particles(
&self,
options: &ParticleOptions,
@@ -91,8 +92,10 @@ impl Game<'_> {
spread: [f64; 3],
speed: f64,
count: u32,
state: Option<BlockStateId>,
) {
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, None)) else {
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, state))
else {
return;
};
let packet = C::LevelParticles(play::LevelParticles {
@@ -364,24 +367,32 @@ impl Game<'_> {
/// the players (a guest's turn through `as_guest`, a host's hit on a
/// guest) the packet waits for the turn to end so that it reaches every
/// player, the guest too.
#[allow(clippy::too_many_arguments)]
pub(super) fn lan_send_particles(
&mut self,
dimension: &'static str,
options: &ParticleOptions,
state: Option<BlockStateId>,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
count: i32,
) {
if self.lan.is_none() {
// The server is running (its sounds are captured for the guests).
if self.audio.capture.is_some() {
self.creatures
.lan_particles
.push((options.clone(), at, spread, speed, count));
if self.serving.is_some() || self.options.dedicated.is_some() {
self.creatures.lan_particles.push((
dimension,
options.clone(),
state,
at,
spread,
speed,
count,
));
}
return;
}
let Some(particle) = self.network_particle(options, None) else {
let Some(particle) = self.network_particle(options, state) else {
return;
};
let Some(host) = self.lan.as_ref() else {
@@ -394,11 +405,16 @@ impl Game<'_> {
pos: at,
dist: spread.map(|d| d as f32),
max_speed: [speed as f32; 3],
count: count as i32,
count,
randomization: 0,
})
.encode();
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
for g in host
.hub
.guests
.iter()
.filter(|g| g.placed && g.dimension == dimension)
{
// BlockPos.closerToCenterThan(at, 32): from the centre of the
// player's block.
let d: f64 = (0..3)
@@ -412,21 +428,25 @@ impl Game<'_> {
/// The particles guests' turns put off.
pub(super) fn flush_lan_particles(&mut self) {
for (options, at, spread, speed, count) in std::mem::take(&mut self.creatures.lan_particles)
for (dimension, options, state, at, spread, speed, count) in
std::mem::take(&mut self.creatures.lan_particles)
{
self.lan_send_particles(&options, at, spread, speed, count);
self.lan_send_particles(dimension, &options, state, at, spread, speed, count);
}
}
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
/// sound and the push reach them on their own (the level's sounds, the
/// motion a hurt player is sent), so the packet carries neither.
/// `ServerLevel.explode`'s packet to each player within 64 blocks, with
/// the push `pushed` holds for them (`ServerExplosion.hitPlayers`: a
/// creative player, whom nothing hurt). The sound reaches them on its own
/// (the level's sounds), and so does a hurt player's push (the motion
/// they are sent).
pub(super) fn lan_explode(
&mut self,
center: [f64; 3],
small: bool,
radius: f32,
block_count: usize,
pushed: Vec<(u32, [f64; 3])>,
) {
// In a guest's turn (their hit on an end crystal or a minecart) the
// host holds the players (`as_guest`): the packet waits for the turn
@@ -434,7 +454,7 @@ impl Game<'_> {
if self.lan_acting.is_some() && self.lan.is_none() {
self.creatures
.lan_explosions
.push((center, small, radius, block_count));
.push((center, small, radius, block_count, pushed));
return;
}
let emitter = if small {
@@ -446,16 +466,16 @@ impl Game<'_> {
if let Some(packet) =
self.explosion_packet(center, radius, block_count, emitter, sound, true)
{
self.send_explosion(packet, &[]);
self.send_explosion(packet, &pushed);
}
}
/// The explosions guests' turns put off.
pub(super) fn flush_lan_explosions(&mut self) {
for (center, small, radius, block_count) in
for (center, small, radius, block_count, pushed) in
std::mem::take(&mut self.creatures.lan_explosions)
{
self.lan_explode(center, small, radius, block_count);
self.lan_explode(center, small, radius, block_count, pushed);
}
}
@@ -438,6 +438,7 @@ impl Game<'_> {
}
}
host.hub.send_to(conn, &packet);
host.hub.guests[gi].inventory_told();
for packet in data {
host.hub.send_to(conn, &packet);
}
+47 -12
View File
@@ -89,11 +89,14 @@ pub(super) struct Parked {
/// Which passes of the server tick the level in place runs: the first
/// reads the network (`Hub::poll`), the last ends the tick for every player
/// (`Hub::end_tick`). A tick with one level is both.
/// (`Hub::end_tick`). A tick with one level is both. `running` is the
/// tick rate manager's answer for this server tick, asked once before the
/// levels (`MinecraftServer.tickServer`); `None` outside `tick_server`.
#[derive(Clone, Copy)]
pub(super) struct Pass {
pub first: bool,
pub last: bool,
pub running: Option<bool>,
}
impl Default for Pass {
@@ -101,6 +104,7 @@ impl Default for Pass {
Self {
first: true,
last: true,
running: None,
}
}
}
@@ -122,6 +126,11 @@ impl Levels {
self.parked.get_mut(&dim)
}
/// The mobs of a parked level.
pub(super) fn parked_mobs(&self, dim: Dim) -> Option<&[Mob]> {
self.parked.get(&dim).map(|level| level.mobs.as_slice())
}
/// The levels that are not in place, by dimension.
pub(super) fn parked_dims(&self) -> Vec<Dim> {
self.parked.keys().copied().collect()
@@ -138,10 +147,11 @@ impl Game<'_> {
&self.settings,
&self.world_root,
dim,
self.level_shapes.get(dim.id()),
self.level.seed,
)?;
let mut world = World::new(self.res.facts().air, self.level.seed);
world.shape = dim.shape();
world.shape = self.level_shapes.get(dim.id());
world.fast_lava = dim == Dim::Nether;
Ok(Parked {
world,
@@ -338,8 +348,19 @@ impl Game<'_> {
/// and loaded and its world ticked. The first pass reads the network and runs the
/// tick's shared parts (the clock, the weather, functions, the autosave);
/// the next ones only their level. While `paused`, only the connections
/// run.
/// run. The native server tick hook brackets it (`ServerTickStart` before
/// the first level, `ServerTickEnd` after the last).
pub(super) fn tick_server(&mut self, paused: bool) -> Result<()> {
// `tickRateManager.tick()` once per server tick, before any level
// (each `ServerLevel.tick` only reads `runsNormally`), and the native
// `ServerTickStart` before that level work, where NeoForge fires
// `ServerTickEvent.Pre`. A paused pass and a held server tick nothing.
let running = (!paused && !self.agent.hold_server).then(|| self.clock.world_runs());
if let Some(running) = running
&& (self.serving.is_some() || self.options.dedicated.is_some())
{
self.native_lifecycle_server_tick_start(running);
}
let dims = self.levels_to_tick();
let n = dims.len();
let mut result = Ok(());
@@ -351,6 +372,7 @@ impl Game<'_> {
self.level_pass = Pass {
first: k == 0,
last: k + 1 == n,
running,
};
self.update_dedicated_streaming();
self.update_world();
@@ -360,7 +382,7 @@ impl Game<'_> {
} else if k == 0 {
self.tick()
} else {
let running = self.clock.world_runs();
let running = running.unwrap_or_else(|| self.clock.world_runs());
if running {
self.tick_weather_effects();
}
@@ -388,26 +410,39 @@ impl Game<'_> {
/// `tick_server` as a server thread runs it. With `HORIZON_SPIKES=MS`, a
/// tick longer than MS is logged with its thread CPU time and spans.
pub(super) fn tick_server_timed(&mut self, paused: bool) -> Result<()> {
let Some(threshold) = self.options.spikes else {
self.tick_server_spans(paused, |_, _, _| {})
}
/// `tick_server_timed`, handing the tick's wall time, thread CPU time
/// and spans (milliseconds) to `record` while spans are on: a dedicated
/// server's performance report turns them on to record its ticks.
pub(super) fn tick_server_spans(
&mut self,
paused: bool,
record: impl FnOnce(f32, Option<f32>, Vec<horizon_client::spans::Span>),
) -> Result<()> {
if self.options.spikes.is_none() && !horizon_client::spans::enabled() {
return self.tick_server(paused);
};
}
let (started, cpu) = (Instant::now(), horizon_client::spans::thread_cpu());
let result = self.tick_server(paused);
let wall = started.elapsed().as_secs_f32() * 1000.0;
let cpu = horizon_client::spans::thread_cpu()
.zip(cpu)
.map(|(end, start)| (end - start).as_secs_f32() * 1000.0);
// Taken every tick: nothing else takes this thread's spans.
let spans = horizon_client::spans::take();
if wall > threshold {
let cpu = horizon_client::spans::thread_cpu()
.zip(cpu)
.map_or_else(String::new, |(end, start)| {
format!(", cpu {:.1} ms", (end - start).as_secs_f32() * 1000.0)
});
if let Some(threshold) = self.options.spikes
&& wall > threshold
{
let cpu = cpu.map_or_else(String::new, |c| format!(", cpu {c:.1} ms"));
log::info!(
"Horizon: slow tick {wall:.1} ms{cpu} ({} mobs): {}",
self.mobs.len(),
horizon_client::spans::describe(&spans)
);
}
record(wall, cpu, spans);
result
}
+1 -1
View File
@@ -1504,7 +1504,7 @@ impl Game<'_> {
texts: None,
partial: 0.0,
game_time: self.world.game_time,
max_y: self.dim.max_y(),
max_y: self.world.shape.max_y() - 1,
world: &self.world,
sprites: &self.gui.atlas.sprites,
nether: self.dim == super::Dim::Nether,
+100 -7
View File
@@ -197,6 +197,7 @@ impl Game<'_> {
self.worn_block_geometry(mob, camera, alpha, light, out);
self.head_item_geometry(mob, camera, alpha, light, true, out);
self.golem_flower_geometry(mob, camera, alpha, light, out);
self.antenna_block_geometry(mob, camera, alpha, light, out);
if mob.species() == Species::Enderman
&& let Some(state) = mob.extra.carried
{
@@ -208,6 +209,43 @@ impl Game<'_> {
self.block_model_vertices(state, 42, t, light, true, out);
}
}
/// `BlockDecorationLayer.submit`: the block item in a copper golem's
/// antenna slot (a flower, or any block item), drawn at the antenna by
/// `CopperGolemModel.applyBlockOnAntennaTransform` with `NO_OVERLAY`. Only
/// the block's default state is drawn, not the item's block state component.
fn antenna_block_geometry(
&self,
mob: &Mob,
camera: glam::DVec3,
alpha: f64,
light: u32,
out: &mut Vec<Vertex>,
) {
use glam::{Mat4, Vec3};
if mob.species() != Species::CopperGolem || horizon_client::mobs::trader::is_invisible(mob)
{
return;
}
let Some(stack) = mob.saddle_item() else {
return;
};
let Some(state) = self.pack.assets.registry.default_state(&stack.item) else {
return;
};
let Some(head) =
render::part_transform(mob, alpha as f32, camera, self.res.mob_data(), "head")
else {
return;
};
// UNIT_CUBE_BOTTOM_CENTER_TO_ANTENNA_CENTER: translation(-0.5, 0, -0.5)
// then rotateAround(Z 180, 0.5, 0.5, 0.5), which JOML post-multiplies.
let unit = Mat4::from_translation(Vec3::new(-0.5, 0.0, -0.5))
* Mat4::from_translation(Vec3::splat(0.5))
* Mat4::from_rotation_z(180.0f32.to_radians())
* Mat4::from_translation(Vec3::splat(-0.5));
let t = head * Mat4::from_translation(Vec3::new(0.0, -1.75, 0.0)) * unit;
self.block_model_vertices(state, 42, t, light, true, out);
}
/// `IronGolemFlowerLayer`: the poppy in the right arm while the golem
/// offers it (`getOfferFlowerTick() > 0`), drawn with `NO_OVERLAY`.
fn golem_flower_geometry(
@@ -327,9 +365,19 @@ impl Game<'_> {
for (item, off_hand) in [(&mob.extra.main_hand, false), (&mob.extra.off_hand, true)] {
let Some(item) = item else { continue };
let left = off_hand != mob.main_arm_left();
let Some(t) =
// PlayerItemInHandLayer.submitArmWithItem (avatars only): a spyglass
// in use, with no swing in progress, is held to the eye (`heldOnHead`).
let spyglass_to_eye = &**item == "minecraft:spyglass"
&& (&*mob.kind.id == "minecraft:player" || mob.species() == Species::Mannequin)
&& off_hand == mob.extra.using_off_hand
&& mob.extra.using_item > 0
&& horizon_client::mobs::anim::state(mob, alpha as f32).swing < 1.0e-5;
let t = if spyglass_to_eye {
render::held_to_eye_matrix(mob, alpha as f32, camera, self.res.mob_data(), left)
} else {
render::held_item_transform(mob, alpha as f32, camera, self.res.mob_data(), left)
else {
};
let Some(t) = t else {
continue;
};
let slot = if off_hand {
@@ -350,6 +398,12 @@ impl Game<'_> {
continue;
};
stack.clone()
} else if mob.species() == Species::Witch
&& horizon_client::mobs::witch::holds_potion(item)
&& &**item != "minecraft:potion"
{
// A drinking witch's main hand holds the potion's type, drawn as a potion item.
ItemStack::new("minecraft:potion", 1).with_potion(item)
} else {
ItemStack::new(item, 1)
};
@@ -383,7 +437,9 @@ impl Game<'_> {
// PandaHoldsItemLayer and FoxHeldItemLayer show the item as it lies
// on the ground.
let (context, ordinal) = match (mob.species(), left) {
(Species::Panda | Species::Fox, _) => (Context::Ground, 7),
// The spyglass held to the eye is the HEAD display.
_ if spyglass_to_eye => (Context::Head, 5),
(Species::Panda | Species::Fox | Species::Witch, _) => (Context::Ground, 7),
(_, true) => (Context::ThirdPersonLeftHand, 1),
(_, false) => (Context::ThirdPersonRightHand, 2),
};
@@ -409,9 +465,7 @@ impl Game<'_> {
lit: bool,
out: &mut Vec<Vertex>,
) {
if horizon_client::mobs::trader::is_invisible(mob) {
return;
}
// The layer also draws on an invisible mob.
let species = mob.species();
if species == Species::SnowGolem
|| horizon_client::mobs::species::body_slot(species)
@@ -455,7 +509,46 @@ impl Game<'_> {
return;
};
if let Some(kind) = super::armor_stands::skull_kind(&stack.item) {
let pose = head * glam::Mat4::from_scale(glam::Vec3::splat(1.1875));
// Only the renderers that add a `CustomHeadLayer` draw a skull:
// the humanoid mobs, the illagers, the villagers, the copper golem
// and the avatars.
if !(&*mob.kind.id == "minecraft:player"
|| matches!(
species,
Species::Zombie
| Species::Husk
| Species::Drowned
| Species::ZombieVillager
| Species::Skeleton
| Species::Stray
| Species::Bogged
| Species::Parched
| Species::WitherSkeleton
| Species::Piglin
| Species::PiglinBrute
| Species::ZombifiedPiglin
| Species::Pillager
| Species::Vindicator
| Species::Evoker
| Species::Illusioner
| Species::Villager
| Species::WanderingTrader
| Species::CopperGolem
| Species::Mannequin
))
{
return;
}
// translateToHead, the renderer's `skullYOffset` (the villager's
// `CUSTOM_HEAD_TRANSFORMS`), then the skull's SKULL_SCALE.
let y_offset = if species == Species::Villager {
-0.074_218_75
} else {
0.0
};
let pose = head
* glam::Mat4::from_translation(glam::Vec3::new(0.0, y_offset, 0.0))
* glam::Mat4::from_scale(glam::Vec3::splat(1.1875));
let special = horizon_assets::item_model::SpecialModel {
kind: if kind == "player" {
horizon_assets::item_model::SpecialKind::PlayerHead
+390 -43
View File
@@ -4,12 +4,18 @@
//! mount's health and jump bar on the HUD.
use super::Game;
use horizon_client::{
blocks::Fluid,
hud::HudBatch,
items::ItemStack,
mobs::{MobEvent, Species, Who, equine},
motion::{MotionWorld, PlayerMotion},
pose::{Dimensions, Pose},
sprint::Keys,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_core::{
geometry::{Aabb, Vec3, VoxelShape, trig},
random::RandomSource,
};
/// `passengerAttachments` minus the player's 0.6 vehicle attachment.
fn seat(s: Species) -> f64 {
@@ -364,12 +370,10 @@ impl Game<'_> {
let jump = self.charge_mount_jump(i, keys);
let m = &mut self.mobs[i];
if jump > 0.0 {
// onPlayerJump: rear while jumping (horses).
if matches!(
m.species(),
Species::Horse | Species::SkeletonHorse | Species::ZombieHorse
) {
m.extra.counter = 20;
// AbstractHorse.onPlayerJump: slide on the keys and rear (horses,
// donkeys and mules).
if equine::is_equine(m.species()) {
equine::on_player_jump(m);
}
// The nautilus plays its dash sound itself.
if equine::is_equine(m.species()) {
@@ -413,18 +417,15 @@ impl Game<'_> {
let mut at = Vec3::new(m.position.x, m.position.y + seat(m.species()), m.position.z);
if horizon_client::mobs::camel::is_camel(m.species()) {
// Camel.getPassengerAttachmentPoint: the driver half a block
// forward, at the body anchor (lower while sitting).
let scale = if m.is_baby() { 0.6 } else { 1.0 };
let anchor = if horizon_client::mobs::camel::sitting(m) {
(2.375 - 1.43) * scale - if m.is_baby() { 0.09375 } else { 0.375 } + 0.2 * scale
} else {
2.375 * scale - if m.is_baby() { 0.09375 } else { 0.375 }
};
let yaw = m.yaw.to_radians();
// forward, at the body anchor (lower while sitting), turned with
// the camel.
let a = horizon_client::mobs::camel::passenger_attachment(m);
let turned = Vec3::new(a[0], a[1], a[2])
.rotate_y(-m.yaw * (std::f64::consts::PI / 180.0) as f32);
at = Vec3::new(
m.position.x - f64::from(0.5 * scale * yaw.sin()),
m.position.y + f64::from(anchor) - 0.6,
m.position.z + f64::from(0.5 * scale * yaw.cos()),
m.position.x + turned.x,
m.position.y + turned.y - 0.6,
m.position.z + turned.z,
);
}
if m.species() == Species::HappyGhast {
@@ -450,6 +451,12 @@ impl Game<'_> {
m.position.z + z * yaw.cos(),
);
}
// AbstractHorse.getPassengerAttachmentPoint: the rider slides back and
// up while the horse rears.
let rear = equine::rear_offset(m);
let rear = Vec3::new(rear[0], rear[1], rear[2])
.rotate_y(-m.yaw * (std::f64::consts::PI / 180.0) as f32);
at = Vec3::new(at.x + rear.x, at.y + rear.y, at.z + rear.z);
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
p.position = at;
@@ -471,34 +478,32 @@ impl Game<'_> {
self.dismount_happy_ghast(id);
return;
}
let yaw = m.yaw.to_radians();
let side = Vec3::new(f64::from(yaw.cos()), 0.0, f64::from(yaw.sin()));
let w = f64::from(m.width()) / 2.0 + 0.4;
let base = m.position;
let candidates = [
Vec3::new(base.x - side.x * w, base.y, base.z - side.z * w),
Vec3::new(base.x + side.x * w, base.y, base.z + side.z * w),
Vec3::new(base.x, base.y + f64::from(m.height()), base.z),
];
let spot = candidates
.into_iter()
.find(|c| {
let f = |dy: i32| {
self.res
.facts()
.get(self.world.block(
c.x.floor() as i32,
c.y.floor() as i32 + dy,
c.z.floor() as i32,
))
.full_collision
};
!f(0) && !f(1)
})
.unwrap_or(candidates[2]);
let mount = Mount {
species: m.species(),
position: m.position,
bounds: m.bounds(),
width: m.width(),
height: m.height(),
yaw: m.yaw,
};
let Some(p) = self.player.as_ref() else {
return;
};
let rider = Rider {
yaw: self.yaw,
width: p.dimensions.width,
poses: rider_poses(p),
};
let (spot, pose) = dismount_location(&mount, &rider, &GameDismountWorld(self));
if let Some(p) = self.player.as_mut() {
p.position = spot;
p.velocity = Vec3::ZERO;
// LivingEntity.setPose: the dismount pose that fit.
if let Some(pose) = pose
&& let Err(e) = p.set_pose(pose)
{
log::warn!("dismount pose: {e}");
}
}
}
pub(super) fn mount_event(&mut self, e: &MobEvent) {
@@ -551,6 +556,62 @@ impl Game<'_> {
_ => {}
}
}
/// `Entity.propagateFallToPassengers`: whoever rides the mob `mob` takes
/// `causeFallDamage` of its fall, as the mount's own damage does. The local
/// player, the server players and the mob on its back each get it, with
/// their own safe distance and armour.
pub(super) fn fall_to_passengers(&mut self, mob: u32, distance: f64, modifier: f32) {
if self.riding == Some(mob) {
self.rider_fall_damage(distance, modifier);
}
let guests: Vec<u32> = self.lan.as_ref().map_or_else(Vec::new, |h| {
h.hub
.guests
.iter()
.filter(|g| g.vehicle == Some(mob) && Some(g.conn) != self.lan_acting)
.map(|g| g.conn)
.collect()
});
if !guests.is_empty()
&& let Some(mut host) = self.lan.take()
{
for conn in guests {
self.guest_hit_by(&mut host, conn, |g| g.rider_fall_damage(distance, modifier));
}
self.lan = Some(host);
}
let rider = self
.mobs
.iter()
.find(|m| m.id == mob)
.and_then(|m| m.extra.rider);
if let Some(rider) = rider
&& let Some(r) = self.mobs.iter_mut().find(|m| m.id == rider)
{
let mut events = Vec::new();
r.cause_fall_damage_from(distance, modifier, &mut events);
let _ = self.handle_mob_events(events);
}
}
/// The player this game runs (the local one, or the server player it acts
/// as) takes the fall of the mount under it. `cause_mount_fall` runs the
/// landing code's own fall damage for the player: creative flight is
/// exempt, and the statistic, the sounds and the damage records wait in the
/// player's events for its next tick, as a landing of its own leaves them.
fn rider_fall_damage(&mut self, distance: f64, modifier: f32) {
let Some(player) = self.player.as_mut() else {
return;
};
let _ = MotionWorld {
entities: &[],
player,
scene: &mut self.world,
assets: &self.pack.assets,
collision_binding: self.collision,
resources: self.motion,
}
.cause_mount_fall(distance, modifier);
}
/// `PlayerRideableJumping`: horses, camels and nautili (the dash).
fn jumps(species: Species) -> bool {
equine::is_equine(species) || horizon_client::mobs::nautilus::is_nautilus(species)
@@ -598,3 +659,289 @@ impl Game<'_> {
hearts
}
}
/// The mount a rider gets off: its kind, where it stands, its box,
/// `getBbWidth`, `getBbHeight` and body yaw.
struct Mount {
species: Species,
position: Vec3,
bounds: Aabb,
width: f32,
height: f32,
yaw: f32,
}
/// The rider getting off: `getYRot`, `getBbWidth`, and
/// `getLocalBoundsForPose` for each of `Player.getDismountPoses`.
struct Rider {
yaw: f32,
width: f32,
poses: Vec<(Pose, Dimensions)>,
}
/// `Player.getDismountPoses`: standing, crouching, then crawling.
const DISMOUNT_POSES: [Pose; 3] = [Pose::Standing, Pose::Crouching, Pose::Swimming];
/// The rider's box in each dismount pose, at their `minecraft:scale`.
fn rider_poses(p: &PlayerMotion) -> Vec<(Pose, Dimensions)> {
// EntityDimensions.scale: every player pose scales alike, so the
// standing width gives the scale without a mutable attribute lookup.
let scale = p.dimensions.width / Pose::Standing.dimensions(1.0).width;
DISMOUNT_POSES
.iter()
.map(|&pose| (pose, pose.dimensions(scale)))
.collect()
}
/// The level queries a dismount makes.
trait DismountWorld {
/// A full collision block (the generic rule's test).
fn full_collision(&self, p: [i32; 3]) -> bool;
/// `getFluidState(pos).is(FluidTags.LAVA)`.
fn lava(&self, p: [i32; 3]) -> bool;
/// `BlockGetter.getBlockFloorHeight(pos)`.
fn floor_height(&self, p: [i32; 3]) -> f64;
/// `DismountHelper.canDismountTo(level, passenger, box)`.
fn can_dismount_to(&self, bounds: Aabb) -> bool;
}
struct GameDismountWorld<'a, 'g>(&'a Game<'g>);
impl DismountWorld for GameDismountWorld<'_, '_> {
fn full_collision(&self, p: [i32; 3]) -> bool {
let g = self.0;
g.res
.facts()
.get(g.world.block(p[0], p[1], p[2]))
.full_collision
}
fn lava(&self, p: [i32; 3]) -> bool {
let g = self.0;
g.res.facts().get(g.world.block(p[0], p[1], p[2])).fluid == Fluid::Lava
}
fn floor_height(&self, p: [i32; 3]) -> f64 {
let shape = self.0.block_collision_shape(p);
let below = std::cell::OnceCell::<VoxelShape>::new();
horizon_core::geometry::block_floor_height(&shape, || {
below.get_or_init(|| self.0.block_collision_shape([p[0], p[1] - 1, p[2]]))
})
}
fn can_dismount_to(&self, bounds: Aabb) -> bool {
self.0.can_dismount_to(bounds)
}
}
/// `getDismountLocationForPassenger`, and the pose the rider takes there.
fn dismount_location(
mount: &Mount,
rider: &Rider,
world: &impl DismountWorld,
) -> (Vec3, Option<Pose>) {
if mount.species == Species::Strider {
return strider_dismount_location(mount, rider, world);
}
// Beside the mount, else on top of it.
let yaw = mount.yaw.to_radians();
let side = Vec3::new(f64::from(yaw.cos()), 0.0, f64::from(yaw.sin()));
let w = f64::from(mount.width) / 2.0 + 0.4;
let base = mount.position;
let candidates = [
Vec3::new(base.x - side.x * w, base.y, base.z - side.z * w),
Vec3::new(base.x + side.x * w, base.y, base.z + side.z * w),
Vec3::new(base.x, base.y + f64::from(mount.height), base.z),
];
let spot = candidates
.into_iter()
.find(|c| {
let f = |dy: i32| {
world.full_collision([
c.x.floor() as i32,
c.y.floor() as i32 + dy,
c.z.floor() as i32,
])
};
!f(0) && !f(1)
})
.unwrap_or(candidates[2]);
(spot, None)
}
/// `Entity.getCollisionHorizontalEscapeVector`: out of the vehicle's box
/// along `yaw`, to the edge of the square it spans.
fn escape_vector(vehicle_width: f64, passenger_width: f64, yaw: f32) -> Vec3 {
// 1.0E-5F: a float literal widened to double.
let distance = (vehicle_width + passenger_width + f64::from(1.0e-5_f32)) / 2.0;
let radians = f64::from(yaw * 0.017_453_292);
let (sin, cos) = (-trig::sin(radians), trig::cos(radians));
let max = f64::from(sin.abs().max(cos.abs()));
Vec3::new(
f64::from(sin) * distance / max,
0.0,
f64::from(cos) * distance / max,
)
}
/// `Strider.getDismountLocationForPassenger`: the columns along the rider's
/// facing and 22.5 and 45 degrees either side of it, each from the top of
/// the strider's box down to half a block below its feet. The first block
/// with no lava, a valid floor (`DismountHelper.isBlockFloorValid`) and room
/// for one of the rider's dismount poses (`canDismountTo`) wins, with that
/// pose; without one, on top of the strider with the pose unchanged.
fn strider_dismount_location(
strider: &Mount,
rider: &Rider,
world: &impl DismountWorld,
) -> (Vec3, Option<Pose>) {
let (vehicle, passenger) = (f64::from(strider.width), f64::from(rider.width));
let top = strider.bounds.max_position().y;
let bottom = strider.bounds.min_position().y - 0.5;
// A LinkedHashSet: each block once, in the order first reached.
let mut blocks: Vec<[i32; 3]> = Vec::new();
for turn in [0.0, -22.5, 22.5, -45.0, 45.0] {
let offset = escape_vector(vehicle, passenger, rider.yaw + turn);
let (x, z) = (
(strider.position.x + offset.x).floor() as i32,
(strider.position.z + offset.z).floor() as i32,
);
let mut y = top;
let mut block_y = top.floor() as i32;
while y > bottom {
if !blocks.contains(&[x, block_y, z]) {
blocks.push([x, block_y, z]);
}
block_y -= 1;
y -= 1.0;
}
}
for p in blocks {
if world.lava(p) {
continue;
}
// DismountHelper.isBlockFloorValid.
let floor = world.floor_height(p);
if floor.is_infinite() || floor >= 1.0 {
continue;
}
// Vec3.upFromBottomCenterOf.
let at = Vec3::new(
f64::from(p[0]) + 0.5,
f64::from(p[1]) + floor,
f64::from(p[2]) + 0.5,
);
for (pose, dimensions) in &rider.poses {
if world.can_dismount_to(dimensions.bounds(at)) {
return (at, Some(*pose));
}
}
}
(Vec3::new(strider.position.x, top, strider.position.z), None)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
/// A lava sea up to y 30 with full blocks placed in it; air above.
struct Sea {
blocks: HashMap<[i32; 3], ()>,
}
impl Sea {
fn with(blocks: &[[i32; 3]]) -> Self {
Self {
blocks: blocks.iter().map(|&b| (b, ())).collect(),
}
}
fn solid(&self, p: [i32; 3]) -> bool {
self.blocks.contains_key(&p)
}
}
impl DismountWorld for Sea {
fn full_collision(&self, p: [i32; 3]) -> bool {
self.solid(p)
}
fn lava(&self, p: [i32; 3]) -> bool {
p[1] <= 30 && !self.solid(p)
}
fn floor_height(&self, p: [i32; 3]) -> f64 {
if self.solid(p) {
1.0
} else if self.solid([p[0], p[1] - 1, p[2]]) {
0.0
} else {
f64::NEG_INFINITY
}
}
fn can_dismount_to(&self, bounds: Aabb) -> bool {
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let cells = |a: f64, b: f64| a.floor() as i32..=(b - 1.0e-7).floor() as i32;
!cells(lo.x, hi.x).any(|x| {
cells(lo.y, hi.y).any(|y| cells(lo.z, hi.z).any(|z| self.solid([x, y, z])))
})
}
}
/// The top of a lava source at y 30 (`FlowingFluid.getOwnHeight`, 8/9).
const SURFACE: f64 = 30.0 + 8.0 / 9.0;
/// A strider (0.9 by 1.7) standing on the lava at x, z, facing south.
fn strider_at(x: f64, z: f64) -> Mount {
Mount {
species: Species::Strider,
position: Vec3::new(x, SURFACE, z),
bounds: Aabb::new(
Vec3::new(x - 0.45, SURFACE, z - 0.45),
Vec3::new(x + 0.45, SURFACE + 1.7, z + 0.45),
),
width: 0.9,
height: 1.7,
yaw: 0.0,
}
}
fn rider(yaw: f32) -> Rider {
Rider {
yaw,
width: 0.6,
poses: DISMOUNT_POSES
.iter()
.map(|&pose| (pose, pose.dimensions(1.0)))
.collect(),
}
}
fn block_of(at: Vec3) -> [i32; 3] {
[
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
]
}
#[test]
fn a_strider_in_open_lava_puts_its_rider_on_its_back() {
// #3513: getting off in a lava sea used to put the player beside the
// strider, in the lava; with no floor in reach vanilla puts the rider
// on top of the strider instead.
let sea = Sea::with(&[]);
let (at, pose) = dismount_location(&strider_at(0.5, 0.5), &rider(0.0), &sea);
assert!(!sea.lava(block_of(at)), "dismounted into lava at {at:?}");
assert_eq!(at, Vec3::new(0.5, SURFACE + 1.7, 0.5));
assert_eq!(pose, None);
}
#[test]
fn a_strider_by_the_shore_puts_its_rider_on_the_shore() {
// Netherrack 45 degrees to the right of a rider facing south (+z);
// the straight-ahead and 22.5 degree columns are lava.
let sea = Sea::with(&[[-1, 30, 1]]);
let (at, pose) = dismount_location(&strider_at(0.5, 0.5), &rider(0.0), &sea);
assert_eq!(at, Vec3::new(-0.5, 31.0, 1.5));
assert_eq!(pose, Some(Pose::Standing));
}
#[test]
fn a_low_ceiling_on_the_shore_makes_the_rider_crawl_off() {
let sea = Sea::with(&[[0, 30, 1], [0, 32, 1]]);
let (at, pose) = dismount_location(&strider_at(0.5, 0.5), &rider(0.0), &sea);
assert_eq!(at, Vec3::new(0.5, 31.0, 1.5));
assert_eq!(pose, Some(Pose::Swimming));
}
#[test]
fn the_escape_vector_reaches_the_edge_of_the_square() {
let edge = (0.9 + 0.6 + f64::from(1.0e-5_f32)) / 2.0;
let v = escape_vector(0.9, 0.6, 0.0);
assert!((v.z - edge).abs() < 1.0e-6 && v.x.abs() < 1.0e-6);
// Facing west of south: the corner of the square.
let v = escape_vector(0.9, 0.6, 45.0);
assert!((v.x + edge).abs() < 1.0e-3 && (v.z - edge).abs() < 1.0e-3);
}
}
+155 -4
View File
@@ -6,6 +6,22 @@
//! Horizon's own HUD extras do, and keep a mod's settings where the
//! WebAssembly mod of the same id kept them.
//!
//! API 13 adds using an item (`native_use_item_on`, `native_use_item`).
//!
//! API 12 adds item tooltips (`native_tooltip`).
//!
//! API 11 adds server particle emission (`native_send_particles`).
//!
//! API 10 adds server menus (`native_menu_clicked`, `native_menu_closed`,
//! and `native_open_menu`, `native_exchange_items`... in `native_server.rs`).
//!
//! API 9 adds the acting player's plain items (`native_held`,
//! `native_count_items`, `native_take_items`, `native_give_items`... in
//! `native_server.rs`).
//!
//! API 8 adds server commands and players (`native_register_commands`,
//! `native_perform_command`, and the helpers in `native_server.rs`).
//!
//! API 7 adds block-change observations (`native_block_changed`).
//!
//! API 6 adds neighbour-change observations (`native_neighbor_changed`).
@@ -31,16 +47,20 @@
//! either (`tests::hook_points_stay_hookable`).
use super::native_lifecycle::{NativeLifecycleContext, ServerTickPhase, WorldPhase};
use super::{
Game, GameLevel, LevelDimension, Screen, execution_role::ExecutionRole,
Game, GameLevel, LevelDimension, Screen,
execution_role::ExecutionRole,
native_panel_recipes::RecipePage,
native_server::{NativeCommand, NativeServerPlayer},
};
use horizon_client::{
chat::ChatSource,
extras::Corner,
extras_hud::Corners,
hud::{HudBatch, rgba},
items::ItemStack,
keybinds,
mod_list::{self, Installed, NativePage},
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::position::BlockPos;
@@ -51,7 +71,7 @@ use winit::{event::MouseButton, keyboard::KeyCode};
/// The hook points' API version. The launcher reads this line from the
/// source it builds; keep its form.
#[allow(dead_code)]
pub(crate) const NATIVE_HOOKS_API: u32 = 7;
pub(crate) const NATIVE_HOOKS_API: u32 = 13;
impl GameLevel<'_> {
/// An authoritative server committed a changed state through `Level.setBlock`.
@@ -326,8 +346,11 @@ impl Game<'_> {
) {
let _ = (context, phase, error_text);
}
/// The authoritative server tick's phases, read-only. A paused pass
/// fires nothing; a frozen world carries `world_running` false except
/// The authoritative server tick's phases: `ServerTickStart` before any
/// level is ticked (NeoForge's `ServerTickEvent.Pre`), `ServerTickEnd`
/// after the last (`.Post`). Read blocks with `native_server_block`, by
/// dimension: which level `self.world` holds here is not defined. A paused
/// pass fires nothing; a frozen world carries `world_running` false except
/// during a requested step.
pub(super) fn native_server_tick(
&mut self,
@@ -386,9 +409,137 @@ impl Game<'_> {
pub(super) fn native_panel_event(&mut self, mod_id: &str, event: &NativePanelEvent) {
let _ = (mod_id, event);
}
/// The commands native mods add to the server's command tree: push
/// yours. Asked once when a world opens to players (a dedicated
/// server, a LAN host, single player's server thread); every player's
/// chat box then completes them as vanilla's, within their level. A
/// name vanilla has is left out. API 8.
pub(super) fn native_register_commands(&mut self, commands: &mut Vec<NativeCommand>) {
let _ = commands;
}
/// A player's command on the server (`Commands.performCommand`),
/// without its slash, for a command a mod registered and the player's
/// level allows: set `ran` when it is yours. The player's state is
/// swapped in for the call, so `native_player` and the other helpers
/// read that player; `native_feedback` answers them. API 8.
pub(super) fn native_perform_command(
&mut self,
player: &NativeServerPlayer,
command: &str,
ran: &mut bool,
) {
let _ = (player, command, ran);
}
/// A click in a menu a mod opened (`native_open_menu`), which moved
/// nothing: the menu's `slot` (its own first, then the player's
/// inventory, `native_menu_inventory_slot`; -999 outside), the `button`
/// and the click's `mode` (`ClickType`'s id) of the player `uuid`.
/// No player's state is swapped in: the menu helpers take the player's
/// UUID. API 10.
pub(super) fn native_menu_clicked(
&mut self,
uuid: u128,
window: i32,
slot: i32,
button: i32,
mode: i32,
) {
let _ = (uuid, window, slot, button, mode);
}
/// A menu a mod opened closed: the player closed it, the server or a mod
/// opened another, or the player left. API 10.
pub(super) fn native_menu_closed(&mut self, uuid: u128, window: i32) {
let _ = (uuid, window);
}
/// An item's tooltip, wherever the game shows one (slots, the creative
/// tabs, the recipe book, chat's hover): the stack, with its components
/// (`stack.data()`: a shulker box's contents...), and its lines, the name
/// first, each with a colour as `rgba` packs it (`u32::MAX` keeps the
/// text's own). Add, change or take out lines; `Text::legacy` reads `§`
/// codes. Asked each frame the tooltip shows, never for one the stack
/// hides. API 12.
pub(super) fn native_tooltip(&self, stack: &ItemStack, lines: &mut Vec<(Text, u32)>) {
let _ = (stack, lines);
}
/// A player uses `stack` (right click) with `self.hand` on the block at
/// `pos`, `face` the face clicked (`[0, 1, 0]` is the top), once the
/// block itself let the click through (a chest still opens): where
/// `ItemStack.useOn` starts. Set `claimed` to take the use: the hand
/// swings and neither the item's `useOn` nor its `use` runs.
/// Server-side, for every player of the world (the helpers read the
/// acting one); an item on cooldown is never used. API 13.
pub(super) fn native_use_item_on(
&mut self,
stack: &ItemStack,
pos: [i32; 3],
face: [i32; 3],
claimed: &mut bool,
) {
let _ = (stack, pos, face, claimed);
}
/// A player uses `stack` (right click) with `self.hand`: in the air, or
/// on a block once `native_use_item_on` let it through, where `Item.use`
/// starts. Each click reaches it once. Set `claimed` to take the use: the
/// hand swings and the item's own use doesn't run. Server-side, for every
/// player of the world; an item on cooldown is never used. API 13.
pub(super) fn native_use_item(&mut self, stack: &ItemStack, claimed: &mut bool) {
let _ = (stack, claimed);
}
}
/// Helpers for native mods, kept as stable as the hook points.
/// Server-level helpers for native mods.
#[allow(dead_code)]
impl GameLevel<'_> {
/// Queue particles in this authoritative level. A zero count sends one
/// directional particle. The return value means accepted, not delivered.
/// Block particle types require their block state; item, trail and
/// vibration particles are not supported by the current network dispatch.
pub(super) fn native_send_particles(
&mut self,
options: &ParticleOptions,
state: Option<BlockStateId>,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: i32,
) -> bool {
use ParticleType as T;
let supported = match options.ty {
T::Block | T::BlockMarker | T::FallingDust | T::DustPillar | T::BlockCrumble => {
state.is_some_and(|id| self.registry.state(id).is_some())
}
T::Item | T::Trail | T::Vibration => false,
T::Geyser | T::GeyserPlume | T::GeyserBase | T::GeyserPoof => options
.geyser
.is_some_and(|(_, impulse)| impulse.is_finite()),
_ => true,
};
if !self.native_authoritative
|| !supported
|| !at.into_iter().all(f64::is_finite)
|| !spread
.into_iter()
.all(|v| v.is_finite() && v.abs() <= f32::MAX as f64)
|| !speed.is_finite()
|| speed.abs() > f32::MAX as f64
|| !options.color.into_iter().all(f32::is_finite)
|| !options.to_color.into_iter().all(f32::is_finite)
|| !options.scale.is_finite()
{
return false;
}
let dimension = match self.dimension {
LevelDimension::Overworld => "minecraft:overworld",
LevelDimension::Nether => "minecraft:the_nether",
LevelDimension::End => "minecraft:the_end",
};
self.native_particles
.push((dimension, options.clone(), state, at, spread, speed, count));
true
}
}
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
@@ -394,55 +394,6 @@ mod tests {
assert!(lifecycle.world_close_final().is_none());
}
#[test]
fn dedicated_open_and_close_sites_bound_the_server_lifecycle() {
let source = include_str!("dedicated.rs")
.split("#[cfg(test)]")
.next()
.unwrap_or_default();
let open = source
.find("self.native_lifecycle_open_started();")
.unwrap();
let load = source.find("self.load_world(WorldRequest::new(").unwrap();
let ready = source.find("self.native_lifecycle_world_ready();").unwrap();
let tick = source.find("self.tick_server_timed(paused)?;").unwrap();
assert!(open < load && load < ready && ready < tick);
let stop = source.split("fn stop_dedicated").nth(1).unwrap();
let begin = stop.find("self.native_lifecycle.begin_close();").unwrap();
let save = stop.find("self.save_game()?;").unwrap();
let close = stop.find("self.native_lifecycle_world_closed();").unwrap();
let release = stop.find("self.lan_close(false);").unwrap();
assert!(begin < save && save < close && close < release);
}
#[test]
fn failed_server_ticks_cancel_before_end() {
let tick = include_str!("levels.rs")
.split("fn tick_server(&mut self, paused: bool)")
.nth(1)
.unwrap()
.split("fn tick_server_timed")
.next()
.unwrap();
let first_cancel = tick
.find("self.native_lifecycle.cancel_server_tick();")
.unwrap();
let result = tick.find("result?;").unwrap();
let close = tick
.find("if let Err(error) = self.close_idle_levels()")
.unwrap();
let second_cancel = tick
.rfind("self.native_lifecycle.cancel_server_tick();")
.unwrap();
let end = tick
.find("self.native_lifecycle_server_tick_end(paused);")
.unwrap();
assert!(
first_cancel < result && result < close && close < second_cancel && second_cancel < end
);
}
#[test]
fn lifecycle_delivery_stays_off_the_deprecated_in_frame_path() {
let game = include_str!("../game.rs")
@@ -0,0 +1,365 @@
//! Server menus for native mods (docs/native-mods.md, "Server menus", API
//! 10): a vanilla chest menu (`generic_9x1` to `generic_9x6`) a mod opens
//! for a player and fills with items to look at. No click moves anything:
//! each reaches the mod (`native_menu_clicked`), as does the menu's closing
//! (`native_menu_closed`), and the client shows the menu as the server sends
//! it again. A stock client shows it; nothing on the player's side.
//! `native_exchange_items` swaps two players' stacks in one step, checked
//! first, so a trade built on these menus never moves an item before both
//! sides are settled.
use super::Game;
use horizon_client::{
items::ItemStack,
lan::{data::component_nbt, menu::NativeMenuEvent},
text::Text,
};
/// The inventory slot (0..9 the hotbar, 9..36 the rest) a click on `slot`
/// of a native menu of `size` slots landed on, when it is the player's
/// inventory below the menu (`ChestMenu`: the menu's slots, then the
/// inventory's 27, then the hotbar's 9).
#[allow(dead_code)]
pub(crate) fn native_menu_inventory_slot(size: usize, slot: i32) -> Option<usize> {
let n = usize::try_from(slot).ok()?.checked_sub(size)?;
match n {
0..27 => Some(n + 9),
27..36 => Some(n - 27),
_ => None,
}
}
/// Adds every stack of `gets` to the 36 slots of `inventory`, onto matching
/// stacks first, then into empty slots, hotbar first; false when one doesn't
/// fit whole. `max` is an item's stack size.
fn fit(
inventory: &mut [Option<ItemStack>],
gets: &[ItemStack],
max: &impl Fn(&ItemStack) -> u32,
) -> bool {
for stack in gets {
let mut left = stack.count;
let max = max(stack).max(1);
for slot in inventory.iter_mut().take(36).flatten() {
if left > 0 && slot.same_item_same_components(stack) && slot.count < max {
let n = (max - slot.count).min(left);
slot.count += n;
left -= n;
}
}
for slot in inventory.iter_mut().take(36) {
if left > 0 && slot.is_none() {
let n = max.min(left);
*slot = Some(ItemStack {
count: n,
..stack.clone()
});
left -= n;
}
}
if left > 0 {
return false;
}
}
true
}
/// Two players' offers swapped, or nothing changed: each offer is the
/// inventory slots it takes with the stack each must still hold, exactly.
/// The stacks leave their slots, then go to the other side where they fit
/// whole; an Err (which side, why) leaves both inventories as they were.
fn exchange(
a: &mut Vec<Option<ItemStack>>,
a_offer: &[(usize, ItemStack)],
b: &mut Vec<Option<ItemStack>>,
b_offer: &[(usize, ItemStack)],
max: impl Fn(&ItemStack) -> u32,
) -> Result<(), String> {
fn take(
inv: &[Option<ItemStack>],
offer: &[(usize, ItemStack)],
who: &str,
) -> Result<(Vec<Option<ItemStack>>, Vec<ItemStack>), String> {
let mut left = inv.to_vec();
let mut taken = Vec::new();
for (slot, expected) in offer {
match left.get_mut(*slot).filter(|_| *slot < 36) {
Some(s) if s.as_ref() == Some(expected) => taken.push(s.take().expect("checked")),
_ => return Err(format!("{who} no longer holds their offer")),
}
}
Ok((left, taken))
}
let (mut a_after, a_gives) = take(a, a_offer, "the first player")?;
let (mut b_after, b_gives) = take(b, b_offer, "the second player")?;
if !fit(&mut a_after, &b_gives, &max) {
return Err("the first player has no room".into());
}
if !fit(&mut b_after, &a_gives, &max) {
return Err("the second player has no room".into());
}
*a = a_after;
*b = b_after;
Ok(())
}
/// Server menus, for server mods (API 10). The players are named by UUID;
/// none of these runs while a player's state is swapped in (inside
/// `native_perform_command`, the menus can be opened and filled, but
/// `native_player_stacks` and `native_exchange_items` refuse: call them from
/// `native_menu_clicked` or `native_server_tick`).
#[allow(dead_code)]
impl Game<'_> {
fn native_conn(&self, uuid: u128) -> Option<u32> {
let host = self.lan.as_ref()?;
let g = host
.hub
.guests
.iter()
.find(|g| g.placed && g.uuid == uuid)?;
Some(g.conn)
}
/// Opens a chest menu of `rows` rows (1 to 6) titled `title` for the
/// player, showing `items` (by slot, padded with empty ones), in place of
/// the menu they had open: its window id.
pub(super) fn native_open_menu(
&mut self,
uuid: u128,
rows: u8,
title: Text,
items: Vec<Option<ItemStack>>,
) -> Option<i32> {
let conn = self.native_conn(uuid)?;
let host = self.lan.as_mut()?;
host.hub
.open_native_menu(conn, rows, component_nbt(&title.component()), items)
}
/// Replaces what the player's open native menu shows; sent with the
/// tick's container sync.
pub(super) fn native_set_menu_items(&mut self, uuid: u128, items: Vec<Option<ItemStack>>) {
if let Some(conn) = self.native_conn(uuid)
&& let Some(host) = self.lan.as_mut()
{
host.hub.set_native_menu(conn, items);
}
}
/// Closes the player's native menu (a `native_menu_closed` follows).
pub(super) fn native_close_menu(&mut self, uuid: u128) {
if let Some(conn) = self.native_conn(uuid)
&& let Some(host) = self.lan.as_mut()
&& host
.hub
.guest(conn)
.and_then(|g| g.menu.as_ref())
.is_some_and(|m| m.slots == horizon_client::lan::menu::Slots::Native)
{
host.hub.close_container(conn);
}
}
/// The player's 36 inventory slots (0..9 the hotbar), to show or offer
/// them; None for a player not here, dead, or whose state is swapped in.
pub(super) fn native_player_stacks(&self, uuid: u128) -> Option<Vec<Option<ItemStack>>> {
let host = self.lan.as_ref()?;
let g = host
.hub
.guests
.iter()
.find(|g| g.placed && g.uuid == uuid)?;
(!g.dead && self.lan_acting != Some(g.conn)).then(|| {
let mut stacks = g.inventory.clone();
stacks.resize(36, None);
stacks
})
}
/// Swaps two players' offers (inventory slots, each with the stack it
/// must still hold, exactly) in one step: the stacks leave their slots
/// and go to the other player where they fit whole, or nothing moves and
/// the Err says why. Both must be here and alive, neither with their
/// state swapped in. Their screens follow with the tick's sync.
pub(super) fn native_exchange_items(
&mut self,
a: u128,
a_offer: &[(usize, ItemStack)],
b: u128,
b_offer: &[(usize, ItemStack)],
) -> Result<(), String> {
if a == b {
return Err("a player can't trade with themselves".into());
}
let acting = self.lan_acting;
let items = self.res.items();
let Some(host) = self.lan.as_mut() else {
return Err("no server".into());
};
let find = |uuid: u128| {
host.hub
.guests
.iter()
.position(|g| g.placed && !g.dead && g.uuid == uuid)
.filter(|&i| acting != Some(host.hub.guests[i].conn))
};
let (Some(ia), Some(ib)) = (find(a), find(b)) else {
return Err("both players must be here".into());
};
let mut inv_a = std::mem::take(&mut host.hub.guests[ia].inventory);
let mut inv_b = std::mem::take(&mut host.hub.guests[ib].inventory);
let result = exchange(&mut inv_a, a_offer, &mut inv_b, b_offer, |s| {
items.get(&s.item).map_or(64, |i| i.max_stack)
});
host.hub.guests[ia].inventory = inv_a;
host.hub.guests[ib].inventory = inv_b;
result
}
}
/// Where the game calls the menu hook points.
impl Game<'_> {
/// The clicks on and closings of native menus the hub collected, each
/// told to the mods with the hub back in `self.lan`, no player's state
/// swapped in.
pub(super) fn run_native_menu_events(&mut self) {
loop {
let Some(host) = self.lan.as_mut() else {
return;
};
if host.hub.native_menu_events.is_empty() {
return;
}
match host.hub.native_menu_events.remove(0) {
NativeMenuEvent::Click {
uuid,
window,
slot,
button,
mode,
} => self.native_menu_clicked(uuid, window, slot, button, mode),
NativeMenuEvent::Closed { uuid, window } => self.native_menu_closed(uuid, window),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stack(item: &str, count: u32) -> ItemStack {
ItemStack {
item: item.into(),
count,
damage: 0,
extra: None,
}
}
fn inventory(stacks: &[(usize, ItemStack)]) -> Vec<Option<ItemStack>> {
let mut inv = vec![None; 36];
for (i, s) in stacks {
inv[*i] = Some(s.clone());
}
inv
}
fn max(s: &ItemStack) -> u32 {
if &*s.item == "minecraft:diamond_sword" {
1
} else {
64
}
}
#[test]
fn menu_clicks_map_to_the_inventory_below() {
// A 6-row chest: 54 slots, then the inventory's 27, then the hotbar.
assert_eq!(native_menu_inventory_slot(54, 3), None);
assert_eq!(native_menu_inventory_slot(54, 54), Some(9));
assert_eq!(native_menu_inventory_slot(54, 80), Some(35));
assert_eq!(native_menu_inventory_slot(54, 81), Some(0));
assert_eq!(native_menu_inventory_slot(54, 89), Some(8));
assert_eq!(native_menu_inventory_slot(54, 90), None);
assert_eq!(native_menu_inventory_slot(54, -999), None);
assert_eq!(native_menu_inventory_slot(9, 9), Some(9));
}
#[test]
fn an_exchange_swaps_both_offers() {
let mut a = inventory(&[
(0, stack("minecraft:diamond", 5)),
(1, stack("minecraft:dirt", 64)),
]);
let mut b = inventory(&[
(4, stack("minecraft:diamond_sword", 1)),
(5, stack("minecraft:diamond", 60)),
]);
let a_offer = [(0, stack("minecraft:diamond", 5))];
let b_offer = [(4, stack("minecraft:diamond_sword", 1))];
exchange(&mut a, &a_offer, &mut b, &b_offer, max).unwrap();
// A gets the sword in the slot their diamonds left; B's diamonds
// fill their stack to 64, the rest in the first empty slot.
assert_eq!(a[0], Some(stack("minecraft:diamond_sword", 1)));
assert_eq!(a[1], Some(stack("minecraft:dirt", 64)));
assert_eq!(b[0], Some(stack("minecraft:diamond", 1)));
assert_eq!(b[4], None);
assert_eq!(b[5], Some(stack("minecraft:diamond", 64)));
let count = |inv: &[Option<ItemStack>], item: &str| -> u32 {
inv.iter()
.flatten()
.filter(|s| &*s.item == item)
.map(|s| s.count)
.sum()
};
// Nothing made, nothing lost.
assert_eq!(
count(&a, "minecraft:diamond") + count(&b, "minecraft:diamond"),
65
);
assert_eq!(
count(&a, "minecraft:diamond_sword") + count(&b, "minecraft:diamond_sword"),
1
);
}
#[test]
fn an_exchange_that_cant_complete_moves_nothing() {
let a0 = inventory(&[(0, stack("minecraft:diamond", 5))]);
let b0 = inventory(&[(2, stack("minecraft:emerald", 3))]);
// The offer changed since it was made (moved, dropped, used).
let (mut a, mut b) = (a0.clone(), b0.clone());
let stale = [(0, stack("minecraft:diamond", 6))];
assert!(exchange(&mut a, &stale, &mut b, &[], max).is_err());
assert_eq!((&a, &b), (&a0, &b0));
// An empty or out-of-range slot.
assert!(
exchange(
&mut a,
&[(7, stack("minecraft:diamond", 5))],
&mut b,
&[],
max
)
.is_err()
);
assert!(
exchange(
&mut a,
&[(36, stack("minecraft:diamond", 5))],
&mut b,
&[],
max
)
.is_err()
);
assert_eq!((&a, &b), (&a0, &b0));
// No room: B's inventory is full of other things.
let full: Vec<_> = (0..36).map(|_| Some(stack("minecraft:dirt", 64))).collect();
let mut b = full.clone();
let offer = [(0, stack("minecraft:diamond", 5))];
assert!(exchange(&mut a, &offer, &mut b, &[], max).is_err());
assert_eq!((&a, &b), (&a0, &full));
// The same slot offered twice is the second time empty.
let twice = [
(0, stack("minecraft:diamond", 5)),
(0, stack("minecraft:diamond", 5)),
];
let mut b = b0.clone();
assert!(exchange(&mut a, &twice, &mut b, &[], max).is_err());
assert_eq!((&a, &b), (&a0, &b0));
}
}
@@ -0,0 +1,697 @@
//! Server commands and players for native mods (docs/native-mods.md,
//! "Server commands and players", API 8): the commands mods add to the
//! server's command tree (`native_register_commands`), a player's command
//! reaching them (`native_perform_command`), and the helpers a server mod
//! answers with: the players online, a line to one of them or to all, the
//! command's feedback and the world's folder; and (API 9) the acting
//! player's plain items: count, take, give. They act on the server's own
//! players (the hub's: a dedicated server's, a LAN host's, single player's
//! server thread's), never on a client's.
use super::{Game, lan::LanHost};
use horizon_client::{items::ItemStack, lan::data::component_nbt, text::Text};
use horizon_protocol::packets::play::{self, Clientbound as C};
use serde_json::{Map, Value, json};
use std::{collections::HashMap, path::PathBuf};
/// A player on the server, as a server hook sees them.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeServerPlayer {
pub(crate) uuid: u128,
pub(crate) name: String,
/// The operator level, 0 to 4 (`ops.json`; single player's owner with
/// cheats on is 4).
pub(crate) permission: u8,
/// `minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end`.
pub(crate) dimension: String,
pub(crate) position: [f64; 3],
/// 0 survival, 1 creative, 2 adventure, 3 spectator.
pub(crate) game_mode: i32,
}
/// A command a native mod adds, as one usage: words, then arguments
/// `<name:kind>`, optional ones last in brackets `[name:kind]`:
/// `pay <player:player> <amount:integer>`, `balance [player:player]`,
/// `eco give <player:player> <amount:integer>`. Kinds: `word`, `text` (the
/// rest of the line, last), `integer`, `number`, `boolean`, `player`,
/// `item`, `block_pos`. Usages of one command share their words; the
/// server merges them into one tree.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct NativeCommand {
pub(crate) usage: String,
/// The operator level a player needs to see and run it (0: everyone).
pub(crate) level: u8,
}
#[allow(dead_code)]
impl NativeCommand {
/// A usage every player may run.
pub(crate) fn new(usage: &str) -> Self {
Self {
usage: usage.to_owned(),
level: 0,
}
}
/// The same, for operators of `level` and above.
pub(crate) fn op(usage: &str, level: u8) -> Self {
Self {
usage: usage.to_owned(),
level: level.min(4),
}
}
}
/// The brigadier parser of an argument kind, and its properties.
fn parser(kind: &str) -> Option<(&'static str, Value)> {
Some(match kind {
"word" => ("brigadier:string", json!({"type": "word"})),
"text" => ("brigadier:string", json!({"type": "greedy"})),
"integer" => ("brigadier:integer", Value::Null),
"number" => ("brigadier:double", Value::Null),
"boolean" => ("brigadier:bool", Value::Null),
"player" => (
"minecraft:entity",
json!({"amount": "single", "type": "players"}),
),
"item" => ("minecraft:item_stack", Value::Null),
"block_pos" => ("minecraft:block_pos", Value::Null),
_ => return None,
})
}
/// The report's name of an operator level (`commands.json`'s
/// `permissions`).
const LEVELS: [&str; 5] = ["", "moderators", "gamemasters", "admins", "owners"];
/// One parsed word of a usage: its key in the tree and its node, without
/// children.
fn usage_nodes(usage: &str) -> Result<Vec<(String, Value, bool)>, String> {
let mut out = Vec::new();
let mut optional_seen = false;
let mut greedy_seen = false;
for word in usage.split_whitespace() {
if greedy_seen {
return Err(format!("`{usage}`: a text argument must be last"));
}
let (inner, optional) = if let Some(w) = word.strip_prefix('<') {
(w.strip_suffix('>'), false)
} else if let Some(w) = word.strip_prefix('[') {
(w.strip_suffix(']'), true)
} else {
if optional_seen {
return Err(format!("`{usage}`: `{word}` follows an optional argument"));
}
let literal = !word.is_empty()
&& word
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "_-".contains(c));
if !literal {
return Err(format!("`{usage}`: `{word}` is not a command word"));
}
out.push((word.to_owned(), json!({"type": "literal"}), false));
continue;
};
let Some((name, kind)) = inner.and_then(|w| w.split_once(':')) else {
return Err(format!("`{usage}`: `{word}` is not `<name:kind>`"));
};
if optional_seen && !optional {
return Err(format!("`{usage}`: `{word}` follows an optional argument"));
}
let Some((parser, properties)) = parser(kind) else {
return Err(format!("`{usage}`: unknown kind `{kind}`"));
};
optional_seen |= optional;
greedy_seen |= kind == "text";
let mut node = json!({"type": "argument", "parser": parser});
if !properties.is_null() {
node["properties"] = properties;
}
out.push((name.to_owned(), node, optional));
}
if out.first().is_none_or(|n| n.1["type"] != "literal") {
return Err(format!("`{usage}`: a command starts with its name"));
}
Ok(out)
}
/// Native mods' commands as a root in the commands report's format
/// (`lan::commands`, which sends each player what their level allows), and
/// each command's level: the lowest of its usages. A node is runnable where
/// only optional arguments follow it. A command vanilla has (`vanilla`
/// answers) or a usage that doesn't parse is left out, with why.
pub(super) fn command_tree(
commands: &[NativeCommand],
vanilla: impl Fn(&str) -> bool,
) -> (Value, HashMap<String, u8>, Vec<String>) {
let mut root = Map::new();
let mut levels: HashMap<String, u8> = HashMap::new();
let mut problems = Vec::new();
for command in commands {
let nodes = match usage_nodes(&command.usage) {
Ok(n) => n,
Err(e) => {
problems.push(e);
continue;
}
};
if vanilla(&nodes[0].0) {
problems.push(format!("`{}`: vanilla has /{}", command.usage, nodes[0].0));
continue;
}
let mut children = &mut root;
for (i, (name, node, _)) in nodes.iter().enumerate() {
let runnable = nodes.get(i + 1).is_none_or(|n| n.2);
let entry = children.entry(name.clone()).or_insert_with(|| {
let mut n = node.clone();
n["executable"] = false.into();
n["level"] = 4.into();
n
});
if entry["type"] != node["type"] || entry["parser"] != node["parser"] {
problems.push(format!(
"`{}`: `{name}` is another kind in an earlier usage",
command.usage
));
break;
}
if runnable {
entry["executable"] = true.into();
}
let level = entry["level"]
.as_u64()
.unwrap_or(4)
.min(command.level.into());
entry["level"] = level.into();
if !entry["children"].is_object() {
entry["children"] = Value::Object(Map::new());
}
children = entry["children"].as_object_mut().expect("made above");
}
let level = levels.entry(nodes[0].0.clone()).or_insert(4);
*level = (*level).min(command.level);
}
let mut root = Value::Object(root);
finish(&mut root);
let tree = if levels.is_empty() {
Value::Null
} else {
json!({"type": "root", "children": root})
};
(tree, levels, problems)
}
/// Turns the working `level` into the report's `permissions`, and drops
/// empty child lists.
fn finish(children: &mut Value) {
let Some(map) = children.as_object_mut() else {
return;
};
for node in map.values_mut() {
let level = node["level"].as_u64().unwrap_or(0) as usize;
let object = node.as_object_mut().expect("a node");
object.remove("level");
if level > 0 {
object.insert(
"permissions".into(),
json!({"type": "require", "permission": {"level": LEVELS[level.min(4)]}}),
);
}
if object
.get("children")
.is_some_and(|c| c.as_object().is_some_and(Map::is_empty))
{
object.remove("children");
} else if let Some(c) = object.get_mut("children") {
finish(c);
}
}
}
/// Helpers for server mods, kept as stable as the hook points.
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// The players on this server, in the order they joined. Empty on a
/// client and in a world nobody else can join.
pub(super) fn native_server_players(&self) -> Vec<NativeServerPlayer> {
let Some(host) = &self.lan else {
return Vec::new();
};
host.hub
.guests
.iter()
.filter(|g| g.placed)
.map(|g| NativeServerPlayer {
uuid: g.uuid,
name: g.name.clone(),
permission: g.permission,
dimension: g.dimension.clone(),
position: g.position,
game_mode: g.game_mode,
})
.collect()
}
/// A system line in that player's chat; false when they aren't on.
pub(super) fn native_tell(&self, uuid: u128, line: Text) -> bool {
let Some(host) = &self.lan else {
return false;
};
let Some(g) = host.hub.guests.iter().find(|g| g.placed && g.uuid == uuid) else {
return false;
};
host.hub.send_to(g.conn, &system_chat(&line));
true
}
/// A system line in every player's chat and the server's log.
pub(super) fn native_broadcast(&self, line: Text) {
let plain = line.plain();
if self.options.dedicated.is_some() {
super::dedicated::log("INFO", &plain);
} else {
log::info!("{plain}");
}
let Some(host) = &self.lan else {
return;
};
let packet = system_chat(&line);
for g in host.hub.guests.iter().filter(|g| g.placed) {
host.hub.send_to(g.conn, &packet);
}
}
/// A line of the command's answer, inside `native_perform_command`:
/// sent to the player whose command it is after the call, as vanilla
/// commands' feedback is.
pub(super) fn native_feedback(&mut self, line: Text) {
match &mut self.chat_capture {
Some(lines) => lines.push(line),
None => self.system_message(line),
}
}
/// The open world's folder (`world/`, beside its `level.dat`): a server
/// mod keeps its data in a folder of its own there, so the world's
/// backups carry it. None when no world is open.
pub(super) fn native_world_dir(&self) -> Option<PathBuf> {
(!self.world_root.as_os_str().is_empty()).then(|| self.world_root.clone())
}
}
/// Plain stacks: no components beyond the count (no damage, name,
/// enchantment...), the only ones the item helpers count, take or give, so
/// a mod never prices a worn or enchanted item as a new one.
fn plain(stack: &ItemStack) -> bool {
stack.damage == 0 && stack.extra.is_none()
}
/// The slots the item helpers look at, in `Inventory.add`'s order: the
/// selected slot, the off hand, then the hotbar and the main inventory.
fn order(selected: usize) -> impl Iterator<Item = usize> {
std::iter::once(selected)
.chain(std::iter::once(OFFHAND))
.chain((0..36).filter(move |&i| i != selected))
}
const OFFHAND: usize = 36;
/// The 36 inventory slots and the off hand as one list ([`OFFHAND`] last).
fn slots<'a>(
inventory: &'a mut [Option<ItemStack>],
offhand: &'a mut Option<ItemStack>,
) -> Vec<&'a mut Option<ItemStack>> {
inventory
.iter_mut()
.take(36)
.chain(std::iter::once(offhand))
.collect()
}
/// How many of plain `item` the slots hold.
fn count_in(slots: &[&mut Option<ItemStack>], item: &str) -> u32 {
slots
.iter()
.filter_map(|s| s.as_ref())
.filter(|s| &*s.item == item && plain(s))
.map(|s| s.count)
.sum()
}
/// Takes up to `count` plain `item`, selected slot first; what it took.
fn take_from(
mut slots: Vec<&mut Option<ItemStack>>,
selected: usize,
item: &str,
count: u32,
) -> u32 {
let mut taken = 0;
for i in order(selected) {
let slot = &mut *slots[i];
if let Some(s) = slot
&& &*s.item == item
&& plain(s)
{
let n = s.count.min(count - taken);
s.count -= n;
taken += n;
if s.count == 0 {
*slot = None;
}
}
if taken == count {
break;
}
}
taken
}
/// How many plain `item` (stacking to `max`) still fit.
fn room_in(slots: &[&mut Option<ItemStack>], item: &str, max: u32) -> u32 {
slots
.iter()
.enumerate()
.map(|(i, s)| match s.as_ref() {
// getFreeSlot: never into an empty off hand.
None if i == OFFHAND => 0,
None => max,
Some(s) if &*s.item == item && plain(s) => max.saturating_sub(s.count),
Some(_) => 0,
})
.sum()
}
/// Adds up to `count` plain `item`, onto its stacks first
/// (`getSlotWithRemainingSpace`: the off hand's too), then into empty slots
/// of the inventory (`getFreeSlot`), in [`order`]; what it added. Nothing
/// is dropped.
fn add_to(
mut slots: Vec<&mut Option<ItemStack>>,
selected: usize,
item: &str,
max: u32,
count: u32,
) -> u32 {
let mut added = 0;
for pass in 0..2 {
for i in order(selected) {
let slot = &mut *slots[i];
let n = match slot {
Some(s) if pass == 0 && &*s.item == item && plain(s) => {
let n = max.saturating_sub(s.count).min(count - added);
s.count += n;
n
}
None if pass == 1 && i != OFFHAND => {
let n = max.min(count - added);
*slot = Some(ItemStack {
item: item.into(),
count: n,
damage: 0,
extra: None,
});
n
}
_ => 0,
};
added += n;
if added == count {
return added;
}
}
}
added
}
/// The acting player's items, for server mods (API 9): the player whose
/// action or command a hook hears (`native_perform_command`), as their
/// state is swapped in. Only plain stacks count; the inventory's 36 slots
/// and the off hand, never the armour. Each change reaches the player's
/// screen as any change to their inventory does.
#[allow(dead_code)]
impl Game<'_> {
/// The main hand's stack: its item, count, and whether it is plain.
pub(super) fn native_held(&self) -> Option<(String, u32, bool)> {
let s = self.inventory.get(self.selected)?.as_ref()?;
Some((s.item.to_string(), s.count, plain(s)))
}
/// How many of plain `item` (`minecraft:diamond`) the player carries.
pub(super) fn native_count_items(&mut self, item: &str) -> u32 {
count_in(&slots(&mut self.inventory, &mut self.offhand), item)
}
/// Every plain item the player carries, with how many, by id.
pub(super) fn native_carried_items(&self) -> Vec<(String, u32)> {
let mut out: Vec<(String, u32)> = Vec::new();
let held = self
.inventory
.iter()
.take(36)
.chain(std::iter::once(&self.offhand));
for s in held.flatten().filter(|s| plain(s)) {
match out.iter_mut().find(|(i, _)| *i == *s.item) {
Some((_, n)) => *n += s.count,
None => out.push((s.item.to_string(), s.count)),
}
}
out
}
/// Takes up to `count` plain `item`, the main hand first; what it took.
pub(super) fn native_take_items(&mut self, item: &str, count: u32) -> u32 {
if self.player.is_none() {
return 0;
}
let selected = self.selected;
take_from(
slots(&mut self.inventory, &mut self.offhand),
selected,
item,
count,
)
}
/// How many plain `item` still fit; 0 for an unknown item.
pub(super) fn native_room_for(&mut self, item: &str) -> u32 {
let Some(max) = self.res.items().get(item).map(|i| i.max_stack) else {
return 0;
};
room_in(
&slots(&mut self.inventory, &mut self.offhand),
item,
max.max(1),
)
}
/// Gives up to `count` plain `item` where they fit, as `Inventory.add`
/// places them; what it gave (nothing is dropped). 0 for an unknown item.
pub(super) fn native_give_items(&mut self, item: &str, count: u32) -> u32 {
let Some(max) = self.res.items().get(item).map(|i| i.max_stack) else {
return 0;
};
if self.player.is_none() {
return 0;
}
let selected = self.selected;
add_to(
slots(&mut self.inventory, &mut self.offhand),
selected,
item,
max.max(1),
count,
)
}
}
fn system_chat(line: &Text) -> C {
C::SystemChat(play::SystemChat {
content: component_nbt(&line.component()),
overlay: false,
})
}
/// Where the game calls the server hook points.
impl Game<'_> {
/// Asks the mods for their commands once per hub, and puts them in every
/// player's command tree.
pub(super) fn register_native_commands(&mut self, host: &mut LanHost) {
if host.native_commands.is_some() {
return;
}
let mut commands = Vec::new();
self.native_register_commands(&mut commands);
let (tree, levels, problems) =
command_tree(&commands, |name| host.hub.vanilla_command(name));
for p in problems {
log::warn!("Horizon: a native mod's command was left out: {p}");
}
host.hub.set_mod_commands(0, tree);
host.native_commands = Some(levels);
}
/// The level a guest needs for a mod's command (`line`, without its
/// slash), when a mod registered it.
pub(super) fn native_command_level(host: &LanHost, line: &str) -> Option<u8> {
let name = line.split(' ').next().unwrap_or_default();
host.native_commands.as_ref()?.get(name).copied()
}
/// A guest's command for the mods, inside their swap
/// (`run_guest_command`): true when one ran it.
pub(super) fn perform_native_command(&mut self, conn: u32, permission: u8, line: &str) -> bool {
let Some(host) = &self.lan else {
return false;
};
if Self::native_command_level(host, line).is_none_or(|level| level > permission) {
return false;
}
let Some(g) = host.hub.guest(conn) else {
return false;
};
let player = NativeServerPlayer {
uuid: g.uuid,
name: g.name.clone(),
permission,
dimension: g.dimension.clone(),
position: g.position,
game_mode: g.game_mode,
};
let mut ran = false;
self.native_perform_command(&player, line, &mut ran);
if !ran {
// Registered, but no mod took it: vanilla's unknown command.
self.native_feedback(
Text::translate("command.unknown.command", &[])
.with_color(horizon_client::text::RED),
);
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tree(commands: &[NativeCommand]) -> (Value, HashMap<String, u8>, Vec<String>) {
command_tree(commands, |name| name == "give")
}
fn stack(item: &str, count: u32) -> Option<ItemStack> {
Some(ItemStack {
item: item.into(),
count,
damage: 0,
extra: None,
})
}
#[test]
fn items_are_taken_from_the_hand_first_and_only_plain() {
let mut inv = vec![None; 36];
inv[0] = stack("minecraft:diamond", 10);
inv[4] = stack("minecraft:diamond", 5);
inv[8] = stack("minecraft:diamond", 7);
inv[8].as_mut().unwrap().damage = 3;
let mut off = stack("minecraft:diamond", 2);
assert_eq!(
count_in(&slots(&mut inv, &mut off), "minecraft:diamond"),
17
);
assert_eq!(
take_from(slots(&mut inv, &mut off), 4, "minecraft:diamond", 6),
6
);
assert!(inv[4].is_none(), "the selected slot first");
assert_eq!(off.as_ref().unwrap().count, 1, "then the off hand");
assert_eq!(
take_from(slots(&mut inv, &mut off), 4, "minecraft:diamond", 100),
11
);
assert_eq!(inv[8].as_ref().unwrap().count, 7, "a damaged stack stays");
assert!(inv[0].is_none() && off.is_none());
}
#[test]
fn items_are_given_only_where_they_fit() {
let mut inv = vec![stack("minecraft:dirt", 64); 36];
inv[3] = stack("minecraft:diamond", 60);
inv[9] = None;
let mut off = None;
assert_eq!(
room_in(&slots(&mut inv, &mut off), "minecraft:diamond", 64),
4 + 64
);
assert_eq!(
add_to(slots(&mut inv, &mut off), 0, "minecraft:diamond", 64, 200),
68
);
assert_eq!(inv[3].as_ref().unwrap().count, 64, "onto its stack first");
assert!(off.is_none(), "never into an empty off hand (getFreeSlot)");
assert_eq!(inv[9].as_ref().unwrap().count, 64);
assert_eq!(
room_in(&slots(&mut inv, &mut off), "minecraft:diamond", 64),
0
);
assert_eq!(
add_to(slots(&mut inv, &mut off), 0, "minecraft:diamond", 64, 1),
0
);
}
#[test]
fn usages_merge_into_one_tree_with_runnable_nodes() {
let (t, levels, problems) = tree(&[
NativeCommand::new("balance [player:player]"),
NativeCommand::new("pay <player:player> <amount:integer>"),
NativeCommand::op("eco give <player:player> <amount:integer>", 3),
NativeCommand::op("eco take <player:player> <amount:integer>", 3),
]);
assert!(problems.is_empty(), "{problems:?}");
let c = &t["children"];
assert_eq!(c["balance"]["executable"], true);
assert_eq!(c["balance"]["children"]["player"]["executable"], true);
assert_eq!(
c["balance"]["children"]["player"]["parser"],
"minecraft:entity"
);
assert_eq!(c["pay"]["executable"], false);
assert_eq!(c["pay"]["children"]["player"]["executable"], false);
let amount = &c["pay"]["children"]["player"]["children"]["amount"];
assert_eq!(amount["executable"], true);
assert_eq!(amount["parser"], "brigadier:integer");
assert!(amount.get("children").is_none());
assert_eq!(c["eco"]["permissions"]["permission"]["level"], "admins");
assert!(c["eco"]["children"]["give"].is_object());
assert!(c["eco"]["children"]["take"].is_object());
assert!(c["pay"].get("permissions").is_none());
assert_eq!(levels["eco"], 3);
assert_eq!(levels["pay"], 0);
}
#[test]
fn a_command_takes_its_lowest_level() {
let (t, levels, _) = tree(&[
NativeCommand::new("warp [name:word]"),
NativeCommand::op("warp set <name:word>", 2),
]);
assert_eq!(levels["warp"], 0);
assert!(t["children"]["warp"].get("permissions").is_none());
let set = &t["children"]["warp"]["children"]["set"];
assert_eq!(set["permissions"]["permission"]["level"], "gamemasters");
}
#[test]
fn bad_usages_and_vanilla_names_are_left_out() {
let (t, levels, problems) = tree(&[
NativeCommand::new("give <x:integer>"),
NativeCommand::new("a [x:integer] <y:integer>"),
NativeCommand::new("b <x:text> <y:word>"),
NativeCommand::new("c <x:colour>"),
NativeCommand::new("<x:integer>"),
NativeCommand::new("Bad"),
NativeCommand::new("d <x:integer>"),
NativeCommand::new("d <x:word>"),
]);
assert_eq!(problems.len(), 7, "{problems:?}");
assert_eq!(levels.keys().collect::<Vec<_>>(), ["d"]);
assert_eq!(
t["children"]["d"]["children"]["x"]["parser"],
"brigadier:integer"
);
let (none, levels, _) = tree(&[]);
assert!(none.is_null() && levels.is_empty());
}
}
@@ -41,20 +41,19 @@ impl Game<'_> {
self.levels.parked_world(dim)
}
.ok_or(NativeBlockReadError::UnavailableDimension)?;
read_block(world, dim, self.pack.assets.registry, pos)
read_block(world, self.pack.assets.registry, pos)
}
}
/// Resolve an existing world state, preserving unloaded and invalid states.
fn read_block(
world: &World,
dimension: Dim,
registry: &BlockRegistry,
[x, y, z]: [i32; 3],
) -> Result<BlockState, NativeBlockReadError> {
if !(-30_000_000..30_000_000).contains(&x)
|| !(-30_000_000..30_000_000).contains(&z)
|| !(dimension.min_y()..=dimension.max_y()).contains(&y)
|| !(world.shape.min_y..world.shape.max_y()).contains(&y)
{
return Err(NativeBlockReadError::OutOfBounds);
}
@@ -84,30 +83,32 @@ mod tests {
let mut world = World::new(BlockStateId(0), 0);
world.chunks.insert((-1, 0), Arc::new(Chunk::empty(-1, 0)));
world.set(-1, 64, 1, BlockStateId(1)).unwrap();
let block = read_block(&world, Dim::Overworld, &registry, [-1, 64, 1]).unwrap();
// The Nether's build range starts at y 0 (its level's shape).
let mut nether = World::new(BlockStateId(0), 0);
nether.shape = horizon_client::chunk::LevelShape::NETHER;
nether.chunks.insert((-1, 0), Arc::new(Chunk::empty(-1, 0)));
let block = read_block(&world, &registry, [-1, 64, 1]).unwrap();
assert_eq!(block.name, "test:pillar");
assert_eq!(block.properties["axis"], "x");
assert_eq!(
read_block(&world, Dim::Overworld, &registry, [-1, 65, 1])
.unwrap()
.name,
read_block(&world, &registry, [-1, 65, 1]).unwrap().name,
"minecraft:air"
);
assert_eq!(
read_block(&world, Dim::Overworld, &registry, [0, 64, 1]),
read_block(&world, &registry, [0, 64, 1]),
Err(NativeBlockReadError::Unloaded)
);
assert_eq!(
read_block(&world, Dim::Nether, &registry, [-1, -1, 1]),
read_block(&nether, &registry, [-1, -1, 1]),
Err(NativeBlockReadError::OutOfBounds)
);
assert_eq!(
read_block(&world, Dim::Overworld, &registry, [30_000_000, 64, 1]),
read_block(&world, &registry, [30_000_000, 64, 1]),
Err(NativeBlockReadError::OutOfBounds)
);
world.set(-1, 64, 1, BlockStateId(99)).unwrap();
assert_eq!(
read_block(&world, Dim::Overworld, &registry, [-1, 64, 1]),
read_block(&world, &registry, [-1, 64, 1]),
Err(NativeBlockReadError::InvalidState)
);
assert_eq!(world.chunks.len(), 1, "reads must not load chunks");
@@ -0,0 +1,167 @@
//! A game started without its prepared files (the game build run by hand, not
//! through the launcher): a window that says where to get the launcher
//! (`horizon_client::no_resources`) and quits when it is closed. No loader, no
//! crash report: nothing went wrong in the game.
use super::*;
use horizon_client::no_resources::{Action, DOWNLOAD_URL, NoResources};
use winit::event::MouseButton;
struct Missing {
window: Option<Arc<Window>>,
renderer: Option<Renderer>,
gui: UiResources,
screen: NoResources,
/// The pointer in physical pixels.
pointer: (f64, f64),
api: horizon_client::graphics::GraphicsApi,
error: Option<Box<dyn std::error::Error>>,
}
/// Shows the screen until the player closes it.
pub(super) fn show(looked_in: &std::path::Path, settings: &Settings) -> Result<()> {
let mut app = Missing {
window: None,
renderer: None,
gui: UiResources {
atlas: HudAtlas::build(looked_in)?,
panorama: Arc::new(horizon_client::gfx::panorama::Faces {
size: 1,
pixels: [0u8; 4].repeat(6),
}),
},
screen: NoResources::new(looked_in),
pointer: (0.0, 0.0),
api: settings.horizon.api,
error: None,
};
horizon_client::hud::GUI_SCALE.store(settings.gui_scale, Ordering::Relaxed);
EventLoop::new()?.run_app(&mut app)?;
app.error.map_or(Ok(()), Err)
}
impl Missing {
fn size(&self) -> [u32; 2] {
self.renderer
.as_ref()
.map_or([1280, 720], |r| [r.config.width, r.config.height])
}
/// `f` over a Ui of the window's size, the cursor where the pointer is.
fn with_ui<R>(
&self,
batch: &mut HudBatch,
f: impl FnOnce(&mut horizon_client::ui_theme::Ui) -> R,
) -> R {
let cursor = (
self.pointer.0 as f32 / batch.scale,
self.pointer.1 as f32 / batch.scale,
);
let mut ui = horizon_client::ui_theme::Ui::new(&self.gui.atlas, batch, cursor);
let result = f(&mut ui);
ui.finish();
result
}
fn draw(&mut self) {
let mut batch = HudBatch::new(self.size());
self.with_ui(&mut batch, |ui| self.screen.draw(ui));
let Some(renderer) = &mut self.renderer else {
return;
};
match renderer.render(&loading_frame(&batch)) {
Ok(true) => return,
Ok(false) => {}
Err(e) => log::warn!("Horizon: frame failed: {e}"),
}
// The frame was not shown (the first one often isn't): the glyphs it
// rasterized never reached the atlas, so draw them again next frame.
horizon_client::ui_theme::reset_atlas();
if let Some(window) = &self.window {
window.request_redraw();
}
}
fn click(&mut self, event_loop: &ActiveEventLoop) {
let mut batch = HudBatch::new(self.size());
match self.with_ui(&mut batch, |ui| self.screen.click(ui)) {
Some(Action::Download) => packs::open_uri(DOWNLOAD_URL),
Some(Action::Quit) => event_loop.exit(),
None => {}
}
}
}
impl ApplicationHandler for Missing {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() {
return;
}
let attributes = Window::default_attributes()
.with_title("Horizon")
.with_window_icon(horizon_client::window::icon())
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
let made = event_loop
.create_window(attributes)
.map_err(Into::into)
.and_then(|window| {
let window = Arc::new(window);
let renderer =
loading_renderer(window.clone(), false, Some(&self.gui), None, self.api)?;
Ok((window, renderer))
});
match made {
Ok((window, renderer)) => {
window.request_redraw();
self.window = Some(window);
self.renderer = Some(renderer);
}
Err(e) => {
self.error = Some(e);
event_loop.exit();
}
}
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
let Some(window) = self.window.clone().filter(|w| w.id() == id) else {
return;
};
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => {
if let Some(r) = &mut self.renderer {
r.resize(size.width, size.height);
}
window.request_redraw();
}
WindowEvent::RedrawRequested => self.draw(),
WindowEvent::CursorMoved { position, .. } => {
self.pointer = (position.x, position.y);
window.request_redraw();
}
WindowEvent::MouseInput {
state: ElementState::Released,
button: MouseButton::Left,
..
} => self.click(event_loop),
WindowEvent::KeyboardInput { event, .. }
if event.state == ElementState::Pressed
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape) =>
{
event_loop.exit()
}
_ => {}
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if horizon_threads::stop_requests() > 0 {
event_loop.exit();
return;
}
// Wake now and then for Ctrl+C in the console; redraws come with events.
event_loop.set_control_flow(ControlFlow::WaitUntil(
Instant::now() + Duration::from_millis(250),
));
}
}
+91 -1
View File
@@ -35,6 +35,10 @@ pub struct EntityMemory {
/// the dragon): the UUID and compound they were loaded with, found again by
/// type and position (an entity that has moved since loading is saved afresh).
anon: HashMap<(String, [u64; 3]), (Vec3, Option<[i32; 4]>, Tag)>,
/// UUIDs of the vehicles `restore_saved_vehicle` brought in from the
/// player's file: their chunk copies must not spawn a second one. Cleared
/// with the rest when a world closes.
restored: HashSet<[i32; 4]>,
}
fn anon_key(kind: &str, p: Vec3) -> (String, [u64; 3]) {
@@ -249,6 +253,9 @@ impl Game<'_> {
for tag in tags {
let kind = entity_nbt::kind(&tag).unwrap_or_default();
let uuid = entity_nbt::uuid(&tag);
if uuid.is_some_and(|u| self.restored_vehicle(u).is_some()) {
continue;
}
if matches!(
kind.as_str(),
"minecraft:experience_orb"
@@ -471,8 +478,11 @@ impl Game<'_> {
}
/// A saved mob and, under `Passengers`, the mobs riding it.
pub(super) fn load_mob(&mut self, tag: Tag, mount: Option<u32>) -> Option<u32> {
self.next_entity += 1;
let uuid = entity_nbt::uuid(&tag);
if let Some(id) = uuid.and_then(|u| self.restored_vehicle(u)) {
return Some(id);
}
self.next_entity += 1;
// Structure mobs that generation left to finish: saves from before
// they were finalized at placement, or a placement that could not.
let mut tag = tag;
@@ -644,6 +654,86 @@ impl Game<'_> {
}
i
}
/// `RootVehicle.Attach` names the immediate mount, not the root of its tree.
pub(super) fn root_vehicle_uuid(&mut self, id: Option<u32>) -> Option<[i32; 4]> {
Some(self.uuid_for(id?))
}
/// The vehicle with this UUID, among the ones in the world.
pub(super) fn vehicle_by_uuid(&self, uuid: [i32; 4]) -> Option<u32> {
let id = *self
.persist
.uuids
.iter()
.find(|(_, u)| **u == uuid)
.map(|(id, _)| id)?;
let there = self.mobs.iter().any(|m| m.id == id && m.alive())
|| self.boats.iter().any(|b| b.id == id)
|| self.carts.iter().any(|c| c.id == id);
there.then_some(id)
}
/// A vehicle already in the world that was restored from the player's file
/// during this load (not any entity whose UUID is remembered).
fn restored_vehicle(&self, uuid: [i32; 4]) -> Option<u32> {
if !self.persist.restored.contains(&uuid) {
return None;
}
self.vehicle_by_uuid(uuid)
}
/// Vanilla stores the root entity in the player's file, outside chunk entities.
/// Keep the raw wrapper until attachment succeeds, including unsupported trees.
pub(super) fn restore_saved_vehicle(
&mut self,
uuid: [i32; 4],
raw: Option<&Tag>,
) -> Option<u32> {
if let Some(id) = self.vehicle_by_uuid(uuid) {
return Some(id);
}
let tag = raw?.get("RootVehicle")?.get("Entity")?.clone();
let root_uuid = entity_nbt::uuid(&tag)?;
let kind = entity_nbt::kind(&tag)?;
if !self.res.mob_types().contains_key(&kind)
&& super::boats::any_boat_wood(&kind).is_none()
&& super::minecarts::CartKind::from_id(&kind).is_none()
{
return None;
}
if self.vehicle_by_uuid(root_uuid).is_none() {
let at = entity_nbt::position(&tag)?;
let key = (
(at.x.floor() as i32).div_euclid(16),
(at.z.floor() as i32).div_euclid(16),
);
self.persist.restored.insert(root_uuid);
self.world.loaded_entities.push((key, vec![tag]));
self.spawn_loaded_entities();
}
self.persist.restored.insert(uuid);
self.vehicle_by_uuid(uuid)
}
/// `ServerPlayer.loadAndSpawnParentVehicle`: the ride the player's file
/// names, once the vehicle is in the world.
pub(super) fn restore_root_vehicle(&mut self) {
if self.serving.is_some()
|| self.options.dedicated.is_some()
|| self.riding.is_some()
|| self.player.is_none()
{
return;
}
let Some(uuid) = self.pending_vehicle else {
return;
};
let raw = self.level.player_extra.clone();
let Some(id) = self.restore_saved_vehicle(uuid, raw.as_ref()) else {
return;
};
if let Some(Tag::Compound(p)) = &mut self.level.player_extra {
p.remove(&"RootVehicle".into());
}
self.pending_vehicle = None;
self.riding = Some(id);
}
/// A mob's entity tag with the mobs riding it under `Passengers`.
pub(super) fn mob_tree_nbt(&mut self, i: usize) -> Tag {
let id = self.mobs[i].id;
+29 -3
View File
@@ -24,7 +24,11 @@ use horizon_world::{
registry::BlockStateId,
world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world},
};
use std::{io, path::Path, sync::Arc};
use std::{
io,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
/// `Level.clip` with `ClipContext.Block.VISUAL` and no fluids:
/// `BlockBehaviour.getVisualShape` is the collision shape except for the
@@ -137,8 +141,25 @@ fn own_invisible(effects: &horizon_client::effects::PlayerEffects, shared_flag:
fn body_visible(invisible: bool, team: Option<&horizon_client::scoreboard::Team>) -> bool {
!horizon_client::scoreboard::invisible_to(team, team, false, invisible)
}
/// The player's model type (`PlayerModel` with the default skin).
/// The player's model type for each resources root it was asked for.
static PLAYER_TYPES: Mutex<Vec<(PathBuf, Option<Arc<MobType>>)>> = Mutex::new(Vec::new());
/// The player's model type (`PlayerModel` with the default skin), read once
/// per resources root: `entity-models.json` doesn't change while the game
/// runs, and parsing it again held every world join for about 100 ms. The
/// world resources read it as the game starts (`load_world_rest`), so a join
/// finds it; a resource pack change's new root is read once.
pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
let mut cache = PLAYER_TYPES.lock().unwrap_or_else(|e| e.into_inner());
if let Some((_, kind)) = cache.iter().find(|(cached, _)| cached == root) {
return kind.clone();
}
let kind = load_player_type(root);
cache.push((root.to_owned(), kind.clone()));
kind
}
/// `player_type` from the document: the model and its layers from one parse.
fn load_player_type(root: &Path) -> Option<Arc<MobType>> {
let models = horizon_client::mobs::EntityModelDocument::load(root).ok()?;
models.model("minecraft:player#main").map(|model| {
Arc::new(MobType {
@@ -786,7 +807,12 @@ impl Game<'_> {
return;
};
let assets = &self.armor_stand_assets;
let Some((texture, _)) = assets.equipment.get(asset).and_then(|k| k.get("wings")) else {
let Some((texture, _)) = assets
.equipment
.get(asset)
.and_then(|k| k.get("wings"))
.and_then(|layers| layers.first())
else {
return;
};
let sprites = &self.gui.atlas.sprites;
+13 -2
View File
@@ -200,14 +200,25 @@ impl Game<'_> {
continue;
}
let pos = m.position;
// NearestItemSensor: the nearest wanted item within 32 that the piglin
// sees (eye to eye); GoToWantedItem then walks to it within 9 blocks.
let w = self.mob_world_base(&[]);
let eye = Vec3::new(pos.x, m.eye_y(), pos.z);
let best = self
.dropped
.iter()
.enumerate()
.map(|(j, e)| (j, e.position.distance_squared(pos)))
.filter(|(_, d)| *d <= 81.0)
.filter(|(_, d)| *d < 32.0 * 32.0)
.filter(|&(j, _)| self.piglin_wants(m, &self.dropped[j].stack))
.min_by(|a, b| a.1.total_cmp(&b.1));
.filter(|&(j, _)| {
// ItemEntity's eye height is 0.2125.
let e = &self.dropped[j];
let at = 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))
.filter(|(_, d)| *d <= 81.0);
let Some((j, _)) = best else {
self.mobs[i].extra.point = None;
continue;
+126 -1
View File
@@ -18,7 +18,9 @@ use horizon_client::{
hud_layout, hud_overlays, inventory_effects,
items::ItemStack,
mesher::Vertex,
mobs::{Damage, DamageKind, Who},
particles::{ParticleOptions, ParticleType},
projectiles::{Projectile, ProjectileKind},
text::Text,
};
use horizon_core::{
@@ -512,6 +514,47 @@ impl Game<'_> {
_ => Ok(false),
}
}
/// `PotionItem.useOn`: a water bottle used on a block that turns to mud
/// (`#convertible_to_mud`), not on its underside, is poured out and the
/// block becomes mud.
pub(super) fn use_water_bottle_on_block(
&mut self,
pos: [i32; 3],
face: [i32; 3],
stack: &ItemStack,
) -> horizon_client::Result<bool> {
if &*stack.item != "minecraft:potion"
|| stack.potion() != Some("minecraft:water")
|| face == [0, -1, 0]
|| !self.block_in_tag("minecraft:convertible_to_mud", &self.block_name(pos))
{
return Ok(false);
}
let centre = pos.map(|v| f64::from(v) + 0.5);
self.audio
.play("minecraft:entity.generic.splash", Some(centre), 1.0, 1.0);
self.fill_held(ItemStack::new("minecraft:glass_bottle", 1));
// ServerLevel.sendParticles: five splashes above the block.
for _ in 0..5 {
let r = &mut self.world.random;
let at = [
f64::from(pos[0]) + r.next_double(),
f64::from(pos[1] + 1),
f64::from(pos[2]) + r.next_double(),
];
self.particles
.add(&ParticleOptions::simple(ParticleType::Splash), at, [0.0; 3]);
}
self.audio
.play("minecraft:item.bottle.empty", Some(centre), 1.0, 1.0);
self.post_block_game_event("minecraft:fluid_place", pos, None, None);
if let Some(mud) = self.pack.assets.registry.default_state("minecraft:mud") {
self.level_set(pos, mud)?;
}
self.stats.add("used", &stack.item, 1);
self.swing_arm();
Ok(true)
}
/// `ItemUtils.createFilledResult`: replace one of the held stack.
fn fill_held(&mut self, filled: ItemStack) {
if self.creative {
@@ -554,7 +597,12 @@ impl Game<'_> {
self.drink_ominous_bottle(slot, stack);
return true;
}
let (lasting, now) = PlayerEffects::potion_effects(stack.potion(), 1.0);
// PotionContents.onConsume: the potion's and the custom effects, at the
// stack's POTION_DURATION_SCALE.
let (lasting, now) = PlayerEffects::contents_effects(
&brewing::contents_effects(stack),
stack.potion_duration_scale(),
);
for e in lasting {
self.add_player_effect(e);
}
@@ -577,6 +625,83 @@ impl Game<'_> {
}
true
}
/// `Arrow.doPostHurtEffects` and `SpectralArrow.doPostHurtEffects`: the
/// arrow's item gives its target Glowing (spectral) or its potion contents
/// at the stack's duration scale (a tipped arrow's default is an eighth);
/// a mob's arrow without such an item (a stray's) carries its own effect.
/// Instant effects act at once: harming hurts and healing heals, the
/// other way round for the undead.
pub(super) fn arrow_post_hurt_effects(&mut self, p: &Projectile, who: Who) {
let from_item = p.arrow.as_ref().map(|a| &a.pickup_item);
let mut effects: Vec<(&'static str, i32, i32)> = match from_item {
Some(s) if &*s.item == "minecraft:spectral_arrow" => vec![("glowing", 200, 0)],
Some(s) => {
let scale = s.potion_duration_scale();
brewing::contents_effects(s)
.into_iter()
.map(|(id, d, amp)| {
(id, horizon_client::effects::scaled_duration(d, scale), amp)
})
.collect()
}
None => Vec::new(),
};
if effects.is_empty()
&& let ProjectileKind::Arrow {
effect: Some((id, ticks)),
..
} = p.kind
&& let Some(e) = brewing::effect(id)
{
effects.push((e.id, ticks, 0));
}
if effects.is_empty() {
return;
}
match who {
Who::Player => {
for (id, duration, amp) in effects {
if brewing::effect(id).is_some_and(|e| e.instant) {
if let Some(a) = horizon_client::effects::instant(id, amp, 1.0) {
self.apply_effect_action(a);
}
} else {
self.add_player_effect(EffectInstance::new(id, duration, amp));
}
}
}
Who::Mob(id) => {
let tags = &self.res.stations().enchantments.entity_tags;
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id && m.alive()) else {
return;
};
let inverted = tags
.get("minecraft:inverted_healing_and_harm")
.is_some_and(|t| t.contains(&m.kind.id));
for (effect, duration, amp) in effects {
match effect {
"instant_damage" | "instant_health" => {
// `LivingEntity.addEffect` refuses nothing here; the
// tick hurts with magic (6 << amp) or heals (4 << amp).
if (effect == "instant_damage") != inverted {
m.damage((6 << amp.min(30)) as f32, Damage::of(DamageKind::Magic));
} else {
m.health =
(m.health + (4 << amp.min(30)) as f32).min(m.attrs.max_health);
}
}
_ => {
if mob_can_be_affected(tags, &m.kind.id, effect)
&& let Some(name) = mob_effect_id(effect)
{
m.add_effect(name, duration, amp);
}
}
}
}
}
}
}
pub(super) fn apply_effect_action(&mut self, action: EffectAction) {
let creative = self.creative;
let rules = self.world.health_rules;
+213 -41
View File
@@ -36,6 +36,7 @@ use horizon_protocol::{
play::{self, Clientbound as C, Serverbound as S},
},
registry::{Builtin, DimensionType, Dynamic, ceil_log2},
secure_chat::Signature,
text::{Language, plain},
};
use horizon_world::{
@@ -63,6 +64,13 @@ fn menu_title(tag: &Tag) -> horizon_client::text::Text {
remote_text(tag).resolve_legacy()
}
/// A player message Chat Delay holds back, and its signature.
type QueuedChat = (
horizon_client::text::Text,
horizon_client::chat::ChatSource,
Option<Signature>,
);
/// Streaming needs a generator; a server world never generates.
struct NoTerrain;
impl ChunkGenerator for NoTerrain {
@@ -183,8 +191,12 @@ pub(super) struct Remote {
pub chat: Vec<(String, Instant)>,
/// Lines received since joining, for the control socket's events.
pub chat_received: u64,
/// `ChatListener.delayedMessageQueue`: player chat held back by Chat Delay.
chat_queue: VecDeque<(horizon_client::text::Text, horizon_client::chat::ChatSource)>,
/// `ChatListener.delayedMessageQueue`: player chat held back by Chat
/// Delay, with the signature a deletion names it by.
chat_queue: VecDeque<QueuedChat>,
/// `ClientPacketListener.messageSignatureCache`: the signatures packed
/// ones index, as the server's copy for this connection holds them.
signatures: horizon_protocol::secure_chat::SignatureCache,
/// `ChatListener.previousMessageTime`: when a message last showed, if one has.
chat_previous: Option<Instant>,
/// The server's command tree (ClientboundCommandsPacket).
@@ -537,6 +549,7 @@ impl Game<'_> {
chat: Vec::new(),
chat_received: 0,
chat_queue: VecDeque::new(),
signatures: Default::default(),
chat_previous: None,
commands: None,
custom_completions: Default::default(),
@@ -793,23 +806,39 @@ impl Game<'_> {
&mut self,
content: horizon_client::text::Text,
source: horizon_client::chat::ChatSource,
) -> bool {
self.signed_chat_line(content, source, None)
}
/// `chat_line` of a player message, keeping its signature for a
/// deletion (`GuiMessage.signature`).
fn signed_chat_line(
&mut self,
content: horizon_client::text::Text,
source: horizon_client::chat::ChatSource,
signature: Option<Signature>,
) -> bool {
if !self.ui.chat.shows(source) {
return false;
}
let text = content.plain();
// addServerSystemMessage and addClientSystemMessage both carry
// GuiMessageTag.systemSinglePlayer: the grey bar at the line's left.
let indicator = (source != horizon_client::chat::ChatSource::Player)
.then_some(horizon_client::chat::SYSTEM_INDICATOR);
self.ui.chat.add(
&self.gui.atlas,
content,
indicator,
source,
self.ticks,
self.screen == Screen::Chat,
);
let focused = self.screen == Screen::Chat;
if source == horizon_client::chat::ChatSource::Player {
self.ui
.chat
.add_signed(&self.gui.atlas, content, signature, self.ticks, focused);
} else {
// addServerSystemMessage and addClientSystemMessage both carry
// GuiMessageTag.systemSinglePlayer: the grey bar at the line's left.
let indicator = Some(horizon_client::chat::SYSTEM_INDICATOR);
self.ui.chat.add(
&self.gui.atlas,
content,
indicator,
source,
self.ticks,
focused,
);
}
if let Some(r) = self.remote.as_mut() {
r.chat.push((text, Instant::now()));
r.chat_received += 1;
@@ -822,24 +851,54 @@ impl Game<'_> {
/// `ChatListener.handleMessage` for player and disguised chat: held in the
/// queue while Chat Delay is set, shown at once otherwise.
pub(super) fn handle_player_chat(&mut self, line: horizon_client::text::Text) {
pub(super) fn handle_player_chat(
&mut self,
line: horizon_client::text::Text,
signature: Option<Signature>,
) {
let source = horizon_client::chat::ChatSource::Player;
if self.chat_delay_ms() > 0 {
if let Some(r) = self.remote.as_mut() {
r.chat_queue.push_back((line, source));
r.chat_queue.push_back((line, source, signature));
}
} else {
self.accept_chat(line, source);
self.accept_chat(line, source, signature);
}
}
/// `ClientPacketListener.handleDeleteChat`: the packed signature names a
/// message; one still held back by Chat Delay never shows
/// (`ChatListener.removeFromDelayedMessageQueue`), a shown one is
/// replaced (`ChatComponent.deleteMessage`). A signature the cache
/// doesn't hold is an invalid packet.
fn handle_delete_chat(&mut self, packed: &(i32, Option<Vec<u8>>)) -> Result<()> {
let Some(r) = self.remote.as_mut() else {
return Ok(());
};
let Some(signature) = r.signatures.unpack(packed) else {
let reason =
horizon_client::text::Text::translate("multiplayer.disconnect.invalid_packet", &[]);
return self.leave_server(Some(reason.plain()));
};
let queued = r.chat_queue.len();
r.chat_queue
.retain(|(_, _, s)| s.as_ref() != Some(&signature));
if r.chat_queue.len() == queued {
self.ui
.chat
.delete_message(&self.gui.atlas, &signature, self.ticks);
}
Ok(())
}
/// A message's `accept`: shown, it starts the Chat Delay wait again.
fn accept_chat(
&mut self,
line: horizon_client::text::Text,
source: horizon_client::chat::ChatSource,
signature: Option<Signature>,
) -> bool {
let shown = self.chat_line(line, source);
let shown = self.signed_chat_line(line, source, signature);
if shown && let Some(r) = self.remote.as_mut() {
r.chat_previous = Some(Instant::now());
}
@@ -885,9 +944,10 @@ impl Game<'_> {
}
// A message the Chat option hides is dropped and the next one is tried,
// until one shows.
while let Some((line, source)) = self.remote.as_mut().and_then(|r| r.chat_queue.pop_front())
while let Some((line, source, signature)) =
self.remote.as_mut().and_then(|r| r.chat_queue.pop_front())
{
if self.accept_chat(line, source) {
if self.accept_chat(line, source, signature) {
break;
}
}
@@ -903,8 +963,8 @@ impl Game<'_> {
else {
return;
};
for (line, source) in queue {
self.chat_line(line, source);
for (line, source, signature) in queue {
self.signed_chat_line(line, source, signature);
}
if let Some(r) = self.remote.as_mut() {
r.chat_previous = None;
@@ -1091,6 +1151,7 @@ impl Game<'_> {
r.players.clear();
r.tab_players.clear();
r.listed.clear();
r.signatures = Default::default();
r.custom_completions.clear();
}
self.ui.registries.custom_completions.clear();
@@ -1232,7 +1293,9 @@ impl Game<'_> {
.unwrap_or_default();
if let Some(r) = self.remote.as_mut() {
let bits = ceil_log2(biomes.len());
r.chunk_format = Arc::new(ChunkFormat::new(&r.dimension, biomes, bits));
let column = r.chunk_format.layout();
r.chunk_format =
Arc::new(ChunkFormat::new(&r.dimension, biomes, bits).in_column(column));
r.registries = registries;
}
}
@@ -1242,9 +1305,21 @@ impl Game<'_> {
return;
};
let dimension = r.registries.dimension(spawn.dimension_type);
let shape = super::Dim::from_id(&dimension.name).shape();
// The server's dimension type gives the level's shape, and its
// chunks the shape's column.
let shape = horizon_client::chunk::LevelShape::new(
dimension.min_y,
dimension.height,
dimension.logical_height,
dimension.has_skylight,
)
.unwrap_or_else(|_| super::Dim::from_id(&dimension.name).shape());
let same = r.dimension.name == dimension.name && keep_world;
r.chunk_format = Arc::new(r.chunk_format.with_dimension(&dimension));
r.chunk_format = Arc::new(
r.chunk_format
.with_dimension(&dimension)
.in_column(shape.column()),
);
r.dimension = dimension;
r.game_mode = spawn.game_type;
r.flat = spawn.flat;
@@ -2127,6 +2202,7 @@ impl Game<'_> {
C::SetCamera(p) => self.remote_set_camera(p.camera_id),
C::LevelChunkWithLight(p) => self.remote_chunk(&p, None),
C::LightUpdate(p) => self.remote_light_update(&p),
C::ChunksBiomes(p) => self.remote_chunks_biomes(&p),
C::ForgetLevelChunk(p) => {
let [x, z] = p.pos;
self.streaming.forget(&mut self.world, x, z);
@@ -2713,6 +2789,10 @@ impl Game<'_> {
self.remote_firework_explosion(p.id);
}
self.remote_fangs_event(p.id as u32, p.event);
// Arrow.handleEntityEvent 0: a tipped arrow's burst of particles.
if p.event == 0 {
self.remote_arrow_puff(p.id as u32);
}
// LivingEntity.handleEntityEvent 2: a spear's hit felt.
if p.event == 2 {
self.remote_kinetic_hit(p.id);
@@ -3035,9 +3115,7 @@ impl Game<'_> {
}
}
C::ContainerSetContent(p) => {
if let Some(r) = self.remote.as_mut() {
r.state_id = p.state;
}
self.remote_menu_state(p.container, p.state);
for (slot, item) in p.items.iter().enumerate() {
let stack = self.to_stack(item);
self.set_menu_slot(p.container, slot as i32, stack);
@@ -3046,9 +3124,7 @@ impl Game<'_> {
self.remote_synced();
}
C::ContainerSetSlot(p) => {
if let Some(r) = self.remote.as_mut() {
r.state_id = p.state;
}
self.remote_menu_state(p.container, p.state);
let stack = self.to_stack(&p.item);
self.tutorial_get_item(stack.as_ref());
// ClientPacketListener.handleContainerSetSlot: a hotbar slot that
@@ -3195,18 +3271,43 @@ impl Game<'_> {
}
C::PlayerChat(p) => {
use horizon_client::scoreboard::tag_to_json;
// SignedMessageBody.Packed.unpack: the last seen it names must
// be in the cache; then the message joins it
// (MessageSignatureCache.push).
let Some(r) = self.remote.as_mut() else {
return Ok(());
};
let last_seen: Option<Vec<Signature>> =
p.last_seen.iter().map(|s| r.signatures.unpack(s)).collect();
let Some(last_seen) = last_seen else {
log::error!(
"Message from player with ID {:032x} referenced unrecognized signature id",
p.sender
);
let reason = horizon_client::text::Text::translate(
"multiplayer.disconnect.invalid_packet",
&[],
);
return self.leave_server(Some(reason.plain()));
};
let signature = p
.signature
.as_deref()
.and_then(horizon_protocol::secure_chat::signature);
r.signatures.push(&last_seen, signature.as_ref());
let content = p
.unsigned_content
.as_ref()
.map_or_else(|| serde_json::Value::from(p.content.as_str()), tag_to_json);
let line = self.format_chat(&p.chat_type, content);
self.handle_player_chat(line);
self.handle_player_chat(line, signature);
}
C::DisguisedChat(p) => {
let content = horizon_client::scoreboard::tag_to_json(&p.message);
let line = self.format_chat(&p.chat_type, content);
self.handle_player_chat(line);
self.handle_player_chat(line, None);
}
C::DeleteChat(p) => self.handle_delete_chat(&p.signature)?,
C::PlayerInfoUpdate(p) => {
if let Some(r) = self.remote.as_mut() {
// handlePlayerInfoUpdate: new entries first, then every
@@ -4138,6 +4239,8 @@ impl Game<'_> {
fn tick_remote_level(&mut self) -> Result<()> {
self.ticks += 1;
// Hud.tick ends with ChatComponent.tick.
self.ui.chat.tick(&self.gui.atlas, self.ticks);
// AbstractSignEditScreen.tick: the editor closes once its sign is removed.
if let Some(edit) = &self.sign_edit
&& !matches!(
@@ -4623,7 +4726,8 @@ impl Game<'_> {
// server never sends the fluid contact, and the renderers read it (cod, salmon and
// tropical fish lie on their side without `in_water`, #1591).
// A frozen level ticks only the players, as below.
let (fluids, thundering): (Vec<_>, bool) = {
// Entity.isInRain for the wolves, which dry off by their own `Wolf.tick`.
let (fluids, rain, thundering): (Vec<_>, Vec<_>, bool) = {
let w = self.mob_world_base(&[]);
let players = self.remote.as_ref().map(|r| &r.level_players);
let fluids = self
@@ -4634,7 +4738,15 @@ impl Game<'_> {
.then(|| w.fluids(m.bounds(), m.eye_y()))
})
.collect();
(fluids, w.thundering)
let rain = self
.mobs
.iter()
.map(|m| {
m.species() == horizon_client::mobs::Species::Wolf
&& horizon_client::mobs::tame::in_rain(m, &w)
})
.collect();
(fluids, rain, w.thundering)
};
for (m, fluids) in self.mobs.iter_mut().zip(fluids) {
if let Some(fluids) = fluids {
@@ -4682,7 +4794,7 @@ impl Game<'_> {
let players = &r.level_players;
let mut using = Vec::new();
let mut mob_fx = Vec::new();
for m in &mut self.mobs {
for (m, rain) in self.mobs.iter_mut().zip(rain) {
let player = players.contains_key(&(m.id as i32));
if (!running && !player) || driven == Some(m.id) {
continue;
@@ -4723,8 +4835,11 @@ impl Game<'_> {
}
// The particles these mobs' own ticks draw on the client side.
{
use horizon_client::mobs::{Species as S, cube, illusioner, nautilus, wither};
use horizon_client::mobs::{
Species as S, cube, illusioner, nautilus, tame, wither,
};
match m.species() {
S::Wolf => tame::client_tick(m, rain, &mut mob_fx),
S::Slime | S::MagmaCube | S::SulfurCube => cube::client_tick(m, &mut mob_fx),
S::Nautilus | S::ZombieNautilus => nautilus::client_tick(m, &mut mob_fx),
S::Illusioner => illusioner::client_tick(m, &mut mob_fx),
@@ -4826,8 +4941,13 @@ impl Game<'_> {
horizon_client::mobs::aquatic::squid_client_step(m, &mut mob_fx);
// WalkAnimationState.update from the horizontal distance moved.
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
let target = (((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0);
m.limb_speed += (target - m.limb_speed) * 0.4;
let (target, factor) = if horizon_client::mobs::camel::is_camel(m.species()) {
// Camel.updateWalkAnimation.
horizon_client::mobs::camel::walk_target(m, (dx * dx + dz * dz).sqrt() as f32)
} else {
((((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0), 0.4)
};
m.limb_speed += (target - m.limb_speed) * factor;
m.limb_position += m.limb_speed;
if m.species() == horizon_client::mobs::Species::Rabbit {
horizon_client::mobs::rabbit::post_tick(m);
@@ -5026,11 +5146,17 @@ impl Game<'_> {
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot).
let mut creative_slots = Vec::new();
if self.creative {
// A server's open menu takes clicks, which it applies itself: what
// this client predicts for them isn't an edit to send (it would
// overwrite the server's stacks whenever the prediction is wrong).
let in_menu = r.menu.is_some();
let stacks = creative_sync_stacks(&self.inventory, &self.offhand, &self.armor);
for (i, stack) in stacks.into_iter().enumerate() {
if stack != r.synced[i] {
r.synced[i] = stack.clone();
creative_slots.push((creative_sync_slot(i), stack));
if !in_menu {
creative_slots.push((creative_sync_slot(i), stack));
}
}
}
}
@@ -5448,7 +5574,9 @@ impl Game<'_> {
let stand_fits = held
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:armor_stand")
&& self.armor_stand_spawn(&hit).is_some();
&& held
.as_ref()
.is_some_and(|s| self.armor_stand_spawn(&hit, s).is_some());
let seq = self.next_sequence();
let cursor = [
(hit.point[0] - f64::from(hit.pos[0])) as f32,
@@ -5976,6 +6104,12 @@ impl Game<'_> {
}
// SpyglassItem.use (its sound is the server's).
"minecraft:spyglass" => true,
// InstrumentItem.use: the horn plays here too (`playSound(player, ...)`
// skips the player's own client), the server's copy tells the others.
"minecraft:goat_horn" => {
self.remote_use_instrument(held);
return;
}
// EnderEyeItem.use: startUsingItem unless an End portal frame is
// aimed at, with or without a stronghold to throw the eye at.
"minecraft:ender_eye" => !self.aims_at_end_portal_frame(),
@@ -5991,6 +6125,27 @@ impl Game<'_> {
self.using = Some((self.held_slot(), 0));
}
}
/// `InstrumentItem.use` on this client: unless the horn is on cooldown,
/// it is held up, plays its sound at the player and starts its cooldown.
fn remote_use_instrument(&mut self, held: &ItemStack) {
let Some(instrument) = self.res.items().instrument(held) else {
return;
};
let group = horizon_client::cooldowns::group(held, None);
if self.cooldowns.is_on_cooldown(&group) {
return;
}
self.using = Some((self.held_slot(), 0));
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio
.play(&instrument.sound, at, instrument.range / 16.0, 1.0);
if instrument.use_duration > 0.0 {
self.add_cooldown(group, instrument.use_ticks());
}
}
/// Whether `stack` stays in use until the button lets go (bows,
/// crossbows, tridents, spyglasses, shields, eyes of ender, whose
/// `getUseDuration` 0 never counts down to the end): its use only counts
@@ -6090,6 +6245,15 @@ impl Game<'_> {
self.using = in_hand.then_some((slot, ticks + 1));
return;
}
// A goat horn is held up for its instrument's use duration.
if let Some(duration) = stack
.as_ref()
.and_then(|s| self.res.items().instrument(s))
.map(|i| i.use_ticks().max(0) as u32)
{
self.using = (in_hand && ticks + 1 < duration).then_some((slot, ticks + 1));
return;
}
let consumable = stack
.as_ref()
.and_then(|s| self.items().get(&s.item))
@@ -6539,6 +6703,14 @@ impl Game<'_> {
}
self.remote_menu_ops(&[(Some(internal), i32::from(right), input)]);
}
/// `AbstractContainerMenu.setItem`/`initializeContents`: the state id a
/// click answers with is the open menu's, not another window's.
fn remote_menu_state(&mut self, container: i32, state: i32) {
let open = self.remote_menu().0;
if let Some(r) = self.remote.as_mut().filter(|_| container == open) {
r.state_id = state;
}
}
/// The server's open menu: its window id and menu type (0, "inventory"
/// when none is open).
pub(super) fn remote_menu(&self) -> (i32, String) {
+135 -32
View File
@@ -9,7 +9,7 @@ use horizon_client::{
};
use horizon_core::paletted::{PalettedContainer, Strategy};
use horizon_protocol::{
chunk::{LightData, SectionLight},
chunk::{LightData, Paletted, SectionLight},
packets::play::{self, Clientbound as C},
registry::DimensionType,
};
@@ -44,6 +44,14 @@ impl ChunkFormat {
biome_bits,
}
}
/// Decodes into chunks of `column`.
pub(super) fn in_column(self, column: ColumnLayout) -> Self {
Self { column, ..self }
}
/// The column decoded chunks hold.
pub(super) fn layout(&self) -> ColumnLayout {
self.column
}
pub(super) fn with_dimension(&self, dimension: &DimensionType) -> Self {
Self {
column: self.column,
@@ -60,20 +68,8 @@ impl ChunkFormat {
let mut chunk = Chunk::with_layout(p.x, p.z, self.column);
let offset = (self.min_y - self.column.min_y()).div_euclid(16);
let column = self.column.sections() as i32;
// Biome table index per quart cell of the dimension's own sections.
let mut biome_cells: Vec<[u32; 64]> = Vec::with_capacity(sections.len());
for (i, s) in sections.iter().enumerate() {
let target = offset + i as i32;
let mut cells = [0u32; 64];
for (j, cell) in cells.iter_mut().enumerate() {
*cell = u32::from(
self.biome_map
.get(s.biomes.get(j) as usize)
.copied()
.unwrap_or(0),
);
}
biome_cells.push(cells);
if !(0..column).contains(&target) {
continue;
}
@@ -104,30 +100,14 @@ impl ChunkFormat {
}
let mut section = Section {
blocks,
biomes: PalettedContainer::from_values(BIOME_STRATEGY, &cells),
biomes: PalettedContainer::new(BIOME_STRATEGY, 0),
light: Light::Uniform(0xF0),
};
section.compact();
chunk.sections[target as usize] = Arc::new(section);
}
// Sections outside the dimension's range take its nearest biomes
// (the column's top or bottom cell layer repeated).
if !biome_cells.is_empty() {
let last = biome_cells.len() - 1;
for si in 0..column {
let i = si - offset;
if (0..sections.len() as i32).contains(&i) {
continue;
}
let (edge, layer) = if i < 0 { (0, 0) } else { (last, 3) };
let mut cells = [0u32; 64];
for (j, cell) in cells.iter_mut().enumerate() {
*cell = biome_cells[edge][layer * 16 + j % 16];
}
Arc::make_mut(&mut chunk.sections[si as usize]).biomes =
PalettedContainer::from_values(BIOME_STRATEGY, &cells);
}
}
let biomes: Vec<_> = sections.iter().map(|s| &s.biomes).collect();
self.put_biomes(&mut chunk, &biomes);
// ClientPacketListener.applyLightData: the column keeps the light
// the server sent; nothing is relit.
let (layers, above) = self.light(&p.light);
@@ -135,6 +115,51 @@ impl ChunkFormat {
Ok(chunk)
}
/// The dimension's sections' biome containers (in the server's
/// registry) into the column's sections. Sections outside the
/// dimension's range take its nearest biomes (the column's top or bottom
/// cell layer repeated).
fn put_biomes(&self, chunk: &mut Chunk, biomes: &[&Paletted]) {
if biomes.is_empty() {
return;
}
let offset = (self.min_y - self.column.min_y()).div_euclid(16);
// Biome table index per quart cell of the dimension's own sections.
let biome_cells: Vec<[u32; 64]> = biomes
.iter()
.map(|b| {
std::array::from_fn(|j| {
u32::from(self.biome_map.get(b.get(j) as usize).copied().unwrap_or(0))
})
})
.collect();
let last = biome_cells.len() - 1;
for si in 0..self.column.sections() as i32 {
let i = si - offset;
let cells = if (0..biome_cells.len() as i32).contains(&i) {
biome_cells[i as usize]
} else {
let (edge, layer) = if i < 0 { (0, 0) } else { (last, 3) };
std::array::from_fn(|j| biome_cells[edge][layer * 16 + j % 16])
};
let mut container = PalettedContainer::from_values(BIOME_STRATEGY, &cells);
if let Some(v) = container.uniform() {
container = PalettedContainer::new(BIOME_STRATEGY, v);
}
Arc::make_mut(&mut chunk.sections[si as usize]).biomes = container;
}
}
/// `LevelChunk.replaceBiomes`: each of the dimension's sections reads
/// its biome container from the packet's buffer, in order.
fn replace_biomes(&self, chunk: &mut Chunk, data: &play::ChunkBiomeData) -> Result<(), String> {
let biomes = data
.biomes(self.sections, self.biome_bits)
.map_err(|e| e.to_string())?;
self.put_biomes(chunk, &biomes.iter().collect::<Vec<_>>());
Ok(())
}
/// `ClientPacketListener.readSectionList`: the sky and block layers of a
/// light packet, by section of the client's column, and the sky light
/// above the column's top section (from the light section just above
@@ -274,6 +299,43 @@ impl Game<'_> {
}
}
/// `ClientPacketListener.handleChunksBiomes`: each loaded column takes
/// its new biomes (`ClientChunkCache.replaceBiomes`), then every section
/// of it and of its neighbours is drawn again, as their colours blend
/// across columns (`LevelExtractor.setSectionDirty`).
pub(super) fn remote_chunks_biomes(&mut self, p: &play::ChunksBiomes) {
let Some(format) = self.remote.as_ref().map(|r| r.chunk_format.clone()) else {
return;
};
for data in &p.chunks {
let [x, z] = data.pos;
if !self.remote_chunk_in_range(x, z) {
log::warn!("Ignoring chunk since it's not in the view range: {x}, {z}");
continue;
}
let Some(chunk) = self.world.chunks.get_mut(&(x, z)) else {
log::warn!("Ignoring chunk since it's not present: {x}, {z}");
continue;
};
if let Err(e) = format.replace_biomes(Arc::make_mut(chunk), data) {
log::warn!("Horizon: biomes of chunk {x} {z}: {e}");
}
}
for data in &p.chunks {
let [x, z] = data.pos;
for dz in -1..=1 {
for dx in -1..=1 {
if !self.world.chunks.contains_key(&(x + dx, z + dz)) {
continue;
}
for sy in 0..self.world.layout().sections() as i32 {
self.world.dirty_sections.insert((x + dx, sy, z + dz));
}
}
}
}
}
/// `handleLightUpdatePacket`: the sections it names take its light.
pub(super) fn remote_light_update(&mut self, p: &play::LightUpdate) {
let Some(format) = self.remote.as_ref().map(|r| r.chunk_format.clone()) else {
@@ -360,4 +422,45 @@ mod tests {
}
assert!(matches!(decoder.next(), Next::Decoding));
}
/// `LevelChunk.replaceBiomes`: the packet's containers go to the
/// dimension's sections (y 0 to 255 in the Nether), mapped to our biome
/// table; the blocks stay, and the sections outside the dimension take
/// its nearest biomes as a chunk packet's do.
#[test]
fn chunks_biomes_replace_a_columns_biomes() {
let nether = DimensionType::vanilla("minecraft:the_nether");
let format = ChunkFormat::new(&nether, vec![0, 7, 9], 2);
let mut chunk = Chunk::empty(2, -3);
chunk.set(3, 70, 4, 5);
// Wire biome 1 everywhere, with 2 in the lowest and the highest cells.
let containers: Vec<Paletted> = (0..nether.sections())
.map(|i| {
let mut cells = [1; 64];
if i == 0 {
cells[0] = 2;
}
if i == nether.sections() - 1 {
cells[63] = 2;
}
Paletted::from_values(&cells, Wiring::Biomes, 2)
})
.collect();
let data = play::ChunkBiomeData::from_biomes([2, -3], &containers);
format.replace_biomes(&mut chunk, &data).unwrap();
assert_eq!(chunk.get(3, 70, 4), 5);
assert_eq!(chunk.biome(8, 70, 8), 7);
assert_eq!(chunk.biome(0, 0, 0), 9);
assert_eq!(chunk.biome(4, 0, 0), 7);
assert_eq!(chunk.biome(15, 255, 15), 9);
// Below and above the Nether: its bottom and top cell layers.
assert_eq!(chunk.biome(0, -64, 0), 9);
assert_eq!(chunk.biome(15, 319, 15), 9);
assert_eq!(chunk.biome(0, 319, 0), 7);
// A buffer short of the dimension's sections changes nothing.
let short = play::ChunkBiomeData::from_biomes([2, -3], &containers[..3]);
let mut other = Chunk::empty(2, -3);
assert!(format.replace_biomes(&mut other, &short).is_err());
assert_eq!(other.biome(8, 70, 8), 0);
}
}
@@ -127,6 +127,8 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
}
// SnowGolem.DATA_PUMPKIN_ID: 16 while the pumpkin is on.
(Species::SnowGolem, 16, Value::Byte(flags)) => m.extra.sheared = flags & 16 == 0,
// Bogged.DATA_SHEARED: the mushrooms on its head are gone.
(Species::Bogged, 16, Value::Bool(sheared)) => m.extra.sheared = *sheared,
// Tadpole.AGE_LOCKED (after AbstractFish's FROM_BUCKET).
(Species::Tadpole, 17, Value::Bool(locked)) => m.extra.age_locked = *locked,
// MushroomCow.DATA_TYPE (after AgeableMob's DATA_BABY_ID and
@@ -114,8 +114,10 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
p.previous = p.position;
p.age += 1;
// AbstractArrow.tick: `inGroundTime` counts the ticks in a block.
// AbstractArrow.tick: the shake timer runs down, and `inGroundTime` counts
// the ticks in a block.
if let Some(a) = p.arrow.as_mut() {
a.shake = (a.shake - 1).max(0);
a.in_ground_time = if p.stuck { a.in_ground_time + 1 } else { 0 };
}
if p.stuck {
@@ -130,6 +132,12 @@ fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
p.position = at;
p.velocity = Vec3::ZERO;
p.stuck = true;
// AbstractArrow.onSyncedDataUpdated: IN_GROUND turning true starts the shake.
if let Some(a) = p.arrow.as_mut()
&& a.shake <= 0
{
a.shake = 7;
}
return;
}
p.position = to;
@@ -164,8 +172,7 @@ impl Game<'_> {
/// fifth tick in a block; a spectral arrow an effect sparkle at its
/// position while flying.
fn arrow_effect_particles(&mut self) {
// The arrow's box: `EntityType.ARROW` is 0.5 x 0.5.
const SIZE: f64 = 0.5;
let mut puffs: Vec<(Vec3, i32, u32)> = Vec::new();
for p in &self.creatures.projectiles {
let Some(a) = p.arrow.as_ref().filter(|_| p.is_arrow()) else {
continue;
@@ -178,30 +185,54 @@ impl Game<'_> {
}
continue;
}
let color = a.effect_color;
let count = match (p.stuck, a.in_ground_time % 5) {
(true, 0) => 1,
(true, _) => 0,
(false, _) => 2,
};
if color == -1 {
continue;
}
// ColorParticleOption.create(ENTITY_EFFECT, colour): ARGB as floats.
let channel = |shift: u32| (color as u32 >> shift & 255) as f32 / 255.0;
let options = ParticleOptions::colored(
ParticleType::EntityEffect,
[channel(16), channel(8), channel(0), channel(24)],
);
for _ in 0..count {
// getRandomX(0.5), getRandomY, getRandomZ(0.5).
let r = self.particles.random();
let x = at.x + SIZE * (2.0 * r.next_double() - 1.0) * 0.5;
let y = at.y + SIZE * r.next_double();
let z = at.z + SIZE * (2.0 * r.next_double() - 1.0) * 0.5;
self.particles.add(&options, [x, y, z], [0.0; 3]);
}
puffs.push((at, a.effect_color, count));
}
for (at, color, count) in puffs {
self.arrow_makeparticle(at, color, count);
}
}
/// `Arrow.makeParticle`: `count` entity-effect swirls in `color` (an ARGB
/// int, -1 for none) inside the arrow's 0.5 x 0.5 box.
fn arrow_makeparticle(&mut self, at: Vec3, color: i32, count: u32) {
// `EntityType.ARROW` is 0.5 x 0.5.
const SIZE: f64 = 0.5;
if color == -1 {
return;
}
// ColorParticleOption.create(ENTITY_EFFECT, colour): ARGB as floats.
let channel = |shift: u32| (color as u32 >> shift & 255) as f32 / 255.0;
let options = ParticleOptions::colored(
ParticleType::EntityEffect,
[channel(16), channel(8), channel(0), channel(24)],
);
for _ in 0..count {
// getRandomX(0.5), getRandomY, getRandomZ(0.5).
let r = self.particles.random();
let x = at.x + SIZE * (2.0 * r.next_double() - 1.0) * 0.5;
let y = at.y + SIZE * r.next_double();
let z = at.z + SIZE * (2.0 * r.next_double() - 1.0) * 0.5;
self.particles.add(&options, [x, y, z], [0.0; 3]);
}
}
/// `Arrow.handleEntityEvent(0)`: a tipped arrow stuck for 600 ticks bursts
/// into twenty particles in its colour. The server turns it into a plain
/// arrow and does not send the colour again, so it stops here.
pub(super) fn remote_arrow_puff(&mut self, id: u32) {
let Some(p) = self.creatures.projectiles.iter_mut().find(|p| p.id == id) else {
return;
};
let Some(a) = p.arrow.as_mut() else {
return;
};
let (at, color) = (p.position, std::mem::replace(&mut a.effect_color, -1));
self.arrow_makeparticle(at, color, 20);
}
/// `AbstractHurtingProjectile.createParticleTrail`: one smoke puff half a
@@ -424,6 +455,16 @@ impl Game<'_> {
*critical = flags & 1 != 0;
}
(ProjectileKind::Arrow { .. }, 10, Value::Bool(in_ground)) => {
// AbstractArrow.onSyncedDataUpdated: IN_GROUND turning true after
// the first tick starts the shake.
if *in_ground
&& !p.stuck
&& p.age > 0
&& let Some(a) = p.arrow.as_mut()
&& a.shake <= 0
{
a.shake = 7;
}
p.stuck = *in_ground;
}
// Arrow.ID_EFFECT_COLOR.
@@ -111,6 +111,11 @@ impl Game<'_> {
};
let vehicle_id = vehicle as u32;
for m in &mut self.mobs {
// `Entity.isVehicle` (the saddle reins of a ridden horse): the
// server sends no rider on the mob itself, only this list.
if m.id == vehicle_id {
m.extra.rider = passengers.first().map(|&p| p as u32);
}
if m.extra.mount == Some(vehicle_id) && !passengers.contains(&(m.id as i32)) {
m.extra.mount = None;
m.extra.passenger = false;
@@ -291,6 +296,11 @@ impl Game<'_> {
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
let m = &mut self.mobs[i];
if jump > 0.0 && equine::is_equine(m.species()) {
// LocalPlayer.aiStep also runs `onPlayerJump` here: the mount slides
// on the rider's keys and rears while the server does the same.
equine::on_player_jump(m);
}
// playerJumpPendingScale waits for the ground.
let pending = m.rider.map_or(0.0, |r| r.jump);
m.rider = Some(equine::Rider {
+15 -1
View File
@@ -169,7 +169,7 @@ impl Game<'_> {
fn level_min_y(&self) -> i32 {
match self.remote.as_ref() {
Some(r) => r.dimension.min_y,
None => self.dim.min_y(),
None => self.world.shape.min_y,
}
}
/// `SkyRenderer.shouldRenderDarkDisc`: eye below the horizon and not under
@@ -489,6 +489,20 @@ impl Game<'_> {
},
);
}
// SpellParticle.isCloseToScopingPlayer: the eye of a local player looking
// through a spyglass in first person.
let scope_eye = self
.player
.as_ref()
.filter(|_| self.camera_type.first_person() && self.scoping())
.map(|p| {
[
p.position.x,
p.position.y + f64::from(p.dimensions.eye_height),
p.position.z,
]
});
self.particles.set_scope_eye(scope_eye);
let level = ParticleLevel {
world,
facts,
@@ -349,6 +349,12 @@ impl Game<'_> {
if self.block_in_tag("minecraft:climbable", name) || open_trapdoor {
return VoxelShape::empty();
}
self.block_collision_shape(p)
}
/// `BlockState.getCollisionShape(level, pos)`: the shape under the empty
/// collision context.
pub(super) fn block_collision_shape(&self, p: [i32; 3]) -> VoxelShape {
let state = self.world.block(p[0], p[1], p[2]);
let binding = self.collision;
let mut scene = SceneCollisions {
world: &self.world,
@@ -364,6 +370,22 @@ impl Game<'_> {
)
.unwrap_or_else(|_| VoxelShape::empty())
}
/// `DismountHelper.canDismountTo(level, entity, box)`: no block collides
/// with the box and it is inside the world border.
pub(super) fn can_dismount_to(&self, bounds: Aabb) -> bool {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
if !no_collision_empty(&mut scene, bounds) {
return false;
}
// WorldBorder.isWithinBounds(box).
let (lo, hi) = (bounds.min(Axis::X), bounds.max(Axis::X) - 1.0e-5);
let (lz, hz) = (bounds.min(Axis::Z), bounds.max(Axis::Z) - 1.0e-5);
self.border.contains(lo, lz) && self.border.contains(hi, hz)
}
pub(super) fn block_in_tag(&self, tag: &str, block: &str) -> bool {
self.res
.items()
@@ -427,12 +449,7 @@ impl Game<'_> {
position.z + half,
),
);
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
if !no_collision_empty(&mut scene, bounds) {
if !self.can_dismount_to(bounds) {
return None;
}
let name = |q: [i32; 3]| {
@@ -450,10 +467,7 @@ impl Game<'_> {
{
return None;
}
// WorldBorder.isWithinBounds(box).
let (lo, hi) = (bounds.min(Axis::X), bounds.max(Axis::X) - 1.0e-5);
let (lz, hz) = (bounds.min(Axis::Z), bounds.max(Axis::Z) - 1.0e-5);
(self.border.contains(lo, lz) && self.border.contains(hi, hz)).then_some(position)
Some(position)
}
/// `Block.isPossibleToRespawnInThis`: neither solid nor liquid; banners,
/// signs and pressure plates override it to always.
@@ -284,11 +284,12 @@ impl Loading {
Err(TryRecvError::Disconnected) => {
// The loader sends `Done` before it returns: it panicked.
let reason = match self.handle.take().map(JoinHandle::join) {
Some(Err(payload)) => format!(": {}", panic_text(payload.as_ref())),
_ => String::new(),
Some(Err(payload)) => panic_text(payload.as_ref()).to_owned(),
Some(Ok(())) => "it returned without a result".into(),
None => "no loader thread was running".into(),
};
return Err(
format!("resource loader stopped before finishing{reason}").into()
format!("resource loader stopped before finishing: {reason}").into(),
);
}
}
@@ -887,7 +888,7 @@ mod tests {
let mut loading = loading(receiver);
assert_eq!(
loading.poll_menu_resources().unwrap_err().to_string(),
"resource loader stopped before finishing"
"resource loader stopped before finishing: no loader thread was running"
);
}
}
+195 -5
View File
@@ -127,6 +127,123 @@ impl Kind {
}
}
/// `CyclingSlotBackground`: the icons an empty slot cycles through, one every
/// 30 ticks, the next fading in over 4 (`CyclingSlotBackground.tick` in
/// `advance`, `CyclingSlotBackground.extractRenderState` in `shown`).
#[derive(Default)]
struct CyclingSlot {
icons: Vec<&'static str>,
tick: u32,
index: usize,
/// The client tick it was last advanced to.
at: Option<u64>,
}
impl CyclingSlot {
const CHANGE_TICKS: u32 = 30;
const FADE_TICKS: f32 = 4.0;
/// `CyclingSlotBackground.tick(newIcons)` for each client tick since the last call.
fn advance(&mut self, now: u64, icons: &[&'static str]) {
let ticks = self.at.map_or(0, |at| now.saturating_sub(at).min(600));
self.at = Some(now);
if self.icons != icons {
self.icons = icons.to_vec();
self.index = 0;
}
for _ in 0..ticks {
if !self.icons.is_empty() {
self.tick += 1;
if self.tick.is_multiple_of(Self::CHANGE_TICKS) {
self.index = (self.index + 1) % self.icons.len();
}
}
}
}
/// What `CyclingSlotBackground.extractRenderState` draws for an empty slot: the icon before
/// this one fading out while this one fades in, with their alphas.
fn shown(&self, partial: f32) -> Vec<(&'static str, f32)> {
if self.icons.is_empty() {
return Vec::new();
}
let fading = self.icons.len() > 1 && self.tick >= Self::CHANGE_TICKS;
let alpha = if fading {
(((self.tick % Self::CHANGE_TICKS) as f32 + partial).min(Self::FADE_TICKS))
/ Self::FADE_TICKS
} else {
1.0
};
let mut shown = Vec::new();
if alpha < 1.0 {
let previous = (self.index + self.icons.len() - 1) % self.icons.len();
shown.push((self.icons[previous], 1.0 - alpha));
}
shown.push((self.icons[self.index], alpha));
shown
}
}
/// `SmithingScreen`'s `EMPTY_SLOT_SMITHING_TEMPLATES`.
const EMPTY_TEMPLATE_ICONS: [&str; 2] = [
"smithing_template_armor_trim",
"smithing_template_netherite_upgrade",
];
/// The base and additions slot icons of the template in the smithing table:
/// `SmithingTemplateItem.createNetheriteUpgradeTemplate` (the 11 tools and
/// armour of `SmithingTemplateItem.createNetheriteUpgradeIconList`, then the
/// ingot) and `SmithingTemplateItem.createArmorTrimTemplate` (the four armour
/// pieces, then the seven trim materials), as `getBaseSlotEmptyIcons` and
/// `getAdditionalSlotEmptyIcons` give them.
fn smithing_slot_icons(
template: Option<&str>,
) -> ([&'static str; 11], usize, [&'static str; 7], usize) {
let none = ([""; 11], 0, [""; 7], 0);
match template.map(|t| t.trim_start_matches("minecraft:")) {
Some("netherite_upgrade_smithing_template") => (
[
"helmet",
"sword",
"chestplate",
"pickaxe",
"leggings",
"axe",
"boots",
"hoe",
"shovel",
"nautilus_armor",
"spear",
],
11,
["ingot", "", "", "", "", "", ""],
1,
),
Some(t) if t.ends_with("_armor_trim_smithing_template") => (
[
"helmet",
"chestplate",
"leggings",
"boots",
"",
"",
"",
"",
"",
"",
"",
],
4,
[
"ingot",
"redstone_dust",
"lapis_lazuli",
"quartz",
"diamond",
"emerald",
"amethyst_shard",
],
7,
),
_ => none,
}
}
pub(super) struct Station {
pub(super) kind: Kind,
pub(super) pos: [i32; 3],
@@ -161,6 +278,8 @@ pub(super) struct Station {
pub(super) beacon_levels: Option<i32>,
/// The loom's patterns, selection and scroller (game/loom.rs).
pub(super) loom: super::loom::LoomState,
/// The smithing table's three `CyclingSlotBackground`s: the template, base and additions slots.
cycling: [CyclingSlot; 3],
}
impl Station {
fn new(kind: Kind, pos: [i32; 3]) -> Self {
@@ -186,6 +305,7 @@ impl Station {
beacon: [None; 2],
beacon_levels: None,
loom: Default::default(),
cycling: Default::default(),
}
}
pub(super) fn new_kind(kind: Kind, pos: [i32; 3]) -> Self {
@@ -1221,6 +1341,21 @@ impl Game<'_> {
if self.station.is_none() && !brewing {
return false;
}
// `SmithingScreen.containerTick`: the icons of the empty slots follow the template.
let (now, partial) = (self.ticks, self.clock.camera_fraction() as f32);
if let Some(s) = self.station.as_mut().filter(|s| s.kind == Kind::Smithing) {
let template = s.inputs[0].as_ref().map(|t| t.item.clone());
let (base, base_len, additions, additions_len) =
smithing_slot_icons(template.as_deref());
let lists: [&[&'static str]; 3] = [
&EMPTY_TEMPLATE_ICONS,
&base[..base_len],
&additions[..additions_len],
];
for (cycling, icons) in s.cycling.iter_mut().zip(lists) {
cycling.advance(now, icons);
}
}
let (ox, oy) = self.inventory_origin(batch);
let local = (self.cursor.0 - ox, self.cursor.1 - oy);
let mut tooltip: Option<Vec<(horizon_client::text::Text, u32)>> = None;
@@ -1609,15 +1744,23 @@ impl Game<'_> {
}
}
Kind::Smithing => {
let icons = ["smithing_template_netherite_upgrade", "", ""];
for (i, icon) in icons.iter().enumerate() {
if s.inputs[i].is_none() && !icon.is_empty() {
let (sx, sy) = kind.input_pos(i);
batch.sprite(
// `CyclingSlotBackground.extractRenderState`: an empty slot shows the
// icons of what the template in the table takes.
for (i, cycling) in s.cycling.iter().enumerate() {
if s.inputs[i].is_some() {
continue;
}
let (sx, sy) = kind.input_pos(i);
for (icon, alpha) in cycling.shown(partial) {
batch.sprite_scaled(
atlas,
&format!("gui/sprites/container/slot/{icon}"),
ox + sx,
oy + sy,
16.0,
16.0,
((alpha.clamp(0.0, 1.0) * 255.0).round() as u32) << 24
| 0x00FF_FFFF,
);
}
}
@@ -2189,6 +2332,11 @@ impl Game<'_> {
));
}
}
// Native mods last, once getTooltipLines has built every line; a
// tooltip the stack hides stays hidden.
if !nested && !lines.is_empty() {
self.native_tooltip(stack, &mut lines);
}
lines
}
/// Menu state for the control socket.
@@ -2605,3 +2753,45 @@ mod tests {
assert!(!s.stonecutter_scrolls());
}
}
#[cfg(test)]
mod cycling_tests {
use super::*;
#[test]
fn an_empty_slot_cycles_its_icons_every_30_ticks_and_fades_the_next_in_over_4() {
let icons = ["helmet", "sword", "chestplate"];
let mut slot = CyclingSlot::default();
slot.advance(100, &icons);
// The first icon, fully shown, until the 30th tick.
assert_eq!(slot.shown(0.5), vec![("helmet", 1.0)]);
slot.advance(129, &icons);
assert_eq!(slot.shown(0.5), vec![("helmet", 1.0)]);
// The 30th tick moves to the next icon, which fades in over 4 ticks while the
// one before fades out.
slot.advance(130, &icons);
assert_eq!(slot.shown(0.0), vec![("helmet", 1.0), ("sword", 0.0)]);
assert_eq!(slot.shown(2.0), vec![("helmet", 0.5), ("sword", 0.5)]);
assert_eq!(slot.shown(4.0), vec![("sword", 1.0)]);
// A new list starts again from its first icon, keeping the tick count.
slot.advance(131, &["ingot"]);
assert_eq!(slot.shown(0.0), vec![("ingot", 1.0)]);
// No icons, nothing shown.
slot.advance(132, &[]);
assert!(slot.shown(0.0).is_empty());
}
#[test]
fn the_icons_of_a_smithing_slot_follow_the_template() {
let (base, n, additions, m) =
smithing_slot_icons(Some("minecraft:netherite_upgrade_smithing_template"));
assert_eq!(base[..n].len(), 11);
assert_eq!(additions[..m], ["ingot"]);
let (base, n, additions, m) =
smithing_slot_icons(Some("minecraft:coast_armor_trim_smithing_template"));
assert_eq!(base[..n], ["helmet", "chestplate", "leggings", "boots"]);
assert_eq!(additions[..m].len(), 7);
assert_eq!(smithing_slot_icons(Some("minecraft:stick")).1, 0);
assert_eq!(smithing_slot_icons(None).3, 0);
}
}
+29 -1
View File
@@ -8,7 +8,7 @@ use horizon_client::{
mesher::Vertex,
mobs::{Mob, Species, sulfur_cube},
};
use horizon_core::random::RandomSource;
use horizon_core::{position::BlockPos, random::RandomSource};
fn block_transform(base: glam::Mat4, baby: bool) -> glam::Mat4 {
base * glam::Mat4::from_scale(glam::Vec3::new(1.0, -1.0, -1.0))
@@ -150,6 +150,34 @@ impl Game<'_> {
}
false
}
/// `SulfurCube.primeWhenOnPoweredPosition`: an explosive cube standing in
/// or next to redstone power lights its fuse (`getBestOwnOrNeighbourSignal`;
/// no block has an own signal, so the neighbour signal is all there is).
pub(super) fn sulfur_cube_power_priming(&mut self) {
if !self.ui.rules.bool("tnt_explodes") {
return;
}
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::SulfurCube
|| !sulfur_cube::archetype(m).is_some_and(|a| a.explosion.is_some())
|| !m.alive()
|| sulfur_cube::primed(m)
{
continue;
}
let at = BlockPos::new(
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
let signal =
self.with_level(|b, level, ticks| b.best_neighbor_signal(level, ticks, at));
if signal != 0 {
sulfur_cube::prime(&mut self.mobs[i], false);
}
}
}
/// `SulfurCubeSearchForItemsGoal` and `pickUpItem`: an adult with nothing
/// inside hops to a block it can swallow within 8 and takes it in.
pub(super) fn sulfur_cube_pickups(&mut self) {
+26 -17
View File
@@ -223,6 +223,24 @@ impl Game<'_> {
((d.brightness as u32 >> 20 & 15) * 16) | (((d.brightness as u32 >> 4 & 15) * 16) << 8)
}
}
/// `Display.shouldRenderAtSqrDistance` and, when width and height are set,
/// `EntityRenderer.shouldRender` with `Display.getInterpolatedBoundingBox`
/// inflated by 0.5. Width or height 0 is `noCulling`: distance only.
fn display_in_view(
&self,
camera: glam::DVec3,
origin: glam::DVec3,
data: &TextDisplayData,
view_scale: f64,
) -> bool {
let range = f64::from(data.view_range) * 64.0 * view_scale;
if text_display::no_culling(data.width, data.height) {
return (origin - camera).length_squared() < range * range;
}
let (lo, hi) = text_display::culling_box(origin, data.width, data.height);
self.entity_in_view(camera, origin, range, lo, hi)
}
/// `DisplayRenderer.BlockDisplayRenderer` and `ItemDisplayRenderer`: the
/// block model or the item under the display's transformation, lit like
/// any entity and never tinted by a hurt overlay.
@@ -246,8 +264,7 @@ impl Game<'_> {
display.last_progress = display.progress(partial as f32);
let d = display.shown.blend(&display.from, display.last_progress);
let position = display.previous.lerp(display.position, partial);
let range = f64::from(d.view_range) * 64.0 * scale;
if position.distance_squared(camera) >= range * range {
if !self.display_in_view(camera, position, &d, scale) {
continue;
}
let rotation = std::array::from_fn(|i| {
@@ -295,18 +312,15 @@ impl Game<'_> {
) {
self.text_display_vertices = 0;
self.mark_translucent_entity(normal.len(), "minecraft:text_display");
let atlas = &self.gui.atlas;
let angles = self.display_camera_angles();
let scale = self.view_scale();
for display in self.text_displays.values_mut() {
if display.shows != Shows::Text {
continue;
}
let background = self.settings.text_background(0.25);
let mut displays = std::mem::take(&mut self.text_displays);
for display in displays.values_mut().filter(|d| d.shows == Shows::Text) {
display.last_progress = display.progress(partial as f32);
let d = display.shown.blend(&display.from, display.last_progress);
let position = display.previous.lerp(display.position, partial);
let range = f64::from(d.view_range) * 64.0 * scale;
if position.distance_squared(camera) >= range * range || d.text.is_empty() {
if d.text.is_empty() || !self.display_in_view(camera, position, &d, scale) {
continue;
}
let rotation = std::array::from_fn(|i| {
@@ -315,7 +329,7 @@ impl Game<'_> {
});
let lines = display
.lines
.get_or_insert_with(|| Lines::split(&atlas.font, &d.text, d.line_width));
.get_or_insert_with(|| Lines::split(&self.gui.atlas.font, &d.text, d.line_width));
let matrix = glam::Mat4::from_translation((position - camera).as_vec3())
* d.matrix(rotation, angles)
* lines.pose();
@@ -323,13 +337,7 @@ impl Game<'_> {
continue;
}
let mut batch = HudBatch::new([1, 1]);
text_display::draw(
&mut batch,
atlas,
&d,
lines,
self.settings.text_background(0.25),
);
text_display::draw(&mut batch, &self.gui.atlas, &d, lines, background);
let light = Self::display_light(&self.world, &d, position);
// Font.DisplayMode: SEE_THROUGH over everything, else POLYGON_OFFSET.
let through = d.flags & 2 != 0;
@@ -358,6 +366,7 @@ impl Game<'_> {
self.text_display_vertices += batch.vertices.len() / 6 * 4;
self.map_patches.extend(batch.patches);
}
self.text_displays = displays;
}
}
+157 -45
View File
@@ -2,9 +2,9 @@
//! controls and sorting, both off by default: while an extra is off its
//! hooks return false and the screen clicks exactly as vanilla's. Each action is made of ordinary slot clicks
//! (the menu engine's, or `ContainerClick`s on a server), so it works
//! wherever vanilla's clicks do. They run in the player inventory and the
//! wherever vanilla's clicks do. They run in the player inventory, the
//! chest-like containers (chests, barrels, ender chests, shulker boxes,
//! hoppers, dispensers).
//! hoppers, dispensers), furnaces and crafting tables.
//!
//! - Right-drag holding a stack: one item into each slot entered, at once.
//! - Left-drag holding a stack, before vanilla's drag has spread over a
@@ -12,8 +12,10 @@
//! the hand (when it all fits); with Shift, those slots are quick-moved.
//! - Shift + left-drag with an empty hand: every slot entered is quick-moved.
//! - The wheel over a slot, with an empty hand: down moves one of its items
//! to the other side (container and inventory, or hotbar and inventory),
//! up brings one of the same item back.
//! where a Shift click would send it (`quickMoveStack`: fuel to the fuel
//! slot, a smeltable to the input), up brings one of the same item from the
//! other side. Down over a result slot takes one craft or the whole furnace
//! output to the inventory; nothing ever goes into a result slot.
//!
//! Sorting draws a small button by the container's title and one by the
//! inventory's (an optional key sorts the part under the cursor); the
@@ -39,6 +41,23 @@ pub(super) struct Tweaks {
/// Wheel steps not yet acted on (a trackpad scrolls in fractions).
wheel: f32,
}
/// A server's menu the tweaks run in: their clicks are vanilla's in these,
/// and the menu engine knows which slot takes what. Not the menus whose slots
/// cost or trigger something (anvils, enchanting, merchants, beacons, crafters).
pub(super) fn tweak_menu(name: &str) -> bool {
name == "inventory"
|| name.starts_with("minecraft:generic_9x")
|| matches!(
name,
"minecraft:shulker_box"
| "minecraft:hopper"
| "minecraft:generic_3x3"
| "minecraft:furnace"
| "minecraft:blast_furnace"
| "minecraft:smoker"
| "minecraft:crafting"
)
}
/// A drag the tweaks follow.
#[derive(Clone, Debug)]
struct Drag {
@@ -62,7 +81,7 @@ impl Game<'_> {
return false;
}
if self.remote.is_some() {
self.remote_storage_open()
self.remote_menu_type().is_none_or(tweak_menu)
} else {
matches!(self.container_kind(), None | Some(BlockEntity::Chest(_)))
}
@@ -80,6 +99,28 @@ impl Game<'_> {
}
true
}
/// Puts the hand's stack (`right`: one of it) in `slot`, as a one-slot
/// drag (`QUICK_CRAFT`) and not a click: a server that has the hand empty
/// (the stack it was to come from just burned as fuel) ignores a drag, but
/// a click would pick up what's in `slot`.
fn tweak_put(&mut self, slot: usize, right: bool) -> bool {
let kind = i32::from(right) << 2;
let ops = [
(None, kind, Input::QuickCraft),
(Some(slot), kind | 1, Input::QuickCraft),
(None, kind | 2, Input::QuickCraft),
];
if self.remote.is_some() {
return self.remote_menu_ops(&ops);
}
if !self.menu_ops(&ops) {
return false;
}
if let Some(key) = self.container {
self.container_changed(key);
}
true
}
/// A press on `slot`: true when the tweaks took it (a right-drag
/// placing), else vanilla's press runs and the tweaks watch the drag.
pub(super) fn tweak_press(&mut self, slot: Option<usize>, right: bool, shift: bool) -> bool {
@@ -92,7 +133,8 @@ impl Game<'_> {
}
let held = self.carried.clone();
if right {
if shift || held.is_none() {
// A result slot takes nothing: its press stays vanilla's.
if shift || held.is_none() || self.tweak_result(slot) {
return false;
}
// RMB: the press places at once, then the drag goes on.
@@ -123,11 +165,7 @@ impl Game<'_> {
let Some(held) = drag.held.clone() else {
return;
};
let accepts = match self.slot_view(slot) {
None => true,
Some(s) => s.same_item_same_components(&held) && s.count < self.max_stack(&s),
};
if accepts && self.tweak_click(slot, 1, Input::Pickup) {
if self.tweak_takes_one(slot, &held) && self.tweak_put(slot, true) {
drag.held = (held.count > 1).then(|| ItemStack {
count: held.count - 1,
..held
@@ -192,7 +230,7 @@ impl Game<'_> {
if !self.settings.extras.mouse_tweaks_wheel || !self.tweaks_on() || self.carried.is_some() {
return false;
}
let Some(slot) = slot.filter(|&s| s != 200) else {
let Some(slot) = slot else {
self.tweaks.wheel = 0.0;
return false;
};
@@ -208,73 +246,111 @@ impl Game<'_> {
}
true
}
/// The slots across from `slot`, in quick-move order: a container's
/// go to the inventory (hotbar first, from the right), the inventory's
/// to the container; without one, hotbar and main inventory trade.
/// `slot` is a result slot of the open menu.
fn tweak_result(&mut self, slot: usize) -> bool {
let mut result = false;
self.menu_session(|menu, _, to_menu| {
result = to_menu(slot).is_some_and(|i| menu.is_result(i));
Some(false)
});
result
}
/// `slot` takes one more of `stack`, by the open menu's rules.
fn tweak_takes_one(&mut self, slot: usize, stack: &ItemStack) -> bool {
let mut takes = false;
self.menu_session(|menu, env, to_menu| {
takes = to_menu(slot).is_some_and(|i| menu.takes_one(env, i, stack));
Some(false)
});
takes
}
/// Where vanilla's quick move would put `slot`'s items, tried on a copy
/// of the menu that's dropped (`Some(false)` keeps nothing).
fn tweak_landing(&mut self, slot: usize) -> Vec<usize> {
let slots = self.slots();
let mut landing = Vec::new();
self.menu_session(|menu, env, to_menu| {
let from = to_menu(slot)?;
landing = menu
.quick_move_landing(env, from)
.into_iter()
.filter_map(|i| slots.iter().copied().find(|&g| to_menu(g) == Some(i)))
.collect();
Some(false)
});
landing
}
/// The slots across from `slot`: a container's or crafting grid's face
/// the inventory (hotbar first, from the right), the inventory's face
/// them; without one, hotbar and main inventory trade.
fn other_side(&self, slot: usize) -> Vec<usize> {
let container: Vec<usize> = self
let table = self.craft_size == 3;
let own: Vec<usize> = self
.slots()
.into_iter()
.filter(|s| (300..500).contains(s))
.filter(|s| (300..500).contains(s) || (table && (100..109).contains(s)))
.collect();
let player = || (0..9).rev().chain((9..36).rev());
match slot {
300..500 => player().collect(),
0..36 if !container.is_empty() => container,
100..109 | 300..500 => player().collect(),
0..36 if !own.is_empty() => own,
0..9 => (9..36).collect(),
9..36 => (0..9).collect(),
_ => Vec::new(),
}
}
/// Wheel down: one item of `slot` to the other side, where a stack of
/// it has room, else to the first empty slot (`moveItemStackTo`).
/// Wheel down: one item of `slot` to where a quick move sends it. A
/// result can't be split or put back: one craft (or the furnace's whole
/// output) goes, when it all fits.
fn tweak_push(&mut self, slot: usize) {
let Some(stack) = self.slot_view(slot) else {
return;
};
let max = self.max_stack(&stack);
let side = self.other_side(slot);
let views: Vec<(usize, Option<ItemStack>)> =
side.iter().map(|&s| (s, self.slot_view(s))).collect();
let target = views
.iter()
.find(|(_, v)| {
v.as_ref()
.is_some_and(|v| v.same_item_same_components(&stack) && v.count < max)
})
.or_else(|| views.iter().find(|(_, v)| v.is_none()))
.map(|(s, _)| *s);
let Some(target) = target else {
let landing = self.tweak_landing(slot);
let Some(&target) = landing.first() else {
return;
};
if self.tweak_result(slot) {
if self.tweak_click(slot, 0, Input::Pickup) {
for target in landing {
self.tweak_put(target, false);
}
}
return;
}
if self.tweak_click(slot, 0, Input::Pickup) {
self.tweak_click(target, 1, Input::Pickup);
self.tweak_put(target, true);
if stack.count > 1 {
self.tweak_click(slot, 0, Input::Pickup);
self.tweak_put(slot, false);
}
}
}
/// Wheel up: one item of the same kind from the other side into `slot`.
/// A result slot takes none and gives none.
fn tweak_pull(&mut self, slot: usize) {
let Some(stack) = self.slot_view(slot) else {
return;
};
if stack.count >= self.max_stack(&stack) {
if !self.tweak_takes_one(slot, &stack) {
return;
}
let side = self.other_side(slot);
let source = side.into_iter().find_map(|s| {
self.slot_view(s)
.filter(|v| v.same_item_same_components(&stack))
.map(|v| (s, v.count))
});
let mut source = None;
for s in self.other_side(slot) {
if let Some(v) = self.slot_view(s)
&& v.same_item_same_components(&stack)
&& !self.tweak_result(s)
{
source = Some((s, v.count));
break;
}
}
let Some((source, count)) = source else {
return;
};
if self.tweak_click(source, 0, Input::Pickup) {
self.tweak_click(slot, 1, Input::Pickup);
self.tweak_put(slot, true);
if count > 1 {
self.tweak_click(source, 0, Input::Pickup);
self.tweak_put(source, false);
}
}
}
@@ -411,3 +487,39 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::tweak_menu;
#[test]
fn the_tweaks_run_in_the_menus_with_plain_click_rules() {
for menu in [
"inventory",
"minecraft:generic_9x1",
"minecraft:generic_9x6",
"minecraft:shulker_box",
"minecraft:hopper",
"minecraft:generic_3x3",
"minecraft:furnace",
"minecraft:blast_furnace",
"minecraft:smoker",
"minecraft:crafting",
] {
assert!(tweak_menu(menu), "{menu}");
}
for menu in [
"minecraft:anvil",
"minecraft:enchantment",
"minecraft:merchant",
"minecraft:beacon",
"minecraft:grindstone",
"minecraft:stonecutter",
"minecraft:loom",
"minecraft:brewing_stand",
"minecraft:crafter_3x3",
] {
assert!(!tweak_menu(menu), "{menu}");
}
}
}
@@ -12,6 +12,8 @@ pub(super) struct WorldRequest {
pub(super) struct PreparedWorld {
pub save_root: PathBuf,
/// The levels' shapes, from the world's dimension types.
pub shapes: horizon_client::chunk::LevelShapes,
pub level: Level,
pub world: World,
pub streaming: Streaming,
@@ -115,11 +117,14 @@ impl WorldRequest {
level.position = None;
}
let dim = Dim::from_id(&level.dimension);
let (mut streaming, save, locator) =
make_streaming(res, pack, options, settings, &save_root, dim, level.seed)?;
let shapes = level_shapes(&save_root, res.biomes());
let shape = shapes.get(dim.id());
let (mut streaming, save, locator) = make_streaming(
res, pack, options, settings, &save_root, dim, shape, level.seed,
)?;
streaming.radius = settings.render_distance;
let mut world = World::new(res.facts().air, level.seed);
world.shape = dim.shape();
world.shape = shape;
world.fast_lava = dim == Dim::Nether;
world.game_time = level.time;
world.day_time = options.time.unwrap_or(level.day_time);
@@ -146,6 +151,7 @@ impl WorldRequest {
}
Ok(PreparedWorld {
save_root,
shapes,
level,
world,
streaming,
@@ -337,6 +343,46 @@ fn world_worldgen_overrides(
overrides
}
/// The shapes of the world's levels: their dimension types, the world's
/// enabled data packs' over vanilla's. A type the codec refuses keeps the
/// vanilla shape, with a warning.
pub(super) fn level_shapes(
world_root: &std::path::Path,
biomes: &horizon_client::biome::Biomes,
) -> horizon_client::chunk::LevelShapes {
use horizon_client::chunk::{LevelShape, LevelShapes};
let packs = horizon_client::datapack::discover(world_root);
let enabled = horizon_client::datapack::enabled_ids(world_root, &packs);
let enabled: Vec<_> = enabled
.iter()
.filter_map(|id| packs.iter().find(|p| &p.id == id))
.collect();
let mut shapes = LevelShapes::VANILLA;
if enabled.is_empty() {
return shapes;
}
// What the packs leave out is logged when the generator reads them.
let (overrides, _) =
horizon_client::datapack::worldgen_overrides(&enabled, |b| biomes.contains(b));
for level in [
"minecraft:overworld",
"minecraft:the_nether",
"minecraft:the_end",
] {
let shape = overrides.level_height(level).and_then(|h| {
LevelShape::new(h.min_y, h.height, h.logical_height, h.has_skylight).ok()
});
match shape {
Some(shape) => *shapes.get_mut(level) = shape,
None => log::warn!(
"Horizon: data pack worldgen: the dimension type of {level} is not one the \
game accepts; the level keeps the vanilla build height"
),
}
}
shapes
}
pub(super) fn make_streaming(
res: &Resources,
pack: &PackResources,
@@ -344,11 +390,12 @@ pub(super) fn make_streaming(
settings: &Settings,
world_root: &std::path::Path,
dim: Dim,
shape: horizon_client::chunk::LevelShape,
seed: i64,
) -> Result<(Streaming, Arc<SaveDir>, Option<Arc<VanillaTerrain>>)> {
let save = Arc::new(SaveDir::new(
horizon_client::save::dimension_dir(world_root, dim.id()),
dim.shape(),
shape,
));
let mut locator = None;
let flat = (dim == Dim::Overworld)
@@ -359,7 +406,7 @@ pub(super) fn make_streaming(
&s,
pack.assets.registry,
res.biomes(),
dim.shape(),
shape,
)
});
let mut streaming = if let Some(inner) = flat {
+27 -11
View File
@@ -13,7 +13,7 @@ use horizon_protocol::{
chunk::{
BIOMES, BLOCKS, BlockEntityInfo, ChunkData, Heightmaps, LightData, Paletted, Strategy,
},
packets::play::LevelChunkWithLight,
packets::play::{ChunkBiomeData, LevelChunkWithLight},
registry::{Builtin, ceil_log2},
};
use horizon_world::nbt::Tag;
@@ -263,15 +263,6 @@ impl SectionCoder {
values.push(v as i32);
}
}
let biome_values: Vec<i32> = (0..BIOMES)
.map(|j| {
let internal = section.biomes.get(j) as u16;
self.biome_ids
.get(usize::from(internal))
.copied()
.unwrap_or(0)
})
.collect();
(
horizon_protocol::chunk::Section {
non_empty,
@@ -288,11 +279,26 @@ impl SectionCoder {
crate::chunk::block_strategy().global_bits,
),
},
biomes: Paletted::from_values(&biome_values, Strategy::Biomes, self.biome_bits),
biomes: self.biomes(section),
},
entities,
)
}
/// The section's biome container (`PalettedContainerRO.write`'s), in
/// network biome ids.
fn biomes(&self, section: &Section) -> Paletted {
let values: Vec<i32> = (0..BIOMES)
.map(|j| {
let internal = section.biomes.get(j) as u16;
self.biome_ids
.get(usize::from(internal))
.copied()
.unwrap_or(0)
})
.collect();
Paletted::from_values(&values, Strategy::Biomes, self.biome_bits)
}
}
/// Sections kept before the ones no longer resident are dropped.
@@ -453,6 +459,16 @@ impl ChunkEncoder {
}
}
/// `ClientboundChunksBiomesPacket.ChunkBiomeData`: the column's biome
/// containers, one per section of the level (`extractChunkData`).
pub fn biome_data(&self, chunk: &Chunk) -> ChunkBiomeData {
let biomes: Vec<Paletted> = chunk.sections[self.sections.clone()]
.iter()
.map(|s| self.coder.biomes(s))
.collect();
ChunkBiomeData::from_biomes([chunk.x, chunk.z], &biomes)
}
/// The client heightmaps (Heightmap.prime over the column), from the
/// cache while the column's sections are unchanged.
fn heightmaps(&self, chunk: &Chunk, tables: &crate::save::SaveTables) -> ColumnHeightmaps {
@@ -84,6 +84,11 @@ impl CommandTree {
})
}
/// Whether vanilla has a command of this name.
pub fn has(&self, name: &str) -> bool {
self.root["children"].get(name).is_some()
}
/// The ClientboundCommandsPacket for a player with `level` permissions.
pub fn packet(&self, level: u8, dedicated: bool, builtin: &Builtin) -> Commands {
self.packet_with(level, dedicated, builtin, &Value::Null)
+16 -1
View File
@@ -861,6 +861,21 @@ fn login_and_configure(
_ => {}
}
};
// horizon-clients-only: the brand the client sent ("vanilla" when it sent
// none) must be Horizon's.
if shared.config.horizon_clients_only && brand != "horizon" {
let text = super::HORIZON_CLIENTS_ONLY_REASON;
let reason = horizon_world::nbt::Tag::String(horizon_world::nbt::JavaString::from(text));
let _ = send(c, cfg::Clientbound::Disconnect(cfg::Disconnect { reason }));
if let Some(log) = &shared.config.log {
let line = format!(
"Disconnecting {name}: its client is \"{brand}\", not Horizon (horizon-clients-only)"
);
(log.write)("Server thread", "INFO", &line);
}
lost(shared, &format!("{name} ({})", uuid_string(uuid)), text);
return Ok(None);
}
// A Horizon client hears that this server runs its mods' quiet commands
// (horizon_protocol::quiet); a vanilla one is told nothing.
if brand == "horizon" {
@@ -910,7 +925,7 @@ fn login_and_configure(
return Ok(None);
}
}
for packet in sync.registry_packets(known_pack) {
for packet in sync.registry_packets(known_pack, &shared.config.levels) {
send(c, cfg::Clientbound::RegistryData(packet))?;
}
send(c, cfg::Clientbound::UpdateTags(sync.tags.clone()))?;
+136 -2
View File
@@ -2,7 +2,7 @@
//! level events, animations and block containers with their clicks.
use super::{
hub::Hub,
menu::{self, Menu, Rules, Slots, View},
menu::{self, Menu, NativeMenuEvent, Rules, Slots, View},
};
use crate::{containers::BlockEntity, items::ItemStack, world::World};
use horizon_protocol::{
@@ -245,6 +245,62 @@ impl Hub {
self.send_menu_content(i, items);
}
/// Opens a native mod's menu of `rows` rows (1 to 6, a chest's
/// `generic_9xN`) showing `items` (padded to the rows' slots), over the
/// guest's inventory: no click moves anything, each is a
/// [`NativeMenuEvent`]. The window's id, or None for an unknown guest.
pub fn open_native_menu(
&mut self,
conn: u32,
rows: u8,
title: Tag,
mut items: Vec<Option<ItemStack>>,
) -> Option<i32> {
let rows = rows.clamp(1, 6);
let kind = self
.sync
.builtin
.get("minecraft:menu")
.and_then(|m| m.id(&format!("minecraft:generic_9x{rows}")))?;
let i = self.guests.iter().position(|g| g.conn == conn)?;
self.close_container_at(i);
let size = usize::from(rows) * 9;
items.resize(size, None);
let g = &mut self.guests[i];
g.native_menu = items.clone();
g.next_window = g.next_window % 100 + 1;
let window = g.next_window;
g.send(&C::OpenScreen(play::OpenScreen {
container: window,
menu: kind,
title,
}));
g.menu = Some(Menu {
window,
kind,
pos: None,
slots: Slots::Native,
size,
state: 0,
sent: Vec::new(),
drag: None,
sent_data: Vec::new(),
});
self.send_menu_content(i, items);
Some(window)
}
/// Replaces what a guest's open native menu shows; the changed slots go
/// out with the tick's container sync.
pub fn set_native_menu(&mut self, conn: u32, mut items: Vec<Option<ItemStack>>) {
if let Some(g) = self.guest_mut(conn)
&& let Some(m) = g.menu.as_ref().filter(|m| m.slots == Slots::Native)
{
items.resize(m.size, None);
g.native_menu = items;
}
}
/// Open a trading window for a guest (`Merchant.openTradingScreen`):
/// its id, or None when the menu type is unknown.
pub fn open_merchant_window(&mut self, conn: u32, title: Tag) -> Option<i32> {
@@ -309,6 +365,7 @@ impl Hub {
});
menu.sent = items;
g.send(&packet);
g.inventory_told();
}
/// `AbstractContainerMenu.sendAllDataToRemote` for the guest's open
@@ -335,6 +392,12 @@ impl Hub {
g.send(&C::ContainerClose(play::ContainerClose {
container: menu.window,
}));
if menu.slots == Slots::Native {
g.native_menu.clear();
let (uuid, window) = (g.uuid, menu.window);
self.native_menu_events
.push(NativeMenuEvent::Closed { uuid, window });
}
}
}
@@ -353,7 +416,13 @@ impl Hub {
if let Some(g) = self.guest_mut(conn)
&& g.menu.as_ref().is_some_and(|m| m.window == window)
{
g.menu = None;
let native = g.menu.take().is_some_and(|m| m.slots == Slots::Native);
if native {
g.native_menu.clear();
let uuid = g.uuid;
self.native_menu_events
.push(NativeMenuEvent::Closed { uuid, window });
}
}
}
@@ -405,6 +474,19 @@ impl Hub {
self.resend_menu(conn, p.container, world);
return Vec::new();
}
// A native mod's menu: the click is the mod's to answer, and the
// whole menu and the cursor go back as they were.
if in_menu && self.guests[i].menu.as_ref().map(|m| m.slots) == Some(Slots::Native) {
self.native_menu_events.push(NativeMenuEvent::Click {
uuid: self.guests[i].uuid,
window: p.container,
slot: i32::from(p.slot),
button: i32::from(p.button),
mode: p.input,
});
self.resend_menu(conn, p.container, world);
return Vec::new();
}
// A menu that is no longer valid takes no clicks.
if let Some(m) = self.guests[i].menu.as_ref().filter(|_| in_menu)
&& !m.slots.still_valid(world, &self.guests[i], m.pos)
@@ -507,6 +589,28 @@ impl Hub {
}
}
/// What the client predicted for click `p` is what the server has, for a
/// menu of `total` slots read through `view`.
pub fn click_agreed(
&mut self,
i: usize,
p: &play::ContainerClick,
total: usize,
view: &dyn Fn(usize) -> Option<ItemStack>,
changed: &[usize],
) -> bool {
let carried = self.guests[i].carried.clone();
if !self.hashed_matches(&p.carried, &carried) {
return false;
}
let claimed: HashMap<usize, &Option<Hashed>> =
p.changed.iter().map(|(s, h)| (*s as usize, h)).collect();
(0..total).all(|slot| match claimed.get(&slot) {
Some(c) => self.hashed_matches(c, &view(slot)),
None => !changed.contains(&slot),
})
}
/// After a click: tell the client about every slot it got wrong, or that
/// it did not claim, and the carried stack.
fn answer_click(
@@ -517,6 +621,29 @@ impl Hub {
changed: &[usize],
) {
let in_menu = p.container != 0;
// handleContainerClick: a click made without the latest state gets the
// whole menu (`broadcastFullState`). Answering it slot by slot can leave
// the client with a correction of a click it has already gone past.
let g = &self.guests[i];
let open = g
.menu
.as_ref()
.filter(|_| in_menu)
.map(|m| (m.size, m.state));
let stale = match open {
Some((_, state)) => p.state != state,
None => !in_menu && p.state != g.menu_state,
};
if stale {
match open {
Some((size, _)) => {
let items = (0..size).map(|s| self.view_slot(i, world, s)).collect();
self.send_menu_content(i, items);
}
None => self.send_inventory(i),
}
return;
}
let claimed: HashMap<usize, Option<Hashed>> = p
.changed
.iter()
@@ -566,6 +693,13 @@ impl Hub {
// What the client now believes equals the server's state.
let items: Vec<_> = (0..total).map(|s| self.view_slot(i, world, s)).collect();
if in_menu {
// Nor is the inventory menu's sync to send what this click settled:
// ServerPlayer.tick broadcasts only the open menu.
let own = open.map_or(0, |(size, _)| size);
let settled = claimed.keys().copied().chain(changed.iter().copied());
for slot in settled.filter(|&s| s >= own && s < own + 36) {
self.guests[i].menu_sent[slot - own + 9] = items[slot].clone();
}
if let Some(m) = self.guests[i].menu.as_mut() {
m.sent = items;
}
+65 -8
View File
@@ -2,6 +2,7 @@
//! network order, with their data for clients that do not know the vanilla
//! pack) and the tags of every registry, loaded from the prepared resources
//! (`data/minecraft/...`) and the registries report.
use crate::chunk::LevelShapes;
use horizon_protocol::{
packets::configuration::{RegistryData, RegistryEntry, RegistryTags, TagEntry, UpdateTags},
registry::{Builtin, Dynamic},
@@ -184,7 +185,10 @@ impl SyncData {
/// ClientboundRegistryDataPacket per registry. With `known_pack` the
/// client rebuilds the entries from its own data, so none is sent.
pub fn registry_packets(&self, known_pack: bool) -> Vec<RegistryData> {
/// The registries for a client, with the data of a dimension type the
/// world's data packs changed (`levels`) even when it knows the vanilla
/// pack.
pub fn registry_packets(&self, known_pack: bool, levels: &LevelShapes) -> Vec<RegistryData> {
self.registries
.iter()
.map(|(key, e)| RegistryData {
@@ -193,13 +197,18 @@ impl SyncData {
.names
.iter()
.zip(&e.json)
.map(|(name, json)| RegistryEntry {
id: name.clone(),
data: if known_pack {
None
} else {
Some(json_to_nbt(json))
},
.map(|(name, json)| {
let changed = (key == "minecraft:dimension_type")
.then(|| changed_dimension_type(name, json, levels))
.flatten();
RegistryEntry {
id: name.clone(),
data: match changed {
Some(json) => Some(json_to_nbt(&json)),
None if known_pack => None,
None => Some(json_to_nbt(json)),
},
}
})
.collect(),
})
@@ -300,6 +309,27 @@ impl SyncData {
}
}
/// The vanilla dimension type `name` (its JSON `json`) with the shape the
/// world gives the level of that name, if its data packs changed it.
fn changed_dimension_type(name: &str, json: &Value, levels: &LevelShapes) -> Option<Value> {
if !matches!(
name,
"minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end"
) {
return None;
}
let shape = levels.get(name);
if shape == LevelShapes::VANILLA.get(name) {
return None;
}
let mut json = json.clone();
json["min_y"] = shape.min_y.into();
json["height"] = shape.height.into();
json["logical_height"] = shape.logical_height.into();
json["has_skylight"] = shape.sky_light.into();
Some(json)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -312,6 +342,33 @@ mod tests {
assert!(matches!(t.get("c"), Some(Tag::String(_))));
assert!(matches!(t.get("b"), Some(Tag::List(l)) if l.len() == 2));
}
#[test]
fn a_changed_dimension_type_is_sent_with_its_shape() {
let vanilla: Value =
serde_json::from_str(r#"{"min_y":-64,"height":384,"logical_height":384,"has_skylight":true,"ultrawarm":false}"#)
.unwrap();
let mut levels = LevelShapes::VANILLA;
assert_eq!(
changed_dimension_type("minecraft:overworld", &vanilla, &levels),
None
);
levels.overworld = crate::chunk::LevelShape::new(-64, 2096, 2096, true).unwrap();
let sent = changed_dimension_type("minecraft:overworld", &vanilla, &levels).unwrap();
assert_eq!(
(sent["min_y"].as_i64(), sent["height"].as_i64()),
(Some(-64), Some(2096))
);
assert_eq!(sent["ultrawarm"], false, "the rest of the type is kept");
assert_eq!(
changed_dimension_type("minecraft:the_nether", &vanilla, &levels),
None
);
assert_eq!(
changed_dimension_type("minecraft:overworld_caves", &vanilla, &levels),
None
);
}
}
#[cfg(test)]
+24 -3
View File
@@ -169,6 +169,9 @@ pub struct Guest {
/// What the player's file says of the body (`Entity.load`: the fire
/// and air ticks and the fall distance), for the first body it gets.
pub loaded_body: Option<SavedBody>,
/// The vehicle the player's file names (`ServerPlayer.saveParentVehicle`'s
/// "RootVehicle"), for the ride to come back on the next login.
pub saved_vehicle: Option<[i32; 4]>,
/// The hurt and attack logs of the body it had before it respawned,
/// until the new body takes them over (the control socket's `hurtlog`
/// reads across a death).
@@ -188,6 +191,8 @@ pub struct Guest {
pub craft_result: Option<ItemStack>,
/// The ender chest inventory (`PlayerEnderChestContainer`, 27 slots).
pub ender_items: Vec<Option<ItemStack>>,
/// What a native mod's menu shows (`Slots::Native`), while one is open.
pub native_menu: Vec<Option<ItemStack>>,
pub health: f32,
/// The saved `AbsorptionAmount`, until the player's first body on the
/// server takes it (the body holds the live amount).
@@ -334,6 +339,7 @@ impl Guest {
tracker: Tracker::default(),
motion: None,
loaded_body: None,
saved_vehicle: None,
logs_kept: None,
inventory: vec![None; 36],
armor: [None, None, None, None],
@@ -343,6 +349,7 @@ impl Guest {
craft_grid: Default::default(),
craft_result: None,
ender_items: vec![None; 27],
native_menu: Vec::new(),
health: 20.0,
saved_absorption: None,
food: 20,
@@ -490,13 +497,19 @@ impl Guest {
/// `Player.isWithinBlockInteractionRange(pos, 1.0)`, which the server
/// checks a block action against (ServerPlayerGameMode.handleBlockBreakAction,
/// ServerGamePacketListenerImpl.handleUseItemOn): from the eyes of the
/// guest's body in its pose (crouching, swimming, gliding).
pub fn may_reach_block(&self, pos: [i32; 3]) -> bool {
/// guest's body in its pose (crouching, swimming, gliding), with its
/// `block_interaction_range` (4.5, 5 in creative, more with `/attribute`).
pub fn may_reach_block(&mut self, pos: [i32; 3]) -> bool {
let eye = self
.motion
.as_ref()
.map_or_else(|| self.eye_height(), |m| f64::from(m.dimensions.eye_height));
let range = if self.creative() { 5.0 } else { 4.5 };
let otherwise = if self.creative() { 5.0 } else { 4.5 };
let range = self
.motion
.as_mut()
.and_then(|m| m.attribute("minecraft:block_interaction_range").ok())
.unwrap_or(otherwise);
within_block_range(
[self.position[0], self.position[1] + eye, self.position[2]],
range + 1.0,
@@ -517,6 +530,14 @@ impl Guest {
_ => None,
}
}
/// The open menu's packets told the client every inventory slot: the
/// inventory menu's own sync has nothing to add. Sent again, it could land
/// on clicks the client has predicted since.
pub fn inventory_told(&mut self) {
for slot in 9..45 {
self.menu_sent[slot] = self.menu_slot(slot);
}
}
pub fn set_menu_slot(&mut self, slot: usize, item: Option<ItemStack>) {
match slot {
0 => self.craft_result = item,
+56 -14
View File
@@ -64,6 +64,8 @@ pub struct HubConfig {
/// the owner's level from `ops`, the others' from Allow Commands and
/// the guests' command access, and the integrated-only commands.
pub integrated: bool,
/// The shapes of the world's levels (their dimension types).
pub levels: crate::chunk::LevelShapes,
}
/// The host player as the other players see them.
@@ -168,6 +170,8 @@ pub enum HostEvent {
}
pub struct Hub {
/// Clicks on and closings of native mods' menus, for the game.
pub native_menu_events: Vec<super::menu::NativeMenuEvent>,
pub lan: Lan,
pub config: HubConfig,
pub encoder: ChunkEncoder,
@@ -361,8 +365,7 @@ impl Hub {
.unwrap_or(0);
let clock_ids = (0..sync.dynamic.len("minecraft:world_clock") as i32).collect();
let mut encoder = encoder;
let d = horizon_protocol::registry::DimensionType::vanilla(&config.dimension);
encoder.set_dimension(level_shape(&d));
encoder.set_dimension(config.levels.get(&config.dimension));
Self {
lan,
config,
@@ -394,6 +397,7 @@ impl Hub {
entities: Default::default(),
entity_out: Vec::new(),
mod_commands: (serde_json::Value::Null, None),
native_menu_events: Vec::new(),
teams: Vec::new(),
}
}
@@ -516,6 +520,18 @@ impl Hub {
g.sender.send(body.clone());
}
}
/// `ServerPlayer.sendSystemMessage` to the guest with `uuid`, in any
/// dimension.
pub fn system_message_to_player(&self, uuid: u128, content: Tag) {
let body = C::SystemChat(play::SystemChat {
content,
overlay: false,
})
.encode();
for g in self.guests.iter().filter(|g| g.placed && g.uuid == uuid) {
g.sender.send(body.clone());
}
}
/// `ServerPlayer.sendOverlayMessage` to every guest (the action bar).
pub fn overlay_message(&self, content: Tag) {
self.broadcast(&C::SystemChat(play::SystemChat {
@@ -941,6 +957,11 @@ impl Hub {
}
}
/// Whether vanilla's command tree has a command of this name.
pub fn vanilla_command(&self, name: &str) -> bool {
self.sync.commands.as_ref().is_some_and(|t| t.has(name))
}
/// The key [`Hub::set_mod_commands`] last had: the game compares it
/// with its server mods' each tick.
pub fn mod_commands_key(&self) -> Option<u64> {
@@ -1323,6 +1344,17 @@ impl Hub {
fn remove_guest(&mut self, i: usize, reason: &str) -> Guest {
let mut g = self.guests.remove(i);
if let Some(m) = g
.menu
.as_ref()
.filter(|m| m.slots == super::menu::Slots::Native)
{
self.native_menu_events
.push(super::menu::NativeMenuEvent::Closed {
uuid: g.uuid,
window: m.window,
});
}
if g.left_reason.is_empty() {
g.left_reason = reason.to_owned();
}
@@ -1565,6 +1597,26 @@ impl Hub {
(!batch.is_empty()).then_some(batch)
}
/// `ChunkMap.resendBiomesForChunks`: each player in the level is sent,
/// in one packet, the biomes of those of `columns` it has been sent
/// (`getPlayers(pos, false)`), in the order given.
pub fn resend_biomes(&self, world: &World, columns: &[(i32, i32)]) {
let data: Vec<_> = columns
.iter()
.filter_map(|key| Some((*key, self.encoder.biome_data(world.chunks.get(key)?))))
.collect();
for g in self.guests.iter().filter(|g| self.here(g)) {
let chunks: Vec<_> = data
.iter()
.filter(|(key, _)| g.sent_chunks.contains(key))
.map(|(_, d)| d.clone())
.collect();
if !chunks.is_empty() {
g.send(&C::ChunksBiomes(play::ChunksBiomes { chunks }));
}
}
}
/// `ChunkHolder.broadcastChanges`' light part: the light sections that
/// changed in a column go to the players for whom it is on the border of
/// what they track (`ChunkMap.isChunkOnTrackedBorder`); the others light
@@ -2784,8 +2836,8 @@ impl Hub {
.sync
.id("minecraft:dimension_type", dimension)
.unwrap_or(0);
let d = horizon_protocol::registry::DimensionType::vanilla(dimension);
self.encoder.set_dimension(level_shape(&d));
self.encoder
.set_dimension(self.config.levels.get(dimension));
}
/// ServerPlayer.teleport(TeleportTransition) into the level the hub now
@@ -2975,16 +3027,6 @@ fn shared_flags(sneaking: bool, sprinting: bool, body: &BodyFlags) -> i8 {
shared
}
/// The shape a level of the dimension type `d` has.
fn level_shape(d: &horizon_protocol::registry::DimensionType) -> crate::chunk::LevelShape {
crate::chunk::LevelShape {
sky_light: d.has_skylight,
min_y: d.min_y,
height: d.height,
logical_height: d.height,
}
}
#[cfg(test)]
mod tests {
use super::*;
+33 -1
View File
@@ -37,6 +37,24 @@ pub struct Menu {
pub sent_data: Vec<i16>,
}
/// What a guest did with a native mod's menu (`Slots::Native`), for the game
/// to tell the mod once the hub is back in its hands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NativeMenuEvent {
/// A click (`ServerboundContainerClickPacket`): the menu's slot (its own
/// first, then the player's 36; -999 outside), the button and the mode.
Click {
uuid: u128,
window: i32,
slot: i32,
button: i32,
mode: i32,
},
/// The menu closed: by the player, by the server, another opened, or
/// the player left.
Closed { uuid: u128, window: i32 },
}
/// The y of the key a chest minecart's or chest boat's items are kept under
/// while a menu shows them.
pub const VEHICLE_KEY_Y: i32 = -100_000;
@@ -51,6 +69,9 @@ pub enum Slots {
Double((i32, i32, i32), (i32, i32, i32)),
/// The player's own ender chest inventory (`PlayerEnderChestContainer`).
Ender,
/// A native mod's menu (`Guest::native_menu`): items to look at, which no
/// click moves; the clicks go to the mod.
Native,
}
impl Slots {
@@ -58,7 +79,7 @@ impl Slots {
match self {
Slots::Block(k) => vec![k],
Slots::Double(a, b) => vec![a, b],
Slots::Ender => Vec::new(),
Slots::Ender | Slots::Native => Vec::new(),
}
}
/// Whether the block entities behind the slots are still there
@@ -74,6 +95,7 @@ impl Slots {
pub fn still_valid(self, world: &World, guest: &Guest, at: Option<(i32, i32, i32)>) -> bool {
let blocks = match self {
Slots::Ender => at.into_iter().collect(),
Slots::Native => Vec::new(),
_ => self.halves(),
};
// A vehicle's container is kept under a key, not a block: the game
@@ -88,6 +110,9 @@ impl Slots {
if self == Slots::Ender {
return guest.ender_items.get(n).cloned().flatten();
}
if self == Slots::Native {
return guest.native_menu.get(n).cloned().flatten();
}
for k in self.halves() {
let slots = world.block_entities.get(&k)?.slots();
if n < slots.len() {
@@ -102,6 +127,9 @@ impl Slots {
if self == Slots::Ender {
return Some(guest.ender_items.clone());
}
if self == Slots::Native {
return Some(guest.native_menu.clone());
}
let mut items = Vec::new();
for k in self.halves() {
items.extend_from_slice(world.block_entities.get(&k)?.slots());
@@ -114,6 +142,10 @@ impl Slots {
guest.ender_items = items;
return;
}
if self == Slots::Native {
// Only the mod changes what it shows.
return;
}
let mut rest = items.into_iter();
for k in self.halves() {
if let Some(e) = world.block_entities.get_mut(&k) {
Loaded 100 of 234 files, more files were not shown because too many files have changed in this diff. Show more